@powersync/service-module-mongodb-storage 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716

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 (213) hide show
  1. package/CHANGELOG.md +129 -7
  2. package/dist/storage/MongoBucketStorage.d.ts +42 -5
  3. package/dist/storage/MongoBucketStorage.js +404 -215
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +39 -14
  6. package/dist/storage/implementation/MongoBucketBatch.js +169 -98
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +21 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  12. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  13. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  14. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  16. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  18. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +35 -2
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +2 -1
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +7 -4
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -1
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +3 -3
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +10 -8
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  29. package/dist/storage/implementation/MongoStorageProvider.js +8 -4
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +32 -11
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +95 -31
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +59 -10
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
  38. package/dist/storage/implementation/MongoWriteBatch.js +149 -0
  39. package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
  42. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  43. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  44. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  45. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  46. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  47. package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
  48. package/dist/storage/implementation/common/PersistedBatch.js +57 -14
  49. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  50. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
  51. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  53. package/dist/storage/implementation/db.d.ts +9 -1
  54. package/dist/storage/implementation/db.js +28 -1
  55. package/dist/storage/implementation/db.js.map +1 -1
  56. package/dist/storage/implementation/models.d.ts +35 -0
  57. package/dist/storage/implementation/models.js.map +1 -1
  58. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +11 -0
  59. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +227 -221
  60. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  61. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
  62. package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
  63. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  64. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  65. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  66. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  67. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  68. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +66 -17
  69. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  70. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
  71. package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
  72. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  73. package/dist/storage/implementation/v1/models.js +1 -0
  74. package/dist/storage/implementation/v1/models.js.map +1 -1
  75. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  76. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  77. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  78. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +14 -2
  79. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +261 -194
  80. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  81. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +83 -25
  82. package/dist/storage/implementation/v3/MongoCompactorV3.js +702 -159
  83. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  85. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  86. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  87. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  88. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  89. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  90. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  91. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +92 -32
  92. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  93. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  94. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  95. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  96. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
  97. package/dist/storage/implementation/v3/PersistedBatchV3.js +77 -45
  98. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  99. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  100. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  101. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  102. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  103. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  104. package/dist/storage/implementation/v3/compact-utils.d.ts +108 -0
  105. package/dist/storage/implementation/v3/compact-utils.js +223 -0
  106. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  107. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  108. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  109. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  110. package/dist/storage/implementation/v3/models.d.ts +90 -6
  111. package/dist/storage/implementation/v3/models.js.map +1 -1
  112. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  113. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
  114. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  115. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  116. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
  117. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
  118. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  119. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
  120. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
  121. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  122. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  123. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
  124. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  125. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  126. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  127. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  128. package/dist/storage/storage-index.d.ts +2 -1
  129. package/dist/storage/storage-index.js +1 -0
  130. package/dist/storage/storage-index.js.map +1 -1
  131. package/dist/types/types.d.ts +28 -1
  132. package/dist/types/types.js +28 -7
  133. package/dist/types/types.js.map +1 -1
  134. package/dist/utils/test-utils.d.ts +4 -2
  135. package/dist/utils/test-utils.js +7 -4
  136. package/dist/utils/test-utils.js.map +1 -1
  137. package/dist/utils/util.js +3 -1
  138. package/dist/utils/util.js.map +1 -1
  139. package/package.json +10 -9
  140. package/src/storage/MongoBucketStorage.ts +545 -277
  141. package/src/storage/implementation/MongoBucketBatch.ts +258 -124
  142. package/src/storage/implementation/MongoCompactor.ts +37 -346
  143. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  144. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  145. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  146. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +46 -1
  147. package/src/storage/implementation/MongoPersistedReplicationStream.ts +12 -4
  148. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +48 -29
  149. package/src/storage/implementation/MongoStorageProvider.ts +16 -4
  150. package/src/storage/implementation/MongoSyncBucketStorage.ts +133 -40
  151. package/src/storage/implementation/MongoSyncRulesLock.ts +78 -12
  152. package/src/storage/implementation/MongoWriteBatch.ts +172 -0
  153. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
  154. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  155. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  156. package/src/storage/implementation/common/PersistedBatch.ts +76 -20
  157. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
  158. package/src/storage/implementation/db.ts +36 -2
  159. package/src/storage/implementation/models.ts +39 -0
  160. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +272 -254
  161. package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
  162. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  163. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +88 -28
  164. package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
  165. package/src/storage/implementation/v1/models.ts +1 -0
  166. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  167. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +339 -247
  168. package/src/storage/implementation/v3/MongoCompactorV3.ts +955 -225
  169. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  170. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  171. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +112 -42
  172. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  173. package/src/storage/implementation/v3/PersistedBatchV3.ts +94 -51
  174. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  175. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  176. package/src/storage/implementation/v3/compact-utils.ts +329 -0
  177. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  178. package/src/storage/implementation/v3/models.ts +97 -4
  179. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
  180. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  181. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
  182. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
  183. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
  184. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  185. package/src/storage/storage-index.ts +2 -0
  186. package/src/types/types.ts +42 -7
  187. package/src/utils/test-utils.ts +9 -5
  188. package/src/utils/util.ts +3 -1
  189. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  190. package/test/src/__snapshots__/storage_sync.test.ts.snap +794 -205
  191. package/test/src/chunk_compaction_config.test.ts +38 -0
  192. package/test/src/cleanup-stopped-sync-configs.test.ts +59 -26
  193. package/test/src/compact-utils.test.ts +251 -0
  194. package/test/src/concurrent_writers.test.ts +599 -0
  195. package/test/src/connection_config.test.ts +159 -0
  196. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  197. package/test/src/helpers/concurrentWriter.mjs +29 -0
  198. package/test/src/helpers/s3TestFactory.ts +12 -12
  199. package/test/src/object_storage_usage.test.ts +268 -0
  200. package/test/src/parameter_compacting_v1.test.ts +120 -0
  201. package/test/src/parameter_compacting_v3.test.ts +192 -0
  202. package/test/src/parameter_compaction_fence.test.ts +276 -0
  203. package/test/src/storage.test.ts +280 -24
  204. package/test/src/storage_compacting.test.ts +1097 -253
  205. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  206. package/test/src/storage_s3_checksums.test.ts +6 -7
  207. package/test/src/storage_s3_compaction_lifecycle.test.ts +143 -17
  208. package/test/src/storage_s3_reading.test.ts +294 -6
  209. package/test/src/storage_s3_writing.test.ts +4 -3
  210. package/test/src/storage_sync.test.ts +412 -80
  211. package/test/src/storeCurrentData.test.ts +5 -5
  212. package/test/src/util.ts +1 -1
  213. package/tsconfig.tsbuildinfo +1 -1
@@ -1,21 +1,23 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
- import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
- import { storage } from '@powersync/service-core';
2
+ import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import { InternalOpId, storage } from '@powersync/service-core';
4
+ import { EventDefinitionId } from '@powersync/service-sync-rules';
4
5
  import * as bson from 'bson';
5
6
  import { mongoTableId } from '../../../utils/util.js';
6
7
  import { canCheckpointState } from '../CheckpointState.js';
7
8
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
8
9
  import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
9
- import { syncRuleStateUpdatePipeline } from '../SyncRuleStateUpdate.js';
10
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
11
+ import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
10
12
  import { PersistedBatch } from '../common/PersistedBatch.js';
11
13
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
14
+ import { SyncRuleDocumentBase } from '../models.js';
12
15
  import { PersistedBatchV3 } from './PersistedBatchV3.js';
13
16
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
14
17
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
15
- import { ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
18
+ import { CustomCheckpointRequestDocumentV3, ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
16
19
  import {
17
20
  createNewSourceTable,
18
- designateEventCarrier,
19
21
  overlappingSourceTableFilter,
20
22
  planSourceTableReconciliation,
21
23
  sourceTableDesiredResolution,
@@ -27,6 +29,10 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
27
29
  declare readonly db: VersionedPowerSyncMongoV3;
28
30
 
29
31
  private readonly store: SourceRecordStore;
32
+ /** Avoid repeating idempotent createIndexes calls for every checkpoint flush. */
33
+ private readonly initializedCustomCheckpointEventIds = new Set<EventDefinitionId>();
34
+ // Used only to validate supplied checkpoint ids; it does not initialize collections.
35
+ private readonly knownEventIds: ReadonlySet<EventDefinitionId>;
30
36
  private readonly syncConfigIds: bson.ObjectId[];
31
37
  private needsActivationV3 = true;
32
38
  private lastWaitingLogThrottledV3 = 0;
@@ -38,6 +44,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
38
44
  throw new ReplicationAssertionError('Missing sync config id for v3 batch');
39
45
  }
40
46
  this.syncConfigIds = syncConfigIds;
47
+ this.knownEventIds = new Set(this.mapping.allEventDefinitionIds());
41
48
  this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
42
49
  }
43
50
 
@@ -45,7 +52,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
45
52
  return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
46
53
  logger: this.logger,
47
54
  objectStorage: this.options.objectStorage,
48
- inlineThresholdBytes: this.options.inlineThresholdBytes
55
+ inlineThresholdBytes: this.options.inlineThresholdBytes,
56
+ signal: this.options.signal,
57
+ objectStorageUsageWriterId: this.objectStorageUsageWriterId
49
58
  });
50
59
  }
51
60
 
@@ -53,14 +62,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
53
62
  return this.store;
54
63
  }
55
64
 
56
- protected override async onReplicationTransactionFlush(
57
- session: lib_mongo.mongo.ClientSession,
58
- lastOp: bigint
59
- ): Promise<void> {
65
+ protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
66
+ return (stream as ReplicationStreamDocumentV3).last_persisted_op ?? 0n;
67
+ }
68
+
69
+ protected override onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void {
60
70
  // Durably advance the stream-level head of persisted ops within the flush transaction.
61
71
  // This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
62
72
  // config that replicates nothing) covers all ops persisted before a potential crash.
63
- await this.db.sync_rules.updateOne(
73
+ writes.updateOne(
74
+ this.db.sync_rules,
64
75
  {
65
76
  _id: this.replicationStreamId
66
77
  },
@@ -68,8 +79,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
68
79
  $max: {
69
80
  last_persisted_op: lastOp
70
81
  }
71
- },
72
- { session }
82
+ }
73
83
  );
74
84
  }
75
85
 
@@ -90,7 +100,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
90
100
  this.syncConfigIds.map((id) => id.toHexString()),
91
101
  table.ref,
92
102
  bucketDataSourceIds,
93
- parameterLookupSourceIds
103
+ parameterLookupSourceIds,
104
+ [...table.eventDefinitionIds!]
94
105
  )
95
106
  );
96
107
  }
@@ -132,7 +143,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
132
143
  .sourceRecords(this.replicationStreamId, mongoTableId(table.id))
133
144
  .drop()
134
145
  .catch((error) => {
135
- if (lib_mongo.isMongoServerError(error) && error.codeName === 'NamespaceNotFound') {
146
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
136
147
  return;
137
148
  }
138
149
  throw error;
@@ -146,42 +157,58 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
146
157
  const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
147
158
  const syncConfig = parsedOverride?.hydratedSyncConfig ?? this.sync_rules;
148
159
  const mapping = parsedOverride?.mapping ?? this.mapping;
160
+ const eventById = (parsedOverride ?? this.options.parsedSyncConfig).eventById;
149
161
 
150
162
  const { connection_id, source } = options;
151
- const context: SourceTableReconciliationContext = {
152
- connectionId: connection_id,
153
- connectionTag: source.connectionTag,
154
- identity: {
155
- schema: source.schema,
156
- name: source.name,
157
- objectId: source.objectId,
158
- replicaIdColumns: source.replicaIdColumns.map((column) => ({
159
- name: column.name,
160
- type: column.type,
161
- type_oid: column.typeId
162
- }))
163
- },
164
- storeCurrentData: source.sendsCompleteRows !== true,
165
- syncConfig,
166
- mapping,
167
- desired: sourceTableDesiredResolution(syncConfig, source, mapping)
163
+ const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
164
+ const identity = {
165
+ schema: source.schema,
166
+ name: source.name,
167
+ objectId: source.objectId,
168
+ replicaIdColumns: source.replicaIdColumns.map((column) => ({
169
+ name: column.name,
170
+ type: column.type,
171
+ type_oid: column.typeId
172
+ }))
168
173
  };
169
174
 
170
175
  let result: storage.ResolveTablesResult | null = null;
171
176
  const session = this.db.client.startSession();
172
177
  await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
173
178
 
174
- await session.withTransaction(async () => {
179
+ await this.withTransaction(async () => {
175
180
  const col = this.db.sourceTables(this.replicationStreamId);
176
181
 
177
- // Fetch every persisted source-table doc that can overlap this physical table.
178
- // Exact-identity docs are candidates for reuse; non-exact overlaps are possible drops.
182
+ // Find records that overlap by name or relation id.
179
183
  const candidateDocs = await col
180
- .find(overlappingSourceTableFilter(connection_id, context.identity), { session })
184
+ .find(overlappingSourceTableFilter(connection_id, identity), { session })
181
185
  .toArray();
182
186
 
183
- // Pure planning: which docs to retain (and narrow), whether a new doc is needed for
184
- // uncovered memberships, and which docs conflict with the current identity.
187
+ const candidateTables = candidateDocs.map((doc) =>
188
+ sourceTableFromDocument(doc, source.connectionTag, syncConfig, mapping, eventById)
189
+ );
190
+ const candidates = candidateTables.map((table) => table.clone());
191
+ const resolution = await reconcile({ source, candidates });
192
+ storage.validateSourceTableCandidateResolution(candidates, resolution);
193
+
194
+ // Persist metadata from the reconciler without mutating the queried documents.
195
+ for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
196
+ await col.updateOne({ _id: mongoTableId(id) }, { $set: { source_metadata: sourceMetadata } }, { session });
197
+ }
198
+
199
+ const context: SourceTableReconciliationContext = {
200
+ connectionId: connection_id,
201
+ connectionTag: source.connectionTag,
202
+ identity,
203
+ storeCurrentData: source.sendsCompleteRows !== true,
204
+ syncConfig,
205
+ mapping,
206
+ desired: sourceTableDesiredResolution(syncConfig, source, mapping, eventById),
207
+ sourceCompatibleTables: resolution.compatibleTables,
208
+ newTableSourceMetadata: resolution.newTableValues.sourceMetadata
209
+ };
210
+
211
+ // Plan record reuse, membership changes, creation, and removal.
185
212
  const plan = planSourceTableReconciliation(candidateDocs, context);
186
213
 
187
214
  // Persist narrowing for incomplete snapshots only. Snapshot-complete docs keep stale
@@ -194,7 +221,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
194
221
  {
195
222
  $set: {
196
223
  bucket_data_source_ids: update.memberships.bucketDataSourceIds,
197
- parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds
224
+ parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds,
225
+ event_definition_ids: update.memberships.eventDefinitionIds
198
226
  }
199
227
  },
200
228
  { session }
@@ -212,14 +240,13 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
212
240
  plan.tables.push(table);
213
241
  }
214
242
 
215
- // If memberships are split across multiple source tables, only one may fire events.
216
- designateEventCarrier(plan.tables, context.desired.triggersEvent);
217
-
218
243
  result = {
219
244
  tables: plan.tables,
220
- dropTables: plan.dropDocs.map((doc) => sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping))
245
+ dropTables: plan.dropDocs.map((doc) =>
246
+ sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping, eventById)
247
+ )
221
248
  };
222
- });
249
+ }, session);
223
250
 
224
251
  return result!;
225
252
  }
@@ -232,167 +259,155 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
232
259
  return null;
233
260
  }
234
261
 
235
- const refreshed = sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules, this.mapping);
236
- // The event-carrier designation is decided per resolveTables result and not persisted -
237
- // preserve the caller's designation instead of recomputing it from the ref, so that
238
- // refreshing a non-carrier table does not make it fire events.
239
- refreshed.syncEvent = table.syncEvent;
240
- return refreshed;
262
+ return sourceTableFromDocument(
263
+ doc,
264
+ table.ref.connectionTag,
265
+ this.sync_rules,
266
+ this.mapping,
267
+ this.options.parsedSyncConfig.eventById
268
+ );
241
269
  }
242
270
 
243
271
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
244
272
  const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
245
273
 
246
- await this.flush(options);
247
-
248
274
  using _ = this.tracer.span('storage', 'commit');
249
275
 
250
- const now = new Date();
251
-
252
- await this.db.write_checkpoints.updateMany(
253
- {
254
- processed_at_lsn: null,
255
- 'lsns.1': { $lte: lsn }
256
- },
257
- {
258
- $set: {
259
- processed_at_lsn: lsn
260
- }
261
- },
262
- {
263
- session: this.session
264
- }
265
- );
266
-
267
- const preUpdateDocument = (await this.db.sync_rules.findOne(
268
- {
269
- _id: this.replicationStreamId,
270
- 'sync_configs._id': { $in: this.syncConfigIds }
271
- },
272
- {
273
- session: this.session,
274
- projection: {
275
- sync_configs: 1,
276
- last_persisted_op: 1
277
- }
278
- }
279
- )) as ReplicationStreamDocumentV3 | null;
280
-
281
- const states =
282
- preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ?? [];
283
- if (states.length == 0) {
284
- throw new ReplicationAssertionError(
285
- `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
286
- .map((id) => id.toHexString())
287
- .join(',')}`
276
+ const checkpoint = async (stream: SyncRuleDocumentBase, lastOp?: InternalOpId) => {
277
+ const now = new Date();
278
+ const preUpdateDocument = stream as ReplicationStreamDocumentV3;
279
+ const writes = this.db.createWriteBatch(this.session, { ordered: false });
280
+ writes.updateMany(
281
+ this.db.write_checkpoints,
282
+ { processed_at_lsn: null, 'lsns.1': { $lte: lsn } },
283
+ { $set: { processed_at_lsn: lsn } }
288
284
  );
289
- }
290
- // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
291
- // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
292
- // remain embedded in the document and are ignored here.
293
- const missingSyncConfig = preUpdateDocument!.sync_configs.find(
294
- (config) =>
295
- [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
296
- !this.syncConfigIds.some((id) => id.equals(config._id))
297
- );
298
- if (missingSyncConfig != null) {
299
- throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
300
- }
301
285
 
302
- // Effective head of the stream's op sequence.
303
- // last_persisted_op is $max-advanced durably in the same transaction as every flush, and this
304
- // read uses the same session, so it covers all ops persisted by this batch.
305
- const newCheckpoint =
306
- preUpdateDocument?.last_persisted_op == null ? 0n : BigInt(preUpdateDocument.last_persisted_op);
307
-
308
- let checkpointBlocked = false;
309
- let checkpointCreated = false;
310
- let checkpointLogState: unknown = null;
311
- const unblockedConfigIds: bson.ObjectId[] = [];
312
-
313
- for (const state of states) {
314
- if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
315
- // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
316
- // checkpoints are only ever created at that head, so a checkpoint past the head means the
317
- // op sequence or the stored state is corrupt.
286
+ const states =
287
+ preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ??
288
+ [];
289
+ if (states.length == 0) {
318
290
  throw new ReplicationAssertionError(
319
- `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
291
+ `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
292
+ .map((id) => id.toHexString())
293
+ .join(',')}`
320
294
  );
321
295
  }
296
+ // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
297
+ // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
298
+ // remain embedded in the document and are ignored here.
299
+ const missingSyncConfig = preUpdateDocument!.sync_configs.find(
300
+ (config) =>
301
+ [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
302
+ !this.syncConfigIds.some((id) => id.equals(config._id))
303
+ );
304
+ if (missingSyncConfig != null) {
305
+ throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
306
+ }
322
307
 
323
- const canCheckpoint = canCheckpointState(lsn, {
324
- snapshotDone: state.snapshot_done === true,
325
- lastCheckpointLsn: state.last_checkpoint_lsn,
326
- noCheckpointBefore: state.no_checkpoint_before
327
- });
308
+ // Effective head of the stream's op sequence.
309
+ // A combined flush supplies its new head; an empty commit uses the fenced persisted head.
310
+ const newCheckpoint = lastOp ?? this.persistedOpHead(preUpdateDocument);
328
311
 
329
- if (!canCheckpoint) {
330
- checkpointBlocked = true;
331
- // Log the first blocked config's state.
332
- checkpointLogState ??= {
333
- snapshot_done: state.snapshot_done,
334
- last_checkpoint_lsn: state.last_checkpoint_lsn,
335
- no_checkpoint_before: state.no_checkpoint_before
336
- };
337
- continue;
338
- }
312
+ let checkpointBlocked = false;
313
+ let checkpointCreated = false;
314
+ let checkpointLogState: unknown = null;
315
+ const unblockedConfigIds: bson.ObjectId[] = [];
339
316
 
340
- checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
341
- unblockedConfigIds.push(state._id);
342
- }
317
+ for (const state of states) {
318
+ if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
319
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
320
+ // checkpoints are only ever created at that head, so a checkpoint past the head means the
321
+ // op sequence or the stored state is corrupt.
322
+ throw new ReplicationAssertionError(
323
+ `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
324
+ );
325
+ }
343
326
 
344
- // Every commit advances the stream's resume position: commit() flushes first, so all
345
- // source changes up to this lsn have been persisted, even when checkpoints are blocked.
346
- // In the future we could also advance this on flush, when the connector provides the
347
- // current position (see setResumeLsn, which connectors may already call after flushing).
348
- const resumeLsnUpdate = { resume_lsn: lsn };
349
-
350
- if (unblockedConfigIds.length > 0) {
351
- // All unblocked configs get the SAME new value, so we apply it with a single updateOne
352
- // (single-document atomicity).
353
- const updateSet: Record<string, any> = {
354
- last_keepalive_ts: now,
355
- last_fatal_error: null,
356
- last_fatal_error_ts: null
357
- };
358
- // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
359
- // the previous per-config / v1 behaviour.
360
- if (checkpointCreated) {
361
- updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
362
- updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
363
- updateSet['last_checkpoint_ts'] = now;
327
+ const canCheckpoint = canCheckpointState(lsn, {
328
+ snapshotDone: state.snapshot_done === true,
329
+ lastCheckpointLsn: state.last_checkpoint_lsn,
330
+ noCheckpointBefore: state.no_checkpoint_before
331
+ });
332
+
333
+ if (!canCheckpoint) {
334
+ checkpointBlocked = true;
335
+ // Log the first blocked config's state.
336
+ checkpointLogState ??= {
337
+ snapshot_done: state.snapshot_done,
338
+ last_checkpoint_lsn: state.last_checkpoint_lsn,
339
+ no_checkpoint_before: state.no_checkpoint_before
340
+ };
341
+ continue;
342
+ }
343
+
344
+ checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
345
+ unblockedConfigIds.push(state._id);
364
346
  }
365
347
 
366
- await this.db.sync_rules.updateOne(
367
- {
368
- _id: this.replicationStreamId,
369
- 'sync_configs._id': { $in: unblockedConfigIds }
370
- },
371
- { $set: updateSet, $max: resumeLsnUpdate },
372
- {
373
- session: this.session,
374
- arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
348
+ // Every commit advances the stream's resume position: commit() flushes first, so all
349
+ // source changes up to this lsn have been persisted, even when checkpoints are blocked.
350
+ // In the future we could also advance this on flush, when the connector provides the
351
+ // current position (see setResumeLsn, which connectors may already call after flushing).
352
+ const resumeLsnUpdate = {
353
+ resume_lsn: lsn,
354
+ ...(lastOp == null ? {} : { last_persisted_op: lastOp })
355
+ };
356
+
357
+ if (unblockedConfigIds.length > 0) {
358
+ // All unblocked configs get the SAME new value, so we apply it with a single updateOne
359
+ // (single-document atomicity).
360
+ const updateSet: Record<string, any> = {
361
+ last_fatal_error: null,
362
+ last_fatal_error_ts: null
363
+ };
364
+ // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
365
+ // the previous per-config / v1 behaviour.
366
+ if (checkpointCreated) {
367
+ updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
368
+ updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
369
+ updateSet['last_checkpoint_ts'] = now;
375
370
  }
376
- );
377
- } else {
378
- // All selected configs are blocked - only update keepalive/error tracking and the
379
- // resume position.
380
- await this.db.sync_rules.updateOne(
381
- {
382
- _id: this.replicationStreamId
383
- },
384
- {
385
- $set: {
386
- last_keepalive_ts: now,
387
- last_fatal_error: null,
388
- last_fatal_error_ts: null
371
+
372
+ writes.updateOne(
373
+ this.db.sync_rules,
374
+ {
375
+ _id: this.replicationStreamId,
376
+ 'sync_configs._id': { $in: unblockedConfigIds }
389
377
  },
390
- $max: resumeLsnUpdate
391
- },
392
- { session: this.session }
393
- );
394
- }
378
+ { $set: updateSet, $max: resumeLsnUpdate },
379
+ {
380
+ arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
381
+ }
382
+ );
383
+ } else {
384
+ // All selected configs are blocked - only update keepalive/error tracking and the
385
+ // resume position.
386
+ writes.updateOne(
387
+ this.db.sync_rules,
388
+ {
389
+ _id: this.replicationStreamId
390
+ },
391
+ {
392
+ $set: {
393
+ last_fatal_error: null,
394
+ last_fatal_error_ts: null
395
+ },
396
+ $max: resumeLsnUpdate
397
+ }
398
+ );
399
+ }
395
400
 
401
+ await writes.execute();
402
+ return { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint };
403
+ };
404
+ const projection = { sync_configs: 1, last_persisted_op: 1 };
405
+ const { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint } = await this.flushAndCommit(
406
+ checkpoint,
407
+ () => this.withFencedTransaction(() => this.fence(this.session, projection), checkpoint),
408
+ options,
409
+ projection
410
+ );
396
411
  if (checkpointBlocked) {
397
412
  if (Date.now() - this.lastWaitingLogThrottledV3 > 5_000) {
398
413
  this.logger.info(
@@ -423,23 +438,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
423
438
 
424
439
  async setResumeLsn(lsn: string): Promise<void> {
425
440
  using _ = this.tracer.span('storage', 'set_resume_lsn');
426
- await this.db.sync_rules.updateOne(
427
- {
428
- _id: this.replicationStreamId
429
- },
430
- {
431
- $set: {
432
- resume_lsn: lsn
433
- }
434
- },
435
- {
436
- session: this.session,
437
- // Losing occasional resume LSN is fine. That may mean reprocessing
438
- // some source changes in some edge cases, which is not an issue since
439
- // changes are processed in an idempotent way.
440
- writeConcern: { w: 1 }
441
- }
442
- );
441
+ // Losing occasional resume LSN would only reprocess source changes.
442
+ // Keep the lease check atomic, but do not wait for majority replication of this resume hint.
443
+ await this.updateStreamMetadata({ resume_lsn: { $literal: lsn } }, {}, { w: 1 });
443
444
  }
444
445
 
445
446
  private async autoActivateV3(lsn: string): Promise<void> {
@@ -450,7 +451,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
450
451
  const session = this.session;
451
452
  let activated = false;
452
453
  let needsFutureActivationCheck = true;
453
- await session.withTransaction(async () => {
454
+ await this.withTransaction(async () => {
454
455
  // Reset on transaction retries.
455
456
  needsFutureActivationCheck = true;
456
457
  activated = false;
@@ -504,7 +505,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
504
505
  _id: { $ne: this.replicationStreamId },
505
506
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
506
507
  },
507
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
508
+ stopReplicationStreamPipeline(),
508
509
  { session }
509
510
  );
510
511
  activated = true;
@@ -538,7 +539,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
538
539
  } else if (doc.state == storage.SyncRuleState.ACTIVE && processingStates.length == 0) {
539
540
  needsFutureActivationCheck = false;
540
541
  }
541
- });
542
+ }, session);
542
543
  if (activated) {
543
544
  this.logger.info(`Activated new replication stream at ${lsn}`);
544
545
  await this.db.notifyCheckpoint();
@@ -548,26 +549,35 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
548
549
  }
549
550
  }
550
551
 
551
- async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
552
- await this.db.sync_rules.updateOne(
553
- {
554
- _id: this.replicationStreamId,
555
- 'sync_configs._id': { $in: this.syncConfigIds }
556
- },
557
- {
558
- $set: {
559
- 'sync_configs.$[config].snapshot_done': true,
560
- last_keepalive_ts: new Date()
561
- },
562
- $max: {
563
- 'sync_configs.$[config].no_checkpoint_before': no_checkpoint_before_lsn
552
+ private syncConfigMetadataUpdate(syncConfigIds: bson.ObjectId[], set: lib_mongo.mongo.Document) {
553
+ return {
554
+ sync_configs: {
555
+ $map: {
556
+ input: '$sync_configs',
557
+ as: 'config',
558
+ in: {
559
+ $cond: [
560
+ { $in: ['$$config._id', { $literal: syncConfigIds }] },
561
+ { $mergeObjects: ['$$config', set] },
562
+ '$$config'
563
+ ]
564
+ }
564
565
  }
565
- },
566
- {
567
- session: this.session,
568
- arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
569
566
  }
570
- );
567
+ };
568
+ }
569
+
570
+ private snapshotDoneUpdate(no_checkpoint_before_lsn: string) {
571
+ return this.syncConfigMetadataUpdate(this.syncConfigIds, {
572
+ snapshot_done: true,
573
+ no_checkpoint_before: { $max: ['$$config.no_checkpoint_before', { $literal: no_checkpoint_before_lsn }] }
574
+ });
575
+ }
576
+
577
+ async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
578
+ await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
579
+ 'sync_configs._id': { $in: this.syncConfigIds }
580
+ });
571
581
  }
572
582
 
573
583
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
@@ -591,7 +601,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
591
601
  }
592
602
  }
593
603
 
594
- await this.markAllSnapshotDone(no_checkpoint_before_lsn);
604
+ await this.updateStreamMetadataInTransaction(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
605
+ 'sync_configs._id': { $in: this.syncConfigIds }
606
+ });
595
607
  });
596
608
  }
597
609
 
@@ -600,22 +612,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
600
612
  if (syncConfigIds.length == 0) {
601
613
  return;
602
614
  }
603
-
604
- await this.db.sync_rules.updateOne(
605
- {
606
- _id: this.replicationStreamId,
607
- 'sync_configs._id': { $in: syncConfigIds }
608
- },
609
- {
610
- $set: {
611
- 'sync_configs.$[config].snapshot_done': false
612
- }
613
- },
614
- {
615
- session: this.session,
616
- arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
617
- }
618
- );
615
+ await this.updateStreamMetadata(this.syncConfigMetadataUpdate(syncConfigIds, { snapshot_done: false }), {
616
+ 'sync_configs._id': { $in: syncConfigIds }
617
+ });
619
618
  }
620
619
 
621
620
  async markTableSnapshotDone(
@@ -627,7 +626,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
627
626
  const syncConfigIds = this.relevantSyncConfigIdsForTables(tables);
628
627
 
629
628
  await this.withTransaction(async () => {
630
- await this.db.sourceTables(this.replicationStreamId).updateMany(
629
+ const writes = this.db.createWriteBatch(session, { ordered: false });
630
+ writes.updateMany(
631
+ this.db.sourceTables(this.replicationStreamId),
631
632
  { _id: { $in: ids } },
632
633
  {
633
634
  $set: {
@@ -636,31 +637,28 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
636
637
  $unset: {
637
638
  snapshot_status: 1
638
639
  }
639
- },
640
- { session }
640
+ }
641
641
  );
642
642
 
643
643
  if (no_checkpoint_before_lsn != null && syncConfigIds.length > 0) {
644
- await this.db.sync_rules.updateOne(
644
+ writes.updateOne(
645
+ this.db.sync_rules,
645
646
  {
646
647
  _id: this.replicationStreamId,
647
648
  'sync_configs._id': { $in: syncConfigIds }
648
649
  },
649
650
  {
650
- $set: {
651
- last_keepalive_ts: new Date()
652
- },
653
651
  $max: {
654
652
  'sync_configs.$[config].no_checkpoint_before': no_checkpoint_before_lsn
655
653
  }
656
654
  },
657
655
  {
658
- session: this.session,
659
656
  // Only set for sync configs that use this table
660
657
  arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
661
658
  }
662
659
  );
663
660
  }
661
+ await writes.execute();
664
662
  });
665
663
  return tables.map((table) => {
666
664
  const copy = table.clone();
@@ -668,4 +666,98 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
668
666
  return copy;
669
667
  });
670
668
  }
669
+
670
+ protected override async batchCreateCustomWriteCheckpoints(
671
+ session: lib_mongo.mongo.ClientSession,
672
+ opId: InternalOpId
673
+ ): Promise<void> {
674
+ if (this.write_checkpoint_batch.length == 0) {
675
+ return;
676
+ }
677
+
678
+ const checkpointsByEvent = Map.groupBy(this.write_checkpoint_batch, (checkpoint) =>
679
+ this.validateCustomCheckpointEventId(checkpoint.event_id)
680
+ );
681
+
682
+ const writes = this.db.createWriteBatch(session, { ordered: false });
683
+ for (const [eventId, checkpoints] of checkpointsByEvent) {
684
+ this.batchCreateEventCustomWriteCheckpoints(writes, opId, eventId, checkpoints);
685
+ }
686
+ await writes.execute();
687
+ }
688
+
689
+ protected override async prepareCustomWriteCheckpoints(): Promise<void> {
690
+ // Most events never produce custom checkpoints, so create collections lazily
691
+ // only for the event ids present in this batch. This hook runs before the
692
+ // replication transaction because MongoDB cannot create indexes within it.
693
+ const eventIds = new Set(
694
+ this.write_checkpoint_batch.map((checkpoint) => this.validateCustomCheckpointEventId(checkpoint.event_id))
695
+ );
696
+ for (const eventId of eventIds) {
697
+ if (this.initializedCustomCheckpointEventIds.has(eventId)) {
698
+ continue;
699
+ }
700
+ await this.db.initializeCustomCheckpointRequestsCollection({
701
+ replicationStreamId: this.replicationStreamId,
702
+ eventId
703
+ });
704
+ this.initializedCustomCheckpointEventIds.add(eventId);
705
+ }
706
+ }
707
+
708
+ private validateCustomCheckpointEventId(eventId: EventDefinitionId | undefined): EventDefinitionId {
709
+ if (eventId == null) {
710
+ throw new ServiceAssertionError('V3 custom checkpoints require an event definition id');
711
+ }
712
+
713
+ if (!this.knownEventIds.has(eventId)) {
714
+ throw new ServiceAssertionError(`Unknown custom checkpoint event definition ${eventId}`);
715
+ }
716
+ return eventId;
717
+ }
718
+
719
+ private batchCreateEventCustomWriteCheckpoints(
720
+ writes: MongoWriteBatch,
721
+ opId: InternalOpId,
722
+ eventId: EventDefinitionId,
723
+ checkpoints: storage.CustomWriteCheckpointOptions[]
724
+ ): void {
725
+ // A repeated user within an event replaces the complete checkpoint state.
726
+ const uniqueCheckpoints = new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, checkpoint]));
727
+ writes.bulkWriteUnordered(
728
+ this.db.customCheckpointRequests({
729
+ eventId,
730
+ replicationStreamId: this.replicationStreamId
731
+ }),
732
+ [...uniqueCheckpoints.values()].map((checkpointOptions) => {
733
+ const set: Partial<CustomCheckpointRequestDocumentV3> = {
734
+ user_id: checkpointOptions.user_id,
735
+ checkpoint: checkpointOptions.checkpoint,
736
+ op_id: opId
737
+ };
738
+ if (checkpointOptions.checkpoint_requested_at != null) {
739
+ set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
740
+ }
741
+
742
+ return {
743
+ updateOne: {
744
+ filter: {
745
+ user_id: checkpointOptions.user_id
746
+ },
747
+ update: {
748
+ $set: set,
749
+ ...(checkpointOptions.checkpoint_requested_at == null
750
+ ? {
751
+ $unset: {
752
+ checkpoint_requested_at: 1
753
+ }
754
+ }
755
+ : {})
756
+ },
757
+ upsert: true
758
+ }
759
+ };
760
+ })
761
+ );
762
+ }
671
763
  }