@powersync/service-module-mongodb-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (275) hide show
  1. package/CHANGELOG.md +177 -5
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +71 -18
  6. package/dist/storage/MongoBucketStorage.js +516 -202
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +71 -29
  9. package/dist/storage/implementation/MongoBucketBatch.js +140 -66
  10. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  11. package/dist/storage/implementation/MongoChecksums.d.ts +41 -13
  12. package/dist/storage/implementation/MongoChecksums.js +36 -121
  13. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  14. package/dist/storage/implementation/MongoCompactor.d.ts +28 -90
  15. package/dist/storage/implementation/MongoCompactor.js +87 -481
  16. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  17. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  18. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  19. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  20. package/dist/storage/implementation/MongoParameterCompactor.d.ts +82 -8
  21. package/dist/storage/implementation/MongoParameterCompactor.js +312 -76
  22. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  23. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +21 -0
  24. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +93 -0
  25. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  27. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  28. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  29. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  30. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +125 -0
  31. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  32. package/dist/storage/implementation/MongoStorageProvider.js +41 -4
  33. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +81 -37
  35. package/dist/storage/implementation/MongoSyncBucketStorage.js +159 -86
  36. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  37. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  38. package/dist/storage/implementation/MongoSyncRulesLock.js +23 -11
  39. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  40. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +11 -8
  41. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +159 -33
  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 +28 -0
  47. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  48. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  49. package/dist/storage/implementation/common/PersistedBatch.d.ts +34 -2
  50. package/dist/storage/implementation/common/PersistedBatch.js +63 -10
  51. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  52. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  53. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  54. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  55. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  56. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  57. package/dist/storage/implementation/db.d.ts +4 -1
  58. package/dist/storage/implementation/db.js +31 -16
  59. package/dist/storage/implementation/db.js.map +1 -1
  60. package/dist/storage/implementation/models.d.ts +55 -13
  61. package/dist/storage/implementation/models.js.map +1 -1
  62. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +15 -1
  63. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +135 -107
  64. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  65. package/dist/storage/implementation/v1/MongoChecksumsV1.d.ts +4 -4
  66. package/dist/storage/implementation/v1/MongoChecksumsV1.js +119 -7
  67. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  68. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +47 -7
  69. package/dist/storage/implementation/v1/MongoCompactorV1.js +478 -8
  70. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  71. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  72. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  73. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  74. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +33 -27
  75. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +146 -73
  76. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  77. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  78. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  79. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  80. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  81. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  82. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  83. package/dist/storage/implementation/v1/models.d.ts +10 -1
  84. package/dist/storage/implementation/v1/models.js +1 -0
  85. package/dist/storage/implementation/v1/models.js.map +1 -1
  86. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  87. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  88. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  89. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  90. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  91. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  92. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +16 -5
  93. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +393 -262
  94. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  95. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +30 -9
  96. package/dist/storage/implementation/v3/MongoChecksumsV3.js +161 -20
  97. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  98. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +108 -18
  99. package/dist/storage/implementation/v3/MongoCompactorV3.js +1115 -47
  100. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  101. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +12 -3
  102. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +13 -8
  103. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -1
  104. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +54 -0
  105. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +319 -0
  106. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  107. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +61 -36
  108. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +353 -161
  109. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  110. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  111. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  112. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  113. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +8 -3
  114. package/dist/storage/implementation/v3/PersistedBatchV3.js +131 -29
  115. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  116. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  117. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  118. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  119. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +26 -14
  120. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +73 -29
  121. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  122. package/dist/storage/implementation/v3/bucket-format.d.ts +13 -0
  123. package/dist/storage/implementation/v3/bucket-format.js +79 -0
  124. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  125. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  126. package/dist/storage/implementation/v3/chunking.js +30 -0
  127. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  128. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  129. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  130. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  131. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  132. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  133. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  134. package/dist/storage/implementation/v3/models.d.ts +187 -23
  135. package/dist/storage/implementation/v3/models.js +11 -28
  136. package/dist/storage/implementation/v3/models.js.map +1 -1
  137. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +20 -0
  138. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +48 -0
  139. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  140. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +33 -0
  141. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  142. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  143. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +68 -0
  144. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +154 -0
  145. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  146. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  147. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  148. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  149. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +67 -0
  150. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +439 -0
  151. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  152. package/dist/storage/implementation/v3/source-table-utils.d.ts +63 -0
  153. package/dist/storage/implementation/v3/source-table-utils.js +206 -0
  154. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  155. package/dist/storage/storage-index.d.ts +5 -4
  156. package/dist/storage/storage-index.js +5 -4
  157. package/dist/storage/storage-index.js.map +1 -1
  158. package/dist/types/types.d.ts +71 -3
  159. package/dist/types/types.js +58 -2
  160. package/dist/types/types.js.map +1 -1
  161. package/dist/utils/test-utils.d.ts +1 -2
  162. package/dist/utils/test-utils.js +7 -1
  163. package/dist/utils/test-utils.js.map +1 -1
  164. package/dist/utils/util.d.ts +9 -2
  165. package/dist/utils/util.js +131 -5
  166. package/dist/utils/util.js.map +1 -1
  167. package/package.json +13 -19
  168. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  169. package/src/storage/MongoBucketStorage.ts +728 -242
  170. package/src/storage/implementation/MongoBucketBatch.ts +196 -91
  171. package/src/storage/implementation/MongoChecksums.ts +93 -153
  172. package/src/storage/implementation/MongoCompactor.ts +106 -631
  173. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  174. package/src/storage/implementation/MongoParameterCompactor.ts +392 -87
  175. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +122 -0
  176. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  177. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +156 -0
  178. package/src/storage/implementation/MongoStorageProvider.ts +48 -4
  179. package/src/storage/implementation/MongoSyncBucketStorage.ts +226 -118
  180. package/src/storage/implementation/MongoSyncRulesLock.ts +28 -12
  181. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +194 -52
  182. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  183. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +31 -0
  184. package/src/storage/implementation/common/PersistedBatch.ts +83 -15
  185. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  186. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  187. package/src/storage/implementation/db.ts +40 -22
  188. package/src/storage/implementation/models.ts +58 -14
  189. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +174 -125
  190. package/src/storage/implementation/v1/MongoChecksumsV1.ts +154 -12
  191. package/src/storage/implementation/v1/MongoCompactorV1.ts +594 -25
  192. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  193. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +226 -129
  194. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  195. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  196. package/src/storage/implementation/v1/models.ts +12 -1
  197. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  198. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  199. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +502 -324
  200. package/src/storage/implementation/v3/MongoChecksumsV3.ts +250 -32
  201. package/src/storage/implementation/v3/MongoCompactorV3.ts +1439 -70
  202. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +19 -9
  203. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +473 -0
  204. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +453 -211
  205. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  206. package/src/storage/implementation/v3/PersistedBatchV3.ts +169 -45
  207. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  208. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +99 -38
  209. package/src/storage/implementation/v3/bucket-format.ts +99 -0
  210. package/src/storage/implementation/v3/chunking.ts +40 -0
  211. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  212. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  213. package/src/storage/implementation/v3/models.ts +228 -58
  214. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +62 -0
  215. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +44 -0
  216. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +182 -0
  217. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  218. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +573 -0
  219. package/src/storage/implementation/v3/source-table-utils.ts +398 -0
  220. package/src/storage/storage-index.ts +14 -4
  221. package/src/types/types.ts +76 -2
  222. package/src/utils/test-utils.ts +8 -3
  223. package/src/utils/util.ts +188 -7
  224. package/test/src/__snapshots__/storage.test.ts.snap +4 -54
  225. package/test/src/__snapshots__/storage_sync.test.ts.snap +652 -645
  226. package/test/src/cleanup-stopped-sync-configs.test.ts +698 -0
  227. package/test/src/compact-utils.test.ts +226 -0
  228. package/test/src/helpers/MemoryObjectStorage.ts +61 -0
  229. package/test/src/helpers/s3TestFactory.ts +69 -0
  230. package/test/src/object_storage_lifecycle.test.ts +59 -0
  231. package/test/src/object_storage_usage.test.ts +255 -0
  232. package/test/src/parameter_compacting_v1.test.ts +120 -0
  233. package/test/src/parameter_compacting_v3.test.ts +192 -0
  234. package/test/src/parameter_compaction_fence.test.ts +276 -0
  235. package/test/src/setup.ts +6 -1
  236. package/test/src/storage.test.ts +478 -13
  237. package/test/src/storage_compacting.test.ts +2516 -107
  238. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  239. package/test/src/storage_s3_checksums.test.ts +190 -0
  240. package/test/src/storage_s3_compaction_lifecycle.test.ts +543 -0
  241. package/test/src/storage_s3_reading.test.ts +768 -0
  242. package/test/src/storage_s3_writing.test.ts +136 -0
  243. package/test/src/storage_sync.test.ts +1507 -76
  244. package/test/src/storeCurrentData.test.ts +227 -0
  245. package/test/src/util.ts +3 -2
  246. package/tsconfig.json +1 -4
  247. package/tsconfig.tsbuildinfo +1 -1
  248. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  249. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  250. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  251. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  252. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  253. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  254. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  255. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  256. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  257. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  258. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  259. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  260. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  261. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  262. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  263. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  264. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  265. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  266. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  267. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -48
  268. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  269. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  270. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  271. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  272. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  273. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  274. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
  275. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -68
@@ -1,43 +1,58 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
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';
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';
5
5
  import * as bson from 'bson';
6
6
  import { mongoTableId } from '../../../utils/util.js';
7
- import { calculateCheckpointState } from '../CheckpointState.js';
7
+ import { canCheckpointState } from '../CheckpointState.js';
8
8
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
9
- import { syncRuleStateUpdatePipeline } from '../SyncRuleStateUpdate.js';
9
+ import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
10
+ import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
10
11
  import { PersistedBatch } from '../common/PersistedBatch.js';
11
12
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
12
13
  import { PersistedBatchV3 } from './PersistedBatchV3.js';
13
14
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
14
15
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
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 { CustomCheckpointRequestDocumentV3, ReplicationStreamDocumentV3, SourceTableDocumentV3 } from './models.js';
17
+ import {
18
+ createNewSourceTable,
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
+ /** Avoid repeating idempotent createIndexes calls for every checkpoint flush. */
31
+ private readonly initializedCustomCheckpointEventIds = new Set<EventDefinitionId>();
32
+ // Used only to validate supplied checkpoint ids; it does not initialize collections.
33
+ private readonly knownEventIds: ReadonlySet<EventDefinitionId>;
34
+ private readonly syncConfigIds: bson.ObjectId[];
26
35
  private needsActivationV3 = true;
27
36
  private lastWaitingLogThrottledV3 = 0;
28
37
 
29
38
  constructor(options: MongoBucketBatchOptions) {
30
39
  super(options);
31
- if (options.syncConfigId == null) {
40
+ const syncConfigIds = options.syncConfigIds ?? [];
41
+ if (syncConfigIds.length == 0) {
32
42
  throw new ReplicationAssertionError('Missing sync config id for v3 batch');
33
43
  }
34
- this.syncConfigId = options.syncConfigId;
35
- this.store = new SourceRecordStoreV3(this.db, this.group_id, this.mapping);
44
+ this.syncConfigIds = syncConfigIds;
45
+ this.knownEventIds = new Set(this.mapping.allEventDefinitionIds());
46
+ this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
36
47
  }
37
48
 
38
49
  protected createPersistedBatch(writtenSize: number): PersistedBatch {
39
- return new PersistedBatchV3(this.db, this.group_id, this.mapping, writtenSize, {
40
- logger: this.logger
50
+ return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
51
+ logger: this.logger,
52
+ objectStorage: this.options.objectStorage,
53
+ inlineThresholdBytes: this.options.inlineThresholdBytes,
54
+ signal: this.options.signal,
55
+ objectStorageUsageWriterId: this.objectStorageUsageWriterId
41
56
  });
42
57
  }
43
58
 
@@ -45,13 +60,87 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
45
60
  return this.store;
46
61
  }
47
62
 
63
+ protected override async onReplicationTransactionFlush(
64
+ session: lib_mongo.mongo.ClientSession,
65
+ lastOp: bigint
66
+ ): Promise<void> {
67
+ // Durably advance the stream-level head of persisted ops within the flush transaction.
68
+ // This ensures a checkpoint created later (even by an empty commit, or by a freshly-appended
69
+ // config that replicates nothing) covers all ops persisted before a potential crash.
70
+ await this.db.sync_rules.updateOne(
71
+ {
72
+ _id: this.replicationStreamId
73
+ },
74
+ {
75
+ $max: {
76
+ last_persisted_op: lastOp
77
+ }
78
+ },
79
+ { session }
80
+ );
81
+ }
82
+
83
+ private selectedSyncConfigObjectIds(syncConfigIds: string[]): bson.ObjectId[] {
84
+ const selectedIds = new Set(this.syncConfigIds.map((id) => id.toHexString()));
85
+ return syncConfigIds
86
+ .filter((id) => selectedIds.has(id) && bson.ObjectId.isValid(id))
87
+ .map((id) => new bson.ObjectId(id));
88
+ }
89
+
90
+ private relevantSyncConfigIds(table: storage.SourceTable): bson.ObjectId[] {
91
+ const bucketDataSourceIds = table.bucketDataSources.map((source) => this.mapping.bucketSourceId(source));
92
+ const parameterLookupSourceIds = table.parameterLookupSources.map((source) =>
93
+ this.mapping.parameterLookupId(source)
94
+ );
95
+ return this.selectedSyncConfigObjectIds(
96
+ this.mapping.syncConfigIdsForSourceTable(
97
+ this.syncConfigIds.map((id) => id.toHexString()),
98
+ table.ref,
99
+ bucketDataSourceIds,
100
+ parameterLookupSourceIds,
101
+ [...table.eventDefinitionIds!]
102
+ )
103
+ );
104
+ }
105
+
106
+ private relevantSyncConfigIdsForTables(tables: storage.SourceTable[]): bson.ObjectId[] {
107
+ const ids = new Map<string, bson.ObjectId>();
108
+ for (const table of tables) {
109
+ for (const id of this.relevantSyncConfigIds(table)) {
110
+ ids.set(id.toHexString(), id);
111
+ }
112
+ }
113
+ return [...ids.values()];
114
+ }
115
+
116
+ private snapshotBlockingSourceTablesFilter(): Record<string, unknown> {
117
+ const clauses = this.syncConfigIds.flatMap((syncConfigId) => {
118
+ const filter = this.mapping.snapshotBlockingSourceTablesFilter(syncConfigId.toHexString()) as {
119
+ $or?: Record<string, unknown>[];
120
+ };
121
+ return filter.$or ?? [];
122
+ });
123
+
124
+ if (clauses.length == 0) {
125
+ return {
126
+ snapshot_done: false,
127
+ _id: { $exists: false }
128
+ };
129
+ }
130
+
131
+ return {
132
+ snapshot_done: false,
133
+ $or: clauses
134
+ };
135
+ }
136
+
48
137
  protected async cleanupDroppedSourceTables(sourceTables: storage.SourceTable[]) {
49
138
  for (const table of sourceTables) {
50
139
  await this.db
51
- .sourceRecordsV3(this.group_id, mongoTableId(table.id))
140
+ .sourceRecords(this.replicationStreamId, mongoTableId(table.id))
52
141
  .drop()
53
142
  .catch((error) => {
54
- if (lib_mongo.isMongoServerError(error) && error.codeName === 'NamespaceNotFound') {
143
+ if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
55
144
  return;
56
145
  }
57
146
  throw error;
@@ -60,235 +149,120 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
60
149
  }
61
150
 
62
151
  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);
152
+ // The test-only override is a whole parsed set, so the sync rules and the mapping
153
+ // used below always come from the same parse.
154
+ const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
155
+ const syncConfig = parsedOverride?.hydratedSyncConfig ?? this.sync_rules;
156
+ const mapping = parsedOverride?.mapping ?? this.mapping;
157
+ const eventById = (parsedOverride ?? this.options.parsedSyncConfig).eventById;
66
158
 
67
159
  const { connection_id, source } = options;
68
- const { schema, name, objectId, replicaIdColumns, connectionTag } = source;
69
- const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
70
- name: column.name,
71
- type: column.type,
72
- type_oid: column.typeId
73
- }));
160
+ const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
161
+ const identity = {
162
+ schema: source.schema,
163
+ name: source.name,
164
+ objectId: source.objectId,
165
+ replicaIdColumns: source.replicaIdColumns.map((column) => ({
166
+ name: column.name,
167
+ type: column.type,
168
+ type_oid: column.typeId
169
+ }))
170
+ };
74
171
 
75
172
  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
- }
173
+ const session = this.db.client.startSession();
174
+ await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
89
175
 
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
- }
176
+ await session.withTransaction(async () => {
177
+ const col = this.db.sourceTables(this.replicationStreamId);
122
178
 
123
- if (
124
- !sameStringArray(doc.bucket_data_source_ids, bucketDataSourceIds) ||
125
- !sameStringArray(doc.parameter_lookup_source_ids, parameterLookupSourceIds)
126
- ) {
127
- await col.updateOne(
128
- { _id: doc._id },
129
- {
130
- $set: {
131
- bucket_data_source_ids: bucketDataSourceIds,
132
- parameter_lookup_source_ids: parameterLookupSourceIds
133
- }
134
- },
135
- { session }
136
- );
137
- }
179
+ // Find records that overlap by name or relation id.
180
+ const candidateDocs = await col
181
+ .find(overlappingSourceTableFilter(connection_id, identity), { session })
182
+ .toArray();
138
183
 
139
- if (coversDesiredMembership || coversEventOnlyTable) {
140
- if (coversEventOnlyTable) {
141
- retainedEventOnlyTable = true;
142
- }
143
- retainedDocIds.push(doc._id);
144
- tables.push(
145
- this.sourceTableFromDocument(
146
- {
147
- ...doc,
148
- bucket_data_source_ids: bucketDataSourceIds,
149
- parameter_lookup_source_ids: parameterLookupSourceIds
150
- },
151
- connectionTag,
152
- syncRules,
153
- {
154
- bucketDataSources: bucketDataSourceIds.map((id) => bucketSourceById.get(id)!),
155
- parameterLookupSources: parameterLookupSourceIds.map((id) => parameterLookupSourceById.get(id)!)
156
- }
157
- )
158
- );
159
- }
160
- }
184
+ const candidateTables = candidateDocs.map((doc) =>
185
+ sourceTableFromDocument(doc, source.connectionTag, syncConfig, mapping, eventById)
186
+ );
187
+ const candidates = candidateTables.map((table) => table.clone());
188
+ const resolution = await reconcile({ source, candidates });
189
+ storage.validateSourceTableCandidateResolution(candidates, resolution);
161
190
 
162
- const uncoveredBucketIds = [...desiredBucketIds].filter((id) => !coveredBucketIds.has(id));
163
- const uncoveredLookupIds = [...desiredLookupIds].filter((id) => !coveredLookupIds.has(id));
191
+ // Persist metadata from the reconciler without mutating the queried documents.
192
+ for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
193
+ await col.updateOne({ _id: mongoTableId(id) }, { $set: { source_metadata: sourceMetadata } }, { session });
194
+ }
164
195
 
165
- if (uncoveredBucketIds.length > 0 || uncoveredLookupIds.length > 0 || (triggersEvent && tables.length == 0)) {
166
- const id = options.idGenerator ? (options.idGenerator() as bson.ObjectId) : new bson.ObjectId();
167
- const sourceTable = new storage.SourceTable({
168
- id,
169
- ref,
170
- objectId,
171
- replicaIdColumns,
172
- snapshotComplete: false,
173
- bucketDataSources: uncoveredBucketIds.map((id) => bucketSourceById.get(id)!),
174
- parameterLookupSources: uncoveredLookupIds.map((id) => parameterLookupSourceById.get(id)!)
175
- });
176
- sourceTable.syncData = uncoveredBucketIds.length > 0;
177
- sourceTable.syncParameters = uncoveredLookupIds.length > 0;
178
- sourceTable.syncEvent = triggersEvent;
179
-
180
- const createDoc: SourceTableDocumentV3 = {
181
- _id: id,
182
- connection_id,
183
- relation_id: objectId,
184
- schema_name: schema,
185
- table_name: name,
186
- replica_id_columns: null,
187
- replica_id_columns2: normalizedReplicaIdColumns,
188
- snapshot_done: false,
189
- snapshot_status: undefined,
190
- bucket_data_source_ids: uncoveredBucketIds,
191
- parameter_lookup_source_ids: uncoveredLookupIds
192
- };
196
+ const context: SourceTableReconciliationContext = {
197
+ connectionId: connection_id,
198
+ connectionTag: source.connectionTag,
199
+ identity,
200
+ storeCurrentData: source.sendsCompleteRows !== true,
201
+ syncConfig,
202
+ mapping,
203
+ desired: sourceTableDesiredResolution(syncConfig, source, mapping, eventById),
204
+ sourceCompatibleTables: resolution.compatibleTables,
205
+ newTableSourceMetadata: resolution.newTableValues.sourceMetadata
206
+ };
193
207
 
194
- await col.insertOne(createDoc, { session });
195
- initializeSourceRecordsFor.push(createDoc._id);
196
- retainedDocIds.push(createDoc._id);
197
- tables.push(sourceTable);
198
- }
208
+ // Plan record reuse, membership changes, creation, and removal.
209
+ const plan = planSourceTableReconciliation(candidateDocs, context);
199
210
 
200
- const conflictFilter = [{ schema_name: schema, table_name: name }] as Record<string, unknown>[];
201
- if (objectId != null) {
202
- conflictFilter.push({ relation_id: objectId });
203
- }
204
- const dropTables = await col
205
- .find(
211
+ // Persist narrowing for incomplete snapshots only. Snapshot-complete docs keep stale
212
+ // coverage ids so compatible future configs can reuse already-snapshotted data.
213
+ // Narrowing occurs after removing a sync config, meaning we don't process those
214
+ // definitions anymore.
215
+ for (const update of plan.narrowingUpdates) {
216
+ await col.updateOne(
217
+ { _id: update.id },
206
218
  {
207
- connection_id,
208
- _id: { $nin: retainedDocIds },
209
- $or: conflictFilter
219
+ $set: {
220
+ bucket_data_source_ids: update.memberships.bucketDataSourceIds,
221
+ parameter_lookup_source_ids: update.memberships.parameterLookupSourceIds,
222
+ event_definition_ids: update.memberships.eventDefinitionIds
223
+ }
210
224
  },
211
225
  { session }
212
- )
213
- .toArray();
226
+ );
227
+ }
228
+
229
+ // Any desired membership not covered by an existing doc gets a new source table.
230
+ // That table snapshots only the uncovered memberships.
231
+ if (plan.newTableMemberships != null) {
232
+ const id = options.idGenerator ? (options.idGenerator() as bson.ObjectId) : new bson.ObjectId();
233
+ const { doc, table } = createNewSourceTable(id, plan.newTableMemberships, context);
234
+
235
+ await col.insertOne(doc, { session });
236
+ await this.db.initializeSourceRecordsCollection(this.replicationStreamId, doc._id, session);
237
+ plan.tables.push(table);
238
+ }
214
239
 
215
240
  result = {
216
- tables,
217
- dropTables: dropTables.map((doc) =>
218
- this.sourceTableFromDocument(doc as SourceTableDocumentV3, connectionTag, syncRules)
241
+ tables: plan.tables,
242
+ dropTables: plan.dropDocs.map((doc) =>
243
+ sourceTableFromDocument(doc, context.connectionTag, syncConfig, mapping, eventById)
219
244
  )
220
245
  };
221
246
  });
222
247
 
223
- for (const sourceTableId of initializeSourceRecordsFor) {
224
- await this.db.initializeSourceRecordsCollection(this.group_id, sourceTableId);
225
- }
226
-
227
248
  return result!;
228
249
  }
229
250
 
230
- private sourceTableFromDocument(
231
- doc: SourceTableDocumentV3,
232
- connectionTag: string,
233
- syncRules: HydratedSyncConfig,
234
- memberships?: MatchingSources
235
- ): storage.SourceTable {
236
- const resolvedMemberships = memberships ?? this.sourceTableMembershipsFromDocument(doc, syncRules);
237
- const table = new storage.SourceTable({
238
- id: doc._id,
239
- ref: {
240
- connectionTag,
241
- schema: doc.schema_name,
242
- name: doc.table_name
243
- },
244
- objectId: doc.relation_id,
245
- replicaIdColumns: doc.replica_id_columns2!.map(
246
- (c) => ({ name: c.name, typeId: c.type_oid, type: c.type }) satisfies ColumnDescriptor
247
- ),
248
- snapshotComplete: doc.snapshot_done ?? true,
249
- bucketDataSources: resolvedMemberships.bucketDataSources,
250
- parameterLookupSources: resolvedMemberships.parameterLookupSources
251
- });
252
- table.syncData = table.bucketDataSources.length > 0;
253
- table.syncParameters = table.parameterLookupSources.length > 0;
254
- table.syncEvent = syncRules.tableTriggersEvent(table.ref);
255
- table.snapshotStatus =
256
- doc.snapshot_status == null
257
- ? undefined
258
- : {
259
- lastKey: doc.snapshot_status.last_key?.buffer ?? null,
260
- totalEstimatedCount: doc.snapshot_status.total_estimated_count,
261
- replicatedCount: doc.snapshot_status.replicated_count
262
- };
263
- return table;
264
- }
265
-
266
- private sourceTableMembershipsFromDocument(
267
- doc: SourceTableDocumentV3,
268
- syncRules: HydratedSyncConfig
269
- ): MatchingSources {
270
- const bucketDataSourceIds = new Set(doc.bucket_data_source_ids);
271
- const parameterLookupSourceIds = new Set(doc.parameter_lookup_source_ids);
272
-
273
- return {
274
- bucketDataSources: syncRules.bucketDataSources.filter((source) =>
275
- bucketDataSourceIds.has(this.mapping.bucketSourceId(source))
276
- ),
277
- parameterLookupSources: syncRules.bucketParameterLookupSources.filter((source) =>
278
- parameterLookupSourceIds.has(this.mapping.parameterLookupId(source))
279
- )
280
- };
281
- }
282
-
283
251
  async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
284
252
  const doc = (await this.db
285
- .commonSourceTables(this.group_id)
253
+ .sourceTables(this.replicationStreamId)
286
254
  .findOne({ _id: mongoTableId(table.id) }, { session: this.session })) as SourceTableDocumentV3 | null;
287
255
  if (doc == null) {
288
256
  return null;
289
257
  }
290
258
 
291
- return this.sourceTableFromDocument(doc, table.ref.connectionTag, this.sync_rules);
259
+ return sourceTableFromDocument(
260
+ doc,
261
+ table.ref.connectionTag,
262
+ this.sync_rules,
263
+ this.mapping,
264
+ this.options.parsedSyncConfig.eventById
265
+ );
292
266
  }
293
267
 
294
268
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
@@ -296,6 +270,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
296
270
 
297
271
  await this.flush(options);
298
272
 
273
+ using _ = this.tracer.span('storage', 'commit');
274
+
299
275
  const now = new Date();
300
276
 
301
277
  await this.db.write_checkpoints.updateMany(
@@ -313,97 +289,156 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
313
289
  }
314
290
  );
315
291
 
316
- const preUpdateDocument = await this.db.sync_rules.findOne(
292
+ const preUpdateDocument = (await this.db.sync_rules.findOne(
317
293
  {
318
- _id: this.group_id,
319
- 'sync_configs._id': this.syncConfigId
294
+ _id: this.replicationStreamId,
295
+ 'sync_configs._id': { $in: this.syncConfigIds }
320
296
  },
321
297
  {
322
298
  session: this.session,
323
299
  projection: {
324
- snapshot_lsn: 1,
325
- sync_configs: {
326
- $elemMatch: {
327
- _id: this.syncConfigId
328
- }
329
- }
300
+ sync_configs: 1,
301
+ last_persisted_op: 1
330
302
  }
331
303
  }
332
- );
304
+ )) as ReplicationStreamDocumentV3 | null;
333
305
 
334
- const state = (preUpdateDocument as ReplicationStreamDocumentV3)?.sync_configs?.[0];
335
- if (state == null) {
306
+ const states =
307
+ preUpdateDocument?.sync_configs?.filter((config) => this.syncConfigIds.some((id) => id.equals(config._id))) ?? [];
308
+ if (states.length == 0) {
336
309
  throw new ReplicationAssertionError(
337
- `Failed to update checkpoint - no matching sync_config for _id: ${this.group_id}/${this.syncConfigId.toHexString()}`
310
+ `Failed to update checkpoint - no matching sync_config for _id: ${this.replicationStreamId}/${this.syncConfigIds
311
+ .map((id) => id.toHexString())
312
+ .join(',')}`
338
313
  );
339
314
  }
315
+ // The replication job / MongoBucketBatch must be constructed with all replicating (PROCESSING / ACTIVE)
316
+ // sync configs in the stream, otherwise we'll get inconsistencies. Configs in other states (e.g. STOP)
317
+ // remain embedded in the document and are ignored here.
318
+ const missingSyncConfig = preUpdateDocument!.sync_configs.find(
319
+ (config) =>
320
+ [storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE].includes(config.state) &&
321
+ !this.syncConfigIds.some((id) => id.equals(config._id))
322
+ );
323
+ if (missingSyncConfig != null) {
324
+ throw new ReplicationAssertionError(`Replication job not configured for sync config ${missingSyncConfig._id}`);
325
+ }
340
326
 
341
- const checkpointState = calculateCheckpointState({
342
- lsn,
343
- snapshotDone: state.snapshot_done === true,
344
- lastCheckpointLsn: state.last_checkpoint_lsn,
345
- noCheckpointBefore: state.no_checkpoint_before,
346
- keepaliveOp: state.keepalive_op == null ? null : BigInt(state.keepalive_op),
347
- lastCheckpoint: state.last_checkpoint,
348
- persistedOp: this.persisted_op,
349
- createEmptyCheckpoints
350
- });
327
+ // Effective head of the stream's op sequence.
328
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and this
329
+ // read uses the same session, so it covers all ops persisted by this batch.
330
+ const newCheckpoint =
331
+ preUpdateDocument?.last_persisted_op == null ? 0n : BigInt(preUpdateDocument.last_persisted_op);
332
+
333
+ let checkpointBlocked = false;
334
+ let checkpointCreated = false;
335
+ let checkpointLogState: unknown = null;
336
+ const unblockedConfigIds: bson.ObjectId[] = [];
337
+
338
+ for (const state of states) {
339
+ if (state.last_checkpoint != null && state.last_checkpoint > newCheckpoint) {
340
+ // last_persisted_op is $max-advanced durably in the same transaction as every flush, and
341
+ // checkpoints are only ever created at that head, so a checkpoint past the head means the
342
+ // op sequence or the stored state is corrupt.
343
+ throw new ReplicationAssertionError(
344
+ `Invariant violation: sync config ${state._id} has last_checkpoint ${state.last_checkpoint} > stream head ${newCheckpoint}`
345
+ );
346
+ }
351
347
 
352
- const updateSet: Record<string, any> = {
353
- last_keepalive_ts: now,
354
- last_fatal_error: null,
355
- last_fatal_error_ts: null,
356
- 'sync_configs.$[config].keepalive_op': checkpointState.newKeepaliveOp,
357
- 'sync_configs.$[config].last_checkpoint': checkpointState.newLastCheckpoint
358
- };
359
- if (checkpointState.checkpointCreated) {
360
- updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
361
- updateSet['snapshot_lsn'] = null;
362
- updateSet['last_checkpoint_ts'] = now;
348
+ const canCheckpoint = canCheckpointState(lsn, {
349
+ snapshotDone: state.snapshot_done === true,
350
+ lastCheckpointLsn: state.last_checkpoint_lsn,
351
+ noCheckpointBefore: state.no_checkpoint_before
352
+ });
353
+
354
+ if (!canCheckpoint) {
355
+ checkpointBlocked = true;
356
+ // Log the first blocked config's state.
357
+ checkpointLogState ??= {
358
+ snapshot_done: state.snapshot_done,
359
+ last_checkpoint_lsn: state.last_checkpoint_lsn,
360
+ no_checkpoint_before: state.no_checkpoint_before
361
+ };
362
+ continue;
363
+ }
364
+
365
+ checkpointCreated ||= createEmptyCheckpoints || state.last_checkpoint !== newCheckpoint;
366
+ unblockedConfigIds.push(state._id);
363
367
  }
364
368
 
365
- await this.db.sync_rules.updateOne(
366
- {
367
- _id: this.group_id,
368
- 'sync_configs._id': this.syncConfigId
369
- },
370
- {
371
- $set: updateSet
372
- },
373
- {
374
- session: this.session,
375
- arrayFilters: [{ 'config._id': this.syncConfigId }]
369
+ // Every commit advances the stream's resume position: commit() flushes first, so all
370
+ // source changes up to this lsn have been persisted, even when checkpoints are blocked.
371
+ // In the future we could also advance this on flush, when the connector provides the
372
+ // current position (see setResumeLsn, which connectors may already call after flushing).
373
+ const resumeLsnUpdate = { resume_lsn: lsn };
374
+
375
+ if (unblockedConfigIds.length > 0) {
376
+ // All unblocked configs get the SAME new value, so we apply it with a single updateOne
377
+ // (single-document atomicity).
378
+ const updateSet: Record<string, any> = {
379
+ last_keepalive_ts: now,
380
+ last_fatal_error: null,
381
+ last_fatal_error_ts: null
382
+ };
383
+ // Only advance checkpoint fields when an actual (non-empty) checkpoint is created, matching
384
+ // the previous per-config / v1 behaviour.
385
+ if (checkpointCreated) {
386
+ updateSet['sync_configs.$[config].last_checkpoint'] = newCheckpoint;
387
+ updateSet['sync_configs.$[config].last_checkpoint_lsn'] = lsn;
388
+ updateSet['last_checkpoint_ts'] = now;
376
389
  }
377
- );
378
390
 
379
- if (checkpointState.checkpointBlocked) {
391
+ await this.db.sync_rules.updateOne(
392
+ {
393
+ _id: this.replicationStreamId,
394
+ 'sync_configs._id': { $in: unblockedConfigIds }
395
+ },
396
+ { $set: updateSet, $max: resumeLsnUpdate },
397
+ {
398
+ session: this.session,
399
+ arrayFilters: checkpointCreated ? [{ 'config._id': { $in: unblockedConfigIds } }] : undefined
400
+ }
401
+ );
402
+ } else {
403
+ // All selected configs are blocked - only update keepalive/error tracking and the
404
+ // resume position.
405
+ await this.db.sync_rules.updateOne(
406
+ {
407
+ _id: this.replicationStreamId
408
+ },
409
+ {
410
+ $set: {
411
+ last_keepalive_ts: now,
412
+ last_fatal_error: null,
413
+ last_fatal_error_ts: null
414
+ },
415
+ $max: resumeLsnUpdate
416
+ },
417
+ { session: this.session }
418
+ );
419
+ }
420
+
421
+ if (checkpointBlocked) {
380
422
  if (Date.now() - this.lastWaitingLogThrottledV3 > 5_000) {
381
423
  this.logger.info(
382
- `Waiting before creating checkpoint, currently at ${lsn} / ${checkpointState.newKeepaliveOp}. Current state: ${JSON.stringify(
383
- {
384
- snapshot_done: state.snapshot_done,
385
- last_checkpoint_lsn: state.last_checkpoint_lsn,
386
- no_checkpoint_before: state.no_checkpoint_before
387
- }
388
- )}`
424
+ `Waiting before creating checkpoint, currently at ${lsn}. Current state: ${JSON.stringify(checkpointLogState)}`
389
425
  );
390
426
  this.lastWaitingLogThrottledV3 = Date.now();
391
427
  }
392
428
  } else {
393
- if (checkpointState.checkpointCreated) {
394
- this.logger.debug(`Created checkpoint at ${lsn} / ${checkpointState.newLastCheckpoint}`);
429
+ if (checkpointCreated) {
430
+ this.logger.debug(`Created checkpoint at ${lsn}`);
395
431
  }
396
432
  await this.autoActivateV3(lsn);
433
+ // All configs are now checkpointed at newCheckpoint (the stream head).
434
+ await this.sourceRecordStore.postCommitCleanup(newCheckpoint, this.logger);
435
+ }
436
+ if (checkpointCreated) {
397
437
  await this.db.notifyCheckpoint();
398
- this.persisted_op = null;
399
- this.last_checkpoint_lsn = lsn;
400
- if (checkpointState.newLastCheckpoint != null) {
401
- await this.sourceRecordStore.postCommitCleanup(checkpointState.newLastCheckpoint, this.logger);
402
- }
403
438
  }
404
439
  return {
405
- checkpointBlocked: checkpointState.checkpointBlocked,
406
- checkpointCreated: checkpointState.checkpointCreated
440
+ checkpointBlocked,
441
+ checkpointCreated
407
442
  };
408
443
  }
409
444
 
@@ -412,17 +447,23 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
412
447
  }
413
448
 
414
449
  async setResumeLsn(lsn: string): Promise<void> {
450
+ using _ = this.tracer.span('storage', 'set_resume_lsn');
415
451
  await this.db.sync_rules.updateOne(
416
452
  {
417
- _id: this.group_id,
418
- 'sync_configs._id': this.syncConfigId
453
+ _id: this.replicationStreamId
419
454
  },
420
455
  {
421
456
  $set: {
422
- snapshot_lsn: lsn
457
+ resume_lsn: lsn
423
458
  }
424
459
  },
425
- { session: this.session }
460
+ {
461
+ session: this.session,
462
+ // Losing occasional resume LSN is fine. That may mean reprocessing
463
+ // some source changes in some edge cases, which is not an issue since
464
+ // changes are processed in an idempotent way.
465
+ writeConcern: { w: 1 }
466
+ }
426
467
  );
427
468
  }
428
469
 
@@ -433,36 +474,43 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
433
474
 
434
475
  const session = this.session;
435
476
  let activated = false;
477
+ let needsFutureActivationCheck = true;
436
478
  await session.withTransaction(async () => {
479
+ // Reset on transaction retries.
480
+ needsFutureActivationCheck = true;
481
+ activated = false;
482
+
437
483
  const doc = await this.db.sync_rules.findOne(
438
484
  {
439
- _id: this.group_id,
440
- 'sync_configs._id': this.syncConfigId
485
+ _id: this.replicationStreamId,
486
+ 'sync_configs._id': { $in: this.syncConfigIds }
441
487
  },
442
488
  {
443
489
  session,
444
490
  projection: {
445
491
  state: 1,
446
- sync_configs: {
447
- $elemMatch: {
448
- _id: this.syncConfigId
449
- }
450
- }
492
+ sync_configs: 1
451
493
  }
452
494
  }
453
495
  );
454
- const state = (doc as ReplicationStreamDocumentV3)?.sync_configs?.[0];
496
+ const states =
497
+ (doc as ReplicationStreamDocumentV3)?.sync_configs?.filter((config) =>
498
+ this.syncConfigIds.some((id) => id.equals(config._id))
499
+ ) ?? [];
500
+ if (doc == null || states.length == 0) {
501
+ return;
502
+ }
503
+
504
+ const processingStates = states.filter((state) => state.state == storage.SyncRuleState.PROCESSING);
455
505
  if (
456
- doc &&
457
506
  doc.state == storage.SyncRuleState.PROCESSING &&
458
- state?.state == storage.SyncRuleState.PROCESSING &&
459
- state.snapshot_done &&
460
- state.last_checkpoint != null
507
+ processingStates.length == states.length &&
508
+ states.every((state) => state.snapshot_done && state.last_checkpoint != null)
461
509
  ) {
462
510
  await this.db.sync_rules.updateOne(
463
511
  {
464
- _id: this.group_id,
465
- 'sync_configs._id': this.syncConfigId
512
+ _id: this.replicationStreamId,
513
+ 'sync_configs._id': { $in: this.syncConfigIds }
466
514
  },
467
515
  {
468
516
  $set: {
@@ -472,35 +520,64 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
472
520
  },
473
521
  {
474
522
  session,
475
- arrayFilters: [{ 'config._id': this.syncConfigId }]
523
+ arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
476
524
  }
477
525
  );
478
526
 
479
527
  await this.db.sync_rules.updateMany(
480
528
  {
481
- _id: { $ne: this.group_id },
529
+ _id: { $ne: this.replicationStreamId },
482
530
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
483
531
  },
484
- syncRuleStateUpdatePipeline(storage.SyncRuleState.STOP),
532
+ stopReplicationStreamPipeline(),
485
533
  { session }
486
534
  );
487
535
  activated = true;
488
- } else if (doc?.state != storage.SyncRuleState.PROCESSING) {
489
- this.needsActivationV3 = false;
536
+ } else if (
537
+ doc.state == storage.SyncRuleState.ACTIVE &&
538
+ processingStates.length > 0 &&
539
+ processingStates.every((state) => state.snapshot_done && state.last_checkpoint != null)
540
+ ) {
541
+ await this.db.sync_rules.updateOne(
542
+ {
543
+ _id: this.replicationStreamId,
544
+ 'sync_configs._id': { $in: processingStates.map((state) => state._id) }
545
+ },
546
+ {
547
+ $set: {
548
+ 'sync_configs.$[activeConfig].state': storage.SyncRuleState.STOP,
549
+ 'sync_configs.$[processingConfig].state': storage.SyncRuleState.ACTIVE
550
+ }
551
+ },
552
+ {
553
+ session,
554
+ arrayFilters: [
555
+ { 'activeConfig.state': storage.SyncRuleState.ACTIVE },
556
+ { 'processingConfig._id': { $in: processingStates.map((state) => state._id) } }
557
+ ]
558
+ }
559
+ );
560
+ activated = true;
561
+ } else if (doc.state != storage.SyncRuleState.PROCESSING && doc.state != storage.SyncRuleState.ACTIVE) {
562
+ needsFutureActivationCheck = false;
563
+ } else if (doc.state == storage.SyncRuleState.ACTIVE && processingStates.length == 0) {
564
+ needsFutureActivationCheck = false;
490
565
  }
491
566
  });
492
567
  if (activated) {
493
568
  this.logger.info(`Activated new replication stream at ${lsn}`);
494
569
  await this.db.notifyCheckpoint();
495
570
  this.needsActivationV3 = false;
571
+ } else if (!needsFutureActivationCheck) {
572
+ this.needsActivationV3 = false;
496
573
  }
497
574
  }
498
575
 
499
576
  async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
500
577
  await this.db.sync_rules.updateOne(
501
578
  {
502
- _id: this.group_id,
503
- 'sync_configs._id': this.syncConfigId
579
+ _id: this.replicationStreamId,
580
+ 'sync_configs._id': { $in: this.syncConfigIds }
504
581
  },
505
582
  {
506
583
  $set: {
@@ -513,7 +590,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
513
590
  },
514
591
  {
515
592
  session: this.session,
516
- arrayFilters: [{ 'config._id': this.syncConfigId }]
593
+ arrayFilters: [{ 'config._id': { $in: this.syncConfigIds } }]
517
594
  }
518
595
  );
519
596
  }
@@ -521,16 +598,18 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
521
598
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
522
599
  await this.withTransaction(async () => {
523
600
  // Protect against race conditions
524
- const count = await this.db.sourceTablesV3(this.group_id).countDocuments(
525
- {
526
- snapshot_done: false
527
- },
528
- { session: this.session }
529
- );
530
- if (count > 0) {
601
+ const blockingTables = await this.db
602
+ .sourceTables(this.replicationStreamId)
603
+ .find(this.snapshotBlockingSourceTablesFilter(), {
604
+ session: this.session,
605
+ projection: { schema_name: 1, table_name: 1 }
606
+ })
607
+ .toArray();
608
+
609
+ if (blockingTables.length > 0) {
531
610
  if (options?.throwOnConflict ?? true) {
532
611
  throw new ReplicationAssertionError(
533
- `Cannot mark snapshot done while ${count} source table${count == 1 ? '' : 's'} still require snapshotting`
612
+ `Cannot mark snapshot done while source tables still require snapshotting. Tables: ${blockingTables.map((t) => `${t.schema_name}.${t.table_name}`).join(', ')}`
534
613
  );
535
614
  } else {
536
615
  return;
@@ -541,11 +620,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
541
620
  });
542
621
  }
543
622
 
544
- async markTableSnapshotRequired(_table: storage.SourceTable): Promise<void> {
623
+ async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
624
+ const syncConfigIds = this.relevantSyncConfigIds(table);
625
+ if (syncConfigIds.length == 0) {
626
+ return;
627
+ }
628
+
545
629
  await this.db.sync_rules.updateOne(
546
630
  {
547
- _id: this.group_id,
548
- 'sync_configs._id': this.syncConfigId
631
+ _id: this.replicationStreamId,
632
+ 'sync_configs._id': { $in: syncConfigIds }
549
633
  },
550
634
  {
551
635
  $set: {
@@ -554,7 +638,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
554
638
  },
555
639
  {
556
640
  session: this.session,
557
- arrayFilters: [{ 'config._id': this.syncConfigId }]
641
+ arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
558
642
  }
559
643
  );
560
644
  }
@@ -565,9 +649,10 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
565
649
  ): Promise<storage.SourceTable[]> {
566
650
  const session = this.session;
567
651
  const ids = tables.map((table) => mongoTableId(table.id));
652
+ const syncConfigIds = this.relevantSyncConfigIdsForTables(tables);
568
653
 
569
654
  await this.withTransaction(async () => {
570
- await this.db.commonSourceTables(this.group_id).updateMany(
655
+ await this.db.sourceTables(this.replicationStreamId).updateMany(
571
656
  { _id: { $in: ids } },
572
657
  {
573
658
  $set: {
@@ -580,11 +665,11 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
580
665
  { session }
581
666
  );
582
667
 
583
- if (no_checkpoint_before_lsn != null) {
668
+ if (no_checkpoint_before_lsn != null && syncConfigIds.length > 0) {
584
669
  await this.db.sync_rules.updateOne(
585
670
  {
586
- _id: this.group_id,
587
- 'sync_configs._id': this.syncConfigId
671
+ _id: this.replicationStreamId,
672
+ 'sync_configs._id': { $in: syncConfigIds }
588
673
  },
589
674
  {
590
675
  $set: {
@@ -596,7 +681,8 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
596
681
  },
597
682
  {
598
683
  session: this.session,
599
- arrayFilters: [{ 'config._id': this.syncConfigId }]
684
+ // Only set for sync configs that use this table
685
+ arrayFilters: [{ 'config._id': { $in: syncConfigIds } }]
600
686
  }
601
687
  );
602
688
  }
@@ -607,4 +693,96 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
607
693
  return copy;
608
694
  });
609
695
  }
696
+
697
+ protected override async batchCreateCustomWriteCheckpoints(
698
+ session: lib_mongo.mongo.ClientSession,
699
+ opId: InternalOpId
700
+ ): Promise<void> {
701
+ if (this.write_checkpoint_batch.length == 0) {
702
+ return;
703
+ }
704
+
705
+ const checkpointsByEvent = Map.groupBy(this.write_checkpoint_batch, (checkpoint) =>
706
+ this.validateCustomCheckpointEventId(checkpoint.event_id)
707
+ );
708
+
709
+ for (const [eventId, checkpoints] of checkpointsByEvent) {
710
+ await this.batchCreateEventCustomWriteCheckpoints(session, opId, eventId, checkpoints);
711
+ }
712
+ }
713
+
714
+ protected override async prepareCustomWriteCheckpoints(): Promise<void> {
715
+ // Most events never produce custom checkpoints, so create collections lazily
716
+ // only for the event ids present in this batch. This hook runs before the
717
+ // replication transaction because MongoDB cannot create indexes within it.
718
+ const eventIds = new Set(
719
+ this.write_checkpoint_batch.map((checkpoint) => this.validateCustomCheckpointEventId(checkpoint.event_id))
720
+ );
721
+ for (const eventId of eventIds) {
722
+ if (this.initializedCustomCheckpointEventIds.has(eventId)) {
723
+ continue;
724
+ }
725
+ await this.db.initializeCustomCheckpointRequestsCollection({
726
+ replicationStreamId: this.replicationStreamId,
727
+ eventId
728
+ });
729
+ this.initializedCustomCheckpointEventIds.add(eventId);
730
+ }
731
+ }
732
+
733
+ private validateCustomCheckpointEventId(eventId: EventDefinitionId | undefined): EventDefinitionId {
734
+ if (eventId == null) {
735
+ throw new ServiceAssertionError('V3 custom checkpoints require an event definition id');
736
+ }
737
+
738
+ if (!this.knownEventIds.has(eventId)) {
739
+ throw new ServiceAssertionError(`Unknown custom checkpoint event definition ${eventId}`);
740
+ }
741
+ return eventId;
742
+ }
743
+
744
+ private async batchCreateEventCustomWriteCheckpoints(
745
+ session: lib_mongo.mongo.ClientSession,
746
+ opId: InternalOpId,
747
+ eventId: EventDefinitionId,
748
+ checkpoints: storage.CustomWriteCheckpointOptions[]
749
+ ): Promise<void> {
750
+ await this.db
751
+ .customCheckpointRequests({
752
+ eventId,
753
+ replicationStreamId: this.replicationStreamId
754
+ })
755
+ .bulkWrite(
756
+ checkpoints.map((checkpointOptions) => {
757
+ const set: Partial<CustomCheckpointRequestDocumentV3> = {
758
+ user_id: checkpointOptions.user_id,
759
+ checkpoint: checkpointOptions.checkpoint,
760
+ op_id: opId
761
+ };
762
+ if (checkpointOptions.checkpoint_requested_at != null) {
763
+ set.checkpoint_requested_at = checkpointOptions.checkpoint_requested_at;
764
+ }
765
+
766
+ return {
767
+ updateOne: {
768
+ filter: {
769
+ user_id: checkpointOptions.user_id
770
+ },
771
+ update: {
772
+ $set: set,
773
+ ...(checkpointOptions.checkpoint_requested_at == null
774
+ ? {
775
+ $unset: {
776
+ checkpoint_requested_at: 1
777
+ }
778
+ }
779
+ : {})
780
+ },
781
+ upsert: true
782
+ }
783
+ };
784
+ }),
785
+ { session }
786
+ );
787
+ }
610
788
  }