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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +34 -16
  3. package/dist/storage/MongoBucketStorage.js +219 -70
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +42 -23
  6. package/dist/storage/implementation/MongoBucketBatch.js +61 -44
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoChecksums.d.ts +10 -9
  9. package/dist/storage/implementation/MongoChecksums.js +3 -111
  10. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  11. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  12. package/dist/storage/implementation/MongoCompactor.js +18 -9
  13. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  15. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  16. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +6 -5
  18. package/dist/storage/implementation/MongoParameterCompactor.js +24 -1
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +13 -0
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +64 -0
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +123 -0
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  29. package/dist/storage/implementation/MongoStorageProvider.js +5 -2
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +41 -23
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +51 -43
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +3 -3
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +6 -0
  38. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  39. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  40. package/dist/storage/implementation/common/PersistedBatch.d.ts +1 -2
  41. package/dist/storage/implementation/common/PersistedBatch.js +5 -0
  42. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  43. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  44. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  45. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  46. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  47. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  48. package/dist/storage/implementation/db.js +9 -14
  49. package/dist/storage/implementation/db.js.map +1 -1
  50. package/dist/storage/implementation/models.d.ts +4 -13
  51. package/dist/storage/implementation/models.js.map +1 -1
  52. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +9 -1
  53. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +42 -26
  54. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  55. package/dist/storage/implementation/v1/MongoChecksumsV1.js +114 -3
  56. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +15 -17
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +47 -45
  59. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  60. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  61. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  62. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  63. package/dist/storage/implementation/v1/models.d.ts +10 -1
  64. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -4
  65. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +286 -259
  66. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  67. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +27 -7
  68. package/dist/storage/implementation/v3/MongoChecksumsV3.js +202 -18
  69. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +19 -2
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +511 -22
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +62 -0
  74. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +350 -0
  75. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +40 -28
  77. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +232 -116
  78. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +1 -1
  80. package/dist/storage/implementation/v3/PersistedBatchV3.js +43 -20
  81. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +52 -16
  83. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  85. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  86. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  87. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +13 -12
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +29 -27
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  90. package/dist/storage/implementation/v3/bucket-format.d.ts +4 -0
  91. package/dist/storage/implementation/v3/bucket-format.js +58 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  93. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  94. package/dist/storage/implementation/v3/chunking.js +28 -0
  95. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  96. package/dist/storage/implementation/v3/models.d.ts +64 -21
  97. package/dist/storage/implementation/v3/models.js +11 -28
  98. package/dist/storage/implementation/v3/models.js.map +1 -1
  99. package/dist/storage/implementation/v3/source-table-utils.d.ts +55 -0
  100. package/dist/storage/implementation/v3/source-table-utils.js +214 -0
  101. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  102. package/dist/storage/storage-index.d.ts +4 -4
  103. package/dist/storage/storage-index.js +4 -4
  104. package/dist/storage/storage-index.js.map +1 -1
  105. package/dist/types/types.d.ts +2 -2
  106. package/dist/utils/test-utils.d.ts +1 -2
  107. package/dist/utils/test-utils.js +5 -1
  108. package/dist/utils/test-utils.js.map +1 -1
  109. package/dist/utils/util.d.ts +1 -1
  110. package/dist/utils/util.js +3 -2
  111. package/dist/utils/util.js.map +1 -1
  112. package/package.json +9 -19
  113. package/src/storage/MongoBucketStorage.ts +334 -87
  114. package/src/storage/implementation/MongoBucketBatch.ts +86 -58
  115. package/src/storage/implementation/MongoChecksums.ts +6 -138
  116. package/src/storage/implementation/MongoCompactor.ts +19 -9
  117. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  118. package/src/storage/implementation/MongoParameterCompactor.ts +24 -5
  119. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +88 -0
  120. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  121. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +154 -0
  122. package/src/storage/implementation/MongoStorageProvider.ts +5 -2
  123. package/src/storage/implementation/MongoSyncBucketStorage.ts +70 -56
  124. package/src/storage/implementation/MongoSyncRulesLock.ts +7 -4
  125. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +7 -0
  126. package/src/storage/implementation/common/PersistedBatch.ts +7 -3
  127. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  128. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  129. package/src/storage/implementation/db.ts +11 -19
  130. package/src/storage/implementation/models.ts +4 -14
  131. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +55 -34
  132. package/src/storage/implementation/v1/MongoChecksumsV1.ts +137 -4
  133. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +70 -64
  134. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  135. package/src/storage/implementation/v1/models.ts +11 -1
  136. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +358 -314
  137. package/src/storage/implementation/v3/MongoChecksumsV3.ts +262 -26
  138. package/src/storage/implementation/v3/MongoCompactorV3.ts +646 -29
  139. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +480 -0
  140. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +315 -157
  141. package/src/storage/implementation/v3/PersistedBatchV3.ts +62 -36
  142. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +59 -19
  143. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  144. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +41 -36
  145. package/src/storage/implementation/v3/bucket-format.ts +70 -0
  146. package/src/storage/implementation/v3/chunking.ts +38 -0
  147. package/src/storage/implementation/v3/models.ts +93 -55
  148. package/src/storage/implementation/v3/source-table-utils.ts +392 -0
  149. package/src/storage/storage-index.ts +12 -4
  150. package/src/utils/test-utils.ts +6 -3
  151. package/src/utils/util.ts +3 -2
  152. package/test/src/__snapshots__/storage.test.ts.snap +0 -75
  153. package/test/src/__snapshots__/storage_sync.test.ts.snap +48 -48
  154. package/test/src/cleanup-stopped-sync-configs.test.ts +648 -0
  155. package/test/src/storage.test.ts +11 -11
  156. package/test/src/storage_compacting.test.ts +1956 -6
  157. package/test/src/storage_sync.test.ts +1077 -68
  158. package/test/src/storeCurrentData.test.ts +17 -7
  159. package/test/src/util.ts +2 -1
  160. package/tsconfig.json +1 -4
  161. package/tsconfig.tsbuildinfo +1 -1
  162. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  163. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  164. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  165. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  166. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  167. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  168. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  169. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  170. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  171. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  172. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  173. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  174. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +0 -9
  175. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +0 -18
  176. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +0 -1
  177. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  178. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  179. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  180. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  181. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  182. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  183. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  184. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +0 -24
  185. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
@@ -1,6 +1,12 @@
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 { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
1
6
  import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
2
- import { storage, SyncRulesBucketStorage, updateSyncRulesFromYaml } from '@powersync/service-core';
7
+ import { addChecksums, storage, SyncRulesBucketStorage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
8
  import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
9
+ import * as bson from 'bson';
4
10
  import { describe, expect, test } from 'vitest';
5
11
  import { INITIALIZED_MONGO_STORAGE_FACTORY } from './util.js';
6
12
 
@@ -57,9 +63,10 @@ bucket_definitions:
57
63
  `)
58
64
  );
59
65
  const bucketStorage = factory.getInstance(syncRules);
66
+ const syncRulesContent = syncRules.syncConfigContent[0];
60
67
  const { checkpoint } = await populate(bucketStorage, 1);
61
68
 
62
- return { bucketStorage, checkpoint, factory, syncRules };
69
+ return { bucketStorage, checkpoint, factory, syncRules: syncRulesContent };
63
70
  };
64
71
 
65
72
  test('full compact', async () => {
@@ -70,7 +77,7 @@ bucket_definitions:
70
77
  if (storageDb.storageConfig.incrementalReprocessing) {
71
78
  // This should actually never happen on V3, but we test this anyway.
72
79
  // Can remove this if it causes issues in the future.
73
- await (storageDb as VersionedPowerSyncMongoV3).bucketStateV3(bucketStorage.group_id).deleteMany({});
80
+ await (storageDb as VersionedPowerSyncMongoV3).bucketState(bucketStorage.replicationStreamId).deleteMany({});
74
81
  } else {
75
82
  await factory.db.bucket_state.deleteMany({});
76
83
  }
@@ -115,6 +122,7 @@ bucket_definitions:
115
122
  `)
116
123
  );
117
124
  const bucketStorage = factory.getInstance(syncRules);
125
+ const syncRulesContent = syncRules.syncConfigContent[0];
118
126
  const storageDb = (bucketStorage as any).db;
119
127
 
120
128
  await populate(bucketStorage, 2);
@@ -141,7 +149,7 @@ bucket_definitions:
141
149
  expect(result2.buckets).toEqual(0);
142
150
 
143
151
  const users = ['u1', 'u2'];
144
- const userRequests = users.map((user) => bucketRequest(syncRules, `by_user2["${user}"]`));
152
+ const userRequests = users.map((user) => bucketRequest(syncRulesContent, `by_user2["${user}"]`));
145
153
  const [u1Request, u2Request] = userRequests;
146
154
  const checksumAfter = await bucketStorage.getChecksums(checkpoint, userRequests);
147
155
  expect(checksumAfter.get(u1Request.bucket)).toEqual({
@@ -172,7 +180,9 @@ bucket_definitions:
172
180
  // This typically happens when buckets get very large (> 2GiB). We don't want to create that much
173
181
  // data in the tests, so we directly insert the bucket_state here.
174
182
  if (storageDb.storageConfig.incrementalReprocessing) {
175
- const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketStateV3(bucketStorage.group_id);
183
+ const bucketStateCollection = (storageDb as VersionedPowerSyncMongoV3).bucketState(
184
+ bucketStorage.replicationStreamId
185
+ );
176
186
  await bucketStateCollection.insertOne({
177
187
  _id: {
178
188
  d: '1',
@@ -193,7 +203,7 @@ bucket_definitions:
193
203
  } else {
194
204
  await factory.db.bucket_state.insertOne({
195
205
  _id: {
196
- g: bucketStorage.group_id,
206
+ g: bucketStorage.replicationStreamId,
197
207
  b: 'global[]'
198
208
  },
199
209
  last_op: 5n,
@@ -243,3 +253,1943 @@ bucket_definitions:
243
253
  describe('Mongo Sync Parameter Storage Compact', () => {
244
254
  register.registerParameterCompactTests(INITIALIZED_MONGO_STORAGE_FACTORY);
245
255
  });
256
+
257
+ /**
258
+ * V3 Invariant Verification
259
+ *
260
+ * Tests in this block exercise two levels:
261
+ *
262
+ * Unit tests (no MongoDB): call serializeBucketData(), chunkBucketData(), or
263
+ * loadBucketDataDocument() directly. These use makeBucketDataDoc() with fake
264
+ * bucket keys and source table IDs.
265
+ *
266
+ * Integration tests (full MongoDB): provision V3 storage, insert pre-serialized
267
+ * documents directly into collections, trigger compaction via
268
+ * bucketStorage.compact(), then read back and verify. Surfaces exercised:
269
+ *
270
+ * - collection.insertMany (direct bucket_data writes)
271
+ * - bucketStateCollection.insertOne (so compactor discovers buckets)
272
+ * - bucketStorage.compact() (full pipeline: dirtyBucketBatches →
273
+ * compactSingleBucket → delete-all + rechunk in transaction →
274
+ * writeBucketStateUpdates)
275
+ * - collection.find with _id.b filters (verify post-compaction state)
276
+ *
277
+ * The initial write path (MongoBucketBatchV3 → flushBucketDataShared →
278
+ * chunkBucketData → serializeBucketData → bulk write) is NOT exercised here —
279
+ * that path is covered by the existing shared tests in register-compacting-tests.ts.
280
+ */
281
+ describe('V3 invariant verification', () => {
282
+ const BUCKET = 'global[]';
283
+ const TABLE = 'items';
284
+
285
+ function makeBucketDataDoc(overrides: Partial<BucketDataDoc> & { o: bigint }): BucketDataDoc {
286
+ return {
287
+ bucketKey: { replicationStreamId: 1, definitionId: '1', bucket: 'test[]' },
288
+ op: 'PUT',
289
+ source_table: new bson.ObjectId(),
290
+ source_key: 'key',
291
+ table: 'test',
292
+ row_id: 'row1',
293
+ checksum: 1n,
294
+ data: '{"id":"row1"}',
295
+ target_op: null,
296
+ ...overrides
297
+ };
298
+ }
299
+
300
+ async function setupV3Storage() {
301
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
302
+ const syncRules = await factory.updateSyncRules(
303
+ updateSyncRulesFromYaml(
304
+ `
305
+ bucket_definitions:
306
+ global:
307
+ data: [SELECT id as id, description FROM items]
308
+ `,
309
+ { storageVersion: 3 }
310
+ )
311
+ );
312
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
313
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
314
+ const mapping = syncRules.syncConfigContent[0].mapping;
315
+ const definitionId = mapping.allBucketDefinitionIds()[0];
316
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
317
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
318
+ const sourceTableId = new bson.ObjectId();
319
+
320
+ const ctx = {
321
+ replicationStreamId: bucketStorage.replicationStreamId,
322
+ definitionId,
323
+ bucket: BUCKET
324
+ };
325
+
326
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx, definitionId };
327
+ }
328
+
329
+ function makeOp(
330
+ opId: number,
331
+ rowId: string,
332
+ data: string,
333
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
334
+ sourceTableId: bson.ObjectId,
335
+ overrides?: { op?: 'PUT' | 'REMOVE' }
336
+ ): BucketDataDoc {
337
+ return {
338
+ bucketKey: {
339
+ replicationStreamId: ctx.replicationStreamId,
340
+ definitionId: ctx.definitionId,
341
+ bucket: ctx.bucket
342
+ },
343
+ o: BigInt(opId),
344
+ op: overrides?.op ?? 'PUT',
345
+ source_table: sourceTableId,
346
+ source_key: test_utils.rid(rowId),
347
+ table: TABLE,
348
+ row_id: rowId,
349
+ checksum: BigInt(opId * 7),
350
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
351
+ target_op: null
352
+ };
353
+ }
354
+
355
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
356
+ await collection.insertMany(docs);
357
+ }
358
+
359
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
360
+ await bucketStateCollection.insertOne({
361
+ _id: { d: definitionId, b: BUCKET },
362
+ last_op: lastOp,
363
+ estimate_since_compact: { count: 10, bytes: 100 }
364
+ });
365
+ }
366
+
367
+ async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
368
+ await bucketStorage.compact({
369
+ clearBatchLimit: 200,
370
+ moveBatchLimit: 10,
371
+ moveBatchQueryLimit: 10,
372
+ minBucketChanges: 1,
373
+ minChangeRatio: 0,
374
+ maxOpId,
375
+ signal: null as any
376
+ });
377
+ }
378
+
379
+ test('1. ops[] ordering - preserves caller ordering (no implicit sort)', () => {
380
+ const ops = [
381
+ makeBucketDataDoc({ o: 5n, data: '{"id":"c"}' }),
382
+ makeBucketDataDoc({ o: 3n, data: '{"id":"a"}' }),
383
+ makeBucketDataDoc({ o: 7n, data: '{"id":"b"}' })
384
+ ];
385
+
386
+ const doc = serializeBucketData('test[]', ops);
387
+
388
+ expect(doc.ops.map((op) => op.o)).toEqual([5n, 3n, 7n]);
389
+ });
390
+
391
+ test('1. ops[] ordering - preserved after compaction', async () => {
392
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
393
+ const ops = [
394
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
395
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
396
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
397
+ ];
398
+ const doc1 = serializeBucketData(BUCKET, ops);
399
+ await insertDocs(collection, [doc1]);
400
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
401
+
402
+ await compact(bucketStorage, 30n);
403
+
404
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
405
+ for (const doc of docs) {
406
+ for (let i = 1; i < doc.ops.length; i++) {
407
+ expect(doc.ops[i].o).toBeGreaterThanOrEqual(doc.ops[i - 1].o);
408
+ }
409
+ }
410
+ });
411
+
412
+ test('2. range metadata consistency - serializeBucketData fields', () => {
413
+ const ops = [
414
+ makeBucketDataDoc({ o: 3n, checksum: 10n, data: 'aaaa' }),
415
+ makeBucketDataDoc({ o: 5n, checksum: 20n, data: 'bbbbb' }),
416
+ makeBucketDataDoc({ o: 8n, checksum: 30n, data: 'cccccccc' })
417
+ ];
418
+
419
+ const doc = serializeBucketData('test[]', ops);
420
+
421
+ expect(doc._id.o).toBe(8n);
422
+ expect(doc.min_op).toBe(3n);
423
+ expect(doc.count).toBe(3);
424
+ expect(doc.checksum).toBe(60n);
425
+ expect(doc.size).toBe(4 + 5 + 8);
426
+ });
427
+
428
+ test('2. range metadata consistency - after compaction', async () => {
429
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
430
+ const ops = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
431
+ const doc1 = serializeBucketData(BUCKET, ops);
432
+ await insertDocs(collection, [doc1]);
433
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
434
+
435
+ await compact(bucketStorage, 20n);
436
+
437
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
438
+ for (const doc of docs) {
439
+ expect(doc._id.o).toBe(doc.ops.reduce((max, op) => (op.o > max ? op.o : max), 0n));
440
+ expect(doc.min_op).toBe(doc.ops.reduce((min, op) => (op.o < min ? op.o : min), doc.ops[0].o));
441
+ expect(doc.count).toBe(doc.ops.length);
442
+ expect(doc.checksum).toBe(doc.ops.reduce((sum, op) => sum + op.checksum, 0n));
443
+ expect(doc.size).toBe(doc.ops.reduce((sum, op) => sum + (op.data?.length ?? 0), 0));
444
+ }
445
+ });
446
+
447
+ test('3. target_op correctness - max of non-null target_ops', () => {
448
+ const ops = [
449
+ makeBucketDataDoc({ o: 1n, target_op: null }),
450
+ makeBucketDataDoc({ o: 2n, target_op: 10n }),
451
+ makeBucketDataDoc({ o: 3n, target_op: 5n }),
452
+ makeBucketDataDoc({ o: 4n, target_op: null })
453
+ ];
454
+
455
+ const doc = serializeBucketData('test[]', ops);
456
+ expect(doc.target_op).toBe(10n);
457
+ });
458
+
459
+ test('3. target_op correctness - all null yields null', () => {
460
+ const ops = [makeBucketDataDoc({ o: 1n, target_op: null }), makeBucketDataDoc({ o: 2n, target_op: null })];
461
+
462
+ const doc = serializeBucketData('test[]', ops);
463
+ expect(doc.target_op).toBeNull();
464
+ });
465
+
466
+ test('4. no overlapping ranges - multiple documents', () => {
467
+ const opsA = [makeBucketDataDoc({ o: 1n }), makeBucketDataDoc({ o: 3n })];
468
+ const opsB = [makeBucketDataDoc({ o: 5n }), makeBucketDataDoc({ o: 8n })];
469
+ const opsC = [makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 15n })];
470
+
471
+ const docA = serializeBucketData('test[]', opsA);
472
+ const docB = serializeBucketData('test[]', opsB);
473
+ const docC = serializeBucketData('test[]', opsC);
474
+
475
+ const docs = [docA, docB, docC];
476
+
477
+ for (let i = 0; i < docs.length; i++) {
478
+ for (let j = i + 1; j < docs.length; j++) {
479
+ const a = docs[i];
480
+ const b = docs[j];
481
+ const noOverlap = a.min_op > b._id.o || b.min_op > a._id.o;
482
+ expect(noOverlap).toBe(true);
483
+ }
484
+ }
485
+ });
486
+
487
+ test('4. no overlapping ranges - after compaction', async () => {
488
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
489
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
490
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
491
+ const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'E', 'e1', ctx, sourceTableId)];
492
+ const doc1 = serializeBucketData(BUCKET, ops1);
493
+ const doc2 = serializeBucketData(BUCKET, ops2);
494
+ const doc3 = serializeBucketData(BUCKET, ops3);
495
+ await insertDocs(collection, [doc1, doc2, doc3]);
496
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
497
+
498
+ await compact(bucketStorage, 60n);
499
+
500
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
501
+ for (let i = 0; i < docs.length; i++) {
502
+ for (let j = i + 1; j < docs.length; j++) {
503
+ const a = docs[i];
504
+ const b = docs[j];
505
+ const noOverlap = a.min_op > b._id.o || b.min_op > a._id.o;
506
+ expect(noOverlap).toBe(true);
507
+ }
508
+ }
509
+ });
510
+
511
+ test('6. compaction survivor integrity - superseded ops become MOVE tombstones', async () => {
512
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
513
+ const ops = [
514
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
515
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
516
+ makeOp(30, 'A', 'a2', ctx, sourceTableId)
517
+ ];
518
+ const doc1 = serializeBucketData(BUCKET, ops);
519
+ await insertDocs(collection, [doc1]);
520
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
521
+
522
+ await compact(bucketStorage, 30n);
523
+
524
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
525
+ const allOps = docs.flatMap((d) => d.ops);
526
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
527
+ expect(moveOps.length).toBe(1);
528
+ expect(moveOps[0].o).toBe(10n);
529
+ expect(moveOps[0].checksum).toBe(ops[0].checksum);
530
+ expect(moveOps[0].data).toBeNull();
531
+ const putOps = allOps.filter((op) => op.op === 'PUT');
532
+ expect(putOps.length).toBe(2);
533
+ expect(docs[0].target_op).toBe(30n);
534
+ });
535
+
536
+ test('6. compaction survivor integrity - MOVE ops preserved', async () => {
537
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
538
+ const ops = [
539
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
540
+ { ...makeOp(20, 'A', 'a2', ctx, sourceTableId), op: 'MOVE' as const, data: null },
541
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
542
+ ];
543
+ const doc1 = serializeBucketData(BUCKET, ops);
544
+ await insertDocs(collection, [doc1]);
545
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
546
+
547
+ await compact(bucketStorage, 30n);
548
+
549
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
550
+ const allOps = docs.flatMap((d) => d.ops);
551
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
552
+ // Pre-existing MOVE@20 + new MOVE@10 are collapsed into CLEAR
553
+ expect(moveOps.length).toBe(0);
554
+ const clearOps = allOps.filter((op) => op.op === 'CLEAR');
555
+ expect(clearOps.length).toBe(1);
556
+ expect(clearOps[0].o).toBe(20n);
557
+ const putOps = allOps.filter((op) => op.op === 'PUT');
558
+ expect(putOps.length).toBe(1);
559
+ expect(putOps[0].o).toBe(30n);
560
+ });
561
+
562
+ test('6. compaction survivor integrity - CLEAR ops preserved', async () => {
563
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
564
+ const ops = [
565
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
566
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
567
+ { ...makeOp(25, 'A', '', ctx, sourceTableId), op: 'CLEAR' as const, data: null, row_id: undefined }
568
+ ];
569
+ const doc1 = serializeBucketData(BUCKET, ops);
570
+ await insertDocs(collection, [doc1]);
571
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 25n);
572
+
573
+ await compact(bucketStorage, 25n);
574
+
575
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
576
+ const allOps = docs.flatMap((d) => d.ops);
577
+ const clearOps = allOps.filter((op) => op.op === 'CLEAR');
578
+ expect(clearOps.length).toBe(1);
579
+ });
580
+
581
+ test('7. superseded PUT becomes MOVE tombstone, REMOVE survives', async () => {
582
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
583
+ const ops = [
584
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
585
+ makeOp(20, 'A', 'a2', ctx, sourceTableId, { op: 'REMOVE' })
586
+ ];
587
+ const doc1 = serializeBucketData(BUCKET, ops);
588
+ await insertDocs(collection, [doc1]);
589
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
590
+
591
+ await compact(bucketStorage, 20n);
592
+
593
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
594
+ const allOps = docs.flatMap((d) => d.ops);
595
+ // MOVE@10 + REMOVE@20 collapsed into CLEAR@20
596
+ const clearOps = allOps.filter((op) => op.op === 'CLEAR');
597
+ expect(clearOps.length).toBe(1);
598
+ expect(clearOps[0].o).toBe(20n);
599
+ expect(allOps.filter((op) => op.op === 'REMOVE').length).toBe(0);
600
+ expect(allOps.filter((op) => op.op === 'MOVE').length).toBe(0);
601
+ });
602
+
603
+ test('7. empty bucket compact is a no-op', async () => {
604
+ const { bucketStorage, collection } = await setupV3Storage();
605
+
606
+ await bucketStorage.compact({
607
+ clearBatchLimit: 200,
608
+ moveBatchLimit: 10,
609
+ moveBatchQueryLimit: 10,
610
+ minBucketChanges: 1,
611
+ minChangeRatio: 0,
612
+ maxOpId: 1n,
613
+ signal: null as any
614
+ });
615
+
616
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
617
+ expect(docs.length).toBe(0);
618
+ });
619
+
620
+ test('8. BSON limit safety - two large ops get split', () => {
621
+ const halfMB = 600_000;
622
+ const ops = [
623
+ makeBucketDataDoc({ o: 1n, data: 'a'.repeat(halfMB) }),
624
+ makeBucketDataDoc({ o: 2n, data: 'b'.repeat(halfMB) })
625
+ ];
626
+
627
+ const chunks = chunkBucketData(ops, DEFAULT_MAX_DOC_SIZE_BYTES);
628
+ expect(chunks.length).toBe(2);
629
+ });
630
+
631
+ test('8. BSON limit safety - single oversized op gets own chunk', () => {
632
+ const oversized = DEFAULT_MAX_DOC_SIZE_BYTES + 100_000;
633
+ const ops = [makeBucketDataDoc({ o: 1n, data: 'x'.repeat(oversized) })];
634
+
635
+ const chunks = chunkBucketData(ops, DEFAULT_MAX_DOC_SIZE_BYTES);
636
+ expect(chunks.length).toBe(1);
637
+ expect(chunks[0]).toHaveLength(1);
638
+ });
639
+
640
+ test('9. serialization fidelity - null data preserved', () => {
641
+ const ops = [makeBucketDataDoc({ o: 1n, data: null })];
642
+ const doc = serializeBucketData('test[]', ops);
643
+ expect(doc.ops[0].data).toBeNull();
644
+
645
+ const context = { replicationStreamId: 1, definitionId: '1' };
646
+ const deserialized = [...loadBucketDataDocument(context, doc)];
647
+ expect(deserialized[0].data).toBeNull();
648
+ });
649
+
650
+ test('9. serialization fidelity - empty string data preserved', () => {
651
+ const ops = [makeBucketDataDoc({ o: 1n, data: '' })];
652
+ const doc = serializeBucketData('test[]', ops);
653
+ expect(doc.ops[0].data).toBe('');
654
+
655
+ const context = { replicationStreamId: 1, definitionId: '1' };
656
+ const deserialized = [...loadBucketDataDocument(context, doc)];
657
+ expect(deserialized[0].data).toBe('');
658
+ });
659
+
660
+ test('9. serialization fidelity - unicode characters preserved', () => {
661
+ const unicodeData = '{"name":"日本語テスト","emoji":"🎉"}';
662
+ const ops = [makeBucketDataDoc({ o: 1n, data: unicodeData })];
663
+ const doc = serializeBucketData('test[]', ops);
664
+ expect(doc.ops[0].data).toBe(unicodeData);
665
+
666
+ const context = { replicationStreamId: 1, definitionId: '1' };
667
+ const deserialized = [...loadBucketDataDocument(context, doc)];
668
+ expect(deserialized[0].data).toBe(unicodeData);
669
+ });
670
+
671
+ test('10. document _id.o invariant - equals last ops[*].o (caller must sort)', () => {
672
+ const ops = [makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 25n }), makeBucketDataDoc({ o: 7n })];
673
+
674
+ const doc = serializeBucketData('test[]', ops);
675
+ expect(doc._id.o).toBe(7n);
676
+ });
677
+
678
+ test('10. document _id.o invariant - equals max when pre-sorted', () => {
679
+ const ops = [makeBucketDataDoc({ o: 3n }), makeBucketDataDoc({ o: 10n }), makeBucketDataDoc({ o: 25n })];
680
+
681
+ const doc = serializeBucketData('test[]', ops);
682
+ const maxO = ops.reduce((max, op) => (op.o > max ? op.o : max), 0n);
683
+ expect(doc._id.o).toBe(maxO);
684
+ });
685
+
686
+ test('10. document _id.o invariant - every document after compaction', async () => {
687
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
688
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
689
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
690
+ const doc1 = serializeBucketData(BUCKET, ops1);
691
+ const doc2 = serializeBucketData(BUCKET, ops2);
692
+ await insertDocs(collection, [doc1, doc2]);
693
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
694
+
695
+ await compact(bucketStorage, 40n);
696
+
697
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
698
+ for (const doc of docs) {
699
+ const maxO = doc.ops.reduce((max, op) => (op.o > max ? op.o : max), 0n);
700
+ expect(doc._id.o).toBe(maxO);
701
+ }
702
+ });
703
+
704
+ test('compaction with maxOpId filtering - ops above maxOpId preserved as pass-through', async () => {
705
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
706
+ const ops = [
707
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
708
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
709
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
710
+ ];
711
+ const doc1 = serializeBucketData(BUCKET, ops);
712
+ await insertDocs(collection, [doc1]);
713
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
714
+
715
+ await compact(bucketStorage, 15n);
716
+
717
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
718
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops);
719
+
720
+ // All ops survive: ops <= maxOpId are deduplicated, ops > maxOpId pass through.
721
+ expect(allOpsAfter.length).toBe(3);
722
+ // Ops <= maxOpId still present
723
+ const opsBelow = allOpsAfter.filter((op) => op.o <= 15n);
724
+ expect(opsBelow.length).toBe(1);
725
+ expect(opsBelow[0].op).toBe('PUT');
726
+ // Ops > maxOpId preserved unchanged
727
+ const opsAbove = allOpsAfter.filter((op) => op.o > 15n);
728
+ expect(opsAbove.length).toBe(2);
729
+ expect(opsAbove.every((op) => op.op === 'PUT')).toBe(true);
730
+
731
+ // Checksum preserved
732
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
733
+ expect(checksumAfter).toBe(Number(doc1.checksum));
734
+ });
735
+
736
+ test('checksum consistency - aggregation pipeline matches JavaScript addChecksums', async () => {
737
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
738
+ const ops = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
739
+ const doc1 = serializeBucketData(BUCKET, ops);
740
+ await insertDocs(collection, [doc1]);
741
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
742
+
743
+ const allOps = ops;
744
+ let jsChecksum = 0;
745
+ for (const op of allOps) {
746
+ jsChecksum = addChecksums(jsChecksum, Number(op.checksum));
747
+ }
748
+
749
+ const docs = await collection.find({ '_id.b': BUCKET }).toArray();
750
+ const storedOps = docs.flatMap((d) => d.ops);
751
+ let storedChecksum = 0;
752
+ for (const op of storedOps) {
753
+ storedChecksum = addChecksums(storedChecksum, Number(op.checksum));
754
+ }
755
+
756
+ expect(storedChecksum).toBe(jsChecksum);
757
+ });
758
+ });
759
+
760
+ describe('V3 checksum pipeline straddling', () => {
761
+ const BUCKET = 'global[]';
762
+ const TABLE = 'items';
763
+
764
+ async function setup() {
765
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
766
+ const syncRules = await factory.updateSyncRules(
767
+ updateSyncRulesFromYaml(
768
+ `
769
+ bucket_definitions:
770
+ global:
771
+ data: [SELECT id as id, description FROM items]
772
+ `,
773
+ { storageVersion: 3 }
774
+ )
775
+ );
776
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
777
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
778
+ const mapping = syncRules.syncConfigContent[0].mapping;
779
+ const definitionId = mapping.allBucketDefinitionIds()[0];
780
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
781
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
782
+ const sourceTableId = new bson.ObjectId();
783
+ const ctx = {
784
+ replicationStreamId: bucketStorage.replicationStreamId,
785
+ definitionId,
786
+ bucket: BUCKET
787
+ };
788
+
789
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, definitionId, sourceTableId, ctx };
790
+ }
791
+
792
+ function makeOp(
793
+ opId: number,
794
+ rowId: string,
795
+ data: string,
796
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
797
+ sourceTableId: bson.ObjectId
798
+ ): BucketDataDoc {
799
+ return {
800
+ bucketKey: {
801
+ replicationStreamId: ctx.replicationStreamId,
802
+ definitionId: ctx.definitionId,
803
+ bucket: ctx.bucket
804
+ },
805
+ o: BigInt(opId),
806
+ op: 'PUT',
807
+ source_table: sourceTableId,
808
+ source_key: test_utils.rid(rowId),
809
+ table: TABLE,
810
+ row_id: rowId,
811
+ checksum: BigInt(opId * 7),
812
+ data: JSON.stringify({ id: rowId, description: data }),
813
+ target_op: null
814
+ };
815
+ }
816
+
817
+ test('partial checksum with start straddling multi-op document', async () => {
818
+ const { bucketStorage, syncRules, collection, bucketStateCollection, definitionId, sourceTableId, ctx } =
819
+ await setup();
820
+
821
+ // Single document with ops 10-60, min_op=10, _id.o=60
822
+ const ops = [
823
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
824
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
825
+ makeOp(30, 'C', 'c1', ctx, sourceTableId),
826
+ makeOp(40, 'D', 'd1', ctx, sourceTableId),
827
+ makeOp(50, 'E', 'e1', ctx, sourceTableId),
828
+ makeOp(60, 'F', 'f1', ctx, sourceTableId)
829
+ ];
830
+ const doc = serializeBucketData(BUCKET, ops);
831
+ await collection.insertMany([doc]);
832
+
833
+ // Set compacted_state.op_id = 30 to create a partial range starting at 30.
834
+ // The pipeline will query ops where o > 30 and o <= 60.
835
+ // The document has min_op=10 < 30, so it's partially included.
836
+ // The pipeline must $filter ops to only sum those with o > 30 (ops 40, 50, 60).
837
+ const fullChecksum = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
838
+ const partialChecksum = ops
839
+ .filter((op) => op.o > 30n)
840
+ .reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
841
+
842
+ // Partial and full must differ, otherwise the document is fully included and no straddling occurs.
843
+ expect(partialChecksum).not.toBe(fullChecksum);
844
+
845
+ await bucketStateCollection.insertOne({
846
+ _id: { d: definitionId, b: BUCKET },
847
+ last_op: 30n,
848
+ compacted_state: {
849
+ op_id: 30n,
850
+ count: 3,
851
+ checksum: BigInt(
852
+ ops.filter((op) => op.o <= 30n).reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0)
853
+ ),
854
+ bytes: null
855
+ },
856
+ estimate_since_compact: { count: 3, bytes: 100 }
857
+ });
858
+
859
+ const request: storage.BucketChecksumRequest = {
860
+ bucket: BUCKET,
861
+ source: {
862
+ uniqueName: 'global',
863
+ bucketParameters: [],
864
+ getSourceTables: () => new Set(),
865
+ tableSyncsData: () => false,
866
+ evaluateRow: () => [],
867
+ inferSchema: () => ({ objects: {} }),
868
+ bucketQuery: () => ({ ast: {} as any, parameters: [] })
869
+ } as any
870
+ };
871
+
872
+ const result = await bucketStorage.getChecksums(60n, [request]);
873
+ const checksumResult = result.get(BUCKET)!;
874
+
875
+ // The total checksum should be: compacted (ops 10,20,30) + partial (ops 40,50,60)
876
+ expect(checksumResult.checksum).toBe(fullChecksum);
877
+ expect(checksumResult.count).toBe(6);
878
+ });
879
+
880
+ test('partial checksum with end straddling multi-op document', async () => {
881
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
882
+
883
+ // Document with ops 40-60, _id.o=60, min_op=40
884
+ const ops = [
885
+ makeOp(40, 'D', 'd1', ctx, sourceTableId),
886
+ makeOp(50, 'E', 'e1', ctx, sourceTableId),
887
+ makeOp(60, 'F', 'f1', ctx, sourceTableId)
888
+ ];
889
+ const doc = serializeBucketData(BUCKET, ops);
890
+ await collection.insertMany([doc]);
891
+
892
+ // No compacted_state — start from beginning, so the full document is in range.
893
+ // Request checksums with checkpoint=45 (falls between ops 40 and 50).
894
+ // createBucketFilter produces _id.o <= 45.
895
+ // This document has _id.o=60 > 45, so the filter excludes it.
896
+ // But the document contains op 40 which should be included (40 <= 45).
897
+ const checksumUpTo45 = ops
898
+ .filter((op) => op.o <= 45n)
899
+ .reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
900
+
901
+ const checksumAllOps = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
902
+
903
+ // The straddling is real: op 40 is <= 45 but the document's _id.o=60 is > 45
904
+ expect(checksumUpTo45).not.toBe(checksumAllOps);
905
+
906
+ await bucketStateCollection.insertOne({
907
+ _id: { d: definitionId, b: BUCKET },
908
+ last_op: 0n,
909
+ estimate_since_compact: { count: 0, bytes: 0 }
910
+ });
911
+
912
+ const request: storage.BucketChecksumRequest = {
913
+ bucket: BUCKET,
914
+ source: {
915
+ uniqueName: 'global',
916
+ bucketParameters: [],
917
+ getSourceTables: () => new Set(),
918
+ tableSyncsData: () => false,
919
+ evaluateRow: () => [],
920
+ inferSchema: () => ({ objects: {} }),
921
+ bucketQuery: () => ({ ast: {} as any, parameters: [] })
922
+ } as any
923
+ };
924
+
925
+ const result = await bucketStorage.getChecksums(45n, [request]);
926
+ const checksumResult = result.get(BUCKET)!;
927
+
928
+ // If createBucketFilter's _id.o <= 45 excludes this document,
929
+ // the checksum will be 0 instead of checksumUpTo45.
930
+ expect(checksumResult.checksum).toBe(checksumUpTo45);
931
+ expect(checksumResult.count).toBe(1);
932
+ });
933
+ });
934
+
935
+ describe('V3 compaction boundaries', () => {
936
+ const BUCKET = 'global[]';
937
+ const TABLE = 'items';
938
+
939
+ async function setupV3() {
940
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
941
+ const syncRules = await factory.updateSyncRules(
942
+ updateSyncRulesFromYaml(
943
+ `
944
+ bucket_definitions:
945
+ global:
946
+ data: [SELECT id as id, description FROM items]
947
+ `,
948
+ { storageVersion: 3 }
949
+ )
950
+ );
951
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
952
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
953
+ const mapping = syncRules.syncConfigContent[0].mapping;
954
+ const definitionId = mapping.allBucketDefinitionIds()[0];
955
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
956
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
957
+ const sourceTableId = new bson.ObjectId();
958
+
959
+ const ctx = {
960
+ replicationStreamId: bucketStorage.replicationStreamId,
961
+ definitionId,
962
+ bucket: BUCKET
963
+ };
964
+
965
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
966
+ }
967
+
968
+ function makeOp(
969
+ opId: number,
970
+ rowId: string,
971
+ data: string,
972
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
973
+ sourceTableId: bson.ObjectId,
974
+ overrides?: { op?: 'PUT' | 'REMOVE' }
975
+ ): BucketDataDoc {
976
+ return {
977
+ bucketKey: {
978
+ replicationStreamId: ctx.replicationStreamId,
979
+ definitionId: ctx.definitionId,
980
+ bucket: ctx.bucket
981
+ },
982
+ o: BigInt(opId),
983
+ op: overrides?.op ?? 'PUT',
984
+ source_table: sourceTableId,
985
+ source_key: test_utils.rid(rowId),
986
+ table: TABLE,
987
+ row_id: rowId,
988
+ checksum: BigInt(opId * 7),
989
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
990
+ target_op: null
991
+ };
992
+ }
993
+
994
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
995
+ await collection.insertMany(docs);
996
+ }
997
+
998
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
999
+ await bucketStateCollection.insertOne({
1000
+ _id: { d: definitionId, b: BUCKET },
1001
+ last_op: lastOp,
1002
+ estimate_since_compact: { count: 10, bytes: 100 }
1003
+ });
1004
+ }
1005
+
1006
+ async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
1007
+ await bucketStorage.compact({
1008
+ clearBatchLimit: 200,
1009
+ moveBatchLimit: 10,
1010
+ moveBatchQueryLimit: 10,
1011
+ minBucketChanges: 1,
1012
+ minChangeRatio: 0,
1013
+ maxOpId,
1014
+ signal: null as any
1015
+ });
1016
+ }
1017
+
1018
+ async function readAllOps(collection: any): Promise<{ row_id: string; o: bigint; op: string }[]> {
1019
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1020
+ return docs.flatMap((d: any) =>
1021
+ d.ops.map((op: any) => ({ row_id: op.row_id!, o: op.o, op: op.op, target_op: op.target_op ?? undefined }))
1022
+ );
1023
+ }
1024
+
1025
+ async function readAllDocs(collection: any): Promise<BucketDataDocumentV3[]> {
1026
+ return collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1027
+ }
1028
+
1029
+ test('1. superseded ops become MOVE tombstones', async () => {
1030
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1031
+ // Doc1: [A@10, B@20, A@30] — A@10 superseded by A@30, becomes MOVE tombstone
1032
+ const ops = [
1033
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1034
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1035
+ makeOp(30, 'A', 'a2', ctx, sourceTableId)
1036
+ ];
1037
+ const doc1 = serializeBucketData(BUCKET, ops);
1038
+ await insertDocs(collection, [doc1]);
1039
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
1040
+
1041
+ await compact(bucketStorage, 30n);
1042
+
1043
+ const surviving = await readAllOps(collection);
1044
+ expect(surviving).toHaveLength(3);
1045
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1046
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1047
+ expect(surviving[2]).toMatchObject({ row_id: 'A', o: 30n, op: 'PUT' });
1048
+ });
1049
+
1050
+ test('2. first op in document superseded becomes MOVE tombstone', async () => {
1051
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1052
+ // Doc1: [A@10, B@20, C@30], Doc2: [A@40] — A@10 superseded by A@40
1053
+ const ops1 = [
1054
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1055
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1056
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
1057
+ ];
1058
+ const ops2 = [makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1059
+ const doc1 = serializeBucketData(BUCKET, ops1);
1060
+ const doc2 = serializeBucketData(BUCKET, ops2);
1061
+ await insertDocs(collection, [doc1, doc2]);
1062
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1063
+
1064
+ await compact(bucketStorage, 40n);
1065
+
1066
+ const surviving = await readAllOps(collection);
1067
+ expect(surviving).toHaveLength(4);
1068
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1069
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1070
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1071
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1072
+ });
1073
+
1074
+ test('3. last op in document superseded becomes MOVE tombstone', async () => {
1075
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1076
+ // Doc1: [A@10, B@20], Doc2: [C@30, A@40] — A@10 superseded by A@40
1077
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1078
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1079
+ const doc1 = serializeBucketData(BUCKET, ops1);
1080
+ const doc2 = serializeBucketData(BUCKET, ops2);
1081
+ await insertDocs(collection, [doc1, doc2]);
1082
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1083
+
1084
+ await compact(bucketStorage, 40n);
1085
+
1086
+ const surviving = await readAllOps(collection);
1087
+ expect(surviving).toHaveLength(4);
1088
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1089
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1090
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1091
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1092
+ });
1093
+
1094
+ test('4. cascading superseded ops become MOVE tombstones', async () => {
1095
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1096
+ // Doc1: [A@10, A@20] — A@10 superseded by A@20; A@20 superseded by A@30 REMOVE
1097
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'A', 'a2', ctx, sourceTableId)];
1098
+ const ops2 = [makeOp(30, 'A', 'a3', ctx, sourceTableId, { op: 'REMOVE' })];
1099
+ const doc1 = serializeBucketData(BUCKET, ops1);
1100
+ const doc2 = serializeBucketData(BUCKET, ops2);
1101
+ await insertDocs(collection, [doc1, doc2]);
1102
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
1103
+
1104
+ await compact(bucketStorage, 30n);
1105
+
1106
+ const surviving = await readAllOps(collection);
1107
+ expect(surviving).toHaveLength(1);
1108
+ expect(surviving[0]).toMatchObject({ op: 'CLEAR', o: 30n });
1109
+ });
1110
+
1111
+ test('5. one surviving PUT per document plus MOVE tombstones', async () => {
1112
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1113
+ // Doc1: [A@10, B@20], Doc2: [C@30, A@40] — A@10 superseded, becomes MOVE
1114
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1115
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1116
+ const doc1 = serializeBucketData(BUCKET, ops1);
1117
+ const doc2 = serializeBucketData(BUCKET, ops2);
1118
+ await insertDocs(collection, [doc1, doc2]);
1119
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1120
+
1121
+ await compact(bucketStorage, 40n);
1122
+
1123
+ const surviving = await readAllOps(collection);
1124
+ expect(surviving).toHaveLength(4);
1125
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1126
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1127
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1128
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1129
+ });
1130
+
1131
+ test('6. multiple superseded ops from different documents become MOVE tombstones', async () => {
1132
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1133
+ // 3 small docs, each with 2 ops, A@10 superseded by A@40
1134
+ // Doc1: [A@10, B@20] — A@10 becomes MOVE
1135
+ // Doc2: [C@30, A@40] — C@30 survives (A@40 is latest A)
1136
+ // Doc3: [D@50, E@60] — D@50, E@60 survive
1137
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1138
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1139
+ const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'E', 'e1', ctx, sourceTableId)];
1140
+ const doc1 = serializeBucketData(BUCKET, ops1);
1141
+ const doc2 = serializeBucketData(BUCKET, ops2);
1142
+ const doc3 = serializeBucketData(BUCKET, ops3);
1143
+ await insertDocs(collection, [doc1, doc2, doc3]);
1144
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
1145
+
1146
+ await compact(bucketStorage, 60n);
1147
+
1148
+ const surviving = await readAllOps(collection);
1149
+ expect(surviving).toHaveLength(6);
1150
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1151
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1152
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1153
+ expect(surviving[3]).toMatchObject({ row_id: 'A', o: 40n, op: 'PUT' });
1154
+ expect(surviving[4]).toMatchObject({ row_id: 'D', o: 50n, op: 'PUT' });
1155
+ expect(surviving[5]).toMatchObject({ row_id: 'E', o: 60n, op: 'PUT' });
1156
+ });
1157
+
1158
+ test('7. superseded op at document boundary becomes MOVE tombstone', async () => {
1159
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1160
+ // Doc1: [A@10, B@20, C@30], Doc2: [D@40, A@50] — A@10 superseded by A@50
1161
+ const ops1 = [
1162
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1163
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1164
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
1165
+ ];
1166
+ const ops2 = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(50, 'A', 'a2', ctx, sourceTableId)];
1167
+ const doc1 = serializeBucketData(BUCKET, ops1);
1168
+ const doc2 = serializeBucketData(BUCKET, ops2);
1169
+ await insertDocs(collection, [doc1, doc2]);
1170
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
1171
+
1172
+ await compact(bucketStorage, 50n);
1173
+
1174
+ const surviving = await readAllOps(collection);
1175
+ expect(surviving).toHaveLength(5);
1176
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1177
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1178
+ expect(surviving[2]).toMatchObject({ row_id: 'C', o: 30n, op: 'PUT' });
1179
+ expect(surviving[3]).toMatchObject({ row_id: 'D', o: 40n, op: 'PUT' });
1180
+ expect(surviving[4]).toMatchObject({ row_id: 'A', o: 50n, op: 'PUT' });
1181
+ });
1182
+
1183
+ test('8. same row_id ops spanning document boundary produces MOVE tombstone', async () => {
1184
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1185
+ // Doc1: [A@10, B@20], Doc2: [A@30, C@40] — A@10 superseded by A@30
1186
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1187
+ const ops2 = [makeOp(30, 'A', 'a2', ctx, sourceTableId), makeOp(40, 'C', 'c1', ctx, sourceTableId)];
1188
+ const doc1 = serializeBucketData(BUCKET, ops1);
1189
+ const doc2 = serializeBucketData(BUCKET, ops2);
1190
+ await insertDocs(collection, [doc1, doc2]);
1191
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1192
+
1193
+ await compact(bucketStorage, 40n);
1194
+
1195
+ const surviving = await readAllOps(collection);
1196
+ expect(surviving).toHaveLength(4);
1197
+ expect(surviving[0]).toMatchObject({ op: 'MOVE', o: 10n });
1198
+ expect(surviving[1]).toMatchObject({ row_id: 'B', o: 20n, op: 'PUT' });
1199
+ expect(surviving[2]).toMatchObject({ row_id: 'A', o: 30n, op: 'PUT' });
1200
+ expect(surviving[3]).toMatchObject({ row_id: 'C', o: 40n, op: 'PUT' });
1201
+ });
1202
+ });
1203
+
1204
+ describe('V3 MOVE tombstone properties', () => {
1205
+ const BUCKET = 'global[]';
1206
+ const TABLE = 'items';
1207
+
1208
+ async function setupV3() {
1209
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1210
+ const syncRules = await factory.updateSyncRules(
1211
+ updateSyncRulesFromYaml(
1212
+ `
1213
+ bucket_definitions:
1214
+ global:
1215
+ data: [SELECT id as id, description FROM items]
1216
+ `,
1217
+ { storageVersion: 3 }
1218
+ )
1219
+ );
1220
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1221
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1222
+ const mapping = syncRules.syncConfigContent[0].mapping;
1223
+ const definitionId = mapping.allBucketDefinitionIds()[0];
1224
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
1225
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
1226
+ const sourceTableId = new bson.ObjectId();
1227
+
1228
+ const ctx = {
1229
+ replicationStreamId: bucketStorage.replicationStreamId,
1230
+ definitionId,
1231
+ bucket: BUCKET
1232
+ };
1233
+
1234
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1235
+ }
1236
+
1237
+ function makeOp(
1238
+ opId: number,
1239
+ rowId: string,
1240
+ data: string,
1241
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
1242
+ sourceTableId: bson.ObjectId,
1243
+ overrides?: { op?: 'PUT' | 'REMOVE' }
1244
+ ): BucketDataDoc {
1245
+ return {
1246
+ bucketKey: {
1247
+ replicationStreamId: ctx.replicationStreamId,
1248
+ definitionId: ctx.definitionId,
1249
+ bucket: ctx.bucket
1250
+ },
1251
+ o: BigInt(opId),
1252
+ op: overrides?.op ?? 'PUT',
1253
+ source_table: sourceTableId,
1254
+ source_key: test_utils.rid(rowId),
1255
+ table: TABLE,
1256
+ row_id: rowId,
1257
+ checksum: BigInt(opId * 7),
1258
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
1259
+ target_op: null
1260
+ };
1261
+ }
1262
+
1263
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1264
+ await collection.insertMany(docs);
1265
+ }
1266
+
1267
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
1268
+ await bucketStateCollection.insertOne({
1269
+ _id: { d: definitionId, b: BUCKET },
1270
+ last_op: lastOp,
1271
+ estimate_since_compact: { count: 10, bytes: 100 }
1272
+ });
1273
+ }
1274
+
1275
+ async function compact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
1276
+ await bucketStorage.compact({
1277
+ clearBatchLimit: 200,
1278
+ moveBatchLimit: 10,
1279
+ moveBatchQueryLimit: 10,
1280
+ minBucketChanges: 1,
1281
+ minChangeRatio: 0,
1282
+ maxOpId,
1283
+ signal: null as any
1284
+ });
1285
+ }
1286
+
1287
+ test('checksum preserved across compaction with superseded ops', async () => {
1288
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1289
+ const ops = [
1290
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1291
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1292
+ makeOp(30, 'A', 'a2', ctx, sourceTableId),
1293
+ makeOp(40, 'C', 'c1', ctx, sourceTableId),
1294
+ makeOp(50, 'B', 'b2', ctx, sourceTableId)
1295
+ ];
1296
+ const doc1 = serializeBucketData(BUCKET, ops);
1297
+ await insertDocs(collection, [doc1]);
1298
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
1299
+
1300
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1301
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1302
+
1303
+ await compact(bucketStorage, 50n);
1304
+
1305
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1306
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1307
+
1308
+ expect(checksumAfter).toBe(checksumBefore);
1309
+
1310
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops);
1311
+ // Two MOVEs collapsed into one CLEAR
1312
+ expect(allOpsAfter.length).toBe(4);
1313
+ const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
1314
+ expect(clearOps.length).toBe(1);
1315
+ });
1316
+
1317
+ test('checksum preserved across compaction with multiple documents', async () => {
1318
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1319
+ const ops1 = [makeOp(10, 'A', 'a1', ctx, sourceTableId), makeOp(20, 'B', 'b1', ctx, sourceTableId)];
1320
+ const ops2 = [makeOp(30, 'C', 'c1', ctx, sourceTableId), makeOp(40, 'A', 'a2', ctx, sourceTableId)];
1321
+ const ops3 = [makeOp(50, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'B', 'b2', ctx, sourceTableId)];
1322
+ await insertDocs(collection, [
1323
+ serializeBucketData(BUCKET, ops1),
1324
+ serializeBucketData(BUCKET, ops2),
1325
+ serializeBucketData(BUCKET, ops3)
1326
+ ]);
1327
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
1328
+
1329
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1330
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1331
+
1332
+ await compact(bucketStorage, 60n);
1333
+
1334
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1335
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1336
+
1337
+ expect(checksumAfter).toBe(checksumBefore);
1338
+
1339
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops);
1340
+ // Two MOVEs collapsed into one CLEAR
1341
+ expect(allOpsAfter.length).toBe(5);
1342
+ const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
1343
+ expect(clearOps.length).toBe(1);
1344
+ });
1345
+
1346
+ test('tombstones have null data and pack densely after rechunking', async () => {
1347
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1348
+ const ops = [
1349
+ makeOp(10, 'A', 'x'.repeat(500_000), ctx, sourceTableId),
1350
+ makeOp(20, 'B', 'y'.repeat(500_000), ctx, sourceTableId),
1351
+ makeOp(30, 'A', 'z'.repeat(500_000), ctx, sourceTableId)
1352
+ ];
1353
+ const doc1 = serializeBucketData(BUCKET, ops);
1354
+ await insertDocs(collection, [doc1]);
1355
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
1356
+
1357
+ await compact(bucketStorage, 30n);
1358
+
1359
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1360
+ const allOps = docs.flatMap((d) => d.ops);
1361
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
1362
+ expect(moveOps.length).toBe(1);
1363
+ expect(moveOps[0].o).toBe(10n);
1364
+ expect(moveOps[0].data).toBeNull();
1365
+
1366
+ const putOps = allOps.filter((op) => op.op === 'PUT');
1367
+ expect(putOps.length).toBe(2);
1368
+ expect(putOps.every((op) => op.data != null)).toBe(true);
1369
+
1370
+ expect(docs.length).toBe(1);
1371
+ const putSize = putOps.reduce((sum, op) => sum + (op.data?.length ?? 0), 0);
1372
+ expect(docs[0].size).toBe(putSize);
1373
+ });
1374
+
1375
+ test('tombstones and survivors end up in same document after rechunking', async () => {
1376
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1377
+ const ops1 = [
1378
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
1379
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
1380
+ makeOp(30, 'C', 'c1', ctx, sourceTableId)
1381
+ ];
1382
+ const ops2 = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(50, 'A', 'a2', ctx, sourceTableId)];
1383
+ await insertDocs(collection, [serializeBucketData(BUCKET, ops1), serializeBucketData(BUCKET, ops2)]);
1384
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 50n);
1385
+
1386
+ const checksumBefore = (await collection.find({ '_id.b': BUCKET }).toArray()).reduce(
1387
+ (sum, d) => addChecksums(sum, Number(d.checksum)),
1388
+ 0
1389
+ );
1390
+
1391
+ await compact(bucketStorage, 50n);
1392
+
1393
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1394
+ const checksumAfter = docs.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1395
+ expect(checksumAfter).toBe(checksumBefore);
1396
+
1397
+ const allOps = docs.flatMap((d) => d.ops);
1398
+ expect(allOps.length).toBe(5);
1399
+ const moveOp = allOps.find((op) => op.op === 'MOVE');
1400
+ expect(moveOp).toMatchObject({ o: 10n, data: null });
1401
+
1402
+ expect(docs.length).toBe(1);
1403
+
1404
+ const moveOpsInDoc = docs[0].ops.filter((op) => op.op === 'MOVE');
1405
+ const putOpsInDoc = docs[0].ops.filter((op) => op.op === 'PUT');
1406
+ expect(moveOpsInDoc.length).toBe(1);
1407
+ expect(putOpsInDoc.length).toBe(4);
1408
+ });
1409
+ });
1410
+
1411
+ /**
1412
+ * Streaming compactor tests
1413
+ *
1414
+ * These tests exercise the streaming compactor's batched behavior:
1415
+ * reverse-order batched reads, cross-batch seen map dedup with memory
1416
+ * bounding, scoped deletes via $in, and byte-based batch cutting.
1417
+ */
1418
+ describe('Streaming compactor', () => {
1419
+ const BUCKET = 'global[]';
1420
+ const TABLE = 'items';
1421
+
1422
+ async function setupV3() {
1423
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1424
+ const syncRules = await factory.updateSyncRules(
1425
+ updateSyncRulesFromYaml(
1426
+ `
1427
+ bucket_definitions:
1428
+ global:
1429
+ data: [SELECT id as id, description FROM items]
1430
+ `,
1431
+ { storageVersion: 3 }
1432
+ )
1433
+ );
1434
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
1435
+ const db = bucketStorage.db as VersionedPowerSyncMongoV3;
1436
+ const definitionId = bucketStorage.storageIds.bucketDefinitionIds[0];
1437
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
1438
+ const bucketStateCollection = db.bucketState(bucketStorage.replicationStreamId);
1439
+ const sourceTableId = new bson.ObjectId();
1440
+
1441
+ const ctx = {
1442
+ replicationStreamId: bucketStorage.replicationStreamId,
1443
+ definitionId,
1444
+ bucket: BUCKET
1445
+ };
1446
+
1447
+ return { bucketStorage, syncRules, db, collection, bucketStateCollection, sourceTableId, ctx };
1448
+ }
1449
+
1450
+ function makeOp(
1451
+ opId: number,
1452
+ rowId: string,
1453
+ data: string,
1454
+ ctx: { replicationStreamId: number; definitionId: string; bucket: string },
1455
+ sourceTableId: bson.ObjectId,
1456
+ overrides?: { op?: 'PUT' | 'REMOVE' }
1457
+ ): BucketDataDoc {
1458
+ return {
1459
+ bucketKey: {
1460
+ replicationStreamId: ctx.replicationStreamId,
1461
+ definitionId: ctx.definitionId,
1462
+ bucket: ctx.bucket
1463
+ },
1464
+ o: BigInt(opId),
1465
+ op: overrides?.op ?? 'PUT',
1466
+ source_table: sourceTableId,
1467
+ source_key: test_utils.rid(rowId),
1468
+ table: TABLE,
1469
+ row_id: rowId,
1470
+ checksum: BigInt(opId * 7),
1471
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
1472
+ target_op: null
1473
+ };
1474
+ }
1475
+
1476
+ async function insertDocs(collection: any, docs: BucketDataDocumentV3[]) {
1477
+ await collection.insertMany(docs);
1478
+ }
1479
+
1480
+ async function insertBucketState(bucketStateCollection: any, definitionId: string, lastOp: bigint) {
1481
+ await bucketStateCollection.insertOne({
1482
+ _id: { d: definitionId, b: BUCKET },
1483
+ last_op: lastOp,
1484
+ estimate_since_compact: { count: 10, bytes: 100 }
1485
+ });
1486
+ }
1487
+
1488
+ async function readAllOps(
1489
+ collection: any
1490
+ ): Promise<{ row_id: string | undefined; o: bigint; op: string; target_op: bigint | undefined }[]> {
1491
+ const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1492
+ return docs.flatMap((d: any) =>
1493
+ d.ops.map((op: any) => ({
1494
+ row_id: op.row_id ?? undefined,
1495
+ o: op.o,
1496
+ op: op.op,
1497
+ target_op: op.target_op ?? undefined
1498
+ }))
1499
+ );
1500
+ }
1501
+
1502
+ test('1. multi-batch compaction preserves checksum and creates MOVE tombstones', async () => {
1503
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1504
+
1505
+ // 6 documents, 3-4 ops each, 3 rows with 2 versions spread across documents.
1506
+ // Rows A, B, C each have old+new versions:
1507
+ // A: new@24 (Doc1), old@10 (Doc4)
1508
+ // B: new@20 (Doc2), old@6 (Doc5)
1509
+ // C: new@16 (Doc3), old@2 (Doc6)
1510
+ // With moveBatchQueryLimit=2, the streaming compactor must process
1511
+ // 2 documents per batch (3 batches total) and still produce correct output.
1512
+ const doc1 = serializeBucketData(BUCKET, [
1513
+ makeOp(21, 'D', 'd1', ctx, sourceTableId),
1514
+ makeOp(22, 'E', 'e1', ctx, sourceTableId),
1515
+ makeOp(23, 'F', 'f1', ctx, sourceTableId),
1516
+ makeOp(24, 'A', 'a_new', ctx, sourceTableId)
1517
+ ]);
1518
+ const doc2 = serializeBucketData(BUCKET, [
1519
+ makeOp(18, 'G', 'g1', ctx, sourceTableId),
1520
+ makeOp(19, 'H', 'h1', ctx, sourceTableId),
1521
+ makeOp(20, 'B', 'b_new', ctx, sourceTableId)
1522
+ ]);
1523
+ const doc3 = serializeBucketData(BUCKET, [
1524
+ makeOp(14, 'I', 'i1', ctx, sourceTableId),
1525
+ makeOp(15, 'J', 'j1', ctx, sourceTableId),
1526
+ makeOp(16, 'C', 'c_new', ctx, sourceTableId)
1527
+ ]);
1528
+ const doc4 = serializeBucketData(BUCKET, [
1529
+ makeOp(10, 'A', 'a_old', ctx, sourceTableId),
1530
+ makeOp(11, 'K', 'k1', ctx, sourceTableId),
1531
+ makeOp(12, 'L', 'l1', ctx, sourceTableId)
1532
+ ]);
1533
+ const doc5 = serializeBucketData(BUCKET, [
1534
+ makeOp(6, 'B', 'b_old', ctx, sourceTableId),
1535
+ makeOp(7, 'M', 'm1', ctx, sourceTableId),
1536
+ makeOp(8, 'N', 'n1', ctx, sourceTableId)
1537
+ ]);
1538
+ const doc6 = serializeBucketData(BUCKET, [
1539
+ makeOp(2, 'C', 'c_old', ctx, sourceTableId),
1540
+ makeOp(3, 'O', 'o1', ctx, sourceTableId),
1541
+ makeOp(4, 'P', 'p1', ctx, sourceTableId)
1542
+ ]);
1543
+ await insertDocs(collection, [doc1, doc2, doc3, doc4, doc5, doc6]);
1544
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 24n);
1545
+
1546
+ // Compute checksum before compaction
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
+ // Compact with moveBatchQueryLimit=2 to force 3 batches of 2 documents each
1551
+ await bucketStorage.compact({
1552
+ clearBatchLimit: 200,
1553
+ moveBatchLimit: 10,
1554
+ moveBatchQueryLimit: 2,
1555
+ minBucketChanges: 1,
1556
+ minChangeRatio: 0,
1557
+ maxOpId: 24n,
1558
+ signal: null as any
1559
+ });
1560
+
1561
+ // Verify checksum preserved
1562
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1563
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1564
+ expect(checksumAfter).toBe(checksumBefore);
1565
+
1566
+ // Verify MOVE tombstones for superseded old versions (A@10, B@6, C@2)
1567
+ const allOps = await readAllOps(collection);
1568
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
1569
+ expect(moveOps.length).toBe(3);
1570
+ expect(moveOps.map((op) => op.o).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([2n, 6n, 10n]);
1571
+
1572
+ // Verify the newer version of each duplicated row survives as PUT
1573
+ const putOps = allOps.filter((op) => op.op === 'PUT');
1574
+ expect(putOps.length).toBe(16);
1575
+ const putRowIds = putOps.map((op) => op.row_id).sort();
1576
+ expect(putRowIds).toContain('A');
1577
+ expect(putRowIds).toContain('B');
1578
+ expect(putRowIds).toContain('C');
1579
+ });
1580
+
1581
+ test('2. scoped delete isolation - ops above maxOpId preserved', async () => {
1582
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1583
+
1584
+ // Insert ops in _id.o range 100-500 via direct document insertion
1585
+ const doc1 = serializeBucketData(BUCKET, [
1586
+ makeOp(100, 'A', 'a1', ctx, sourceTableId),
1587
+ makeOp(200, 'B', 'b1', ctx, sourceTableId),
1588
+ makeOp(300, 'C', 'c1', ctx, sourceTableId)
1589
+ ]);
1590
+ const doc2 = serializeBucketData(BUCKET, [
1591
+ makeOp(400, 'D', 'd1', ctx, sourceTableId),
1592
+ makeOp(500, 'E', 'e1', ctx, sourceTableId)
1593
+ ]);
1594
+ // One extra document with _id.o=600 — clearly outside the batch range
1595
+ const docOutside = serializeBucketData(BUCKET, [makeOp(600, 'F', 'f_outside', ctx, sourceTableId)]);
1596
+
1597
+ await insertDocs(collection, [doc1, doc2, docOutside]);
1598
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 600n);
1599
+
1600
+ // Run compaction with maxOpId=500n
1601
+ await bucketStorage.compact({
1602
+ clearBatchLimit: 200,
1603
+ moveBatchLimit: 10,
1604
+ moveBatchQueryLimit: 2,
1605
+ minBucketChanges: 1,
1606
+ minChangeRatio: 0,
1607
+ maxOpId: 500n,
1608
+ signal: null as any
1609
+ });
1610
+
1611
+ // The document at _id.o=600 must still be present and unmodified after compaction.
1612
+ // The streaming compactor should only touch documents containing ops <= maxOpId.
1613
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1614
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops);
1615
+ const op600 = allOpsAfter.find((op) => op.o === 600n);
1616
+ expect(op600).toBeDefined();
1617
+ expect(op600!.op).toBe('PUT');
1618
+ expect(op600!.row_id).toBe('F');
1619
+ });
1620
+
1621
+ test('3. seen map overflow - some old ops pass through without tombstoning', async () => {
1622
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1623
+
1624
+ // 20 rows, each with 2 versions (old + new). 40 ops total.
1625
+ // memoryLimitMB=0.001 → idLimitBytes ≈ 1048 bytes.
1626
+ // The seen map adds ~140 bytes per entry (key.length + 140), so it can hold ~7 entries
1627
+ // before overflowing. The streaming compactor processes ops newest-first; after ~7 rows
1628
+ // are tracked, the seen map overflows and subsequent old-version ops are not tombstoned.
1629
+ const rows = Array.from({ length: 20 }, (_, i) => `row${i + 1}`);
1630
+ const allOps: BucketDataDoc[] = [];
1631
+
1632
+ // Old versions at op_ids 1-20 (processed last by descending-order compactor)
1633
+ for (let i = 0; i < 20; i++) {
1634
+ allOps.push(makeOp(i + 1, rows[i], `old_${rows[i]}`, ctx, sourceTableId));
1635
+ }
1636
+ // New versions at op_ids 21-40 (processed first by descending-order compactor)
1637
+ for (let i = 0; i < 20; i++) {
1638
+ allOps.push(makeOp(i + 21, rows[i], `new_${rows[i]}`, ctx, sourceTableId));
1639
+ }
1640
+
1641
+ // Single document containing all ops
1642
+ const doc = serializeBucketData(BUCKET, allOps);
1643
+ await insertDocs(collection, [doc]);
1644
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
1645
+
1646
+ // Compute checksum before compaction
1647
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1648
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1649
+
1650
+ await bucketStorage.compact({
1651
+ clearBatchLimit: 200,
1652
+ moveBatchLimit: 10,
1653
+ moveBatchQueryLimit: 100,
1654
+ minBucketChanges: 1,
1655
+ minChangeRatio: 0,
1656
+ maxOpId: 40n,
1657
+ memoryLimitMB: 0.001,
1658
+ signal: null as any
1659
+ });
1660
+
1661
+ // Bucket checksum must be correct even with overflow — old ops that pass through
1662
+ // as PUT still contribute their original checksum.
1663
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1664
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1665
+ expect(checksumAfter).toBe(checksumBefore);
1666
+
1667
+ // With seen map overflow, fewer than 20 old-version ops become MOVE tombstones.
1668
+ // Some old-version ops pass through as PUT because the seen map was full when
1669
+ // the compactor encountered them. With ~1048 bytes and ~176 bytes per entry,
1670
+ // approximately 5-7 rows are tracked before overflow, producing ~5-7 MOVE
1671
+ // tombstones from the 20 duplicate pairs.
1672
+ const opsAfter = await readAllOps(collection);
1673
+ const moveOps = opsAfter.filter((op) => op.op === 'MOVE');
1674
+ expect(moveOps.length).toBeGreaterThan(0);
1675
+ expect(moveOps.length).toBeLessThanOrEqual(12);
1676
+ });
1677
+
1678
+ test('5. scoped delete does not remove sandwiched non-processable docs', async () => {
1679
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1680
+
1681
+ // Doc1: processable (has ops <= maxOpId=300), _id.o = 300
1682
+ const doc1 = serializeBucketData(BUCKET, [
1683
+ makeOp(200, 'A', 'a1', ctx, sourceTableId),
1684
+ makeOp(300, 'B', 'b1', ctx, sourceTableId)
1685
+ ]);
1686
+ // Doc2: NON-processable (all ops > maxOpId=300), _id.o = 350
1687
+ // This doc is sandwiched between doc1 (_id.o=300) and doc3 (_id.o=500)
1688
+ // in _id.o sort order. A continuous range delete [200, 500] would
1689
+ // incorrectly catch this doc.
1690
+ const doc2 = serializeBucketData(BUCKET, [
1691
+ makeOp(340, 'X', 'x_sandwich', ctx, sourceTableId),
1692
+ makeOp(350, 'Y', 'y_sandwich', ctx, sourceTableId)
1693
+ ]);
1694
+ // Doc3: processable (has ops <= maxOpId=300), _id.o = 400
1695
+ const doc3 = serializeBucketData(BUCKET, [
1696
+ makeOp(250, 'C', 'c1', ctx, sourceTableId),
1697
+ makeOp(400, 'D', 'd1', ctx, sourceTableId)
1698
+ ]);
1699
+
1700
+ await insertDocs(collection, [doc1, doc2, doc3]);
1701
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 400n);
1702
+
1703
+ await bucketStorage.compact({
1704
+ clearBatchLimit: 200,
1705
+ moveBatchLimit: 10,
1706
+ moveBatchQueryLimit: 10,
1707
+ minBucketChanges: 1,
1708
+ minChangeRatio: 0,
1709
+ maxOpId: 300n,
1710
+ signal: null as any
1711
+ });
1712
+
1713
+ // The sandwiched document (doc2, ops 340+350) must survive because ALL
1714
+ // its ops are > maxOpId — it should not be touched by compaction.
1715
+ const opsAfter = await readAllOps(collection);
1716
+ const sandwichOps = opsAfter.filter((op) => op.row_id === 'X' || op.row_id === 'Y');
1717
+ expect(sandwichOps.length).toBe(2);
1718
+ expect(sandwichOps.every((op) => op.op === 'PUT')).toBe(true);
1719
+ });
1720
+
1721
+ test('6. byte-based batch cutting - respects moveBatchByteLimit', async () => {
1722
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1723
+
1724
+ // 6 documents, each with 2 PUT ops. With moveBatchByteLimit=400,
1725
+ // the byte limit should be hit after 1-2 documents, forcing multiple
1726
+ // batch iterations even though the document count limit (10) is never reached.
1727
+ const rows = Array.from({ length: 12 }, (_, i) => `row${i + 1}`);
1728
+ const docs: BucketDataDocumentV3[] = [];
1729
+ for (let i = 0; i < 6; i++) {
1730
+ docs.push(
1731
+ serializeBucketData(BUCKET, [
1732
+ makeOp(i * 2 + 1, rows[i * 2], `data_${rows[i * 2]}`, ctx, sourceTableId),
1733
+ makeOp(i * 2 + 2, rows[i * 2 + 1], `data_${rows[i * 2 + 1]}`, ctx, sourceTableId)
1734
+ ])
1735
+ );
1736
+ }
1737
+ await insertDocs(collection, docs);
1738
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 12n);
1739
+
1740
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1741
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1742
+
1743
+ await bucketStorage.compact({
1744
+ clearBatchLimit: 200,
1745
+ moveBatchLimit: 10,
1746
+ moveBatchQueryLimit: 10,
1747
+ moveBatchByteLimit: 400,
1748
+ minBucketChanges: 1,
1749
+ minChangeRatio: 0,
1750
+ maxOpId: 12n,
1751
+ signal: null as any
1752
+ });
1753
+
1754
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1755
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1756
+ expect(checksumAfter).toBe(checksumBefore);
1757
+
1758
+ // All 12 ops should survive — no superseding, just byte-based batching
1759
+ const allOps = await readAllOps(collection);
1760
+ expect(allOps.length).toBe(12);
1761
+ expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
1762
+
1763
+ // Multiple documents produced due to byte-based batch cuts
1764
+ expect(docsAfter.length).toBeGreaterThan(1);
1765
+ });
1766
+
1767
+ test('7. cross-batch seen map overflow - dedup continues across batches', async () => {
1768
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1769
+
1770
+ // 10 rows (A-J), 2 versions each. 20 docs total, 1 PUT per doc.
1771
+ // New versions at opIds 20-11 (docs 1-10), old versions at opIds 10-1 (docs 11-20).
1772
+ // Processed newest-first: new versions fill the seen map; old versions that
1773
+ // were tracked get tombstoned; old versions beyond map capacity survive as PUT.
1774
+ const rows = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
1775
+ const docs: BucketDataDocumentV3[] = [];
1776
+
1777
+ // New versions first (higher opIds — processed first in reverse order)
1778
+ for (let i = 0; i < 10; i++) {
1779
+ docs.push(
1780
+ serializeBucketData(BUCKET, [makeOp(20 - i, rows[i], `${rows[i].toLowerCase()}_new`, ctx, sourceTableId)])
1781
+ );
1782
+ }
1783
+ // Old versions (lower opIds — processed later)
1784
+ for (let i = 0; i < 10; i++) {
1785
+ docs.push(
1786
+ serializeBucketData(BUCKET, [makeOp(10 - i, rows[i], `${rows[i].toLowerCase()}_old`, ctx, sourceTableId)])
1787
+ );
1788
+ }
1789
+ await insertDocs(collection, docs);
1790
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
1791
+
1792
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1793
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1794
+
1795
+ // moveBatchQueryLimit=5 forces 4 cross-batch iterations. memoryLimitMB=0.001
1796
+ // limits the seen map to ~6-7 entries, so overflow occurs mid-processing.
1797
+ await bucketStorage.compact({
1798
+ clearBatchLimit: 200,
1799
+ moveBatchLimit: 10,
1800
+ moveBatchQueryLimit: 5,
1801
+ minBucketChanges: 1,
1802
+ minChangeRatio: 0,
1803
+ maxOpId: 20n,
1804
+ memoryLimitMB: 0.001,
1805
+ signal: null as any
1806
+ });
1807
+
1808
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1809
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1810
+ expect(checksumAfter).toBe(checksumBefore);
1811
+
1812
+ const allOps = await readAllOps(collection);
1813
+ const moveOps = allOps.filter((op) => op.op === 'MOVE');
1814
+
1815
+ // At least some rows were tracked before overflow → MOVE tombstones exist
1816
+ expect(moveOps.length).toBeGreaterThan(0);
1817
+
1818
+ // Some old-version ops (o <= 10) survive as PUT — overflow prevented tracking
1819
+ const oldVersionPutOps = allOps.filter((op) => op.op === 'PUT' && op.o <= 10n);
1820
+ expect(oldVersionPutOps.length).toBeGreaterThan(0);
1821
+
1822
+ // Not all 10 old versions became MOVEs (overflow occurred)
1823
+ expect(moveOps.length).toBeLessThan(10);
1824
+
1825
+ // MOVEs have target_op stored at the document level (not per-op in V3).
1826
+ // Documents containing MOVEs should have non-null target_op.
1827
+ const docsWithMoves = docsAfter.filter((d) => d.ops.some((op: any) => op.op === 'MOVE'));
1828
+ expect(docsWithMoves.length).toBeGreaterThan(0);
1829
+ for (const doc of docsWithMoves) {
1830
+ expect(doc.target_op).toBeDefined();
1831
+ expect(doc.target_op).not.toBeNull();
1832
+ }
1833
+ });
1834
+
1835
+ test('8. all ops above maxOpId - paginates without writing', async () => {
1836
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1837
+
1838
+ // 5 docs, each with 1 PUT op at high opIds (all > maxOpId=10).
1839
+ // The compactor should paginate through batches where processableDocs
1840
+ // is always empty, terminating cleanly without touching any data.
1841
+ const docs: BucketDataDocumentV3[] = [];
1842
+ for (let i = 0; i < 5; i++) {
1843
+ docs.push(serializeBucketData(BUCKET, [makeOp((i + 1) * 100, `row${i}`, `data${i}`, ctx, sourceTableId)]));
1844
+ }
1845
+ await insertDocs(collection, docs);
1846
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 500n);
1847
+
1848
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1849
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1850
+
1851
+ await bucketStorage.compact({
1852
+ clearBatchLimit: 200,
1853
+ moveBatchLimit: 10,
1854
+ moveBatchQueryLimit: 2,
1855
+ minBucketChanges: 1,
1856
+ minChangeRatio: 0,
1857
+ maxOpId: 10n,
1858
+ signal: null as any
1859
+ });
1860
+
1861
+ // All ops are > maxOpId, so processableDocs is always empty.
1862
+ // The compactor should paginate through all documents without
1863
+ // deleting or modifying anything.
1864
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1865
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1866
+ expect(checksumAfter).toBe(checksumBefore);
1867
+
1868
+ // All 5 docs should still exist with their PUT ops intact
1869
+ expect(docsAfter.length).toBe(5);
1870
+ const allOps = await readAllOps(collection);
1871
+ expect(allOps.length).toBe(5);
1872
+ expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
1873
+ });
1874
+
1875
+ test('9. mixed-document maxOpId filtering preserves ops above horizon', async () => {
1876
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1877
+
1878
+ // Single document with two ops: one <= maxOpId, one > maxOpId
1879
+ const doc = serializeBucketData(BUCKET, [
1880
+ makeOp(200, 'A', 'old_A', ctx, sourceTableId),
1881
+ makeOp(400, 'B', 'new_B', ctx, sourceTableId)
1882
+ ]);
1883
+ await insertDocs(collection, [doc]);
1884
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 400n);
1885
+
1886
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1887
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1888
+
1889
+ await bucketStorage.compact({
1890
+ clearBatchLimit: 200,
1891
+ moveBatchLimit: 10,
1892
+ moveBatchQueryLimit: 10,
1893
+ minBucketChanges: 1,
1894
+ minChangeRatio: 0,
1895
+ maxOpId: 300n,
1896
+ signal: null as any
1897
+ });
1898
+
1899
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1900
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1901
+ expect(checksumAfter).toBe(checksumBefore);
1902
+
1903
+ const allOps = await readAllOps(collection);
1904
+ expect(allOps.length).toBe(2);
1905
+
1906
+ const op400 = allOps.find((op) => op.o === 400n);
1907
+ expect(op400).toBeDefined();
1908
+ expect(op400!.op).toBe('PUT');
1909
+ expect(op400!.row_id).toBe('B');
1910
+ });
1911
+
1912
+ test('10. compacted documents have non-overlapping ranges', async () => {
1913
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1914
+
1915
+ // Four documents with truly disjoint [min_op, _id.o] ranges.
1916
+ // maxOpId=350: doc1+doc2 are processable, doc3+doc4 are not.
1917
+ // Compaction must preserve range disjointness after rechunking.
1918
+ const doc1 = serializeBucketData(BUCKET, [
1919
+ makeOp(100, 'A', 'a1', ctx, sourceTableId),
1920
+ makeOp(200, 'B', 'b1', ctx, sourceTableId)
1921
+ ]); // range [100, 200]
1922
+ const doc2 = serializeBucketData(BUCKET, [
1923
+ makeOp(300, 'C', 'c1', ctx, sourceTableId),
1924
+ makeOp(400, 'D', 'd1', ctx, sourceTableId)
1925
+ ]); // range [300, 400], mixed (300≤350, 400>350)
1926
+ const doc3 = serializeBucketData(BUCKET, [
1927
+ makeOp(500, 'E', 'e1', ctx, sourceTableId),
1928
+ makeOp(600, 'F', 'f1', ctx, sourceTableId)
1929
+ ]); // range [500, 600], all >350
1930
+ const doc4 = serializeBucketData(BUCKET, [
1931
+ makeOp(700, 'G', 'g1', ctx, sourceTableId),
1932
+ makeOp(800, 'H', 'h1', ctx, sourceTableId)
1933
+ ]); // range [700, 800], all >350
1934
+
1935
+ await insertDocs(collection, [doc1, doc2, doc3, doc4]);
1936
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 800n);
1937
+
1938
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1939
+ const checksumBefore = docsBefore.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1940
+
1941
+ await bucketStorage.compact({
1942
+ clearBatchLimit: 200,
1943
+ moveBatchLimit: 10,
1944
+ moveBatchQueryLimit: 10,
1945
+ minBucketChanges: 1,
1946
+ minChangeRatio: 0,
1947
+ maxOpId: 350n,
1948
+ signal: null as any
1949
+ });
1950
+
1951
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1952
+
1953
+ // Every pair of documents must have disjoint [min_op, _id.o] ranges.
1954
+ const overlaps = docsAfter.flatMap((a, i) =>
1955
+ docsAfter
1956
+ .slice(i + 1)
1957
+ .filter((b) => a._id.o >= b.min_op && b._id.o >= a.min_op)
1958
+ .map((b) => `[${a.min_op}, ${a._id.o}] vs [${b.min_op}, ${b._id.o}]`)
1959
+ );
1960
+ expect(overlaps).toEqual([]);
1961
+
1962
+ // Checksum must be preserved across compaction
1963
+ const checksumAfter = docsAfter.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1964
+ expect(checksumAfter).toBe(checksumBefore);
1965
+ });
1966
+
1967
+ describe('V3 CLEAR pass', () => {
1968
+ async function doCompact(bucketStorage: MongoSyncBucketStorage, maxOpId: bigint) {
1969
+ await bucketStorage.compact({
1970
+ clearBatchLimit: 200,
1971
+ moveBatchLimit: 10,
1972
+ moveBatchQueryLimit: 10,
1973
+ minBucketChanges: 1,
1974
+ minChangeRatio: 0,
1975
+ maxOpId,
1976
+ signal: null as any
1977
+ });
1978
+ }
1979
+
1980
+ // Helper to find a CLEAR op in a bucket
1981
+ async function hasClearOp(collection: any): Promise<boolean> {
1982
+ const allOps = await readAllOps(collection);
1983
+ return allOps.some((op) => op.op === 'CLEAR');
1984
+ }
1985
+
1986
+ // Helper to assert checksum preservation before/after compact
1987
+ async function assertChecksumPreserved(collection: any, compactFn: () => Promise<void>) {
1988
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
1989
+ const checksumBefore = docsBefore.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
1990
+ await compactFn();
1991
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
1992
+ const checksumAfter = docsAfter.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
1993
+ expect(checksumAfter).toBe(checksumBefore);
1994
+ }
1995
+
1996
+ test('duplicate rows produce CLEAR op', async () => {
1997
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1998
+
1999
+ const doc = serializeBucketData(BUCKET, [
2000
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2001
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
2002
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
2003
+ ]);
2004
+ await insertDocs(collection, [doc]);
2005
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
2006
+
2007
+ await doCompact(bucketStorage, 30n);
2008
+
2009
+ expect(await hasClearOp(collection)).toBe(true);
2010
+ });
2011
+
2012
+ test('unique rows produce no CLEAR op', async () => {
2013
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2014
+
2015
+ const doc = serializeBucketData(BUCKET, [
2016
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2017
+ makeOp(20, 'B', 'b1', ctx, sourceTableId)
2018
+ ]);
2019
+ await insertDocs(collection, [doc]);
2020
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 20n);
2021
+
2022
+ await doCompact(bucketStorage, 20n);
2023
+
2024
+ expect(await hasClearOp(collection)).toBe(false);
2025
+ const allOps = await readAllOps(collection);
2026
+ expect(allOps.length).toBe(2);
2027
+ expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
2028
+ });
2029
+
2030
+ test('end-to-end: duplicate + unique rows, checksum preserved', async () => {
2031
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2032
+
2033
+ const doc = serializeBucketData(BUCKET, [
2034
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2035
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
2036
+ makeOp(30, 'B', 'b1', ctx, sourceTableId),
2037
+ makeOp(40, 'A', 'a3', ctx, sourceTableId)
2038
+ ]);
2039
+ await insertDocs(collection, [doc]);
2040
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
2041
+
2042
+ await assertChecksumPreserved(collection, () => doCompact(bucketStorage, 40n));
2043
+
2044
+ expect(await hasClearOp(collection)).toBe(true);
2045
+ const allOps = await readAllOps(collection);
2046
+ const putOps = allOps.filter((op) => op.op === 'PUT');
2047
+ expect(putOps.length).toBe(2);
2048
+ expect(putOps.some((op) => op.row_id === 'B' && op.o === 30n)).toBe(true);
2049
+ expect(putOps.some((op) => op.row_id === 'A' && op.o === 40n)).toBe(true);
2050
+ });
2051
+
2052
+ // 15 MOVE ops, each in its own document. moveBatchQueryLimit=1
2053
+ // forces the MOVE pass to produce 15 separate rechunked documents
2054
+ // (one per batch). clearBatchLimit=5 forces the CLEAR read loop
2055
+ // to paginate through 3 iterations.
2056
+ test('read pagination iterates across multiple read batches', async () => {
2057
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2058
+
2059
+ const docs: BucketDataDocumentV3[] = [];
2060
+ for (let i = 1; i <= 15; i++) {
2061
+ const op = { ...makeOp(i, `row_${i}`, '', ctx, sourceTableId), op: 'MOVE' as const, data: null };
2062
+ docs.push(serializeBucketData(BUCKET, [op]));
2063
+ }
2064
+ await insertDocs(collection, docs);
2065
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 15n);
2066
+
2067
+ const docsBefore = await collection.find({ '_id.b': BUCKET }).toArray();
2068
+ const checksumBefore = docsBefore.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
2069
+
2070
+ await bucketStorage.compact({
2071
+ clearBatchLimit: 5,
2072
+ moveBatchLimit: 1,
2073
+ moveBatchQueryLimit: 1,
2074
+ minBucketChanges: 1,
2075
+ minChangeRatio: 0,
2076
+ maxOpId: 15n,
2077
+ signal: null as any
2078
+ });
2079
+
2080
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2081
+ const checksumAfter = docsAfter.reduce((sum: number, d: any) => addChecksums(sum, Number(d.checksum)), 0);
2082
+ expect(checksumAfter).toBe(checksumBefore);
2083
+
2084
+ expect(await hasClearOp(collection)).toBe(true);
2085
+ const allOps = await readAllOps(collection);
2086
+ expect(allOps.length).toBe(1);
2087
+ expect(allOps[0].op).toBe('CLEAR');
2088
+ });
2089
+
2090
+ test('CLEAR document carries target_op from collapsed MOVE tombstones', async () => {
2091
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2092
+
2093
+ // Three versions of row A: ops 10, 20, 30.
2094
+ // MOVE pass: 30 survives, 20 and 10 become MOVE tombstones (target=30).
2095
+ // CLEAR pass: collapses both MOVEs. The CLEAR doc should carry target_op=30.
2096
+ const doc = serializeBucketData(BUCKET, [
2097
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2098
+ makeOp(20, 'A', 'a2', ctx, sourceTableId),
2099
+ makeOp(30, 'A', 'a3', ctx, sourceTableId)
2100
+ ]);
2101
+ await insertDocs(collection, [doc]);
2102
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 30n);
2103
+
2104
+ await doCompact(bucketStorage, 30n);
2105
+
2106
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2107
+ const clearDoc = docsAfter.find((d: any) => d.ops.some((op: any) => op.op === 'CLEAR'));
2108
+ expect(clearDoc).toBeDefined();
2109
+ expect(clearDoc!.target_op).toBe(30n);
2110
+ });
2111
+
2112
+ test('compacted_state.checksum excludes pass-through ops above maxOpId', async () => {
2113
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2114
+
2115
+ // Row A at 10, 30, 50 — ops 10+30 ≤ maxOpId=40, op 50 > 40 (pass-through)
2116
+ // Row B at 20 — ≤ 40, first occurrence
2117
+ // Row C at 60 — > 40 (pass-through, no earlier version)
2118
+ const doc = serializeBucketData(BUCKET, [
2119
+ makeOp(10, 'A', 'a1', ctx, sourceTableId),
2120
+ makeOp(20, 'B', 'b1', ctx, sourceTableId),
2121
+ makeOp(30, 'A', 'a2', ctx, sourceTableId),
2122
+ makeOp(50, 'A', 'a3', ctx, sourceTableId),
2123
+ makeOp(60, 'C', 'c1', ctx, sourceTableId)
2124
+ ]);
2125
+ await insertDocs(collection, [doc]);
2126
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 60n);
2127
+
2128
+ await doCompact(bucketStorage, 40n);
2129
+
2130
+ const state = await bucketStateCollection.findOne({
2131
+ _id: { d: ctx.definitionId, b: BUCKET }
2132
+ });
2133
+ expect(state).toBeDefined();
2134
+ expect(state!.compacted_state).toBeDefined();
2135
+
2136
+ // MOVE dedup: A@50 pass-through, C@60 pass-through survive as PUT.
2137
+ // A@30 (first ≤ 40) survives, B@20 survives. A@10 → MOVE.
2138
+ // compacted_state covers only ops ≤ 40: MOVE@10, PUT@20, PUT@30 = 3 ops.
2139
+
2140
+ // Expected checksum: only ops ≤ 40
2141
+ const opsWithinHorizon = [10, 20, 30];
2142
+ const expectedChecksum = opsWithinHorizon.reduce((sum, id) => addChecksums(sum, id * 7), 0);
2143
+ expect(state!.compacted_state!.checksum).toBe(BigInt(expectedChecksum));
2144
+ // All 5 ops survive (3 counted + 2 pass-through)
2145
+ const allOps = await readAllOps(collection);
2146
+ expect(allOps.length).toBe(5);
2147
+ });
2148
+
2149
+ test('upperBound pagination prevents re-reading replacement documents', async () => {
2150
+ const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2151
+
2152
+ // Two distinct-row PUTs at ~600KB each. Rechunking produces two
2153
+ // documents: C1(_id.o=10) and C2(_id.o=30). The fix sets upperBound
2154
+ // from the oldest replacement C1, so _id < { o: 10 } excludes both.
2155
+ // No re-read. Both PUTs survive with intact data and valid row_ids.
2156
+ const largeData = 'x'.repeat(600_000);
2157
+ const doc = serializeBucketData(BUCKET, [
2158
+ makeOp(10, 'A', largeData, ctx, sourceTableId),
2159
+ makeOp(30, 'B', largeData, ctx, sourceTableId)
2160
+ ]);
2161
+ await insertDocs(collection, [doc]);
2162
+ await insertBucketState(bucketStateCollection, ctx.definitionId, 40n);
2163
+
2164
+ await bucketStorage.compact({
2165
+ clearBatchLimit: 200,
2166
+ moveBatchLimit: 10,
2167
+ moveBatchQueryLimit: 1,
2168
+ minBucketChanges: 1,
2169
+ minChangeRatio: 0,
2170
+ maxOpId: 40n,
2171
+ signal: null as any
2172
+ });
2173
+
2174
+ const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2175
+ const rawOps = docsAfter.flatMap((d: any) => d.ops) as any[];
2176
+
2177
+ // Row A@10 must survive as PUT with data intact
2178
+ const rowA = rawOps.find((op: any) => op.row_id === 'A' && op.o === 10n);
2179
+ expect(rowA).toBeDefined();
2180
+ expect(rowA!.op).toBe('PUT');
2181
+ expect(rowA!.data).not.toBeNull();
2182
+
2183
+ // Row B@30 must survive as PUT with data intact
2184
+ const rowB = rawOps.find((op: any) => op.row_id === 'B' && op.o === 30n);
2185
+ expect(rowB).toBeDefined();
2186
+ expect(rowB!.op).toBe('PUT');
2187
+ expect(rowB!.data).not.toBeNull();
2188
+
2189
+ // Checksum preserved
2190
+ const expectedChecksum = [10, 30].reduce((sum, id) => addChecksums(sum, id * 7), 0);
2191
+ const checksumAfter = docsAfter.reduce((s: number, d: any) => addChecksums(s, Number(d.checksum)), 0);
2192
+ expect(checksumAfter).toBe(expectedChecksum);
2193
+ });
2194
+ });
2195
+ });