@powersync/service-module-mongodb-storage 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +34 -16
  3. package/dist/storage/MongoBucketStorage.js +219 -70
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +42 -23
  6. package/dist/storage/implementation/MongoBucketBatch.js +61 -44
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoChecksums.d.ts +10 -9
  9. package/dist/storage/implementation/MongoChecksums.js +3 -111
  10. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  11. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  12. package/dist/storage/implementation/MongoCompactor.js +18 -9
  13. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  15. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  16. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +6 -5
  18. package/dist/storage/implementation/MongoParameterCompactor.js +24 -1
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +13 -0
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +64 -0
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +123 -0
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  29. package/dist/storage/implementation/MongoStorageProvider.js +5 -2
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +41 -23
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +51 -43
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +3 -3
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +6 -0
  38. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  39. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  40. package/dist/storage/implementation/common/PersistedBatch.d.ts +1 -2
  41. package/dist/storage/implementation/common/PersistedBatch.js +5 -0
  42. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  43. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  44. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  45. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  46. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  47. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  48. package/dist/storage/implementation/db.js +9 -14
  49. package/dist/storage/implementation/db.js.map +1 -1
  50. package/dist/storage/implementation/models.d.ts +4 -13
  51. package/dist/storage/implementation/models.js.map +1 -1
  52. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +9 -1
  53. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +42 -26
  54. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  55. package/dist/storage/implementation/v1/MongoChecksumsV1.js +114 -3
  56. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +15 -17
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +47 -45
  59. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  60. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  61. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  62. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  63. package/dist/storage/implementation/v1/models.d.ts +10 -1
  64. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -4
  65. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +286 -259
  66. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  67. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +27 -7
  68. package/dist/storage/implementation/v3/MongoChecksumsV3.js +202 -18
  69. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +19 -2
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +511 -22
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +62 -0
  74. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +350 -0
  75. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +40 -28
  77. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +232 -116
  78. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +1 -1
  80. package/dist/storage/implementation/v3/PersistedBatchV3.js +43 -20
  81. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +52 -16
  83. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  85. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  86. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  87. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +13 -12
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +29 -27
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  90. package/dist/storage/implementation/v3/bucket-format.d.ts +4 -0
  91. package/dist/storage/implementation/v3/bucket-format.js +58 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  93. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  94. package/dist/storage/implementation/v3/chunking.js +28 -0
  95. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  96. package/dist/storage/implementation/v3/models.d.ts +64 -21
  97. package/dist/storage/implementation/v3/models.js +11 -28
  98. package/dist/storage/implementation/v3/models.js.map +1 -1
  99. package/dist/storage/implementation/v3/source-table-utils.d.ts +55 -0
  100. package/dist/storage/implementation/v3/source-table-utils.js +214 -0
  101. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  102. package/dist/storage/storage-index.d.ts +4 -4
  103. package/dist/storage/storage-index.js +4 -4
  104. package/dist/storage/storage-index.js.map +1 -1
  105. package/dist/types/types.d.ts +2 -2
  106. package/dist/utils/test-utils.d.ts +1 -2
  107. package/dist/utils/test-utils.js +5 -1
  108. package/dist/utils/test-utils.js.map +1 -1
  109. package/dist/utils/util.d.ts +1 -1
  110. package/dist/utils/util.js +3 -2
  111. package/dist/utils/util.js.map +1 -1
  112. package/package.json +9 -19
  113. package/src/storage/MongoBucketStorage.ts +334 -87
  114. package/src/storage/implementation/MongoBucketBatch.ts +86 -58
  115. package/src/storage/implementation/MongoChecksums.ts +6 -138
  116. package/src/storage/implementation/MongoCompactor.ts +19 -9
  117. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  118. package/src/storage/implementation/MongoParameterCompactor.ts +24 -5
  119. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +88 -0
  120. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  121. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +154 -0
  122. package/src/storage/implementation/MongoStorageProvider.ts +5 -2
  123. package/src/storage/implementation/MongoSyncBucketStorage.ts +70 -56
  124. package/src/storage/implementation/MongoSyncRulesLock.ts +7 -4
  125. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +7 -0
  126. package/src/storage/implementation/common/PersistedBatch.ts +7 -3
  127. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  128. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  129. package/src/storage/implementation/db.ts +11 -19
  130. package/src/storage/implementation/models.ts +4 -14
  131. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +55 -34
  132. package/src/storage/implementation/v1/MongoChecksumsV1.ts +137 -4
  133. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +70 -64
  134. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  135. package/src/storage/implementation/v1/models.ts +11 -1
  136. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +358 -314
  137. package/src/storage/implementation/v3/MongoChecksumsV3.ts +262 -26
  138. package/src/storage/implementation/v3/MongoCompactorV3.ts +646 -29
  139. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +480 -0
  140. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +315 -157
  141. package/src/storage/implementation/v3/PersistedBatchV3.ts +62 -36
  142. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +59 -19
  143. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  144. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +41 -36
  145. package/src/storage/implementation/v3/bucket-format.ts +70 -0
  146. package/src/storage/implementation/v3/chunking.ts +38 -0
  147. package/src/storage/implementation/v3/models.ts +93 -55
  148. package/src/storage/implementation/v3/source-table-utils.ts +392 -0
  149. package/src/storage/storage-index.ts +12 -4
  150. package/src/utils/test-utils.ts +6 -3
  151. package/src/utils/util.ts +3 -2
  152. package/test/src/__snapshots__/storage.test.ts.snap +0 -75
  153. package/test/src/__snapshots__/storage_sync.test.ts.snap +48 -48
  154. package/test/src/cleanup-stopped-sync-configs.test.ts +648 -0
  155. package/test/src/storage.test.ts +11 -11
  156. package/test/src/storage_compacting.test.ts +1956 -6
  157. package/test/src/storage_sync.test.ts +1077 -68
  158. package/test/src/storeCurrentData.test.ts +17 -7
  159. package/test/src/util.ts +2 -1
  160. package/tsconfig.json +1 -4
  161. package/tsconfig.tsbuildinfo +1 -1
  162. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  163. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  164. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  165. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  166. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  167. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  168. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  169. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  170. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  171. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  172. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  173. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  174. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +0 -9
  175. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +0 -18
  176. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +0 -1
  177. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  178. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  179. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  180. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  181. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  182. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  183. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  184. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +0 -24
  185. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
@@ -1,11 +1,11 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
- import { ColumnDescriptor, storage } from '@powersync/service-core';
4
- import { HydratedSyncConfig, MatchingSources } from '@powersync/service-sync-rules';
3
+ import { storage } from '@powersync/service-core';
5
4
  import * as bson from 'bson';
6
5
  import { mongoTableId } from '../../../utils/util.js';
7
- import { calculateCheckpointState } from '../CheckpointState.js';
6
+ import { canCheckpointState } from '../CheckpointState.js';
8
7
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
8
+ import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
9
9
  import { syncRuleStateUpdatePipeline } from '../SyncRuleStateUpdate.js';
10
10
  import { PersistedBatch } from '../common/PersistedBatch.js';
11
11
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
@@ -13,30 +13,36 @@ import { PersistedBatchV3 } from './PersistedBatchV3.js';
13
13
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
14
14
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
15
15
  import { ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
16
-
17
- function sameStringArray(left: string[], right: string[]) {
18
- return left.length == right.length && left.every((value, index) => value == right[index]);
19
- }
16
+ import {
17
+ createNewSourceTable,
18
+ designateEventCarrier,
19
+ overlappingSourceTableFilter,
20
+ planSourceTableReconciliation,
21
+ sourceTableDesiredResolution,
22
+ sourceTableFromDocument,
23
+ SourceTableReconciliationContext
24
+ } from './source-table-utils.js';
20
25
 
21
26
  export class MongoBucketBatchV3 extends MongoBucketBatch {
22
- declare public readonly db: VersionedPowerSyncMongoV3;
27
+ declare readonly db: VersionedPowerSyncMongoV3;
23
28
 
24
29
  private readonly store: SourceRecordStore;
25
- private readonly syncConfigId: bson.ObjectId;
30
+ private readonly syncConfigIds: bson.ObjectId[];
26
31
  private needsActivationV3 = true;
27
32
  private lastWaitingLogThrottledV3 = 0;
28
33
 
29
34
  constructor(options: MongoBucketBatchOptions) {
30
35
  super(options);
31
- if (options.syncConfigId == null) {
36
+ const syncConfigIds = options.syncConfigIds ?? [];
37
+ if (syncConfigIds.length == 0) {
32
38
  throw new ReplicationAssertionError('Missing sync config id for v3 batch');
33
39
  }
34
- this.syncConfigId = options.syncConfigId;
35
- this.store = new SourceRecordStoreV3(this.db, this.group_id, this.mapping);
40
+ this.syncConfigIds = syncConfigIds;
41
+ this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
36
42
  }
37
43
 
38
44
  protected createPersistedBatch(writtenSize: number): PersistedBatch {
39
- return new PersistedBatchV3(this.db, this.group_id, this.mapping, writtenSize, {
45
+ return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
40
46
  logger: this.logger
41
47
  });
42
48
  }
@@ -45,10 +51,83 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
45
51
  return this.store;
46
52
  }
47
53
 
54
+ protected override async onReplicationTransactionFlush(
55
+ session: lib_mongo.mongo.ClientSession,
56
+ lastOp: bigint
57
+ ): Promise<void> {
58
+ // Durably advance the stream-level head of persisted ops within the flush transaction.
59
+ // This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
60
+ // config that replicates nothing) covers all ops persisted before a potential crash.
61
+ await this.db.sync_rules.updateOne(
62
+ {
63
+ _id: this.replicationStreamId
64
+ },
65
+ {
66
+ $max: {
67
+ last_persisted_op: lastOp
68
+ }
69
+ },
70
+ { session }
71
+ );
72
+ }
73
+
74
+ private selectedSyncConfigObjectIds(syncConfigIds: string[]): bson.ObjectId[] {
75
+ const selectedIds = new Set(this.syncConfigIds.map((id) => id.toHexString()));
76
+ return syncConfigIds
77
+ .filter((id) => selectedIds.has(id) && bson.ObjectId.isValid(id))
78
+ .map((id) => new bson.ObjectId(id));
79
+ }
80
+
81
+ private relevantSyncConfigIds(table: storage.SourceTable): bson.ObjectId[] {
82
+ const bucketDataSourceIds = table.bucketDataSources.map((source) => this.mapping.bucketSourceId(source));
83
+ const parameterLookupSourceIds = table.parameterLookupSources.map((source) =>
84
+ this.mapping.parameterLookupId(source)
85
+ );
86
+ return this.selectedSyncConfigObjectIds(
87
+ this.mapping.syncConfigIdsForSourceTable(
88
+ this.syncConfigIds.map((id) => id.toHexString()),
89
+ table.ref,
90
+ bucketDataSourceIds,
91
+ parameterLookupSourceIds
92
+ )
93
+ );
94
+ }
95
+
96
+ private relevantSyncConfigIdsForTables(tables: storage.SourceTable[]): bson.ObjectId[] {
97
+ const ids = new Map<string, bson.ObjectId>();
98
+ for (const table of tables) {
99
+ for (const id of this.relevantSyncConfigIds(table)) {
100
+ ids.set(id.toHexString(), id);
101
+ }
102
+ }
103
+ return [...ids.values()];
104
+ }
105
+
106
+ private snapshotBlockingSourceTablesFilter(): Record<string, unknown> {
107
+ const clauses = this.syncConfigIds.flatMap((syncConfigId) => {
108
+ const filter = this.mapping.snapshotBlockingSourceTablesFilter(syncConfigId.toHexString()) as {
109
+ $or?: Record<string, unknown>[];
110
+ };
111
+ return filter.$or ?? [];
112
+ });
113
+
114
+ if (clauses.length == 0) {
115
+ return {
116
+ snapshot_done: false,
117
+ _id: { $exists: false }
118
+ };
119
+ }
120
+
121
+ return {
122
+ snapshot_done: false,
123
+ $or: clauses
124
+ };
125
+ }
126
+
48
127
  protected async cleanupDroppedSourceTables(sourceTables: storage.SourceTable[]) {
49
128
  for (const table of sourceTables) {
50
129
  await this.db
51
- .sourceRecordsV3(this.group_id, mongoTableId(table.id))
130
+ .sourceRecords(this.replicationStreamId, mongoTableId(table.id))
52
131
  .drop()
53
132
  .catch((error) => {
54
133
  if (lib_mongo.isMongoServerError(error) && error.codeName === 'NamespaceNotFound') {
@@ -60,232 +139,103 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
60
139
  }
61
140
 
62
141
  async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
63
- const ref = options.source;
64
- const syncRules = options.syncRules ?? this.sync_rules;
65
- const matchingSources = syncRules.getMatchingSources(ref);
142
+ // The test-only override is a whole parsed set, so the sync rules and the mapping
143
+ // used below always come from the same parse.
144
+ const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
145
+ const syncConfig = parsedOverride?.hydratedSyncConfig ?? this.sync_rules;
146
+ const mapping = parsedOverride?.mapping ?? this.mapping;
66
147
 
67
148
  const { connection_id, source } = options;
68
- const { schema, name, objectId, replicaIdColumns, connectionTag, sendsCompleteRows } = source;
69
- const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
70
- name: column.name,
71
- type: column.type,
72
- type_oid: column.typeId
73
- }));
149
+ const context: SourceTableReconciliationContext = {
150
+ connectionId: connection_id,
151
+ connectionTag: source.connectionTag,
152
+ identity: {
153
+ schema: source.schema,
154
+ name: source.name,
155
+ objectId: source.objectId,
156
+ replicaIdColumns: source.replicaIdColumns.map((column) => ({
157
+ name: column.name,
158
+ type: column.type,
159
+ type_oid: column.typeId
160
+ }))
161
+ },
162
+ storeCurrentData: source.sendsCompleteRows !== true,
163
+ syncConfig,
164
+ mapping,
165
+ desired: sourceTableDesiredResolution(syncConfig, source, mapping)
166
+ };
74
167
 
75
168
  let result: storage.ResolveTablesResult | null = null;
76
- const initializeSourceRecordsFor: bson.ObjectId[] = [];
77
-
78
- await this.db.client.withSession(async (session) => {
79
- const col = this.db.commonSourceTables(this.group_id);
80
- const exactFilter: Record<string, unknown> = {
81
- connection_id,
82
- schema_name: schema,
83
- table_name: name,
84
- replica_id_columns2: normalizedReplicaIdColumns
85
- };
86
- if (objectId != null) {
87
- exactFilter.relation_id = objectId;
88
- }
169
+ const session = this.db.client.startSession();
170
+ await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
89
171
 
90
- const exactDocs = (await col.find(exactFilter, { session }).toArray()) as SourceTableDocumentV3[];
91
- const bucketSourceById = new Map(
92
- matchingSources.bucketDataSources.map((source) => [this.mapping.bucketSourceId(source), source] as const)
93
- );
94
- const parameterLookupSourceById = new Map(
95
- matchingSources.parameterLookupSources.map(
96
- (source) => [this.mapping.parameterLookupId(source), source] as const
97
- )
98
- );
99
- const desiredBucketIds = new Set(bucketSourceById.keys());
100
- const desiredLookupIds = new Set(parameterLookupSourceById.keys());
101
- const desiredHasMembership = desiredBucketIds.size > 0 || desiredLookupIds.size > 0;
102
- const triggersEvent = syncRules.tableTriggersEvent(ref);
103
-
104
- const coveredBucketIds = new Set<string>();
105
- const coveredLookupIds = new Set<string>();
106
- const retainedDocIds: bson.ObjectId[] = [];
107
- const tables: storage.SourceTable[] = [];
108
- let retainedEventOnlyTable = false;
109
-
110
- for (const doc of exactDocs) {
111
- const bucketDataSourceIds = doc.bucket_data_source_ids.filter((id) => desiredBucketIds.has(id));
112
- const parameterLookupSourceIds = doc.parameter_lookup_source_ids.filter((id) => desiredLookupIds.has(id));
113
- const coversDesiredMembership = bucketDataSourceIds.length > 0 || parameterLookupSourceIds.length > 0;
114
- const coversEventOnlyTable = !desiredHasMembership && triggersEvent && !retainedEventOnlyTable;
115
-
116
- for (const id of bucketDataSourceIds) {
117
- coveredBucketIds.add(id);
118
- }
119
- for (const id of parameterLookupSourceIds) {
120
- coveredLookupIds.add(id);
121
- }
172
+ await session.withTransaction(async () => {
173
+ const col = this.db.sourceTables(this.replicationStreamId);
122
174
 
123
- const updates: Partial<SourceTableDocumentV3> = {};
124
- if (
125
- !sameStringArray(doc.bucket_data_source_ids, bucketDataSourceIds) ||
126
- !sameStringArray(doc.parameter_lookup_source_ids, parameterLookupSourceIds)
127
- ) {
128
- updates.bucket_data_source_ids = bucketDataSourceIds;
129
- updates.parameter_lookup_source_ids = parameterLookupSourceIds;
130
- }
131
- if (Object.keys(updates).length > 0) {
132
- await col.updateOne({ _id: doc._id }, { $set: updates }, { session });
133
- }
175
+ // Fetch every persisted source-table doc that can overlap this physical table.
176
+ // Exact-identity docs are candidates for reuse; non-exact overlaps are possible drops.
177
+ const candidateDocs = await col
178
+ .find(overlappingSourceTableFilter(connection_id, context.identity), { session })
179
+ .toArray();
134
180
 
135
- if (coversDesiredMembership || coversEventOnlyTable) {
136
- if (coversEventOnlyTable) {
137
- retainedEventOnlyTable = true;
138
- }
139
- retainedDocIds.push(doc._id);
140
- const table = this.sourceTableFromDocument(
141
- {
142
- ...doc,
143
- bucket_data_source_ids: bucketDataSourceIds,
144
- parameter_lookup_source_ids: parameterLookupSourceIds
145
- },
146
- connectionTag,
147
- syncRules,
148
- {
149
- bucketDataSources: bucketDataSourceIds.map((id) => bucketSourceById.get(id)!),
150
- parameterLookupSources: parameterLookupSourceIds.map((id) => parameterLookupSourceById.get(id)!)
181
+ // Pure planning: which docs to retain (and narrow), whether a new doc is needed for
182
+ // uncovered memberships, and which docs conflict with the current identity.
183
+ const plan = planSourceTableReconciliation(candidateDocs, context);
184
+
185
+ // Persist narrowing for incomplete snapshots only. Snapshot-complete docs keep stale
186
+ // coverage ids so compatible future configs can reuse already-snapshotted data.
187
+ // Narrowing occurs after removing a sync config, meaning we don't process those
188
+ // definitions anymore.
189
+ for (const update of plan.narrowingUpdates) {
190
+ await col.updateOne(
191
+ { _id: update.id },
192
+ {
193
+ $set: {
194
+ bucket_data_source_ids: update.memberships.bucketDataSourceIds,
195
+ parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds
151
196
  }
152
- );
153
- table.storeCurrentData = sendsCompleteRows !== true;
154
- tables.push(table);
155
- }
197
+ },
198
+ { session }
199
+ );
156
200
  }
157
201
 
158
- const uncoveredBucketIds = [...desiredBucketIds].filter((id) => !coveredBucketIds.has(id));
159
- const uncoveredLookupIds = [...desiredLookupIds].filter((id) => !coveredLookupIds.has(id));
160
-
161
- if (uncoveredBucketIds.length > 0 || uncoveredLookupIds.length > 0 || (triggersEvent && tables.length == 0)) {
202
+ // Any desired membership not covered by an existing doc gets a new source table.
203
+ // That table snapshots only the uncovered memberships.
204
+ if (plan.newTableMemberships != null) {
162
205
  const id = options.idGenerator ? (options.idGenerator() as bson.ObjectId) : new bson.ObjectId();
163
- const sourceTable = new storage.SourceTable({
164
- id,
165
- ref,
166
- objectId,
167
- replicaIdColumns,
168
- snapshotComplete: false,
169
- bucketDataSources: uncoveredBucketIds.map((id) => bucketSourceById.get(id)!),
170
- parameterLookupSources: uncoveredLookupIds.map((id) => parameterLookupSourceById.get(id)!)
171
- });
172
- sourceTable.syncData = uncoveredBucketIds.length > 0;
173
- sourceTable.syncParameters = uncoveredLookupIds.length > 0;
174
- sourceTable.syncEvent = triggersEvent;
175
- sourceTable.storeCurrentData = sendsCompleteRows !== true;
176
-
177
- const createDoc: SourceTableDocumentV3 = {
178
- _id: id,
179
- connection_id,
180
- relation_id: objectId,
181
- schema_name: schema,
182
- table_name: name,
183
- replica_id_columns: null,
184
- replica_id_columns2: normalizedReplicaIdColumns,
185
- snapshot_done: false,
186
- snapshot_status: undefined,
187
- bucket_data_source_ids: uncoveredBucketIds,
188
- parameter_lookup_source_ids: uncoveredLookupIds
189
- };
206
+ const { doc, table } = createNewSourceTable(id, plan.newTableMemberships, context);
190
207
 
191
- await col.insertOne(createDoc, { session });
192
- initializeSourceRecordsFor.push(createDoc._id);
193
- retainedDocIds.push(createDoc._id);
194
- tables.push(sourceTable);
208
+ await col.insertOne(doc, { session });
209
+ await this.db.initializeSourceRecordsCollection(this.replicationStreamId, doc._id, session);
210
+ plan.tables.push(table);
195
211
  }
196
212
 
197
- const conflictFilter = [{ schema_name: schema, table_name: name }] as Record<string, unknown>[];
198
- if (objectId != null) {
199
- conflictFilter.push({ relation_id: objectId });
200
- }
201
- const dropTables = await col
202
- .find(
203
- {
204
- connection_id,
205
- _id: { $nin: retainedDocIds },
206
- $or: conflictFilter
207
- },
208
- { session }
209
- )
210
- .toArray();
213
+ // If memberships are split across multiple source tables, only one may fire events.
214
+ designateEventCarrier(plan.tables, context.desired.triggersEvent);
211
215
 
212
216
  result = {
213
- tables,
214
- dropTables: dropTables.map((doc) =>
215
- this.sourceTableFromDocument(doc as SourceTableDocumentV3, connectionTag, syncRules)
216
- )
217
+ tables: plan.tables,
218
+ dropTables: plan.dropDocs.map((doc) => sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping))
217
219
  };
218
220
  });
219
221
 
220
- for (const sourceTableId of initializeSourceRecordsFor) {
221
- await this.db.initializeSourceRecordsCollection(this.group_id, sourceTableId);
222
- }
223
-
224
222
  return result!;
225
223
  }
226
224
 
227
- private sourceTableFromDocument(
228
- doc: SourceTableDocumentV3,
229
- connectionTag: string,
230
- syncRules: HydratedSyncConfig,
231
- memberships?: MatchingSources
232
- ): storage.SourceTable {
233
- const resolvedMemberships = memberships ?? this.sourceTableMembershipsFromDocument(doc, syncRules);
234
- const table = new storage.SourceTable({
235
- id: doc._id,
236
- ref: {
237
- connectionTag,
238
- schema: doc.schema_name,
239
- name: doc.table_name
240
- },
241
- objectId: doc.relation_id,
242
- replicaIdColumns: doc.replica_id_columns2!.map(
243
- (c) => ({ name: c.name, typeId: c.type_oid, type: c.type }) satisfies ColumnDescriptor
244
- ),
245
- snapshotComplete: doc.snapshot_done ?? true,
246
- bucketDataSources: resolvedMemberships.bucketDataSources,
247
- parameterLookupSources: resolvedMemberships.parameterLookupSources
248
- });
249
- table.syncData = table.bucketDataSources.length > 0;
250
- table.syncParameters = table.parameterLookupSources.length > 0;
251
- table.syncEvent = syncRules.tableTriggersEvent(table.ref);
252
- table.snapshotStatus =
253
- doc.snapshot_status == null
254
- ? undefined
255
- : {
256
- lastKey: doc.snapshot_status.last_key?.buffer ?? null,
257
- totalEstimatedCount: doc.snapshot_status.total_estimated_count,
258
- replicatedCount: doc.snapshot_status.replicated_count
259
- };
260
- return table;
261
- }
262
-
263
- private sourceTableMembershipsFromDocument(
264
- doc: SourceTableDocumentV3,
265
- syncRules: HydratedSyncConfig
266
- ): MatchingSources {
267
- const bucketDataSourceIds = new Set(doc.bucket_data_source_ids);
268
- const parameterLookupSourceIds = new Set(doc.parameter_lookup_source_ids);
269
-
270
- return {
271
- bucketDataSources: syncRules.bucketDataSources.filter((source) =>
272
- bucketDataSourceIds.has(this.mapping.bucketSourceId(source))
273
- ),
274
- parameterLookupSources: syncRules.bucketParameterLookupSources.filter((source) =>
275
- parameterLookupSourceIds.has(this.mapping.parameterLookupId(source))
276
- )
277
- };
278
- }
279
-
280
225
  async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
281
226
  const doc = (await this.db
282
- .commonSourceTables(this.group_id)
227
+ .sourceTables(this.replicationStreamId)
283
228
  .findOne({ _id: mongoTableId(table.id) }, { session: this.session })) as SourceTableDocumentV3 | null;
284
229
  if (doc == null) {
285
230
  return null;
286
231
  }
287
232
 
288
- return this.sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules);
233
+ const refreshed = sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules, this.mapping);
234
+ // The event-carrier designation is decided per resolveTables result and not persisted -
235
+ // preserve the caller's designation instead of recomputing it from the ref, so that
236
+ // refreshing a non-carrier table does not make it fire events.
237
+ refreshed.syncEvent = table.syncEvent;
238
+ return refreshed;
289
239
  }
290
240
 
291
241
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
@@ -310,97 +260,156 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
310
260
  }
311
261
  );
312
262
 
313
- const preUpdateDocument = await this.db.sync_rules.findOne(
263
+ const preUpdateDocument = (await this.db.sync_rules.findOne(
314
264
  {
315
- _id: this.group_id,
316
- 'sync_configs._id': this.syncConfigId
265
+ _id: this.replicationStreamId,
266
+ 'sync_configs._id': { $in: this.syncConfigIds }
317
267
  },
318
268
  {
319
269
  session: this.session,
320
270
  projection: {
321
- snapshot_lsn: 1,
322
- sync_configs: {
323
- $elemMatch: {
324
- _id: this.syncConfigId
325
- }
326
- }
271
+ sync_configs: 1,
272
+ last_persisted_op: 1
327
273
  }
328
274
  }
329
- );
275
+ )) as ReplicationStreamDocumentV3 | null;
330
276
 
331
- const state = (preUpdateDocument as ReplicationStreamDocumentV3)?.sync_configs?.[0];
332
- if (state == null) {
277
+ const states =
278
+ preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ?? [];
279
+ if (states.length == 0) {
333
280
  throw new ReplicationAssertionError(
334
- `Failed to update checkpoint - no matching sync_config for _id: ${this.group_id}/${this.syncConfigId.toHexString()}`
281
+ `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
282
+ .map((id) => id.toHexString())
283
+ .join(',')}`
335
284
  );
336
285
  }
286
+ // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
287
+ // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
288
+ // remain embedded in the document and are ignored here.
289
+ const missingSyncConfig = preUpdateDocument!.sync_configs.find(
290
+ (config) =>
291
+ [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
292
+ !this.syncConfigIds.some((id) => id.equals(config._id))
293
+ );
294
+ if (missingSyncConfig != null) {
295
+ throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
296
+ }
337
297
 
338
- const checkpointState = calculateCheckpointState({
339
- lsn,
340
- snapshotDone: state.snapshot_done === true,
341
- lastCheckpointLsn: state.last_checkpoint_lsn,
342
- noCheckpointBefore: state.no_checkpoint_before,
343
- keepaliveOp: state.keepalive_op == null ? null : BigInt(state.keepalive_op),
344
- lastCheckpoint: state.last_checkpoint,
345
- persistedOp: this.persisted_op,
346
- createEmptyCheckpoints
347
- });
298
+ // Effective head of the stream's op sequence.
299
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and this
300
+ // read uses the same session, so it covers all ops persisted by this batch.
301
+ const newCheckpoint =
302
+ preUpdateDocument?.last_persisted_op == null ? 0n : BigInt(preUpdateDocument.last_persisted_op);
303
+
304
+ let checkpointBlocked = false;
305
+ let checkpointCreated = false;
306
+ let checkpointLogState: unknown = null;
307
+ const unblockedConfigIds: bson.ObjectId[] = [];
308
+
309
+ for (const state of states) {
310
+ if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
311
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
312
+ // checkpoints are only ever created at that head, so a checkpoint past the head means the
313
+ // op sequence or the stored state is corrupt.
314
+ throw new ReplicationAssertionError(
315
+ `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
316
+ );
317
+ }
348
318
 
349
- const updateSet: Record<string, any> = {
350
- last_keepalive_ts: now,
351
- last_fatal_error: null,
352
- last_fatal_error_ts: null,
353
- 'sync_configs.$[config].keepalive_op': checkpointState.newKeepaliveOp,
354
- 'sync_configs.$[config].last_checkpoint': checkpointState.newLastCheckpoint
355
- };
356
- if (checkpointState.checkpointCreated) {
357
- updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
358
- updateSet['snapshot_lsn'] = null;
359
- updateSet['last_checkpoint_ts'] = now;
319
+ const canCheckpoint = canCheckpointState(lsn, {
320
+ snapshotDone: state.snapshot_done === true,
321
+ lastCheckpointLsn: state.last_checkpoint_lsn,
322
+ noCheckpointBefore: state.no_checkpoint_before
323
+ });
324
+
325
+ if (!canCheckpoint) {
326
+ checkpointBlocked = true;
327
+ // Log the first blocked config's state.
328
+ checkpointLogState ??= {
329
+ snapshot_done: state.snapshot_done,
330
+ last_checkpoint_lsn: state.last_checkpoint_lsn,
331
+ no_checkpoint_before: state.no_checkpoint_before
332
+ };
333
+ continue;
334
+ }
335
+
336
+ checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
337
+ unblockedConfigIds.push(state._id);
360
338
  }
361
339
 
362
- await this.db.sync_rules.updateOne(
363
- {
364
- _id: this.group_id,
365
- 'sync_configs._id': this.syncConfigId
366
- },
367
- {
368
- $set: updateSet
369
- },
370
- {
371
- session: this.session,
372
- arrayFilters: [{ 'config._id': this.syncConfigId }]
340
+ // Every commit advances the stream's resume position: commit() flushes first, so all
341
+ // source changes up to this lsn have been persisted, even when checkpoints are blocked.
342
+ // In the future we could also advance this on flush, when the connector provides the
343
+ // current position (see setResumeLsn, which connectors may already call after flushing).
344
+ const resumeLsnUpdate = { resume_lsn: lsn };
345
+
346
+ if (unblockedConfigIds.length > 0) {
347
+ // All unblocked configs get the SAME new value, so we apply it with a single updateOne
348
+ // (single-document atomicity).
349
+ const updateSet: Record<string, any> = {
350
+ last_keepalive_ts: now,
351
+ last_fatal_error: null,
352
+ last_fatal_error_ts: null
353
+ };
354
+ // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
355
+ // the previous per-config / v1 behaviour.
356
+ if (checkpointCreated) {
357
+ updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
358
+ updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
359
+ updateSet['last_checkpoint_ts'] = now;
373
360
  }
374
- );
375
361
 
376
- if (checkpointState.checkpointBlocked) {
362
+ await this.db.sync_rules.updateOne(
363
+ {
364
+ _id: this.replicationStreamId,
365
+ 'sync_configs._id': { $in: unblockedConfigIds }
366
+ },
367
+ { $set: updateSet, $max: resumeLsnUpdate },
368
+ {
369
+ session: this.session,
370
+ arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
371
+ }
372
+ );
373
+ } else {
374
+ // All selected configs are blocked - only update keepalive/error tracking and the
375
+ // resume position.
376
+ await this.db.sync_rules.updateOne(
377
+ {
378
+ _id: this.replicationStreamId
379
+ },
380
+ {
381
+ $set: {
382
+ last_keepalive_ts: now,
383
+ last_fatal_error: null,
384
+ last_fatal_error_ts: null
385
+ },
386
+ $max: resumeLsnUpdate
387
+ },
388
+ { session: this.session }
389
+ );
390
+ }
391
+
392
+ if (checkpointBlocked) {
377
393
  if (Date.now() - this.lastWaitingLogThrottledV3 > 5_000) {
378
394
  this.logger.info(
379
- `Waiting before creating checkpoint, currently at ${lsn} / ${checkpointState.newKeepaliveOp}. Current state: ${JSON.stringify(
380
- {
381
- snapshot_done: state.snapshot_done,
382
- last_checkpoint_lsn: state.last_checkpoint_lsn,
383
- no_checkpoint_before: state.no_checkpoint_before
384
- }
385
- )}`
395
+ `Waiting before creating checkpoint, currently at ${lsn}. Current state: ${JSON.stringify(checkpointLogState)}`
386
396
  );
387
397
  this.lastWaitingLogThrottledV3 = Date.now();
388
398
  }
389
399
  } else {
390
- if (checkpointState.checkpointCreated) {
391
- this.logger.debug(`Created checkpoint at ${lsn} / ${checkpointState.newLastCheckpoint}`);
400
+ if (checkpointCreated) {
401
+ this.logger.debug(`Created checkpoint at ${lsn}`);
392
402
  }
393
403
  await this.autoActivateV3(lsn);
404
+ // All configs are now checkpointed at newCheckpoint (the stream head).
405
+ await this.sourceRecordStore.postCommitCleanup(newCheckpoint, this.logger);
406
+ }
407
+ if (checkpointCreated) {
394
408
  await this.db.notifyCheckpoint();
395
- this.persisted_op = null;
396
- this.last_checkpoint_lsn = lsn;
397
- if (checkpointState.newLastCheckpoint != null) {
398
- await this.sourceRecordStore.postCommitCleanup(checkpointState.newLastCheckpoint, this.logger);
399
- }
400
409
  }
401
410
  return {
402
- checkpointBlocked: checkpointState.checkpointBlocked,
403
- checkpointCreated: checkpointState.checkpointCreated
411
+ checkpointBlocked,
412
+ checkpointCreated
404
413
  };
405
414
  }
406
415
 
@@ -411,12 +420,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
411
420
  async setResumeLsn(lsn: string): Promise<void> {
412
421
  await this.db.sync_rules.updateOne(
413
422
  {
414
- _id: this.group_id,
415
- 'sync_configs._id': this.syncConfigId
423
+ _id: this.replicationStreamId
416
424
  },
417
425
  {
418
426
  $set: {
419
- snapshot_lsn: lsn
427
+ resume_lsn: lsn
420
428
  }
421
429
  },
422
430
  { session: this.session }
@@ -433,33 +441,35 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
433
441
  await session.withTransaction(async () => {
434
442
  const doc = await this.db.sync_rules.findOne(
435
443
  {
436
- _id: this.group_id,
437
- 'sync_configs._id': this.syncConfigId
444
+ _id: this.replicationStreamId,
445
+ 'sync_configs._id': { $in: this.syncConfigIds }
438
446
  },
439
447
  {
440
448
  session,
441
449
  projection: {
442
450
  state: 1,
443
- sync_configs: {
444
- $elemMatch: {
445
- _id: this.syncConfigId
446
- }
447
- }
451
+ sync_configs: 1
448
452
  }
449
453
  }
450
454
  );
451
- const state = (doc as ReplicationStreamDocumentV3)?.sync_configs?.[0];
455
+ const states =
456
+ (doc as ReplicationStreamDocumentV3)?.sync_configs?.filter((config) =>
457
+ this.syncConfigIds.some((id) => id.equals(config._id))
458
+ ) ?? [];
459
+ if (doc == null || states.length == 0) {
460
+ return;
461
+ }
462
+
463
+ const processingStates = states.filter((state) => state.state == storage.SyncRuleState.PROCESSING);
452
464
  if (
453
- doc &&
454
465
  doc.state == storage.SyncRuleState.PROCESSING &&
455
- state?.state == storage.SyncRuleState.PROCESSING &&
456
- state.snapshot_done &&
457
- state.last_checkpoint != null
466
+ processingStates.length == states.length &&
467
+ states.every((state) => state.snapshot_done && state.last_checkpoint != null)
458
468
  ) {
459
469
  await this.db.sync_rules.updateOne(
460
470
  {
461
- _id: this.group_id,
462
- 'sync_configs._id': this.syncConfigId
471
+ _id: this.replicationStreamId,
472
+ 'sync_configs._id': { $in: this.syncConfigIds }
463
473
  },
464
474
  {
465
475
  $set: {
@@ -469,20 +479,45 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
469
479
  },
470
480
  {
471
481
  session,
472
- arrayFilters: [{ 'config._id': this.syncConfigId }]
482
+ arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
473
483
  }
474
484
  );
475
485
 
476
486
  await this.db.sync_rules.updateMany(
477
487
  {
478
- _id: { $ne: this.group_id },
488
+ _id: { $ne: this.replicationStreamId },
479
489
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
480
490
  },
481
491
  syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
482
492
  { session }
483
493
  );
484
494
  activated = true;
485
- } else if (doc?.state != storage.SyncRuleState.PROCESSING) {
495
+ } else if (
496
+ doc.state == storage.SyncRuleState.ACTIVE &&
497
+ processingStates.length > 0 &&
498
+ processingStates.every((state) => state.snapshot_done && state.last_checkpoint != null)
499
+ ) {
500
+ await this.db.sync_rules.updateOne(
501
+ {
502
+ _id: this.replicationStreamId,
503
+ 'sync_configs._id': { $in: processingStates.map((state) => state._id) }
504
+ },
505
+ {
506
+ $set: {
507
+ 'sync_configs.$[activeConfig].state': storage.SyncRuleState.STOP,
508
+ 'sync_configs.$[processingConfig].state': storage.SyncRuleState.ACTIVE
509
+ }
510
+ },
511
+ {
512
+ session,
513
+ arrayFilters: [
514
+ { 'activeConfig.state': storage.SyncRuleState.ACTIVE },
515
+ { 'processingConfig._id': { $in: processingStates.map((state) => state._id) } }
516
+ ]
517
+ }
518
+ );
519
+ activated = true;
520
+ } else if (doc.state != storage.SyncRuleState.PROCESSING && doc.state != storage.SyncRuleState.ACTIVE) {
486
521
  this.needsActivationV3 = false;
487
522
  }
488
523
  });
@@ -496,8 +531,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
496
531
  async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
497
532
  await this.db.sync_rules.updateOne(
498
533
  {
499
- _id: this.group_id,
500
- 'sync_configs._id': this.syncConfigId
534
+ _id: this.replicationStreamId,
535
+ 'sync_configs._id': { $in: this.syncConfigIds }
501
536
  },
502
537
  {
503
538
  $set: {
@@ -510,7 +545,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
510
545
  },
511
546
  {
512
547
  session: this.session,
513
- arrayFilters: [{ 'config._id': this.syncConfigId }]
548
+ arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
514
549
  }
515
550
  );
516
551
  }
@@ -518,16 +553,18 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
518
553
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
519
554
  await this.withTransaction(async () => {
520
555
  // Protect against race conditions
521
- const count = await this.db.sourceTablesV3(this.group_id).countDocuments(
522
- {
523
- snapshot_done: false
524
- },
525
- { session: this.session }
526
- );
527
- if (count > 0) {
556
+ const blockingTables = await this.db
557
+ .sourceTables(this.replicationStreamId)
558
+ .find(this.snapshotBlockingSourceTablesFilter(), {
559
+ session: this.session,
560
+ projection: { schema_name: 1, table_name: 1 }
561
+ })
562
+ .toArray();
563
+
564
+ if (blockingTables.length > 0) {
528
565
  if (options?.throwOnConflict ?? true) {
529
566
  throw new ReplicationAssertionError(
530
- `Cannot mark snapshot done while ${count} source table${count == 1 ? '' : 's'} still require snapshotting`
567
+ `Cannot mark snapshot done while source tables still require snapshotting. Tables: ${blockingTables.map((t) => `${t.schema_name}.${t.table_name}`).join(', ')}`
531
568
  );
532
569
  } else {
533
570
  return;
@@ -538,11 +575,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
538
575
  });
539
576
  }
540
577
 
541
- async markTableSnapshotRequired(_table: storage.SourceTable): Promise<void> {
578
+ async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
579
+ const syncConfigIds = this.relevantSyncConfigIds(table);
580
+ if (syncConfigIds.length == 0) {
581
+ return;
582
+ }
583
+
542
584
  await this.db.sync_rules.updateOne(
543
585
  {
544
- _id: this.group_id,
545
- 'sync_configs._id': this.syncConfigId
586
+ _id: this.replicationStreamId,
587
+ 'sync_configs._id': { $in: syncConfigIds }
546
588
  },
547
589
  {
548
590
  $set: {
@@ -551,7 +593,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
551
593
  },
552
594
  {
553
595
  session: this.session,
554
- arrayFilters: [{ 'config._id': this.syncConfigId }]
596
+ arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
555
597
  }
556
598
  );
557
599
  }
@@ -562,9 +604,10 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
562
604
  ): Promise<storage.SourceTable[]> {
563
605
  const session = this.session;
564
606
  const ids = tables.map((table) => mongoTableId(table.id));
607
+ const syncConfigIds = this.relevantSyncConfigIdsForTables(tables);
565
608
 
566
609
  await this.withTransaction(async () => {
567
- await this.db.commonSourceTables(this.group_id).updateMany(
610
+ await this.db.sourceTables(this.replicationStreamId).updateMany(
568
611
  { _id: { $in: ids } },
569
612
  {
570
613
  $set: {
@@ -577,11 +620,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
577
620
  { session }
578
621
  );
579
622
 
580
- if (no_checkpoint_before_lsn != null) {
623
+ if (no_checkpoint_before_lsn != null && syncConfigIds.length > 0) {
581
624
  await this.db.sync_rules.updateOne(
582
625
  {
583
- _id: this.group_id,
584
- 'sync_configs._id': this.syncConfigId
626
+ _id: this.replicationStreamId,
627
+ 'sync_configs._id': { $in: syncConfigIds }
585
628
  },
586
629
  {
587
630
  $set: {
@@ -593,7 +636,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
593
636
  },
594
637
  {
595
638
  session: this.session,
596
- arrayFilters: [{ 'config._id': this.syncConfigId }]
639
+ // Only set for sync configs that use this table
640
+ arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
597
641
  }
598
642
  );
599
643
  }