@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,8 +1,52 @@
1
+ import { BucketDataDoc } from '@module/storage/implementation/common/BucketDataDoc.js';
2
+ import { MongoSyncBucketStorage } from '@module/storage/implementation/createMongoSyncBucketStorage.js';
3
+ import { loadBucketDataDocument, serializeBucketData } from '@module/storage/implementation/v3/bucket-format.js';
4
+ import { chunkBucketData, DEFAULT_MAX_DOC_SIZE_BYTES } from '@module/storage/implementation/v3/chunking.js';
5
+ import { DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS } from '@module/storage/implementation/v3/compaction-constants.js';
6
+ import { CompactionLease } from '@module/storage/implementation/v3/CompactionLease.js';
7
+ import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
8
+ import { ObjectStorageError } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
1
9
  import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
2
- import { storage, SyncRulesBucketStorage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
- import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
4
- import { describe, expect, test } from 'vitest';
5
- import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
10
+ import { replicaIdToSubkey } from '@module/utils/util.js';
11
+ import { logger as defaultLogger } from '@powersync/lib-services-framework';
12
+ import {
13
+ addChecksums,
14
+ CheckpointChecksumInvalidatedError,
15
+ storage,
16
+ SyncRulesBucketStorage,
17
+ updateSyncRulesFromYaml
18
+ } from '@powersync/service-core';
19
+ import { bucketRequest, compactActive, register, test_utils } from '@powersync/service-core-tests';
20
+ import * as bson from 'bson';
21
+ import { describe, expect, test, vi } from 'vitest';
22
+ import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
23
+
24
+ function makeOp(
25
+ opId: number,
26
+ rowId: string,
27
+ data: string,
28
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
29
+ sourceTableId: bson.ObjectId,
30
+ overrides?: { op?: 'PUT' | 'REMOVE' }
31
+ ): BucketDataDoc {
32
+ const sourceKey = test_utils.rid(rowId);
33
+ return {
34
+ bucketKey: {
35
+ replicationStreamId: ctx.replicationStreamId,
36
+ definitionId: ctx.definitionId,
37
+ bucket: ctx.bucket
38
+ },
39
+ o: BigInt(opId),
40
+ op: overrides?.op ?? 'PUT',
41
+ source_table: sourceTableId,
42
+ source_key: sourceKey,
43
+ subkey: replicaIdToSubkey(sourceTableId, sourceKey),
44
+ table: 'items',
45
+ row_id: rowId,
46
+ checksum: BigInt(opId * 7),
47
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
48
+ };
49
+ }
6
50
 
7
51
  describe('Mongo Sync Bucket Storage Compact', () => {
8
52
  register.registerCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
@@ -46,49 +90,49 @@ describe('Mongo Sync Bucket Storage Compact', () => {
46
90
  return bucketStorage.getCheckpoint();
47
91
  };
48
92
 
49
- const setup = async () => {
93
+ const setup = async (storageVersion?: number) => {
50
94
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
51
95
  const syncRules = await factory.updateSyncRules(
52
- updateSyncRulesFromYaml(`
96
+ updateSyncRulesFromYaml(
97
+ `
53
98
  bucket_definitions:
54
99
  by_user:
55
100
  parameters: select request.user_id() as user_id
56
101
  data: [select * from test where owner_id = bucket.user_id]
57
- `)
102
+ `,
103
+ { storageVersion }
104
+ )
58
105
  );
59
106
  const bucketStorage = factory.getInstance(syncRules);
107
+ const syncRulesContent = syncRules.syncConfigContent[0];
60
108
  const { checkpoint } = await populate(bucketStorage, 1);
61
109
 
62
- return { bucketStorage, checkpoint, factory, syncRules };
110
+ return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
63
111
  };
64
112
 
65
- test('full compact', async () => {
113
+ test('V1 full compact with blank bucket_state', async () => {
66
114
  const { bucketStorage, checkpoint, factory, syncRules } = await setup();
67
115
  const storageDb = bucketStorage.db;
68
116
 
69
- // Simulate bucket_state from old version not being available
70
117
  if (storageDb.storageConfig.incrementalReprocessing) {
71
- // This should actually never happen on V3, but we test this anyway.
72
- // Can remove this if it causes issues in the future.
73
- await (storageDb as VersionedPowerSyncMongoV3).bucketStateV3(bucketStorage.group_id).deleteMany({});
74
- } else {
75
- await factory.db.bucket_state.deleteMany({});
118
+ return;
76
119
  }
120
+ // Simulate a V1 deployment which pre-dates bucket-state population.
121
+ await factory.db.bucket_state.deleteMany({});
77
122
 
78
- await bucketStorage.compact({
123
+ await compactActive(factory, {
79
124
  clearBatchLimit: 200,
80
125
  moveBatchLimit: 10,
81
126
  moveBatchQueryLimit: 10,
82
127
  minBucketChanges: 1,
83
128
  minChangeRatio: 0,
84
- maxOpId: checkpoint,
85
- signal: null as any
129
+ maxOpId: checkpoint
86
130
  });
87
131
 
88
132
  const users = ['u1', 'u2'];
89
133
  const userRequests = users.map((user) => bucketRequest(syncRules, `by_user["${user}"]`));
90
134
  const [u1Request, u2Request] = userRequests;
91
- const checksumAfter = await bucketStorage.getChecksums(checkpoint, userRequests);
135
+ const checksumAfter = await bucketStorage.getChecksums(test_utils.testCheckpoint(checkpoint), userRequests);
92
136
  expect(checksumAfter.get(u1Request.bucket)).toEqual({
93
137
  bucket: u1Request.bucket,
94
138
  checksum: -659469718,
@@ -101,49 +145,54 @@ bucket_definitions:
101
145
  });
102
146
  });
103
147
 
104
- test('populatePersistentChecksumCache', async () => {
148
+ test.each(TEST_STORAGE_VERSIONS)('compactInitialReplication (storage v%s)', async (storageVersion) => {
105
149
  // Populate old replication stream
106
- const { factory } = await setup();
150
+ const { factory } = await setup(storageVersion);
107
151
 
108
152
  // Now populate another replication stream (bucket definition name changed)
109
153
  const syncRules = await factory.updateSyncRules(
110
- updateSyncRulesFromYaml(`
154
+ updateSyncRulesFromYaml(
155
+ `
111
156
  bucket_definitions:
112
157
  by_user2:
113
158
  parameters: select request.user_id() as user_id
114
159
  data: [select * from test where owner_id = bucket.user_id]
115
- `)
160
+ `,
161
+ { storageVersion }
162
+ )
116
163
  );
117
164
  const bucketStorage = factory.getInstance(syncRules);
118
- const storageDb = (bucketStorage as any).db;
165
+ const syncRulesContent = syncRules.syncConfigContent[0];
119
166
 
120
167
  await populate(bucketStorage, 2);
121
168
  const { checkpoint } = await bucketStorage.getCheckpoint();
122
169
 
123
- // Default is to small small numbers - should be a no-op
124
- const result0 = await bucketStorage.populatePersistentChecksumCache({
170
+ // V3's initial lite pass processes every bucket with no prior compact state.
171
+ // Earlier storage versions use the default minimum-change threshold.
172
+ const result0 = await bucketStorage.compactInitialReplication({
125
173
  maxOpId: checkpoint
126
174
  });
127
- expect(result0.buckets).toEqual(0);
175
+ expect(result0.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 2 : 0);
128
176
 
129
- // This should cache the checksums for the two buckets
130
- const result1 = await bucketStorage.populatePersistentChecksumCache({
177
+ // For V1/V2, lower the threshold to populate the checksum cache. V3 has
178
+ // already updated its compacted state, so another initial pass is a no-op.
179
+ const result1 = await bucketStorage.compactInitialReplication({
131
180
  maxOpId: checkpoint,
132
181
  minBucketChanges: 1
133
182
  });
134
- expect(result1.buckets).toEqual(2);
183
+ expect(result1.buckets).toEqual(storageVersion >= storage.STORAGE_VERSION_3 ? 0 : 2);
135
184
 
136
- // This should be a no-op, as the checksums are already cached
137
- const result2 = await bucketStorage.populatePersistentChecksumCache({
185
+ // Repeating it stays a no-op.
186
+ const result2 = await bucketStorage.compactInitialReplication({
138
187
  maxOpId: checkpoint,
139
188
  minBucketChanges: 1
140
189
  });
141
190
  expect(result2.buckets).toEqual(0);
142
191
 
143
192
  const users = ['u1', 'u2'];
144
- const userRequests = users.map((user) => bucketRequest(syncRules, `by_user2["${user}"]`));
193
+ const userRequests = users.map((user) => bucketRequest(syncRulesContent, `by_user2["${user}"]`));
145
194
  const [u1Request, u2Request] = userRequests;
146
- const checksumAfter = await bucketStorage.getChecksums(checkpoint, userRequests);
195
+ const checksumAfter = await bucketStorage.getChecksums(test_utils.testCheckpoint(checkpoint), userRequests);
147
196
  expect(checksumAfter.get(u1Request.bucket)).toEqual({
148
197
  bucket: u1Request.bucket,
149
198
  checksum: -659469718,
@@ -156,90 +205,2450 @@ bucket_definitions:
156
205
  });
157
206
  });
158
207
 
159
- test('dirty bucket discovery handles bigint bucket_state bytes', async () => {
160
- await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
161
- const syncRules = await factory.updateSyncRules(
162
- updateSyncRulesFromYaml(`
163
- bucket_definitions:
164
- global:
165
- data: [select * from test]
166
- `)
208
+ test('v3 initial chunk compaction includes writer-scheduled work with a custom chunk interval', async () => {
209
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
210
+ const result = await (bucketStorage as MongoSyncBucketStorage)
211
+ .createMongoCompactor({
212
+ maxOpId: checkpoint,
213
+ compactChunksOnly: true,
214
+ minCompactChunkIntervalMs: 1
215
+ })
216
+ .compact();
217
+
218
+ expect(result).toBe(2);
219
+ });
220
+
221
+ test('v3 repeated initial chunk compaction reschedules overdue full work beyond the current run', async () => {
222
+ const { bucketStorage, checkpoint } = await setup(storage.STORAGE_VERSION_3);
223
+ const firstResult = await bucketStorage.compactInitialReplication({ maxOpId: checkpoint });
224
+ expect(firstResult.buckets).toBe(2);
225
+
226
+ const bucketStateCollection = (bucketStorage.db as VersionedPowerSyncMongoV3).bucketState(
227
+ bucketStorage.replicationStreamId
167
228
  );
168
- const bucketStorage = factory.getInstance(syncRules);
229
+ await bucketStateCollection.updateMany(
230
+ {},
231
+ {
232
+ $set: {
233
+ first_uncompacted_write: new Date(0),
234
+ next_compact_check: new Date(0)
235
+ }
236
+ }
237
+ );
238
+
239
+ const [{ now }] = await (bucketStorage.db as VersionedPowerSyncMongoV3).db
240
+ .aggregate<{ now: Date }>([{ $documents: [{}] }, { $project: { _id: 0, now: '$$NOW' } }])
241
+ .toArray();
242
+ const result = await bucketStorage.compactInitialReplication({
243
+ maxOpId: checkpoint,
244
+ signal: AbortSignal.timeout(2_000)
245
+ });
246
+
247
+ expect(result.buckets).toBe(0);
248
+ const states = await bucketStateCollection.find({}).toArray();
249
+ expect(states).toHaveLength(2);
250
+ for (const state of states) {
251
+ expect(state.next_compact_check!.getTime()).toBeGreaterThan(
252
+ now.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
253
+ );
254
+ }
255
+ });
256
+
257
+ test('v3 replication writes initialize scheduled compaction state', async () => {
258
+ const { bucketStorage } = await setup();
169
259
  const storageDb = bucketStorage.db;
170
260
 
171
- // This simulates bucket_state created using bigint bytes.
172
- // This typically happens when buckets get very large (> 2GiB). We don't want to create that much
173
- // data in the tests, so we directly insert the bucket_state here.
174
- if (storageDb.storageConfig.incrementalReprocessing) {
175
- const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketStateV3(bucketStorage.group_id);
176
- await bucketStateCollection.insertOne({
177
- _id: {
178
- d: '1',
179
- b: 'global[]'
180
- },
181
- last_op: 5n,
182
- compacted_state: {
183
- op_id: 3n,
184
- count: 3,
185
- checksum: 0n,
186
- bytes: 7n
187
- },
188
- estimate_since_compact: {
189
- count: 2,
190
- bytes: 5n
191
- }
192
- });
193
- } else {
194
- await factory.db.bucket_state.insertOne({
195
- _id: {
196
- g: bucketStorage.group_id,
197
- b: 'global[]'
198
- },
199
- last_op: 5n,
200
- compacted_state: {
201
- op_id: 3n,
202
- count: 3,
203
- checksum: 0n,
204
- bytes: 7n
205
- },
206
- estimate_since_compact: {
207
- count: 2,
208
- bytes: 5n
209
- }
210
- });
261
+ if (!storageDb.storageConfig.incrementalReprocessing) {
262
+ return;
263
+ }
264
+ const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
265
+ bucketStorage.replicationStreamId
266
+ );
267
+ const states = await bucketStateCollection.find({}).toArray();
268
+ expect(states).toHaveLength(2);
269
+ for (const state of states) {
270
+ expect(state.last_op).toBeGreaterThan(0n);
271
+ expect(state.bucket_stats.count).toBe(1);
272
+ expect(state.bucket_stats.chunks).toBe(1);
273
+ expect(state.first_uncompacted_write).toBeInstanceOf(Date);
274
+ expect(state.next_compact_check).toBeInstanceOf(Date);
275
+ expect(state.compacted_state).toBeUndefined();
276
+ expect(state).not.toHaveProperty('estimate_since_compact');
277
+
278
+ const docs = await (storageDb as VersionedPowerSyncMongoV3)
279
+ .bucketData(bucketStorage.replicationStreamId, state._id.d)
280
+ .find({ '_id.b': state._id.b })
281
+ .toArray();
282
+ expect(state.bucket_stats.bytes).toBe(BigInt(docs.reduce((total, document) => total + document.size, 0)));
211
283
  }
284
+ });
285
+ });
286
+ });
212
287
 
213
- // This test uses a couple of "internal" APIs of the compactor.
214
- const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n });
288
+ describe('Mongo Sync Parameter Storage Compact', () => {
289
+ register.registerParameterCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
290
+ });
215
291
 
216
- const dirtyBuckets = compactor.dirtyBucketBatches({
217
- minBucketChanges: 1,
218
- minChangeRatio: 0.39
219
- });
220
- const firstBatch = await dirtyBuckets.next();
292
+ /**
293
+ * V3 Invariant Verification
294
+ *
295
+ * Tests in this block exercise two levels:
296
+ *
297
+ * Unit tests (no MongoDB): call serializeBucketData(), chunkBucketData(), or
298
+ * loadBucketDataDocument() directly. These use makeBucketDataDoc() with fake
299
+ * bucket keys and source table IDs.
300
+ *
301
+ * Integration tests (full MongoDB): provision V3 storage, insert pre-serialized
302
+ * documents directly into collections, trigger compaction via
303
+ * bucketStorage.compact(), then read back and verify. Surfaces exercised:
304
+ *
305
+ * - collection.insertMany (direct bucket_data writes)
306
+ * - bucketStateCollection.insertOne (so compactor discovers buckets)
307
+ * - bucketStorage.compact() (full pipeline: dirtyBucketBatches →
308
+ * compactSingleBucket → delete-all + rechunk in transaction →
309
+ * writeBucketStateUpdates)
310
+ * - collection.find with _id.b filters (verify post-compaction state)
311
+ *
312
+ * The initial write path (MongoBucketBatchV3 → flushBucketDataShared →
313
+ * chunkBucketData → serializeBucketData → bulk write) is NOT exercised here —
314
+ * that path is covered by the existing shared tests in register-compacting-tests.ts.
315
+ */
316
+ describe('V3 invariant verification', () => {
317
+ const BUCKET = 'global[]';
221
318
 
222
- expect(firstBatch.done).toBe(false);
223
- expect(firstBatch.value).toHaveLength(1);
224
- expect(firstBatch.value[0].bucket).toBe('global[]');
225
- expect(firstBatch.value[0].estimatedCount).toBe(5);
226
- expect(typeof firstBatch.value[0].estimatedCount).toBe('number');
227
- expect(firstBatch.value[0].dirtyRatio).toBeCloseTo(5 / 12);
319
+ function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
320
+ const sourceTable = new bson.ObjectId();
321
+ const sourceKey = 'key';
322
+ return {
323
+ bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
324
+ op: 'PUT',
325
+ source_table: sourceTable,
326
+ source_key: sourceKey,
327
+ subkey: replicaIdToSubkey(sourceTable, sourceKey),
328
+ table: 'test',
329
+ row_id: 'row1',
330
+ checksum: 1n,
331
+ data: '{"id":"row1"}',
332
+ ...overrides
333
+ };
334
+ }
228
335
 
229
- const checksumBuckets = await (compactor as any).dirtyBucketBatchForChecksums({
230
- minBucketChanges: 1
336
+ async function setupV3Storage() {
337
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
338
+ const syncRules = await factory.updateSyncRules(
339
+ updateSyncRulesFromYaml(
340
+ `
341
+ bucket_definitions:
342
+ global:
343
+ data: [SELECT id as id, description FROM items]
344
+ `,
345
+ { storageVersion: 3 }
346
+ )
347
+ );
348
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
349
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
350
+ const mapping = syncRules.syncConfigContent[0].mapping;
351
+ const definitionId = mapping.allBucketDefinitionIds()[0];
352
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
353
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
354
+ const sourceTableId = new bson.ObjectId();
355
+
356
+ const ctx = {
357
+ replicationStreamId: bucketStorage.replicationStreamId,
358
+ definitionId,
359
+ bucket: BUCKET
360
+ };
361
+
362
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
363
+ }
364
+
365
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
366
+ await collection.insertMany(docs);
367
+ }
368
+
369
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
370
+ await bucketStateCollection.insertOne({
371
+ _id: { d: definitionId, b: BUCKET },
372
+ last_op: lastOp,
373
+ next_compact_check: new Date(0),
374
+ first_uncompacted_write: new Date(0),
375
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
376
+ });
377
+ }
378
+
379
+ async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
380
+ await bucketStorage.compact({
381
+ clearBatchLimit: 200,
382
+ moveBatchLimit: 10,
383
+ moveBatchQueryLimit: 10,
384
+ maxOpId
385
+ });
386
+ }
387
+
388
+ test('a successful lease renewal clears a transient renewal error', async () => {
389
+ const { bucketStateCollection, ctx } = await setupV3Storage();
390
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 1n);
391
+ const lease = await CompactionLease.claim(
392
+ bucketStateCollection,
393
+ { _id: { d: ctx.definitionId, b: BUCKET } },
394
+ undefined,
395
+ 10 * 60 * 1000
396
+ );
397
+ expect(lease).not.toBeNull();
398
+
399
+ try {
400
+ const transientError = new Error('temporary MongoDB error');
401
+ const renew = (lease as any).renew.bind(lease);
402
+ const updateOne = vi.spyOn(bucketStateCollection, 'updateOne').mockRejectedValueOnce(transientError);
403
+ await renew().catch((error: unknown) => {
404
+ (lease as any).renewalError = error;
231
405
  });
232
- expect(checksumBuckets).toEqual([
233
- {
234
- bucket: 'global[]',
235
- definitionId: storageDb.storageConfig.incrementalReprocessing ? '1' : null,
236
- estimatedCount: 5
237
- }
238
- ]);
406
+ await expect(lease!.throwIfLost()).rejects.toBe(transientError);
407
+
408
+ updateOne.mockRestore();
409
+ await renew();
410
+ await expect(lease!.throwIfLost()).resolves.toBeUndefined();
411
+ } finally {
412
+ await lease?.[Symbol.asyncDispose]();
413
+ }
414
+ });
415
+
416
+ test('a failed scheduled bucket does not block other scheduled buckets', async () => {
417
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
418
+ const badBucket = 'bad[]';
419
+ const goodBucket = 'good[]';
420
+ const goodDocument = serializeBucketData(goodBucket, [
421
+ makeOp(2, 'good', 'good', { ...ctx, bucket: goodBucket }, sourceTableId)
422
+ ]);
423
+ await insertDocs(collection, [goodDocument]);
424
+ await bucketStateCollection.insertMany([
425
+ {
426
+ _id: { d: ctx.definitionId, b: badBucket },
427
+ last_op: 2n,
428
+ next_compact_check: new Date(0),
429
+ // A malformed scheduled bucket with an expired lease must be
430
+ // rescheduled without preventing valid buckets from compacting.
431
+ first_uncompacted_write: new Date(0),
432
+ bucket_stats: { count: 1, bytes: 1n, chunks: 1 },
433
+ compact_lease: { id: new bson.ObjectId(), expires_at: new Date(0) }
434
+ },
435
+ {
436
+ _id: { d: ctx.definitionId, b: goodBucket },
437
+ last_op: 2n,
438
+ next_compact_check: new Date(0),
439
+ first_uncompacted_write: new Date(0),
440
+ bucket_stats: { count: 1, bytes: BigInt(goodDocument.size), chunks: 1 }
441
+ }
442
+ ]);
443
+ await bucketStateCollection.updateOne(
444
+ { _id: { d: ctx.definitionId, b: badBucket } },
445
+ { $unset: { first_uncompacted_write: '' } }
446
+ );
447
+
448
+ await (bucketStorage as MongoSyncBucketStorage)
449
+ .createMongoCompactor({ maxOpId: 2n, compactChunksOnly: true })
450
+ .compact();
451
+
452
+ const [badState, goodState] = await Promise.all([
453
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: badBucket } }),
454
+ bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: goodBucket } })
455
+ ]);
456
+ expect(badState?.next_compact_check).toBeInstanceOf(Date);
457
+ expect(badState!.next_compact_check!.getTime()).toBeGreaterThan(0);
458
+ expect(goodState?.compacted_state?.op_id).toBe(2n);
459
+ });
460
+
461
+ test('aborting scheduled compaction does not reschedule the remaining batch', async () => {
462
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
463
+ const buckets = ['first[]', 'second[]', 'third[]'];
464
+ const documents = buckets.map((bucket) =>
465
+ serializeBucketData(bucket, [makeOp(2, bucket, bucket, { ...ctx, bucket }, sourceTableId)])
466
+ );
467
+ await insertDocs(collection, documents);
468
+ await bucketStateCollection.insertMany(
469
+ documents.map((document, index) => ({
470
+ _id: { d: ctx.definitionId, b: buckets[index] },
471
+ last_op: 2n,
472
+ next_compact_check: new Date(0),
473
+ first_uncompacted_write: new Date(0),
474
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
475
+ }))
476
+ );
477
+
478
+ const abortController = new AbortController();
479
+ const testLogger = defaultLogger.child({});
480
+ vi.spyOn(testLogger, 'info').mockImplementation((message: unknown) => {
481
+ if (typeof message == 'string' && message.startsWith('Compacted bucket chunks ')) {
482
+ abortController.abort();
483
+ }
484
+ return testLogger;
485
+ });
486
+ const errorLog = vi.spyOn(testLogger, 'error');
487
+
488
+ await expect(
489
+ bucketStorage
490
+ .createMongoCompactor({
491
+ maxOpId: 2n,
492
+ compactChunksOnly: true,
493
+ signal: abortController.signal,
494
+ logger: testLogger
495
+ })
496
+ .compact()
497
+ ).rejects.toThrow();
498
+
499
+ const states = await bucketStateCollection.find({ '_id.b': { $in: buckets } }).toArray();
500
+ const completed = states.filter((state) => state.compacted_state != null);
501
+ const remaining = states.filter((state) => state.compacted_state == null);
502
+ expect(completed).toHaveLength(1);
503
+ expect(remaining).toHaveLength(2);
504
+ for (const state of remaining) {
505
+ expect(state.next_compact_check).toEqual(new Date(0));
506
+ expect(state.compact_lease).toBeUndefined();
507
+ }
508
+ expect(errorLog).not.toHaveBeenCalled();
509
+ });
510
+
511
+ test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
512
+ const ops = [
513
+ makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
514
+ makeBucketDataDoc({ o: 3n, data: '{"id":"a"}' }),
515
+ makeBucketDataDoc({ o: 7n, data: '{"id":"b"}' })
516
+ ];
517
+
518
+ const doc = serializeBucketData('test[]', ops);
519
+
520
+ expect(doc.ops!.map((op) => op.o)).toEqual([5n, 3n, 7n]);
521
+ });
522
+
523
+ test('1. ops[] ordering - preserved after compaction', async () => {
524
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
525
+ const ops = [
526
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
527
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
528
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
529
+ ];
530
+ const doc1 = serializeBucketData(BUCKET, ops);
531
+ await insertDocs(collection, [doc1]);
532
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
533
+
534
+ await compact(bucketStorage, 30n);
535
+
536
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
537
+ for (const doc of docs) {
538
+ for (let i = 1; i < doc.ops!.length; i++) {
539
+ expect(doc.ops![i].o).toBeGreaterThanOrEqual(doc.ops![i - 1].o);
540
+ }
541
+ }
542
+ });
543
+
544
+ test('2. range metadata consistency - serializeBucketData fields', () => {
545
+ const ops = [
546
+ makeBucketDataDoc({ o: 3n, checksum: 10n, data: 'aaaa' }),
547
+ makeBucketDataDoc({ o: 5n, checksum: 20n, data: 'bbbbb' }),
548
+ makeBucketDataDoc({ o: 8n, checksum: 30n, data: 'cccccccc' })
549
+ ];
550
+
551
+ const doc = serializeBucketData('test[]', ops);
552
+
553
+ expect(doc._id.o).toBe(8n);
554
+ expect(doc.min_op).toBe(3n);
555
+ expect(doc.count).toBe(3);
556
+ expect(doc.checksum).toBe(60n);
557
+ expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
558
+ });
559
+
560
+ test('2. has_clear_op is only stored when true', () => {
561
+ const putDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 1n })]);
562
+ const clearDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 2n, op: 'CLEAR', data: null })]);
563
+
564
+ expect(putDoc).not.toHaveProperty('has_clear_op');
565
+ expect(clearDoc.has_clear_op).toBe(true);
566
+ });
567
+
568
+ test('2. range metadata consistency - after compaction', async () => {
569
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
570
+ const ops = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
571
+ const doc1 = serializeBucketData(BUCKET, ops);
572
+ await insertDocs(collection, [doc1]);
573
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
574
+
575
+ await compact(bucketStorage, 20n);
576
+
577
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
578
+ for (const doc of docs) {
579
+ expect(doc._id.o).toBe(doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n));
580
+ expect(doc.min_op).toBe(doc.ops!.reduce((min, op) => (op.o < min ? op.o : min), doc.ops![0].o));
581
+ expect(doc.count).toBe(doc.ops!.length);
582
+ expect(doc.checksum).toBe(doc.ops!.reduce((sum, op) => sum + op.checksum, 0n));
583
+ expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
584
+ }
585
+ });
586
+
587
+ test('4. no overlapping ranges - multiple documents', () => {
588
+ const opsA = [makeBucketDataDoc({ o: 1n }), makeBucketDataDoc({ o: 3n })];
589
+ const opsB = [makeBucketDataDoc({ o: 5n }), makeBucketDataDoc({ o: 8n })];
590
+ const opsC = [makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 15n })];
591
+
592
+ const docA = serializeBucketData('test[]', opsA);
593
+ const docB = serializeBucketData('test[]', opsB);
594
+ const docC = serializeBucketData('test[]', opsC);
595
+
596
+ const docs = [docA, docB, docC];
597
+
598
+ for (let i = 0; i < docs.length; i++) {
599
+ for (let j = i + 1; j < docs.length; j++) {
600
+ const a = docs[i];
601
+ const b = docs[j];
602
+ const noOverlap = a.min_op > b._id.o || b.min_op > a._id.o;
603
+ expect(noOverlap).toBe(true);
604
+ }
605
+ }
606
+ });
607
+
608
+ test('4. no overlapping ranges - after compaction', async () => {
609
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
610
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
611
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
612
+ const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'E', 'e1', ctx, sourceTableId)];
613
+ const doc1 = serializeBucketData(BUCKET, ops1);
614
+ const doc2 = serializeBucketData(BUCKET, ops2);
615
+ const doc3 = serializeBucketData(BUCKET, ops3);
616
+ await insertDocs(collection, [doc1, doc2, doc3]);
617
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
618
+
619
+ await compact(bucketStorage, 60n);
620
+
621
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
622
+ for (let i = 0; i < docs.length; i++) {
623
+ for (let j = i + 1; j < docs.length; j++) {
624
+ const a = docs[i];
625
+ const b = docs[j];
626
+ const noOverlap = a.min_op > b._id.o || b.min_op > a._id.o;
627
+ expect(noOverlap).toBe(true);
628
+ }
629
+ }
630
+ });
631
+
632
+ test('6. compaction survivor integrity - superseded ops become MOVE tombstones', async () => {
633
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
634
+ const ops = [
635
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
636
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
637
+ makeOp(30, 'A', 'a2', ctx, sourceTableId)
638
+ ];
639
+ const doc1 = serializeBucketData(BUCKET, ops);
640
+ await insertDocs(collection, [doc1]);
641
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
642
+
643
+ await compact(bucketStorage, 30n);
644
+
645
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
646
+ const allOps = docs.flatMap((d) => d.ops!);
647
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
648
+ expect(moveOps.length).toBe(1);
649
+ expect(moveOps[0].o).toBe(10n);
650
+ expect(moveOps[0].checksum).toBe(ops[0].checksum);
651
+ expect(moveOps[0].data).toBeNull();
652
+ const putOps = allOps.filter((op) => op.op === 'PUT');
653
+ expect(putOps.length).toBe(2);
654
+ expect(docs[0].target_op).toBe(30n);
655
+ });
656
+
657
+ test('6. compaction survivor integrity - MOVE ops preserved', async () => {
658
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
659
+ const ops = [
660
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
661
+ { ...makeOp(20, 'A', 'a2', ctx, sourceTableId), op: 'MOVE' as const, data: null },
662
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
663
+ ];
664
+ const doc1 = serializeBucketData(BUCKET, ops);
665
+ await insertDocs(collection, [doc1]);
666
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
667
+
668
+ await compact(bucketStorage, 30n);
669
+
670
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
671
+ const allOps = docs.flatMap((d) => d.ops!);
672
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
673
+ // Pre-existing MOVE@20 + new MOVE@10 are collapsed into CLEAR
674
+ expect(moveOps.length).toBe(0);
675
+ const clearOps = allOps.filter((op) => op.op === 'CLEAR');
676
+ expect(clearOps.length).toBe(1);
677
+ expect(clearOps[0].o).toBe(20n);
678
+ const putOps = allOps.filter((op) => op.op === 'PUT');
679
+ expect(putOps.length).toBe(1);
680
+ expect(putOps[0].o).toBe(30n);
681
+ });
682
+
683
+ test('6. compaction survivor integrity - CLEAR ops preserved', async () => {
684
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
685
+ const ops = [
686
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
687
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
688
+ { ...makeOp(25, 'A', '', ctx, sourceTableId), op: 'CLEAR' as const, data: null, row_id: undefined }
689
+ ];
690
+ const doc1 = serializeBucketData(BUCKET, ops);
691
+ await insertDocs(collection, [doc1]);
692
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 25n);
693
+
694
+ await compact(bucketStorage, 25n);
695
+
696
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
697
+ const allOps = docs.flatMap((d) => d.ops!);
698
+ const clearOps = allOps.filter((op) => op.op === 'CLEAR');
699
+ expect(clearOps.length).toBe(1);
700
+ });
701
+
702
+ test('7. superseded PUT becomes MOVE tombstone, REMOVE survives', async () => {
703
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
704
+ const ops = [
705
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
706
+ makeOp(20, 'A', 'a2', ctx, sourceTableId, { op: 'REMOVE' })
707
+ ];
708
+ const doc1 = serializeBucketData(BUCKET, ops);
709
+ await insertDocs(collection, [doc1]);
710
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
711
+
712
+ await compact(bucketStorage, 20n);
713
+
714
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
715
+ const allOps = docs.flatMap((d) => d.ops!);
716
+ // MOVE@10 + REMOVE@20 collapsed into CLEAR@20
717
+ const clearOps = allOps.filter((op) => op.op === 'CLEAR');
718
+ expect(clearOps.length).toBe(1);
719
+ expect(clearOps[0].o).toBe(20n);
720
+ expect(allOps.filter((op) => op.op === 'REMOVE').length).toBe(0);
721
+ expect(allOps.filter((op) => op.op === 'MOVE').length).toBe(0);
722
+ });
723
+
724
+ test('7. empty bucket compact is a no-op', async () => {
725
+ const { bucketStorage, collection } = await setupV3Storage();
726
+
727
+ await bucketStorage.compact({
728
+ clearBatchLimit: 200,
729
+ moveBatchLimit: 10,
730
+ moveBatchQueryLimit: 10,
731
+ minBucketChanges: 1,
732
+ minChangeRatio: 0,
733
+ maxOpId: 1n
734
+ });
735
+
736
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
737
+ expect(docs.length).toBe(0);
738
+ });
739
+
740
+ test('8. BSON limit safety - two large ops get split', () => {
741
+ const halfMB = 600_000;
742
+ const ops = [
743
+ makeBucketDataDoc({ o: 1n, data: 'a'.repeat(halfMB) }),
744
+ makeBucketDataDoc({ o: 2n, data: 'b'.repeat(halfMB) })
745
+ ];
746
+
747
+ const chunks = chunkBucketData(ops, DEFAULT_MAX_DOC_SIZE_BYTES);
748
+ expect(chunks.length).toBe(2);
749
+ });
750
+
751
+ test('8. BSON limit safety - single oversized op gets own chunk', () => {
752
+ const oversized = DEFAULT_MAX_DOC_SIZE_BYTES + 100_000;
753
+ const ops = [makeBucketDataDoc({ o: 1n, data: 'x'.repeat(oversized) })];
754
+
755
+ const chunks = chunkBucketData(ops, DEFAULT_MAX_DOC_SIZE_BYTES);
756
+ expect(chunks.length).toBe(1);
757
+ expect(chunks[0]).toHaveLength(1);
758
+ });
759
+
760
+ test('9. serialization fidelity - null data preserved', () => {
761
+ const ops = [makeBucketDataDoc({ o: 1n, data: null })];
762
+ const doc = serializeBucketData('test[]', ops);
763
+ expect(doc.ops![0].data).toBeNull();
764
+
765
+ const context = { replicationStreamId: 1, definitionId: '1' };
766
+ const deserialized = [...loadBucketDataDocument(context, doc)];
767
+ expect(deserialized[0].data).toBeNull();
768
+ });
769
+
770
+ test('9. serialization fidelity - empty string data preserved', () => {
771
+ const ops = [makeBucketDataDoc({ o: 1n, data: '' })];
772
+ const doc = serializeBucketData('test[]', ops);
773
+ expect(doc.ops![0].data).toBe('');
774
+
775
+ const context = { replicationStreamId: 1, definitionId: '1' };
776
+ const deserialized = [...loadBucketDataDocument(context, doc)];
777
+ expect(deserialized[0].data).toBe('');
778
+ });
779
+
780
+ test('9. serialization fidelity - unicode characters preserved', () => {
781
+ const unicodeData = '{"name":"日本語テスト","emoji":"🎉"}';
782
+ const ops = [makeBucketDataDoc({ o: 1n, data: unicodeData })];
783
+ const doc = serializeBucketData('test[]', ops);
784
+ expect(doc.ops![0].data).toBe(unicodeData);
785
+
786
+ const context = { replicationStreamId: 1, definitionId: '1' };
787
+ const deserialized = [...loadBucketDataDocument(context, doc)];
788
+ expect(deserialized[0].data).toBe(unicodeData);
789
+ });
790
+
791
+ test('10. document _id.o invariant - equals last ops[*].o (caller must sort)', () => {
792
+ const ops = [makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 25n }), makeBucketDataDoc({ o: 7n })];
793
+
794
+ const doc = serializeBucketData('test[]', ops);
795
+ expect(doc._id.o).toBe(7n);
796
+ });
797
+
798
+ test('10. document _id.o invariant - equals max when pre-sorted', () => {
799
+ const ops = [makeBucketDataDoc({ o: 3n }), makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 25n })];
800
+
801
+ const doc = serializeBucketData('test[]', ops);
802
+ const maxO = ops.reduce((max, op) => (op.o > max ? op.o : max), 0n);
803
+ expect(doc._id.o).toBe(maxO);
804
+ });
805
+
806
+ test('10. document _id.o invariant - every document after compaction', async () => {
807
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
808
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
809
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
810
+ const doc1 = serializeBucketData(BUCKET, ops1);
811
+ const doc2 = serializeBucketData(BUCKET, ops2);
812
+ await insertDocs(collection, [doc1, doc2]);
813
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
814
+
815
+ await compact(bucketStorage, 40n);
816
+
817
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
818
+ for (const doc of docs) {
819
+ const maxO = doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n);
820
+ expect(doc._id.o).toBe(maxO);
821
+ }
822
+ });
823
+
824
+ test('compaction with maxOpId filtering excludes a straddling document', async () => {
825
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
826
+ const ops = [
827
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
828
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
829
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
830
+ ];
831
+ const doc1 = serializeBucketData(BUCKET, ops);
832
+ await insertDocs(collection, [doc1]);
833
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
834
+
835
+ await compact(bucketStorage, 15n);
836
+
837
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
838
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
839
+
840
+ // The document ends above maxOpId, so the database query excludes it as a unit.
841
+ expect(allOpsAfter.length).toBe(3);
842
+ const opsBelow = allOpsAfter.filter((op) => op.o <= 15n);
843
+ expect(opsBelow.length).toBe(1);
844
+ expect(opsBelow[0].op).toBe('PUT');
845
+ const opsAbove = allOpsAfter.filter((op) => op.o > 15n);
846
+ expect(opsAbove.length).toBe(2);
847
+ expect(opsAbove.every((op) => op.op === 'PUT')).toBe(true);
848
+
849
+ // Checksum preserved
850
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
851
+ expect(checksumAfter).toBe(Number(doc1.checksum));
852
+ });
853
+
854
+ test('checksum consistency - aggregation pipeline matches JavaScript addChecksums', async () => {
855
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
856
+ const ops = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
857
+ const doc1 = serializeBucketData(BUCKET, ops);
858
+ await insertDocs(collection, [doc1]);
859
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
860
+
861
+ const allOps = ops;
862
+ let jsChecksum = 0;
863
+ for (const op of allOps) {
864
+ jsChecksum = addChecksums(jsChecksum, Number(op.checksum));
865
+ }
866
+
867
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
868
+ const storedOps = docs.flatMap((d) => d.ops!);
869
+ let storedChecksum = 0;
870
+ for (const op of storedOps) {
871
+ storedChecksum = addChecksums(storedChecksum, Number(op.checksum));
872
+ }
873
+
874
+ expect(storedChecksum).toBe(jsChecksum);
875
+ });
876
+ });
877
+
878
+ describe('V3 checksum pipeline straddling', () => {
879
+ const BUCKET = 'global[]';
880
+
881
+ async function setup() {
882
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
883
+ const syncRules = await factory.updateSyncRules(
884
+ updateSyncRulesFromYaml(
885
+ `
886
+ bucket_definitions:
887
+ global:
888
+ data: [SELECT id as id, description FROM items]
889
+ `,
890
+ { storageVersion: 3 }
891
+ )
892
+ );
893
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
894
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
895
+ const mapping = syncRules.syncConfigContent[0].mapping;
896
+ const definitionId = mapping.allBucketDefinitionIds()[0];
897
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
898
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
899
+ const sourceTableId = new bson.ObjectId();
900
+ const ctx = {
901
+ replicationStreamId: bucketStorage.replicationStreamId,
902
+ definitionId,
903
+ bucket: BUCKET
904
+ };
905
+
906
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
907
+ }
908
+
909
+ function checksumRequest(): storage.BucketChecksumRequest {
910
+ return {
911
+ bucket: BUCKET,
912
+ source: {
913
+ uniqueName: 'global',
914
+ bucketParameters: [],
915
+ getSourceTables: () => new Set(),
916
+ tableSyncsData: () => false,
917
+ evaluateRow: () => [],
918
+ inferSchema: () => ({ objects: {} }),
919
+ bucketQuery: () => ({ ast: {} as any, parameters: [] })
920
+ } as any
921
+ };
922
+ }
923
+
924
+ test('start straddle recalculates a cached checksum from the beginning', async () => {
925
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
926
+
927
+ // Single document with ops 10-60, min_op=10, _id.o=60
928
+ const ops = [
929
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
930
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
931
+ makeOp(30, 'C', 'c1', ctx, sourceTableId),
932
+ makeOp(40, 'D', 'd1', ctx, sourceTableId),
933
+ makeOp(50, 'E', 'e1', ctx, sourceTableId),
934
+ makeOp(60, 'F', 'f1', ctx, sourceTableId)
935
+ ];
936
+ // Cache the first half before compaction changes the document boundary.
937
+ const firstDoc = serializeBucketData(BUCKET, ops.slice(0, 3));
938
+ await collection.insertOne(firstDoc);
939
+
940
+ const fullChecksum = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
941
+ await bucketStateCollection.insertOne({
942
+ _id: { d: definitionId, b: BUCKET },
943
+ last_op: 30n,
944
+ next_compact_check: undefined,
945
+ first_uncompacted_write: undefined,
946
+ bucket_stats: { count: 3, bytes: 100n, chunks: 1 }
947
+ });
948
+
949
+ const request = checksumRequest();
950
+
951
+ const cached = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
952
+ expect(cached.get(BUCKET)).toMatchObject({ count: 3 });
953
+
954
+ // Compaction rechunks the bucket into one document spanning the cached
955
+ // checkpoint. The requested endpoint is the end of the new document.
956
+ await collection.deleteMany({});
957
+ await collection.insertOne(serializeBucketData(BUCKET, ops, { targetOp: 60n }));
958
+
959
+ const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
960
+ const checksumResult = result.get(BUCKET)!;
961
+
962
+ // The cached checkpoint at 30 is inside the new document. The full result
963
+ // must replace it rather than add the document checksum to it.
964
+ expect(checksumResult.checksum).toBe(fullChecksum);
965
+ expect(checksumResult.count).toBe(6);
966
+ });
967
+
968
+ test('end straddle invalidates the old checkpoint and a later checkpoint succeeds', async () => {
969
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
970
+
971
+ // Document with ops 40-60, _id.o=60, min_op=40
972
+ const ops = [
973
+ makeOp(40, 'D', 'd1', ctx, sourceTableId),
974
+ makeOp(50, 'E', 'e1', ctx, sourceTableId),
975
+ makeOp(60, 'F', 'f1', ctx, sourceTableId)
976
+ ];
977
+ const doc = serializeBucketData(BUCKET, ops, { targetOp: 60n });
978
+ await collection.insertMany([doc]);
979
+
980
+ const checksumAllOps = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
981
+
982
+ await bucketStateCollection.insertOne({
983
+ _id: { d: definitionId, b: BUCKET },
984
+ last_op: 0n,
985
+ next_compact_check: undefined,
986
+ first_uncompacted_write: undefined,
987
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
988
+ });
989
+
990
+ const request = checksumRequest();
991
+
992
+ await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
993
+ CheckpointChecksumInvalidatedError
994
+ );
995
+
996
+ const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
997
+ expect(result.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: checksumAllOps, count: 3 });
998
+ });
999
+
1000
+ test('end straddle without target_op also invalidates the old checkpoint', async () => {
1001
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
1002
+ const ops = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'F', 'f1', ctx, sourceTableId)];
1003
+ await collection.insertOne(serializeBucketData(BUCKET, ops));
1004
+ await bucketStateCollection.insertOne({
1005
+ _id: { d: definitionId, b: BUCKET },
1006
+ last_op: 0n,
1007
+ next_compact_check: undefined,
1008
+ first_uncompacted_write: undefined,
1009
+ bucket_stats: { count: 0, bytes: 0n, chunks: 0 }
1010
+ });
1011
+
1012
+ const request = checksumRequest();
1013
+ await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
1014
+ CheckpointChecksumInvalidatedError
1015
+ );
1016
+ });
1017
+
1018
+ test('has_clear_op replaces a cached checksum', async () => {
1019
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
1020
+ const beforeClear = makeOp(10, 'A', 'a1', ctx, sourceTableId);
1021
+ const clear = { ...makeOp(20, 'clear', '', ctx, sourceTableId), op: 'CLEAR' as const, checksum: 101n, data: null };
1022
+ const afterClear = makeOp(30, 'B', 'b1', ctx, sourceTableId);
1023
+
1024
+ await collection.insertOne(serializeBucketData(BUCKET, [beforeClear]));
1025
+ await bucketStateCollection.insertOne({
1026
+ _id: { d: definitionId, b: BUCKET },
1027
+ last_op: 10n,
1028
+ next_compact_check: undefined,
1029
+ first_uncompacted_write: undefined,
1030
+ bucket_stats: { count: 1, bytes: 100n, chunks: 1 }
1031
+ });
1032
+
1033
+ const request = checksumRequest();
1034
+ const before = await bucketStorage.getChecksums(test_utils.testCheckpoint(10n), [request]);
1035
+ expect(before.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: 70, count: 1 });
1036
+
1037
+ // Compaction replaces everything preceding CLEAR, while the checksum cache
1038
+ // still contains the old checkpoint.
1039
+ await collection.deleteMany({});
1040
+ await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
1041
+ await bucketStateCollection.updateOne(
1042
+ { _id: { d: definitionId, b: BUCKET } },
1043
+ { $set: { last_op: 30n, 'bucket_stats.count': 2 } }
1044
+ );
1045
+
1046
+ const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
1047
+ expect(after.get(BUCKET)).toEqual({
1048
+ bucket: BUCKET,
1049
+ checksum: addChecksums(Number(clear.checksum), Number(afterClear.checksum)),
1050
+ count: 2
239
1051
  });
240
1052
  });
241
1053
  });
242
1054
 
243
- describe('Mongo Sync Parameter Storage Compact', () => {
244
- register.registerParameterCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
1055
+ describe('V3 compaction boundaries', () => {
1056
+ const BUCKET = 'global[]';
1057
+
1058
+ async function setupV3() {
1059
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1060
+ const syncRules = await factory.updateSyncRules(
1061
+ updateSyncRulesFromYaml(
1062
+ `
1063
+ bucket_definitions:
1064
+ global:
1065
+ data: [SELECT id as id, description FROM items]
1066
+ `,
1067
+ { storageVersion: 3 }
1068
+ )
1069
+ );
1070
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1071
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1072
+ const mapping = syncRules.syncConfigContent[0].mapping;
1073
+ const definitionId = mapping.allBucketDefinitionIds()[0];
1074
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
1075
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
1076
+ const sourceTableId = new bson.ObjectId();
1077
+
1078
+ const ctx = {
1079
+ replicationStreamId: bucketStorage.replicationStreamId,
1080
+ definitionId,
1081
+ bucket: BUCKET
1082
+ };
1083
+
1084
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1085
+ }
1086
+
1087
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1088
+ await collection.insertMany(docs);
1089
+ }
1090
+
1091
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
1092
+ await bucketStateCollection.insertOne({
1093
+ _id: { d: definitionId, b: BUCKET },
1094
+ last_op: lastOp,
1095
+ next_compact_check: new Date(0),
1096
+ first_uncompacted_write: new Date(0),
1097
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1098
+ });
1099
+ }
1100
+
1101
+ async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
1102
+ await bucketStorage.compact({
1103
+ clearBatchLimit: 200,
1104
+ moveBatchLimit: 10,
1105
+ moveBatchQueryLimit: 10,
1106
+ minBucketChanges: 1,
1107
+ minChangeRatio: 0,
1108
+ maxOpId
1109
+ });
1110
+ }
1111
+
1112
+ async function readAllOps(collection: any): Promise<{ row_id: string; o: bigint; op: string }[]> {
1113
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1114
+ return docs.flatMap((d: any) =>
1115
+ d.ops!.map((op: any) => ({ row_id: op.row_id!, o: op.o, op: op.op, target_op: op.target_op ?? undefined }))
1116
+ );
1117
+ }
1118
+
1119
+ async function readAllDocs(collection: any): Promise<BucketDataDocumentV3[]> {
1120
+ return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1121
+ }
1122
+
1123
+ test('scheduled compaction claims only due buckets and clears completed full work', async () => {
1124
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1125
+ const docs = [
1126
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'old', ctx, sourceTableId)]),
1127
+ serializeBucketData(BUCKET, [makeOp(2, 'A', 'new', ctx, sourceTableId)])
1128
+ ];
1129
+ await insertDocs(collection, docs);
1130
+ await bucketStateCollection.insertOne({
1131
+ _id: { d: ctx.definitionId, b: BUCKET },
1132
+ last_op: 2n,
1133
+ next_compact_check: new Date(Date.now() + 60_000),
1134
+ first_uncompacted_write: new Date(Date.now() - 60_000),
1135
+ bucket_stats: {
1136
+ count: 2,
1137
+ bytes: BigInt(docs[0].size + docs[1].size),
1138
+ chunks: 2
1139
+ }
1140
+ });
1141
+
1142
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1143
+ expect(
1144
+ (await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } }))?.compacted_state
1145
+ ).toBeUndefined();
1146
+
1147
+ await bucketStateCollection.updateOne(
1148
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1149
+ { $set: { next_compact_check: new Date(Date.now() - 1) } }
1150
+ );
1151
+ await bucketStorage.compact({ maxOpId: 2n, maxCompactFullIntervalMs: 0 });
1152
+
1153
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1154
+ expect(state?.last_full_compact?.op_id).toBe(2n);
1155
+ expect(state?.first_uncompacted_write).toBeUndefined();
1156
+ expect(state?.next_compact_check).toBeUndefined();
1157
+ expect(state?.compact_lease).toBeUndefined();
1158
+ });
1159
+
1160
+ test('capped full compaction records its prefix and starts a fresh scheduling window', async () => {
1161
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1162
+ const prefix = serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]);
1163
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]);
1164
+ await insertDocs(collection, [prefix, untouchedTail]);
1165
+ await bucketStateCollection.insertOne({
1166
+ _id: { d: ctx.definitionId, b: BUCKET },
1167
+ last_op: 2n,
1168
+ next_compact_check: new Date(0),
1169
+ first_uncompacted_write: new Date(0),
1170
+ bucket_stats: {
1171
+ count: prefix.count + untouchedTail.count,
1172
+ bytes: BigInt(prefix.size + untouchedTail.size),
1173
+ chunks: 2
1174
+ }
1175
+ });
1176
+
1177
+ await bucketStorage.compact({ maxOpId: 1n });
1178
+
1179
+ const partialState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1180
+ expect(partialState?.last_full_compact).toMatchObject({
1181
+ op_id: 1n,
1182
+ count: prefix.count,
1183
+ puts: 1
1184
+ });
1185
+ expect(partialState?.first_uncompacted_write).toBeInstanceOf(Date);
1186
+ expect(partialState!.first_uncompacted_write!.getTime()).toBeGreaterThan(0);
1187
+ expect(partialState!.next_compact_check!.getTime()).toBeGreaterThanOrEqual(
1188
+ partialState!.first_uncompacted_write!.getTime() + DEFAULT_MIN_COMPACT_CHUNK_INTERVAL_MS
1189
+ );
1190
+
1191
+ const lastFullCompactAt = partialState!.last_full_compact!.at;
1192
+ const freshFirstUncompactedWrite = partialState!.first_uncompacted_write!;
1193
+ await bucketStateCollection.updateOne(
1194
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1195
+ { $set: { next_compact_check: new Date(0) } }
1196
+ );
1197
+
1198
+ await bucketStorage.compact({ maxOpId: 2n });
1199
+
1200
+ const deferredState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1201
+ expect(deferredState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1202
+ expect(deferredState?.first_uncompacted_write).toEqual(freshFirstUncompactedWrite);
1203
+ expect(deferredState!.next_compact_check!.getTime()).toBeGreaterThan(Date.now());
1204
+
1205
+ // Even after the scheduling window has elapsed, a run with the old cap
1206
+ // must not scan and record the same full-compaction prefix again.
1207
+ await bucketStateCollection.updateOne(
1208
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1209
+ { $set: { first_uncompacted_write: new Date(0), next_compact_check: new Date(0) } }
1210
+ );
1211
+
1212
+ await bucketStorage.compact({ maxOpId: 1n });
1213
+
1214
+ const rescheduledState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1215
+ expect(rescheduledState?.last_full_compact?.at).toEqual(lastFullCompactAt);
1216
+ expect(rescheduledState?.first_uncompacted_write).toEqual(new Date(0));
1217
+ expect(rescheduledState!.next_compact_check!.getTime()).toBeGreaterThan(0);
1218
+ });
1219
+
1220
+ test('explicit compaction skips a bucket with no outstanding full-compaction work', async () => {
1221
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1222
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1223
+ await insertDocs(collection, [document]);
1224
+ await bucketStateCollection.insertOne({
1225
+ _id: { d: ctx.definitionId, b: BUCKET },
1226
+ last_op: 1n,
1227
+ next_compact_check: undefined,
1228
+ first_uncompacted_write: undefined,
1229
+ compacted_state: {
1230
+ op_id: 1n,
1231
+ checksum: document.checksum,
1232
+ count: document.count,
1233
+ bytes: BigInt(document.size),
1234
+ chunks: 1,
1235
+ at: new Date()
1236
+ },
1237
+ last_full_compact: {
1238
+ op_id: 1n,
1239
+ count: document.count,
1240
+ puts: 1,
1241
+ at: new Date()
1242
+ },
1243
+ bucket_stats: { count: document.count, bytes: BigInt(document.size), chunks: 1 }
1244
+ });
1245
+
1246
+ await expect(bucketStorage.compact({ compactBuckets: [BUCKET], maxOpId: 1n })).resolves.toBeUndefined();
1247
+
1248
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1249
+ expect(state?.compact_lease).toBeUndefined();
1250
+ expect(state?.next_compact_check).toBeNull();
1251
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1252
+ });
1253
+
1254
+ test('concurrent scheduled compactors lease a bucket to one worker', async () => {
1255
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1256
+ const document = serializeBucketData(BUCKET, [makeOp(1, 'A', 'value', ctx, sourceTableId)]);
1257
+ await insertDocs(collection, [document]);
1258
+ await bucketStateCollection.insertOne({
1259
+ _id: { d: ctx.definitionId, b: BUCKET },
1260
+ last_op: 1n,
1261
+ next_compact_check: new Date(Date.now() - 1),
1262
+ first_uncompacted_write: new Date(0),
1263
+ bucket_stats: { count: 1, bytes: BigInt(document.size), chunks: 1 }
1264
+ });
1265
+
1266
+ await Promise.all([bucketStorage.compact({ maxOpId: 1n }), bucketStorage.compact({ maxOpId: 1n })]);
1267
+
1268
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1269
+ expect(state?.last_full_compact?.op_id).toBe(1n);
1270
+ expect(state?.compact_lease).toBeUndefined();
1271
+ });
1272
+
1273
+ test('1. superseded ops become MOVE tombstones', async () => {
1274
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1275
+ // Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
1276
+ const ops = [
1277
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1278
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1279
+ makeOp(30, 'A', 'a2', ctx, sourceTableId)
1280
+ ];
1281
+ const doc1 = serializeBucketData(BUCKET, ops);
1282
+ await insertDocs(collection, [doc1]);
1283
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
1284
+
1285
+ await compact(bucketStorage, 30n);
1286
+
1287
+ const surviving = await readAllOps(collection);
1288
+ expect(surviving).toHaveLength(3);
1289
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1290
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1291
+ expect(surviving[2]).toMatchObject({ row_id: 'A', o: 30n, op: 'PUT' });
1292
+ });
1293
+
1294
+ test('2. first op in document superseded becomes MOVE tombstone', async () => {
1295
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1296
+ // Doc1: [A@10, B@20, C@30], Doc2: [A@40] — A@10 superseded by A@40
1297
+ const ops1 = [
1298
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1299
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1300
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
1301
+ ];
1302
+ const ops2 = [makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1303
+ const doc1 = serializeBucketData(BUCKET, ops1);
1304
+ const doc2 = serializeBucketData(BUCKET, ops2);
1305
+ await insertDocs(collection, [doc1, doc2]);
1306
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1307
+
1308
+ await compact(bucketStorage, 40n);
1309
+
1310
+ const surviving = await readAllOps(collection);
1311
+ expect(surviving).toHaveLength(4);
1312
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1313
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1314
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1315
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1316
+ });
1317
+
1318
+ test('3. last op in document superseded becomes MOVE tombstone', async () => {
1319
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1320
+ // Doc1: [A@10, B@20], Doc2: [C@30, A@40] — A@10 superseded by A@40
1321
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1322
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1323
+ const doc1 = serializeBucketData(BUCKET, ops1);
1324
+ const doc2 = serializeBucketData(BUCKET, ops2);
1325
+ await insertDocs(collection, [doc1, doc2]);
1326
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1327
+
1328
+ await compact(bucketStorage, 40n);
1329
+
1330
+ const surviving = await readAllOps(collection);
1331
+ expect(surviving).toHaveLength(4);
1332
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1333
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1334
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1335
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1336
+ });
1337
+
1338
+ test('4. cascading superseded ops become MOVE tombstones', async () => {
1339
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1340
+ // Doc1: [A@10, A@20] — A@10 superseded by A@20; A@20 superseded by A@30 REMOVE
1341
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'A', 'a2', ctx, sourceTableId)];
1342
+ const ops2 = [makeOp(30, 'A', 'a3', ctx, sourceTableId, { op: 'REMOVE' })];
1343
+ const doc1 = serializeBucketData(BUCKET, ops1);
1344
+ const doc2 = serializeBucketData(BUCKET, ops2);
1345
+ await insertDocs(collection, [doc1, doc2]);
1346
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
1347
+
1348
+ await compact(bucketStorage, 30n);
1349
+
1350
+ const surviving = await readAllOps(collection);
1351
+ expect(surviving).toHaveLength(1);
1352
+ expect(surviving[0]).toMatchObject({ op: 'CLEAR', o: 30n });
1353
+ });
1354
+
1355
+ test('5. one surviving PUT per document plus MOVE tombstones', async () => {
1356
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1357
+ // Doc1: [A@10, B@20], Doc2: [C@30, A@40] — A@10 superseded, becomes MOVE
1358
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1359
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1360
+ const doc1 = serializeBucketData(BUCKET, ops1);
1361
+ const doc2 = serializeBucketData(BUCKET, ops2);
1362
+ await insertDocs(collection, [doc1, doc2]);
1363
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1364
+
1365
+ await compact(bucketStorage, 40n);
1366
+
1367
+ const surviving = await readAllOps(collection);
1368
+ expect(surviving).toHaveLength(4);
1369
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1370
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1371
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1372
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1373
+ });
1374
+
1375
+ test('6. multiple superseded ops from different documents become MOVE tombstones', async () => {
1376
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1377
+ // 3 small docs, each with 2 ops, A@10 superseded by A@40
1378
+ // Doc1: [A@10, B@20] — A@10 becomes MOVE
1379
+ // Doc2: [C@30, A@40] — C@30 survives (A@40 is latest A)
1380
+ // Doc3: [D@50, E@60] — D@50, E@60 survive
1381
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1382
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1383
+ const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'E', 'e1', ctx, sourceTableId)];
1384
+ const doc1 = serializeBucketData(BUCKET, ops1);
1385
+ const doc2 = serializeBucketData(BUCKET, ops2);
1386
+ const doc3 = serializeBucketData(BUCKET, ops3);
1387
+ await insertDocs(collection, [doc1, doc2, doc3]);
1388
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
1389
+
1390
+ await compact(bucketStorage, 60n);
1391
+
1392
+ const surviving = await readAllOps(collection);
1393
+ expect(surviving).toHaveLength(6);
1394
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1395
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1396
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1397
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1398
+ expect(surviving[4]).toMatchObject({ row_id: 'D', o: 50n, op: 'PUT' });
1399
+ expect(surviving[5]).toMatchObject({ row_id: 'E', o: 60n, op: 'PUT' });
1400
+ });
1401
+
1402
+ test('7. superseded op at document boundary becomes MOVE tombstone', async () => {
1403
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1404
+ // Doc1: [A@10, B@20, C@30], Doc2: [D@40, A@50] — A@10 superseded by A@50
1405
+ const ops1 = [
1406
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1407
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1408
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
1409
+ ];
1410
+ const ops2 = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(50, 'A', 'a2', ctx, sourceTableId)];
1411
+ const doc1 = serializeBucketData(BUCKET, ops1);
1412
+ const doc2 = serializeBucketData(BUCKET, ops2);
1413
+ await insertDocs(collection, [doc1, doc2]);
1414
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
1415
+
1416
+ await compact(bucketStorage, 50n);
1417
+
1418
+ const surviving = await readAllOps(collection);
1419
+ expect(surviving).toHaveLength(5);
1420
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1421
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1422
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1423
+ expect(surviving[3]).toMatchObject({ row_id: 'D', o: 40n, op: 'PUT' });
1424
+ expect(surviving[4]).toMatchObject({ row_id: 'A', o: 50n, op: 'PUT' });
1425
+ });
1426
+
1427
+ test('8. same row_id ops spanning document boundary produces MOVE tombstone', async () => {
1428
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1429
+ // Doc1: [A@10, B@20], Doc2: [A@30, C@40] — A@10 superseded by A@30
1430
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1431
+ const ops2 = [makeOp(30, 'A', 'a2', ctx, sourceTableId), makeOp(40, 'C', 'c1', ctx, sourceTableId)];
1432
+ const doc1 = serializeBucketData(BUCKET, ops1);
1433
+ const doc2 = serializeBucketData(BUCKET, ops2);
1434
+ await insertDocs(collection, [doc1, doc2]);
1435
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1436
+
1437
+ await compact(bucketStorage, 40n);
1438
+
1439
+ const surviving = await readAllOps(collection);
1440
+ expect(surviving).toHaveLength(4);
1441
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1442
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1443
+ expect(surviving[2]).toMatchObject({ row_id: 'A', o: 30n, op: 'PUT' });
1444
+ expect(surviving[3]).toMatchObject({ row_id: 'C', o: 40n, op: 'PUT' });
1445
+ });
1446
+ });
1447
+
1448
+ describe('V3 MOVE tombstone properties', () => {
1449
+ const BUCKET = 'global[]';
1450
+
1451
+ async function setupV3() {
1452
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1453
+ const syncRules = await factory.updateSyncRules(
1454
+ updateSyncRulesFromYaml(
1455
+ `
1456
+ bucket_definitions:
1457
+ global:
1458
+ data: [SELECT id as id, description FROM items]
1459
+ `,
1460
+ { storageVersion: 3 }
1461
+ )
1462
+ );
1463
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1464
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1465
+ const mapping = syncRules.syncConfigContent[0].mapping;
1466
+ const definitionId = mapping.allBucketDefinitionIds()[0];
1467
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
1468
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
1469
+ const sourceTableId = new bson.ObjectId();
1470
+
1471
+ const ctx = {
1472
+ replicationStreamId: bucketStorage.replicationStreamId,
1473
+ definitionId,
1474
+ bucket: BUCKET
1475
+ };
1476
+
1477
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1478
+ }
1479
+
1480
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1481
+ await collection.insertMany(docs);
1482
+ }
1483
+
1484
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
1485
+ await bucketStateCollection.insertOne({
1486
+ _id: { d: definitionId, b: BUCKET },
1487
+ last_op: lastOp,
1488
+ next_compact_check: new Date(0),
1489
+ first_uncompacted_write: new Date(0),
1490
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1491
+ });
1492
+ }
1493
+
1494
+ async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
1495
+ await bucketStorage.compact({
1496
+ clearBatchLimit: 200,
1497
+ moveBatchLimit: 10,
1498
+ moveBatchQueryLimit: 10,
1499
+ minBucketChanges: 1,
1500
+ minChangeRatio: 0,
1501
+ maxOpId
1502
+ });
1503
+ }
1504
+
1505
+ test('checksum preserved across compaction with superseded ops', async () => {
1506
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1507
+ const ops = [
1508
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1509
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1510
+ makeOp(30, 'A', 'a2', ctx, sourceTableId),
1511
+ makeOp(40, 'C', 'c1', ctx, sourceTableId),
1512
+ makeOp(50, 'B', 'b2', ctx, sourceTableId)
1513
+ ];
1514
+ const doc1 = serializeBucketData(BUCKET, ops);
1515
+ await insertDocs(collection, [doc1]);
1516
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
1517
+
1518
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1519
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1520
+
1521
+ await compact(bucketStorage, 50n);
1522
+
1523
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1524
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1525
+
1526
+ expect(checksumAfter).toBe(checksumBefore);
1527
+
1528
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
1529
+ // Two MOVEs collapsed into one CLEAR
1530
+ expect(allOpsAfter.length).toBe(4);
1531
+ const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
1532
+ expect(clearOps.length).toBe(1);
1533
+ });
1534
+
1535
+ test('checksum preserved across compaction with multiple documents', async () => {
1536
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1537
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1538
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1539
+ const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'B', 'b2', ctx, sourceTableId)];
1540
+ await insertDocs(collection, [
1541
+ serializeBucketData(BUCKET, ops1),
1542
+ serializeBucketData(BUCKET, ops2),
1543
+ serializeBucketData(BUCKET, ops3)
1544
+ ]);
1545
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
1546
+
1547
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1548
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1549
+
1550
+ await compact(bucketStorage, 60n);
1551
+
1552
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1553
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1554
+
1555
+ expect(checksumAfter).toBe(checksumBefore);
1556
+
1557
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
1558
+ // Two MOVEs collapsed into one CLEAR
1559
+ expect(allOpsAfter.length).toBe(5);
1560
+ const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
1561
+ expect(clearOps.length).toBe(1);
1562
+ });
1563
+
1564
+ test('tombstones have null data and pack densely after rechunking', async () => {
1565
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1566
+ const ops = [
1567
+ makeOp(10, 'A', 'x'.repeat(500_000), ctx, sourceTableId),
1568
+ makeOp(20, 'B', 'y'.repeat(500_000), ctx, sourceTableId),
1569
+ makeOp(30, 'A', 'z'.repeat(500_000), ctx, sourceTableId)
1570
+ ];
1571
+ const doc1 = serializeBucketData(BUCKET, ops);
1572
+ await insertDocs(collection, [doc1]);
1573
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
1574
+
1575
+ await compact(bucketStorage, 30n);
1576
+
1577
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1578
+ const allOps = docs.flatMap((d) => d.ops!);
1579
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
1580
+ expect(moveOps.length).toBe(1);
1581
+ expect(moveOps[0].o).toBe(10n);
1582
+ expect(moveOps[0].data).toBeNull();
1583
+
1584
+ const putOps = allOps.filter((op) => op.op === 'PUT');
1585
+ expect(putOps.length).toBe(2);
1586
+ expect(putOps.every((op) => op.data != null)).toBe(true);
1587
+
1588
+ expect(docs.length).toBe(1);
1589
+ // Size calculation is not exact - we just check for a range
1590
+ expect(docs[0].size).toBeGreaterThan(1_000_000);
1591
+ expect(docs[0].size).toBeLessThan(1_001_000);
1592
+ });
1593
+
1594
+ test('tombstones and survivors end up in same document after rechunking', async () => {
1595
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1596
+ const ops1 = [
1597
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1598
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1599
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
1600
+ ];
1601
+ const ops2 = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(50, 'A', 'a2', ctx, sourceTableId)];
1602
+ await insertDocs(collection, [serializeBucketData(BUCKET, ops1), serializeBucketData(BUCKET, ops2)]);
1603
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
1604
+
1605
+ const checksumBefore = (await collection.find({ '_id.b': BUCKET }).toArray()).reduce(
1606
+ (sum, d) => addChecksums(sum, Number(d.checksum)),
1607
+ 0
1608
+ );
1609
+
1610
+ await compact(bucketStorage, 50n);
1611
+
1612
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1613
+ const checksumAfter = docs.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1614
+ expect(checksumAfter).toBe(checksumBefore);
1615
+
1616
+ const allOps = docs.flatMap((d) => d.ops!);
1617
+ expect(allOps.length).toBe(5);
1618
+ const moveOp = allOps.find((op) => op.op === 'MOVE');
1619
+ expect(moveOp).toMatchObject({ o: 10n, data: null });
1620
+
1621
+ expect(docs.length).toBe(1);
1622
+
1623
+ const moveOpsInDoc = docs[0].ops!.filter((op) => op.op === 'MOVE');
1624
+ const putOpsInDoc = docs[0].ops!.filter((op) => op.op === 'PUT');
1625
+ expect(moveOpsInDoc.length).toBe(1);
1626
+ expect(putOpsInDoc.length).toBe(4);
1627
+ });
1628
+ });
1629
+
1630
+ /**
1631
+ * Streaming compactor tests
1632
+ *
1633
+ * These tests exercise the streaming compactor's batched behavior:
1634
+ * reverse-order batched reads, cross-batch seen map dedup with memory
1635
+ * bounding, scoped deletes via $in, and byte-based batch cutting.
1636
+ */
1637
+ describe('Streaming compactor', () => {
1638
+ const BUCKET = 'global[]';
1639
+
1640
+ async function setupV3() {
1641
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1642
+ const syncRules = await factory.updateSyncRules(
1643
+ updateSyncRulesFromYaml(
1644
+ `
1645
+ bucket_definitions:
1646
+ global:
1647
+ data: [SELECT id as id, description FROM items]
1648
+ `,
1649
+ { storageVersion: 3 }
1650
+ )
1651
+ );
1652
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1653
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1654
+ const definitionId = bucketStorage.storageIds.bucketDefinitionIds[0];
1655
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
1656
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
1657
+ const sourceTableId = new bson.ObjectId();
1658
+
1659
+ const ctx = {
1660
+ replicationStreamId: bucketStorage.replicationStreamId,
1661
+ definitionId,
1662
+ bucket: BUCKET
1663
+ };
1664
+
1665
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1666
+ }
1667
+
1668
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1669
+ await collection.insertMany(docs);
1670
+ }
1671
+
1672
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
1673
+ await bucketStateCollection.insertOne({
1674
+ _id: { d: definitionId, b: BUCKET },
1675
+ last_op: lastOp,
1676
+ next_compact_check: new Date(0),
1677
+ first_uncompacted_write: new Date(0),
1678
+ bucket_stats: { count: 10, bytes: 100n, chunks: 1 }
1679
+ });
1680
+ }
1681
+
1682
+ async function readAllOps(
1683
+ collection: any
1684
+ ): Promise<{ row_id: string | undefined; o: bigint; op: string; target_op: bigint | undefined }[]> {
1685
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1686
+ return docs.flatMap((d: any) =>
1687
+ d.ops!.map((op: any) => ({
1688
+ row_id: op.row_id ?? undefined,
1689
+ o: op.o,
1690
+ op: op.op,
1691
+ target_op: op.target_op ?? undefined
1692
+ }))
1693
+ );
1694
+ }
1695
+
1696
+ async function setupCompactedTail() {
1697
+ const setup = await setupV3();
1698
+ const { collection, bucketStateCollection, ctx, sourceTableId } = setup;
1699
+ const documents = [
1700
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1701
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1702
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1703
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1704
+ ];
1705
+ await insertDocs(collection, documents);
1706
+ await bucketStateCollection.insertOne({
1707
+ _id: { d: ctx.definitionId, b: BUCKET },
1708
+ last_op: 4n,
1709
+ next_compact_check: new Date(0),
1710
+ first_uncompacted_write: new Date(0),
1711
+ compacted_state: {
1712
+ op_id: 2n,
1713
+ checksum: documents[0].checksum + documents[1].checksum,
1714
+ count: documents[0].count + documents[1].count,
1715
+ bytes: BigInt(documents[0].size + documents[1].size),
1716
+ chunks: 2,
1717
+ at: new Date(0)
1718
+ },
1719
+ bucket_stats: {
1720
+ count: documents.reduce((total, document) => total + document.count, 0),
1721
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
1722
+ chunks: documents.length
1723
+ }
1724
+ });
1725
+ return setup;
1726
+ }
1727
+
1728
+ async function expectRecoveredCompactedTail(collection: any, bucketStateCollection: any, definitionId: string) {
1729
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1730
+ expect(documents.flatMap((document: BucketDataDocumentV3) => document.ops!.map((op) => op.o))).toEqual([
1731
+ 1n,
1732
+ 2n,
1733
+ 3n,
1734
+ 4n
1735
+ ]);
1736
+
1737
+ const bytes = BigInt(documents.reduce((total: number, document: BucketDataDocumentV3) => total + document.size, 0));
1738
+ const state = await bucketStateCollection.findOne({ _id: { d: definitionId, b: BUCKET } });
1739
+ expect(state?.compacted_state).toMatchObject({
1740
+ op_id: 4n,
1741
+ checksum: 70n,
1742
+ count: 4,
1743
+ bytes,
1744
+ chunks: documents.length
1745
+ });
1746
+ expect(state?.bucket_stats).toEqual({ count: 4, bytes, chunks: documents.length });
1747
+ }
1748
+
1749
+ test('initial compaction merges small chunks and refreshes bucket metadata', async () => {
1750
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1751
+ await insertDocs(collection, [
1752
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId), makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1753
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId), makeOp(4, 'D', 'd', ctx, sourceTableId)])
1754
+ ]);
1755
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 4n);
1756
+
1757
+ const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
1758
+
1759
+ expect(result).toEqual({ buckets: 1 });
1760
+ const documents = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1761
+ expect(documents).toHaveLength(1);
1762
+ expect(documents[0].ops!.map((op) => op.o)).toEqual([1n, 2n, 3n, 4n]);
1763
+
1764
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1765
+ expect(state?.compacted_state).toMatchObject({
1766
+ op_id: 4n,
1767
+ count: 4,
1768
+ checksum: 70n
1769
+ });
1770
+ });
1771
+
1772
+ test('capped chunk compaction repairs stale bucket stats after a committed first merge', async () => {
1773
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1774
+ const operations = [
1775
+ makeOp(1, 'A', 'a', ctx, sourceTableId),
1776
+ makeOp(2, 'B', 'b', ctx, sourceTableId),
1777
+ makeOp(3, 'C', 'c', ctx, sourceTableId),
1778
+ makeOp(4, 'D', 'd', ctx, sourceTableId)
1779
+ ];
1780
+ const originalDocuments = [
1781
+ serializeBucketData(BUCKET, operations.slice(0, 2)),
1782
+ serializeBucketData(BUCKET, operations.slice(2))
1783
+ ];
1784
+ const committedMerge = serializeBucketData(BUCKET, operations);
1785
+ const untouchedTail = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
1786
+ await insertDocs(collection, [committedMerge, untouchedTail]);
1787
+ await bucketStateCollection.insertOne({
1788
+ _id: { d: ctx.definitionId, b: BUCKET },
1789
+ last_op: 5n,
1790
+ next_compact_check: new Date(0),
1791
+ first_uncompacted_write: new Date(0),
1792
+ bucket_stats: {
1793
+ count: originalDocuments.reduce((total, document) => total + document.count, untouchedTail.count),
1794
+ bytes: BigInt(originalDocuments.reduce((total, document) => total + document.size, untouchedTail.size)),
1795
+ chunks: originalDocuments.length + 1
1796
+ }
1797
+ });
1798
+
1799
+ const result = await bucketStorage.compactInitialReplication({ maxOpId: 4n });
1800
+
1801
+ expect(result).toEqual({ buckets: 1 });
1802
+ const expectedStats = {
1803
+ count: committedMerge.count + untouchedTail.count,
1804
+ bytes: BigInt(committedMerge.size + untouchedTail.size),
1805
+ chunks: 2
1806
+ };
1807
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1808
+ expect(state?.bucket_stats).toEqual(expectedStats);
1809
+ expect(state?.compacted_state).toMatchObject({
1810
+ op_id: 4n,
1811
+ checksum: committedMerge.checksum,
1812
+ count: committedMerge.count,
1813
+ bytes: BigInt(committedMerge.size),
1814
+ chunks: 1
1815
+ });
1816
+ });
1817
+
1818
+ test('later full compaction claims fresh state after a committed replacement', async () => {
1819
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1820
+ const documents = [
1821
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1822
+ serializeBucketData(BUCKET, [makeOp(2, 'B', 'b', ctx, sourceTableId)]),
1823
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1824
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1825
+ ];
1826
+ await insertDocs(collection, documents);
1827
+ await bucketStateCollection.insertOne({
1828
+ _id: { d: ctx.definitionId, b: BUCKET },
1829
+ last_op: 4n,
1830
+ next_compact_check: new Date(0),
1831
+ first_uncompacted_write: new Date(0),
1832
+ bucket_stats: {
1833
+ count: documents.reduce((total, document) => total + document.count, 0),
1834
+ bytes: BigInt(documents.reduce((total, document) => total + document.size, 0)),
1835
+ chunks: documents.length
1836
+ }
1837
+ });
1838
+
1839
+ const concurrentDocument = serializeBucketData(BUCKET, [makeOp(5, 'E', 'e', ctx, sourceTableId)]);
1840
+ const compactor = bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] });
1841
+ const originalFlush = (compactor as any).flushCompactionGroup.bind(compactor);
1842
+ let injectedFailure = false;
1843
+ vi.spyOn(compactor as any, 'flushCompactionGroup').mockImplementation(async (...args: any[]) => {
1844
+ const result = await originalFlush(...args);
1845
+ if (!injectedFailure) {
1846
+ injectedFailure = true;
1847
+ await insertDocs(collection, [concurrentDocument]);
1848
+ await bucketStateCollection.updateOne(
1849
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1850
+ {
1851
+ $set: { last_op: 5n },
1852
+ $inc: {
1853
+ 'bucket_stats.count': concurrentDocument.count,
1854
+ 'bucket_stats.bytes': BigInt(concurrentDocument.size),
1855
+ 'bucket_stats.chunks': 1
1856
+ }
1857
+ }
1858
+ );
1859
+ throw new ObjectStorageError('failure after committed full-compaction replacement', {
1860
+ cause: new Error('socket reset'),
1861
+ retryable: true
1862
+ });
1863
+ }
1864
+ return result;
1865
+ });
1866
+
1867
+ await expect(compactor.compact()).rejects.toThrow('failure after committed full-compaction replacement');
1868
+
1869
+ expect(injectedFailure).toBe(true);
1870
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1871
+ expect(interruptedState?.last_op).toBe(5n);
1872
+ expect(interruptedState?.compacted_state).toBeUndefined();
1873
+ expect(interruptedState?.compact_lease).toBeUndefined();
1874
+
1875
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 5n, compactBuckets: [BUCKET] }).compact()).resolves.toBe(
1876
+ 1
1877
+ );
1878
+
1879
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).toArray();
1880
+ expect(currentDocuments).toHaveLength(1);
1881
+ const expectedStats = {
1882
+ count: currentDocuments.reduce((total, document) => total + document.count, 0),
1883
+ bytes: BigInt(currentDocuments.reduce((total, document) => total + document.size, 0)),
1884
+ chunks: currentDocuments.length
1885
+ };
1886
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1887
+ expect(state?.bucket_stats).toEqual(expectedStats);
1888
+ expect(state?.compacted_state).toMatchObject({ op_id: 5n, ...expectedStats });
1889
+ expect(state?.last_full_compact?.op_id).toBe(5n);
1890
+ expect(state?.first_uncompacted_write).toBeUndefined();
1891
+ expect(state?.next_compact_check).toBeUndefined();
1892
+ });
1893
+
1894
+ test('chunk compaction treats a missing cached op as a resume hint', async () => {
1895
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1896
+ const documents = [
1897
+ serializeBucketData(BUCKET, [makeOp(1, 'A', 'a', ctx, sourceTableId)]),
1898
+ serializeBucketData(BUCKET, [makeOp(3, 'C', 'c', ctx, sourceTableId)]),
1899
+ serializeBucketData(BUCKET, [makeOp(4, 'D', 'd', ctx, sourceTableId)])
1900
+ ];
1901
+ await insertDocs(collection, documents);
1902
+ await bucketStateCollection.insertOne({
1903
+ _id: { d: ctx.definitionId, b: BUCKET },
1904
+ last_op: 4n,
1905
+ next_compact_check: new Date(0),
1906
+ first_uncompacted_write: new Date(0),
1907
+ compacted_state: {
1908
+ op_id: 2n,
1909
+ checksum: 21n,
1910
+ count: 2,
1911
+ bytes: 100n,
1912
+ chunks: 2,
1913
+ at: new Date(0)
1914
+ },
1915
+ bucket_stats: { count: 4, bytes: 200n, chunks: 4 }
1916
+ });
1917
+
1918
+ await expect(bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact()).resolves.toBe(
1919
+ 1
1920
+ );
1921
+
1922
+ const currentDocuments = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1923
+ expect(currentDocuments.flatMap((document) => document.ops!.map((op) => op.o))).toEqual([1n, 3n, 4n]);
1924
+ const bytes = BigInt(currentDocuments.reduce((total, document) => total + document.size, 0));
1925
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1926
+ expect(state?.compacted_state).toMatchObject({
1927
+ op_id: 4n,
1928
+ checksum: 56n,
1929
+ count: 3,
1930
+ bytes,
1931
+ chunks: currentDocuments.length
1932
+ });
1933
+ expect(state?.bucket_stats).toEqual({ count: 3, bytes, chunks: currentDocuments.length });
1934
+ });
1935
+
1936
+ test('later chunk compaction rebuilds state after a committed partial merge', async () => {
1937
+ const { bucketStorage, collection, bucketStateCollection, ctx } = await setupCompactedTail();
1938
+ const failedCompactor = bucketStorage.createMongoCompactor({
1939
+ maxOpId: 4n,
1940
+ compactChunksOnly: true
1941
+ });
1942
+ const originalFlush = (failedCompactor as any).flushCompactionGroup.bind(failedCompactor);
1943
+ const flushSpy = vi
1944
+ .spyOn(failedCompactor as any, 'flushCompactionGroup')
1945
+ .mockImplementation(async (...args: any[]) => {
1946
+ const result = await originalFlush(...args);
1947
+ throw new Error('failure after committed chunk merge');
1948
+ });
1949
+
1950
+ await expect(failedCompactor.compact()).resolves.toBe(0);
1951
+ flushSpy.mockRestore();
1952
+
1953
+ const interruptedState = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
1954
+ expect(interruptedState?.compacted_state?.op_id).toBe(2n);
1955
+ expect(await collection.findOne({ _id: { b: BUCKET, o: 2n } })).toBeNull();
1956
+ await bucketStateCollection.updateOne(
1957
+ { _id: { d: ctx.definitionId, b: BUCKET } },
1958
+ { $set: { next_compact_check: new Date(0) } }
1959
+ );
1960
+
1961
+ const result = await bucketStorage.createMongoCompactor({ maxOpId: 4n, compactChunksOnly: true }).compact();
1962
+
1963
+ expect(result).toBe(1);
1964
+ await expectRecoveredCompactedTail(collection, bucketStateCollection, ctx.definitionId);
1965
+ });
1966
+
1967
+ test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
1968
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1969
+
1970
+ // 6 documents, 3-4 ops each, 3 rows with 2 versions spread across documents.
1971
+ // Rows A, B, C each have old+new versions:
1972
+ // A: new@24 (Doc1), old@10 (Doc4)
1973
+ // B: new@20 (Doc2), old@6 (Doc5)
1974
+ // C: new@16 (Doc3), old@2 (Doc6)
1975
+ // With moveBatchQueryLimit=2, the streaming compactor must process
1976
+ // 2 documents per batch (3 batches total) and still produce correct output.
1977
+ const doc1 = serializeBucketData(BUCKET, [
1978
+ makeOp(21, 'D', 'd1', ctx, sourceTableId),
1979
+ makeOp(22, 'E', 'e1', ctx, sourceTableId),
1980
+ makeOp(23, 'F', 'f1', ctx, sourceTableId),
1981
+ makeOp(24, 'A', 'a_new', ctx, sourceTableId)
1982
+ ]);
1983
+ const doc2 = serializeBucketData(BUCKET, [
1984
+ makeOp(18, 'G', 'g1', ctx, sourceTableId),
1985
+ makeOp(19, 'H', 'h1', ctx, sourceTableId),
1986
+ makeOp(20, 'B', 'b_new', ctx, sourceTableId)
1987
+ ]);
1988
+ const doc3 = serializeBucketData(BUCKET, [
1989
+ makeOp(14, 'I', 'i1', ctx, sourceTableId),
1990
+ makeOp(15, 'J', 'j1', ctx, sourceTableId),
1991
+ makeOp(16, 'C', 'c_new', ctx, sourceTableId)
1992
+ ]);
1993
+ const doc4 = serializeBucketData(BUCKET, [
1994
+ makeOp(10, 'A', 'a_old', ctx, sourceTableId),
1995
+ makeOp(11, 'K', 'k1', ctx, sourceTableId),
1996
+ makeOp(12, 'L', 'l1', ctx, sourceTableId)
1997
+ ]);
1998
+ const doc5 = serializeBucketData(BUCKET, [
1999
+ makeOp(6, 'B', 'b_old', ctx, sourceTableId),
2000
+ makeOp(7, 'M', 'm1', ctx, sourceTableId),
2001
+ makeOp(8, 'N', 'n1', ctx, sourceTableId)
2002
+ ]);
2003
+ const doc6 = serializeBucketData(BUCKET, [
2004
+ makeOp(2, 'C', 'c_old', ctx, sourceTableId),
2005
+ makeOp(3, 'O', 'o1', ctx, sourceTableId),
2006
+ makeOp(4, 'P', 'p1', ctx, sourceTableId)
2007
+ ]);
2008
+ await insertDocs(collection, [doc1, doc2, doc3, doc4, doc5, doc6]);
2009
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 24n);
2010
+
2011
+ // Compute checksum before compaction
2012
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2013
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2014
+
2015
+ // Compact with moveBatchQueryLimit=2 to force 3 batches of 2 documents each
2016
+ await bucketStorage.compact({
2017
+ clearBatchLimit: 200,
2018
+ moveBatchLimit: 10,
2019
+ moveBatchQueryLimit: 2,
2020
+ minBucketChanges: 1,
2021
+ minChangeRatio: 0,
2022
+ maxOpId: 24n
2023
+ });
2024
+
2025
+ // Verify checksum preserved
2026
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2027
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2028
+ expect(checksumAfter).toBe(checksumBefore);
2029
+
2030
+ // Verify MOVE tombstones for superseded old versions (A@10, B@6, C@2)
2031
+ const allOps = await readAllOps(collection);
2032
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
2033
+ expect(moveOps.length).toBe(3);
2034
+ expect(moveOps.map((op) => op.o).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([2n, 6n, 10n]);
2035
+
2036
+ // Verify the newer version of each duplicated row survives as PUT
2037
+ const putOps = allOps.filter((op) => op.op === 'PUT');
2038
+ expect(putOps.length).toBe(16);
2039
+ const putRowIds = putOps.map((op) => op.row_id).sort();
2040
+ expect(putRowIds).toContain('A');
2041
+ expect(putRowIds).toContain('B');
2042
+ expect(putRowIds).toContain('C');
2043
+ });
2044
+
2045
+ test('2. scoped delete isolation - ops above maxOpId preserved', async () => {
2046
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2047
+
2048
+ // Insert ops in _id.o range 100-500 via direct document insertion
2049
+ const doc1 = serializeBucketData(BUCKET, [
2050
+ makeOp(100, 'A', 'a1', ctx, sourceTableId),
2051
+ makeOp(200, 'B', 'b1', ctx, sourceTableId),
2052
+ makeOp(300, 'C', 'c1', ctx, sourceTableId)
2053
+ ]);
2054
+ const doc2 = serializeBucketData(BUCKET, [
2055
+ makeOp(400, 'D', 'd1', ctx, sourceTableId),
2056
+ makeOp(500, 'E', 'e1', ctx, sourceTableId)
2057
+ ]);
2058
+ // One extra document with _id.o=600 — clearly outside the batch range
2059
+ const docOutside = serializeBucketData(BUCKET, [makeOp(600, 'F', 'f_outside', ctx, sourceTableId)]);
2060
+
2061
+ await insertDocs(collection, [doc1, doc2, docOutside]);
2062
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 600n);
2063
+
2064
+ // Run compaction with maxOpId=500n
2065
+ await bucketStorage.compact({
2066
+ clearBatchLimit: 200,
2067
+ moveBatchLimit: 10,
2068
+ moveBatchQueryLimit: 2,
2069
+ minBucketChanges: 1,
2070
+ minChangeRatio: 0,
2071
+ maxOpId: 500n
2072
+ });
2073
+
2074
+ // The document at _id.o=600 must still be present and unmodified after compaction.
2075
+ // The streaming compactor should only touch documents containing ops <= maxOpId.
2076
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2077
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
2078
+ const op600 = allOpsAfter.find((op) => op.o === 600n);
2079
+ expect(op600).toBeDefined();
2080
+ expect(op600!.op).toBe('PUT');
2081
+ expect(op600!.row_id).toBe('F');
2082
+
2083
+ const state = await bucketStateCollection.findOne({ _id: { d: ctx.definitionId, b: BUCKET } });
2084
+ expect(state?.bucket_stats).toEqual({
2085
+ count: docsAfter.reduce((total, document) => total + document.count, 0),
2086
+ bytes: BigInt(docsAfter.reduce((total, document) => total + document.size, 0)),
2087
+ chunks: docsAfter.length
2088
+ });
2089
+ });
2090
+
2091
+ test('3. seen map overflow - some old ops pass through without tombstoning', async () => {
2092
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2093
+
2094
+ // 20 rows, each with 2 versions (old + new). 40 ops total.
2095
+ // memoryLimitMB=0.001 → idLimitBytes ≈ 1048 bytes.
2096
+ // The seen map adds ~140 bytes per entry (key.length + 140), so it can hold ~7 entries
2097
+ // before overflowing. The streaming compactor processes ops newest-first; after ~7 rows
2098
+ // are tracked, the seen map overflows and subsequent old-version ops are not tombstoned.
2099
+ const rows = Array.from({ length: 20 }, (_, i) => `row${i + 1}`);
2100
+ const allOps: BucketDataDoc[] = [];
2101
+
2102
+ // Old versions at op_ids 1-20 (processed last by descending-order compactor)
2103
+ for (let i = 0; i < 20; i++) {
2104
+ allOps.push(makeOp(i + 1, rows[i], `old_${rows[i]}`, ctx, sourceTableId));
2105
+ }
2106
+ // New versions at op_ids 21-40 (processed first by descending-order compactor)
2107
+ for (let i = 0; i < 20; i++) {
2108
+ allOps.push(makeOp(i + 21, rows[i], `new_${rows[i]}`, ctx, sourceTableId));
2109
+ }
2110
+
2111
+ // Single document containing all ops
2112
+ const doc = serializeBucketData(BUCKET, allOps);
2113
+ await insertDocs(collection, [doc]);
2114
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
2115
+
2116
+ // Compute checksum before compaction
2117
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2118
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2119
+
2120
+ await bucketStorage.compact({
2121
+ clearBatchLimit: 200,
2122
+ moveBatchLimit: 10,
2123
+ moveBatchQueryLimit: 100,
2124
+ minBucketChanges: 1,
2125
+ minChangeRatio: 0,
2126
+ maxOpId: 40n,
2127
+ memoryLimitMB: 0.001
2128
+ });
2129
+
2130
+ // Bucket checksum must be correct even with overflow — old ops that pass through
2131
+ // as PUT still contribute their original checksum.
2132
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2133
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2134
+ expect(checksumAfter).toBe(checksumBefore);
2135
+
2136
+ // With seen map overflow, fewer than 20 old-version ops become MOVE tombstones.
2137
+ // Some old-version ops pass through as PUT because the seen map was full when
2138
+ // the compactor encountered them. With ~1048 bytes and ~176 bytes per entry,
2139
+ // approximately 5-7 rows are tracked before overflow, producing ~5-7 MOVE
2140
+ // tombstones from the 20 duplicate pairs.
2141
+ const opsAfter = await readAllOps(collection);
2142
+ const moveOps = opsAfter.filter((op) => op.op === 'MOVE');
2143
+ expect(moveOps.length).toBeGreaterThan(0);
2144
+ expect(moveOps.length).toBeLessThanOrEqual(12);
2145
+ });
2146
+
2147
+ test('5. scoped delete does not remove sandwiched non-processable docs', async () => {
2148
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2149
+
2150
+ // Doc1: processable (has ops <= maxOpId=300), _id.o = 300
2151
+ const doc1 = serializeBucketData(BUCKET, [
2152
+ makeOp(200, 'A', 'a1', ctx, sourceTableId),
2153
+ makeOp(300, 'B', 'b1', ctx, sourceTableId)
2154
+ ]);
2155
+ // Doc2: NON-processable (all ops > maxOpId=300), _id.o = 350
2156
+ // This doc is sandwiched between doc1 (_id.o=300) and doc3 (_id.o=500)
2157
+ // in _id.o sort order. A continuous range delete [200, 500] would
2158
+ // incorrectly catch this doc.
2159
+ const doc2 = serializeBucketData(BUCKET, [
2160
+ makeOp(340, 'X', 'x_sandwich', ctx, sourceTableId),
2161
+ makeOp(350, 'Y', 'y_sandwich', ctx, sourceTableId)
2162
+ ]);
2163
+ // Doc3: processable (has ops <= maxOpId=300), _id.o = 400
2164
+ const doc3 = serializeBucketData(BUCKET, [
2165
+ makeOp(250, 'C', 'c1', ctx, sourceTableId),
2166
+ makeOp(400, 'D', 'd1', ctx, sourceTableId)
2167
+ ]);
2168
+
2169
+ await insertDocs(collection, [doc1, doc2, doc3]);
2170
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 400n);
2171
+
2172
+ await bucketStorage.compact({
2173
+ clearBatchLimit: 200,
2174
+ moveBatchLimit: 10,
2175
+ moveBatchQueryLimit: 10,
2176
+ minBucketChanges: 1,
2177
+ minChangeRatio: 0,
2178
+ maxOpId: 300n
2179
+ });
2180
+
2181
+ // The sandwiched document (doc2, ops 340+350) must survive because ALL
2182
+ // its ops are > maxOpId — it should not be touched by compaction.
2183
+ const opsAfter = await readAllOps(collection);
2184
+ const sandwichOps = opsAfter.filter((op) => op.row_id === 'X' || op.row_id === 'Y');
2185
+ expect(sandwichOps.length).toBe(2);
2186
+ expect(sandwichOps.every((op) => op.op === 'PUT')).toBe(true);
2187
+ });
2188
+
2189
+ test('6. byte-based read batches do not constrain output merging', async () => {
2190
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2191
+
2192
+ // 6 documents, each with 2 PUT ops. With moveBatchByteLimit=400,
2193
+ // the byte limit should be hit after 1-2 documents, forcing multiple
2194
+ // batch iterations even though the document count limit (10) is never reached.
2195
+ const rows = Array.from({ length: 12 }, (_, i) => `row${i + 1}`);
2196
+ const docs: BucketDataDocumentV3[] = [];
2197
+ for (let i = 0; i < 6; i++) {
2198
+ docs.push(
2199
+ serializeBucketData(BUCKET, [
2200
+ makeOp(i * 2 + 1, rows[i * 2], `data_${rows[i * 2]}`, ctx, sourceTableId),
2201
+ makeOp(i * 2 + 2, rows[i * 2 + 1], `data_${rows[i * 2 + 1]}`, ctx, sourceTableId)
2202
+ ])
2203
+ );
2204
+ }
2205
+ expect(docs[0].size + docs[1].size).toBeGreaterThan(400);
2206
+ await insertDocs(collection, docs);
2207
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 12n);
2208
+
2209
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2210
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2211
+
2212
+ await bucketStorage.compact({
2213
+ clearBatchLimit: 200,
2214
+ moveBatchLimit: 10,
2215
+ moveBatchQueryLimit: 10,
2216
+ moveBatchByteLimit: 400,
2217
+ minBucketChanges: 1,
2218
+ minChangeRatio: 0,
2219
+ maxOpId: 12n
2220
+ });
2221
+
2222
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2223
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2224
+ expect(checksumAfter).toBe(checksumBefore);
2225
+
2226
+ // All 12 ops should survive — no superseding, just byte-based batching
2227
+ const allOps = await readAllOps(collection);
2228
+ expect(allOps.length).toBe(12);
2229
+ expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
2230
+
2231
+ // Read batches are only a memory bound. The pending output group crosses
2232
+ // those boundaries and merges all post-compaction results that fit.
2233
+ expect(docsAfter).toHaveLength(1);
2234
+ expect(docsAfter[0]).toMatchObject({
2235
+ min_op: docs[0].min_op,
2236
+ _id: docs[docs.length - 1]._id
2237
+ });
2238
+ });
2239
+
2240
+ test('7. cross-batch seen map overflow - dedup continues across batches', async () => {
2241
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2242
+
2243
+ // 10 rows (A-J), 2 versions each. 20 docs total, 1 PUT per doc.
2244
+ // New versions at opIds 20-11 (docs 1-10), old versions at opIds 10-1 (docs 11-20).
2245
+ // Processed newest-first: new versions fill the seen map; old versions that
2246
+ // were tracked get tombstoned; old versions beyond map capacity survive as PUT.
2247
+ const rows = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
2248
+ const docs: BucketDataDocumentV3[] = [];
2249
+
2250
+ // New versions first (higher opIds — processed first in reverse order)
2251
+ for (let i = 0; i < 10; i++) {
2252
+ docs.push(
2253
+ serializeBucketData(BUCKET, [makeOp(20 - i, rows[i], `${rows[i].toLowerCase()}_new`, ctx, sourceTableId)])
2254
+ );
2255
+ }
2256
+ // Old versions (lower opIds — processed later)
2257
+ for (let i = 0; i < 10; i++) {
2258
+ docs.push(
2259
+ serializeBucketData(BUCKET, [makeOp(10 - i, rows[i], `${rows[i].toLowerCase()}_old`, ctx, sourceTableId)])
2260
+ );
2261
+ }
2262
+ await insertDocs(collection, docs);
2263
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
2264
+
2265
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2266
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2267
+
2268
+ // moveBatchQueryLimit=5 forces 4 cross-batch iterations. memoryLimitMB=0.001
2269
+ // limits the seen map to ~6-7 entries, so overflow occurs mid-processing.
2270
+ await bucketStorage.compact({
2271
+ clearBatchLimit: 200,
2272
+ moveBatchLimit: 10,
2273
+ moveBatchQueryLimit: 5,
2274
+ minBucketChanges: 1,
2275
+ minChangeRatio: 0,
2276
+ maxOpId: 20n,
2277
+ memoryLimitMB: 0.001
2278
+ });
2279
+
2280
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2281
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2282
+ expect(checksumAfter).toBe(checksumBefore);
2283
+
2284
+ const allOps = await readAllOps(collection);
2285
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
2286
+
2287
+ // At least some rows were tracked before overflow → MOVE tombstones exist
2288
+ expect(moveOps.length).toBeGreaterThan(0);
2289
+
2290
+ // Some old-version ops (o <= 10) survive as PUT — overflow prevented tracking
2291
+ const oldVersionPutOps = allOps.filter((op) => op.op === 'PUT' && op.o <= 10n);
2292
+ expect(oldVersionPutOps.length).toBeGreaterThan(0);
2293
+
2294
+ // Not all 10 old versions became MOVEs (overflow occurred)
2295
+ expect(moveOps.length).toBeLessThan(10);
2296
+
2297
+ // MOVEs have target_op stored at the document level (not per-op in V3).
2298
+ // Documents containing MOVEs should have non-null target_op.
2299
+ const docsWithMoves = docsAfter.filter((d) => d.ops!.some((op: any) => op.op === 'MOVE'));
2300
+ expect(docsWithMoves.length).toBeGreaterThan(0);
2301
+ for (const doc of docsWithMoves) {
2302
+ expect(doc.target_op).toBeDefined();
2303
+ expect(doc.target_op).not.toBeNull();
2304
+ }
2305
+ });
2306
+
2307
+ test('8. all ops above maxOpId - paginates without writing', async () => {
2308
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2309
+
2310
+ // 5 docs, each with 1 PUT op at high opIds (all > maxOpId=10).
2311
+ // The compactor should paginate through batches where processableDocs
2312
+ // is always empty, terminating cleanly without touching any data.
2313
+ const docs: BucketDataDocumentV3[] = [];
2314
+ for (let i = 0; i < 5; i++) {
2315
+ docs.push(serializeBucketData(BUCKET, [makeOp((i + 1) * 100, `row${i}`, `data${i}`, ctx, sourceTableId)]));
2316
+ }
2317
+ await insertDocs(collection, docs);
2318
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 500n);
2319
+
2320
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2321
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2322
+
2323
+ await bucketStorage.compact({
2324
+ clearBatchLimit: 200,
2325
+ moveBatchLimit: 10,
2326
+ moveBatchQueryLimit: 2,
2327
+ minBucketChanges: 1,
2328
+ minChangeRatio: 0,
2329
+ maxOpId: 10n
2330
+ });
2331
+
2332
+ // All ops are > maxOpId, so processableDocs is always empty.
2333
+ // The compactor should paginate through all documents without
2334
+ // deleting or modifying anything.
2335
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2336
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2337
+ expect(checksumAfter).toBe(checksumBefore);
2338
+
2339
+ // All 5 docs should still exist with their PUT ops intact
2340
+ expect(docsAfter.length).toBe(5);
2341
+ const allOps = await readAllOps(collection);
2342
+ expect(allOps.length).toBe(5);
2343
+ expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
2344
+ });
2345
+
2346
+ test('9. maxOpId filtering leaves a straddling document untouched', async () => {
2347
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2348
+
2349
+ // Single document with two ops: one <= maxOpId, one > maxOpId
2350
+ const doc = serializeBucketData(BUCKET, [
2351
+ makeOp(200, 'A', 'old_A', ctx, sourceTableId),
2352
+ makeOp(400, 'B', 'new_B', ctx, sourceTableId)
2353
+ ]);
2354
+ await insertDocs(collection, [doc]);
2355
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 400n);
2356
+
2357
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2358
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2359
+
2360
+ await bucketStorage.compact({
2361
+ clearBatchLimit: 200,
2362
+ moveBatchLimit: 10,
2363
+ moveBatchQueryLimit: 10,
2364
+ minBucketChanges: 1,
2365
+ minChangeRatio: 0,
2366
+ maxOpId: 300n
2367
+ });
2368
+
2369
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2370
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2371
+ expect(checksumAfter).toBe(checksumBefore);
2372
+
2373
+ const allOps = await readAllOps(collection);
2374
+ expect(allOps.length).toBe(2);
2375
+
2376
+ const op400 = allOps.find((op) => op.o === 400n);
2377
+ expect(op400).toBeDefined();
2378
+ expect(op400!.op).toBe('PUT');
2379
+ expect(op400!.row_id).toBe('B');
2380
+ });
2381
+
2382
+ test('10. compacted documents have non-overlapping ranges', async () => {
2383
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2384
+
2385
+ // Four documents with truly disjoint [min_op, _id.o] ranges.
2386
+ // maxOpId=350: only doc1 is processable; doc2 straddles the horizon.
2387
+ // Compaction must preserve range disjointness after rechunking.
2388
+ const doc1 = serializeBucketData(BUCKET, [
2389
+ makeOp(100, 'A', 'a1', ctx, sourceTableId),
2390
+ makeOp(200, 'B', 'b1', ctx, sourceTableId)
2391
+ ]); // range [100, 200]
2392
+ const doc2 = serializeBucketData(BUCKET, [
2393
+ makeOp(300, 'C', 'c1', ctx, sourceTableId),
2394
+ makeOp(400, 'D', 'd1', ctx, sourceTableId)
2395
+ ]); // range [300, 400], mixed (300≤350, 400>350)
2396
+ const doc3 = serializeBucketData(BUCKET, [
2397
+ makeOp(500, 'E', 'e1', ctx, sourceTableId),
2398
+ makeOp(600, 'F', 'f1', ctx, sourceTableId)
2399
+ ]); // range [500, 600], all >350
2400
+ const doc4 = serializeBucketData(BUCKET, [
2401
+ makeOp(700, 'G', 'g1', ctx, sourceTableId),
2402
+ makeOp(800, 'H', 'h1', ctx, sourceTableId)
2403
+ ]); // range [700, 800], all >350
2404
+
2405
+ await insertDocs(collection, [doc1, doc2, doc3, doc4]);
2406
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 800n);
2407
+
2408
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2409
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2410
+
2411
+ await bucketStorage.compact({
2412
+ clearBatchLimit: 200,
2413
+ moveBatchLimit: 10,
2414
+ moveBatchQueryLimit: 10,
2415
+ minBucketChanges: 1,
2416
+ minChangeRatio: 0,
2417
+ maxOpId: 350n
2418
+ });
2419
+
2420
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
2421
+
2422
+ // Every pair of documents must have disjoint [min_op, _id.o] ranges.
2423
+ const overlaps = docsAfter.flatMap((a, i) =>
2424
+ docsAfter
2425
+ .slice(i + 1)
2426
+ .filter((b) => a._id.o >= b.min_op && b._id.o >= a.min_op)
2427
+ .map((b) => `[${a.min_op}, ${a._id.o}] vs [${b.min_op}, ${b._id.o}]`)
2428
+ );
2429
+ expect(overlaps).toEqual([]);
2430
+
2431
+ // Checksum must be preserved across compaction
2432
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
2433
+ expect(checksumAfter).toBe(checksumBefore);
2434
+ });
2435
+
2436
+ describe('V3 CLEAR pass', () => {
2437
+ async function doCompact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
2438
+ await bucketStorage.compact({
2439
+ clearBatchLimit: 200,
2440
+ moveBatchLimit: 10,
2441
+ moveBatchQueryLimit: 10,
2442
+ minBucketChanges: 1,
2443
+ minChangeRatio: 0,
2444
+ maxOpId
2445
+ });
2446
+ }
2447
+
2448
+ // Helper to find a CLEAR op in a bucket
2449
+ async function hasClearOp(collection: any): Promise<boolean> {
2450
+ const allOps = await readAllOps(collection);
2451
+ return allOps.some((op) => op.op === 'CLEAR');
2452
+ }
2453
+
2454
+ // Helper to assert checksum preservation before/after compact
2455
+ async function assertChecksumPreserved(collection: any, compactFn: () => Promise<void>) {
2456
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2457
+ const checksumBefore = docsBefore.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
2458
+ await compactFn();
2459
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2460
+ const checksumAfter = docsAfter.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
2461
+ expect(checksumAfter).toBe(checksumBefore);
2462
+ }
2463
+
2464
+ test('duplicate rows produce CLEAR op', async () => {
2465
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2466
+
2467
+ const doc = serializeBucketData(BUCKET, [
2468
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2469
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
2470
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
2471
+ ]);
2472
+ await insertDocs(collection, [doc]);
2473
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
2474
+
2475
+ await doCompact(bucketStorage, 30n);
2476
+
2477
+ expect(await hasClearOp(collection)).toBe(true);
2478
+ });
2479
+
2480
+ test('unique rows produce no CLEAR op', async () => {
2481
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2482
+
2483
+ const doc = serializeBucketData(BUCKET, [
2484
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2485
+ makeOp(20, 'B', 'b1', ctx, sourceTableId)
2486
+ ]);
2487
+ await insertDocs(collection, [doc]);
2488
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
2489
+
2490
+ await doCompact(bucketStorage, 20n);
2491
+
2492
+ expect(await hasClearOp(collection)).toBe(false);
2493
+ const allOps = await readAllOps(collection);
2494
+ expect(allOps.length).toBe(2);
2495
+ expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
2496
+ });
2497
+
2498
+ test('end-to-end: duplicate + unique rows, checksum preserved', async () => {
2499
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2500
+
2501
+ const doc = serializeBucketData(BUCKET, [
2502
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2503
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
2504
+ makeOp(30, 'B', 'b1', ctx, sourceTableId),
2505
+ makeOp(40, 'A', 'a3', ctx, sourceTableId)
2506
+ ]);
2507
+ await insertDocs(collection, [doc]);
2508
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
2509
+
2510
+ await assertChecksumPreserved(collection, () => doCompact(bucketStorage, 40n));
2511
+
2512
+ expect(await hasClearOp(collection)).toBe(true);
2513
+ const allOps = await readAllOps(collection);
2514
+ const putOps = allOps.filter((op) => op.op === 'PUT');
2515
+ expect(putOps.length).toBe(2);
2516
+ expect(putOps.some((op) => op.row_id === 'B' && op.o === 30n)).toBe(true);
2517
+ expect(putOps.some((op) => op.row_id === 'A' && op.o === 40n)).toBe(true);
2518
+ });
2519
+
2520
+ // 15 MOVE ops, each in its own document. moveBatchQueryLimit=1
2521
+ // forces the MOVE pass to produce 15 separate rechunked documents
2522
+ // (one per batch). clearBatchLimit=5 forces the CLEAR read loop
2523
+ // to paginate through 3 iterations.
2524
+ test('read pagination iterates across multiple read batches', async () => {
2525
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2526
+
2527
+ const docs: BucketDataDocumentV3[] = [];
2528
+ for (let i = 1; i <= 15; i++) {
2529
+ const op = { ...makeOp(i, `row_${i}`, '', ctx, sourceTableId), op: 'MOVE' as const, data: null };
2530
+ docs.push(serializeBucketData(BUCKET, [op]));
2531
+ }
2532
+ await insertDocs(collection, docs);
2533
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 15n);
2534
+
2535
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2536
+ const checksumBefore = docsBefore.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
2537
+
2538
+ await bucketStorage.compact({
2539
+ clearBatchLimit: 5,
2540
+ moveBatchLimit: 1,
2541
+ moveBatchQueryLimit: 1,
2542
+ minBucketChanges: 1,
2543
+ minChangeRatio: 0,
2544
+ maxOpId: 15n
2545
+ });
2546
+
2547
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2548
+ const checksumAfter = docsAfter.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
2549
+ expect(checksumAfter).toBe(checksumBefore);
2550
+
2551
+ expect(await hasClearOp(collection)).toBe(true);
2552
+ const allOps = await readAllOps(collection);
2553
+ expect(allOps.length).toBe(1);
2554
+ expect(allOps[0].op).toBe('CLEAR');
2555
+ });
2556
+
2557
+ test('CLEAR document carries target_op from collapsed MOVE tombstones', async () => {
2558
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2559
+
2560
+ // Three versions of row A: ops 10, 20, 30.
2561
+ // MOVE pass: 30 survives, 20 and 10 become MOVE tombstones (target=30).
2562
+ // CLEAR pass: collapses both MOVEs. The CLEAR doc should carry target_op=30.
2563
+ const doc = serializeBucketData(BUCKET, [
2564
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2565
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
2566
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
2567
+ ]);
2568
+ await insertDocs(collection, [doc]);
2569
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
2570
+
2571
+ await doCompact(bucketStorage, 30n);
2572
+
2573
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2574
+ const clearDoc = docsAfter.find((d: any) => d.ops!.some((op: any) => op.op === 'CLEAR'));
2575
+ expect(clearDoc).toBeDefined();
2576
+ expect(clearDoc!.target_op).toBe(30n);
2577
+ });
2578
+
2579
+ test('straddling document is excluded from compacted state', async () => {
2580
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2581
+
2582
+ // Row A at 10, 30, 50 — ops 10+30 ≤ maxOpId=40, op 50 > 40 (pass-through)
2583
+ // Row B at 20 — ≤ 40, first occurrence
2584
+ // Row C at 60 — > 40 (pass-through, no earlier version)
2585
+ const doc = serializeBucketData(BUCKET, [
2586
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2587
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
2588
+ makeOp(30, 'A', 'a2', ctx, sourceTableId),
2589
+ makeOp(50, 'A', 'a3', ctx, sourceTableId),
2590
+ makeOp(60, 'C', 'c1', ctx, sourceTableId)
2591
+ ]);
2592
+ await insertDocs(collection, [doc]);
2593
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
2594
+
2595
+ await doCompact(bucketStorage, 40n);
2596
+
2597
+ const state = await bucketStateCollection.findOne({
2598
+ _id: { d: ctx.definitionId, b: BUCKET }
2599
+ });
2600
+ expect(state).toBeDefined();
2601
+ expect(state!.compacted_state).toBeUndefined();
2602
+
2603
+ // The document ends beyond maxOpId, so it is not read or modified.
2604
+ const allOps = await readAllOps(collection);
2605
+ expect(allOps.length).toBe(5);
2606
+ expect(allOps.every((op) => op.op == 'PUT')).toBe(true);
2607
+ });
2608
+
2609
+ test('upperBound pagination prevents re-reading replacement documents', async () => {
2610
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2611
+
2612
+ // Two distinct-row PUTs at ~600KB each. Rechunking produces two
2613
+ // documents: C1(_id.o=10) and C2(_id.o=30). The fix sets upperBound
2614
+ // from the oldest replacement C1, so _id < { o: 10 } excludes both.
2615
+ // No re-read. Both PUTs survive with intact data and valid row_ids.
2616
+ const largeData = 'x'.repeat(600_000);
2617
+ const doc = serializeBucketData(BUCKET, [
2618
+ makeOp(10, 'A', largeData, ctx, sourceTableId),
2619
+ makeOp(30, 'B', largeData, ctx, sourceTableId)
2620
+ ]);
2621
+ await insertDocs(collection, [doc]);
2622
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
2623
+
2624
+ await bucketStorage.compact({
2625
+ clearBatchLimit: 200,
2626
+ moveBatchLimit: 10,
2627
+ moveBatchQueryLimit: 1,
2628
+ minBucketChanges: 1,
2629
+ minChangeRatio: 0,
2630
+ maxOpId: 40n
2631
+ });
2632
+
2633
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2634
+ const rawOps = docsAfter.flatMap((d: any) => d.ops) as any[];
2635
+
2636
+ // Row A@10 must survive as PUT with data intact
2637
+ const rowA = rawOps.find((op: any) => op.row_id === 'A' && op.o === 10n);
2638
+ expect(rowA).toBeDefined();
2639
+ expect(rowA!.op).toBe('PUT');
2640
+ expect(rowA!.data).not.toBeNull();
2641
+
2642
+ // Row B@30 must survive as PUT with data intact
2643
+ const rowB = rawOps.find((op: any) => op.row_id === 'B' && op.o === 30n);
2644
+ expect(rowB).toBeDefined();
2645
+ expect(rowB!.op).toBe('PUT');
2646
+ expect(rowB!.data).not.toBeNull();
2647
+
2648
+ // Checksum preserved
2649
+ const expectedChecksum = [10, 30].reduce((sum, id) => addChecksums(sum, id * 7), 0);
2650
+ const checksumAfter = docsAfter.reduce((s: number, d: any) => addChecksums(s, Number(d.checksum)), 0);
2651
+ expect(checksumAfter).toBe(expectedChecksum);
2652
+ });
2653
+ });
245
2654
  });