@@ -171,7 +171,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
171171 } ) ) ;
172172
173173 let result : storage . ResolveTablesResult | null = null ;
174- const initializeSourceRecordsFor : bson . ObjectId [ ] = [ ] ;
174+ let ensureSourceRecordIndexesFor : bson . ObjectId [ ] = [ ] ;
175175
176176 await this . db . client . withSession ( async ( session ) => {
177177 const col = this . db . commonSourceTables ( this . replicationStreamId ) ;
@@ -209,10 +209,24 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
209209 const coversDesiredMembership = bucketDataSourceIds . length > 0 || parameterLookupSourceIds . length > 0 ;
210210 const coversEventOnlyTable = ! desiredHasMembership && triggersEvent && ! retainedEventOnlyTable ;
211211
212+ // Membership sets must be pairwise disjoint across the docs of one physical table:
213+ // each desired id is covered by exactly one doc, so each definition receives each
214+ // row exactly once. The algorithm maintains this (new docs only get uncovered ids,
215+ // narrowing only removes ids) - overlap means the persisted state is corrupt.
212216 for ( const id of bucketDataSourceIds ) {
217+ if ( coveredBucketIds . has ( id ) ) {
218+ throw new ReplicationAssertionError (
219+ `Source table ${ doc . _id } duplicates coverage of bucket data source ${ id } for ${ schema } .${ name } `
220+ ) ;
221+ }
213222 coveredBucketIds . add ( id ) ;
214223 }
215224 for ( const id of parameterLookupSourceIds ) {
225+ if ( coveredLookupIds . has ( id ) ) {
226+ throw new ReplicationAssertionError (
227+ `Source table ${ doc . _id } duplicates coverage of parameter lookup source ${ id } for ${ schema } .${ name } `
228+ ) ;
229+ }
216230 coveredLookupIds . add ( id ) ;
217231 }
218232
@@ -287,11 +301,23 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
287301 } ;
288302
289303 await col . insertOne ( createDoc , { session } ) ;
290- initializeSourceRecordsFor . push ( createDoc . _id ) ;
291304 retainedDocIds . push ( createDoc . _id ) ;
292305 tables . push ( sourceTable ) ;
293306 }
294307
308+ if ( triggersEvent ) {
309+ // A single source row change must fire each event only once, even when memberships
310+ // are split over multiple source tables (connectors save each row change once per
311+ // table, and save() fires events per call). Designate one table per ref as the
312+ // event carrier. Prefer a snapshot-complete table, so that snapshotting a new
313+ // table for added definitions does not replay events for existing rows.
314+ const eventCarrier = tables . find ( ( table ) => table . snapshotComplete ) ?? tables [ 0 ] ;
315+ for ( const table of tables ) {
316+ table . syncEvent = table === eventCarrier ;
317+ }
318+ }
319+ ensureSourceRecordIndexesFor = retainedDocIds ;
320+
295321 const conflictFilter = [ { schema_name : schema , table_name : name } ] as Record < string , unknown > [ ] ;
296322 if ( objectId != null ) {
297323 conflictFilter . push ( { relation_id : objectId } ) ;
@@ -325,7 +351,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
325351 } ;
326352 } ) ;
327353
328- for ( const sourceTableId of initializeSourceRecordsFor ) {
354+ // Index creation is idempotent. Running it for all retained tables - not only newly
355+ // created ones - heals the index if an earlier resolveTables crashed between inserting
356+ // the document and reaching this point (this runs outside the session on purpose, since
357+ // index creation cannot be transactional).
358+ for ( const sourceTableId of ensureSourceRecordIndexesFor ) {
329359 await this . db . initializeSourceRecordsCollection ( this . replicationStreamId , sourceTableId ) ;
330360 }
331361
@@ -395,7 +425,12 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
395425 return null ;
396426 }
397427
398- return this . sourceTableFromDocument ( doc , table . ref . connectionTag , this . sync_rules ) ;
428+ const refreshed = this . sourceTableFromDocument ( doc , table . ref . connectionTag , this . sync_rules ) ;
429+ // The event-carrier designation is decided per resolveTables result and not persisted -
430+ // preserve the caller's designation instead of recomputing it from the ref, so that
431+ // refreshing a non-carrier table does not make it fire events.
432+ refreshed . syncEvent = table . syncEvent ;
433+ return refreshed ;
399434 }
400435
401436 async commit ( lsn : string , options ?: storage . BucketBatchCommitOptions ) : Promise < storage . CheckpointResult > {
0 commit comments