@powersync/service-module-mongodb-storage 0.18.2 → 0.19.0

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 (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -4,7 +4,13 @@ import { loadBucketDataDocument, serializeBucketData } from '@module/storage/imp
4
4
  import { chunkBucketData, DEFAULT_MAX_DOC_SIZE_BYTES } from '@module/storage/implementation/v3/chunking.js';
5
5
  import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
6
6
  import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
7
- import { addChecksums, storage, SyncRulesBucketStorage, updateSyncRulesFromYaml } from '@powersync/service-core';
7
+ import {
8
+ addChecksums,
9
+ CheckpointChecksumInvalidatedError,
10
+ storage,
11
+ SyncRulesBucketStorage,
12
+ updateSyncRulesFromYaml
13
+ } from '@powersync/service-core';
8
14
  import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
9
15
  import * as bson from 'bson';
10
16
  import { describe, expect, test } from 'vitest';
@@ -88,8 +94,7 @@ bucket_definitions:
88
94
  moveBatchQueryLimit: 10,
89
95
  minBucketChanges: 1,
90
96
  minChangeRatio: 0,
91
- maxOpId: checkpoint,
92
- signal: null as any
97
+ maxOpId: checkpoint
93
98
  });
94
99
 
95
100
  const users = ['u1', 'u2'];
@@ -123,7 +128,6 @@ bucket_definitions:
123
128
  );
124
129
  const bucketStorage = factory.getInstance(syncRules);
125
130
  const syncRulesContent = syncRules.syncConfigContent[0];
126
- const storageDb = (bucketStorage as any).db;
127
131
 
128
132
  await populate(bucketStorage, 2);
129
133
  const { checkpoint } = await bucketStorage.getCheckpoint();
@@ -236,7 +240,7 @@ bucket_definitions:
236
240
  expect(typeof firstBatch.value[0].estimatedCount).toBe('number');
237
241
  expect(firstBatch.value[0].dirtyRatio).toBeCloseTo(5 / 12);
238
242
 
239
- const checksumBuckets = await (compactor as any).dirtyBucketBatchForChecksums({
243
+ const checksumBuckets = await compactor.dirtyBucketBatchForChecksums({
240
244
  minBucketChanges: 1
241
245
  });
242
246
  expect(checksumBuckets).toEqual([
@@ -292,7 +296,6 @@ describe('V3 invariant verification', () => {
292
296
  row_id: 'row1',
293
297
  checksum: 1n,
294
298
  data: '{"id":"row1"}',
295
- target_op: null,
296
299
  ...overrides
297
300
  };
298
301
  }
@@ -347,8 +350,7 @@ bucket_definitions:
347
350
  table: TABLE,
348
351
  row_id: rowId,
349
352
  checksum: BigInt(opId * 7),
350
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
351
- target_op: null
353
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
352
354
  };
353
355
  }
354
356
 
@@ -371,8 +373,7 @@ bucket_definitions:
371
373
  moveBatchQueryLimit: 10,
372
374
  minBucketChanges: 1,
373
375
  minChangeRatio: 0,
374
- maxOpId,
375
- signal: null as any
376
+ maxOpId
376
377
  });
377
378
  }
378
379
 
@@ -385,7 +386,7 @@ bucket_definitions:
385
386
 
386
387
  const doc = serializeBucketData('test[]', ops);
387
388
 
388
- expect(doc.ops.map((op) => op.o)).toEqual([5n, 3n, 7n]);
389
+ expect(doc.ops!.map((op) => op.o)).toEqual([5n, 3n, 7n]);
389
390
  });
390
391
 
391
392
  test('1. ops[] ordering - preserved after compaction', async () => {
@@ -403,8 +404,8 @@ bucket_definitions:
403
404
 
404
405
  const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
405
406
  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);
407
+ for (let i = 1; i < doc.ops!.length; i++) {
408
+ expect(doc.ops![i].o).toBeGreaterThanOrEqual(doc.ops![i - 1].o);
408
409
  }
409
410
  }
410
411
  });
@@ -422,7 +423,15 @@ bucket_definitions:
422
423
  expect(doc.min_op).toBe(3n);
423
424
  expect(doc.count).toBe(3);
424
425
  expect(doc.checksum).toBe(60n);
425
- expect(doc.size).toBe(4 + 5 + 8);
426
+ expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
427
+ });
428
+
429
+ test('2. has_clear_op is only stored when true', () => {
430
+ const putDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 1n })]);
431
+ const clearDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 2n, op: 'CLEAR', data: null })]);
432
+
433
+ expect(putDoc).not.toHaveProperty('has_clear_op');
434
+ expect(clearDoc.has_clear_op).toBe(true);
426
435
  });
427
436
 
428
437
  test('2. range metadata consistency - after compaction', async () => {
@@ -436,33 +445,14 @@ bucket_definitions:
436
445
 
437
446
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
438
447
  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));
448
+ expect(doc._id.o).toBe(doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n));
449
+ expect(doc.min_op).toBe(doc.ops!.reduce((min, op) => (op.o < min ? op.o : min), doc.ops![0].o));
450
+ expect(doc.count).toBe(doc.ops!.length);
451
+ expect(doc.checksum).toBe(doc.ops!.reduce((sum, op) => sum + op.checksum, 0n));
452
+ expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
444
453
  }
445
454
  });
446
455
 
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
456
  test('4. no overlapping ranges - multiple documents', () => {
467
457
  const opsA = [makeBucketDataDoc({ o: 1n }), makeBucketDataDoc({ o: 3n })];
468
458
  const opsB = [makeBucketDataDoc({ o: 5n }), makeBucketDataDoc({ o: 8n })];
@@ -522,7 +512,7 @@ bucket_definitions:
522
512
  await compact(bucketStorage, 30n);
523
513
 
524
514
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
525
- const allOps = docs.flatMap((d) => d.ops);
515
+ const allOps = docs.flatMap((d) => d.ops!);
526
516
  const moveOps = allOps.filter((op) => op.op === 'MOVE');
527
517
  expect(moveOps.length).toBe(1);
528
518
  expect(moveOps[0].o).toBe(10n);
@@ -547,7 +537,7 @@ bucket_definitions:
547
537
  await compact(bucketStorage, 30n);
548
538
 
549
539
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
550
- const allOps = docs.flatMap((d) => d.ops);
540
+ const allOps = docs.flatMap((d) => d.ops!);
551
541
  const moveOps = allOps.filter((op) => op.op === 'MOVE');
552
542
  // Pre-existing MOVE@20 + new MOVE@10 are collapsed into CLEAR
553
543
  expect(moveOps.length).toBe(0);
@@ -573,7 +563,7 @@ bucket_definitions:
573
563
  await compact(bucketStorage, 25n);
574
564
 
575
565
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
576
- const allOps = docs.flatMap((d) => d.ops);
566
+ const allOps = docs.flatMap((d) => d.ops!);
577
567
  const clearOps = allOps.filter((op) => op.op === 'CLEAR');
578
568
  expect(clearOps.length).toBe(1);
579
569
  });
@@ -591,7 +581,7 @@ bucket_definitions:
591
581
  await compact(bucketStorage, 20n);
592
582
 
593
583
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
594
- const allOps = docs.flatMap((d) => d.ops);
584
+ const allOps = docs.flatMap((d) => d.ops!);
595
585
  // MOVE@10 + REMOVE@20 collapsed into CLEAR@20
596
586
  const clearOps = allOps.filter((op) => op.op === 'CLEAR');
597
587
  expect(clearOps.length).toBe(1);
@@ -609,8 +599,7 @@ bucket_definitions:
609
599
  moveBatchQueryLimit: 10,
610
600
  minBucketChanges: 1,
611
601
  minChangeRatio: 0,
612
- maxOpId: 1n,
613
- signal: null as any
602
+ maxOpId: 1n
614
603
  });
615
604
 
616
605
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
@@ -640,7 +629,7 @@ bucket_definitions:
640
629
  test('9. serialization fidelity - null data preserved', () => {
641
630
  const ops = [makeBucketDataDoc({ o: 1n, data: null })];
642
631
  const doc = serializeBucketData('test[]', ops);
643
- expect(doc.ops[0].data).toBeNull();
632
+ expect(doc.ops![0].data).toBeNull();
644
633
 
645
634
  const context = { replicationStreamId: 1, definitionId: '1' };
646
635
  const deserialized = [...loadBucketDataDocument(context, doc)];
@@ -650,7 +639,7 @@ bucket_definitions:
650
639
  test('9. serialization fidelity - empty string data preserved', () => {
651
640
  const ops = [makeBucketDataDoc({ o: 1n, data: '' })];
652
641
  const doc = serializeBucketData('test[]', ops);
653
- expect(doc.ops[0].data).toBe('');
642
+ expect(doc.ops![0].data).toBe('');
654
643
 
655
644
  const context = { replicationStreamId: 1, definitionId: '1' };
656
645
  const deserialized = [...loadBucketDataDocument(context, doc)];
@@ -661,7 +650,7 @@ bucket_definitions:
661
650
  const unicodeData = '{"name":"日本語テスト","emoji":"🎉"}';
662
651
  const ops = [makeBucketDataDoc({ o: 1n, data: unicodeData })];
663
652
  const doc = serializeBucketData('test[]', ops);
664
- expect(doc.ops[0].data).toBe(unicodeData);
653
+ expect(doc.ops![0].data).toBe(unicodeData);
665
654
 
666
655
  const context = { replicationStreamId: 1, definitionId: '1' };
667
656
  const deserialized = [...loadBucketDataDocument(context, doc)];
@@ -696,12 +685,12 @@ bucket_definitions:
696
685
 
697
686
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
698
687
  for (const doc of docs) {
699
- const maxO = doc.ops.reduce((max, op) => (op.o > max ? op.o : max), 0n);
688
+ const maxO = doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n);
700
689
  expect(doc._id.o).toBe(maxO);
701
690
  }
702
691
  });
703
692
 
704
- test('compaction with maxOpId filtering - ops above maxOpId preserved as pass-through', async () => {
693
+ test('compaction with maxOpId filtering excludes a straddling document', async () => {
705
694
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
706
695
  const ops = [
707
696
  makeOp(10, 'A', 'a1', ctx, sourceTableId),
@@ -715,15 +704,13 @@ bucket_definitions:
715
704
  await compact(bucketStorage, 15n);
716
705
 
717
706
  const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
718
- const allOpsAfter = docsAfter.flatMap((d) => d.ops);
707
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
719
708
 
720
- // All ops survive: ops <= maxOpId are deduplicated, ops > maxOpId pass through.
709
+ // The document ends above maxOpId, so the database query excludes it as a unit.
721
710
  expect(allOpsAfter.length).toBe(3);
722
- // Ops <= maxOpId still present
723
711
  const opsBelow = allOpsAfter.filter((op) => op.o <= 15n);
724
712
  expect(opsBelow.length).toBe(1);
725
713
  expect(opsBelow[0].op).toBe('PUT');
726
- // Ops > maxOpId preserved unchanged
727
714
  const opsAbove = allOpsAfter.filter((op) => op.o > 15n);
728
715
  expect(opsAbove.length).toBe(2);
729
716
  expect(opsAbove.every((op) => op.op === 'PUT')).toBe(true);
@@ -747,7 +734,7 @@ bucket_definitions:
747
734
  }
748
735
 
749
736
  const docs = await collection.find({ '_id.b': BUCKET }).toArray();
750
- const storedOps = docs.flatMap((d) => d.ops);
737
+ const storedOps = docs.flatMap((d) => d.ops!);
751
738
  let storedChecksum = 0;
752
739
  for (const op of storedOps) {
753
740
  storedChecksum = addChecksums(storedChecksum, Number(op.checksum));
@@ -809,14 +796,27 @@ bucket_definitions:
809
796
  table: TABLE,
810
797
  row_id: rowId,
811
798
  checksum: BigInt(opId * 7),
812
- data: JSON.stringify({ id: rowId, description: data }),
813
- target_op: null
799
+ data: JSON.stringify({ id: rowId, description: data })
814
800
  };
815
801
  }
816
802
 
817
- test('partial checksum with start straddling multi-op document', async () => {
818
- const { bucketStorage, syncRules, collection, bucketStateCollection, definitionId, sourceTableId, ctx } =
819
- await setup();
803
+ function checksumRequest(): storage.BucketChecksumRequest {
804
+ return {
805
+ bucket: BUCKET,
806
+ source: {
807
+ uniqueName: 'global',
808
+ bucketParameters: [],
809
+ getSourceTables: () => new Set(),
810
+ tableSyncsData: () => false,
811
+ evaluateRow: () => [],
812
+ inferSchema: () => ({ objects: {} }),
813
+ bucketQuery: () => ({ ast: {} as any, parameters: [] })
814
+ } as any
815
+ };
816
+ }
817
+
818
+ test('start straddle recalculates a cached checksum from the beginning', async () => {
819
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
820
820
 
821
821
  // Single document with ops 10-60, min_op=10, _id.o=60
822
822
  const ops = [
@@ -827,57 +827,37 @@ bucket_definitions:
827
827
  makeOp(50, 'E', 'e1', ctx, sourceTableId),
828
828
  makeOp(60, 'F', 'f1', ctx, sourceTableId)
829
829
  ];
830
- const doc = serializeBucketData(BUCKET, ops);
831
- await collection.insertMany([doc]);
830
+ // Cache the first half before compaction changes the document boundary.
831
+ const firstDoc = serializeBucketData(BUCKET, ops.slice(0, 3));
832
+ await collection.insertOne(firstDoc);
832
833
 
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
834
  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
835
  await bucketStateCollection.insertOne({
846
836
  _id: { d: definitionId, b: BUCKET },
847
837
  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
838
  estimate_since_compact: { count: 3, bytes: 100 }
857
839
  });
858
840
 
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
- };
841
+ const request = checksumRequest();
842
+
843
+ const cached = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
844
+ expect(cached.get(BUCKET)).toMatchObject({ count: 3 });
845
+
846
+ // Compaction rechunks the bucket into one document spanning the cached
847
+ // checkpoint. The requested endpoint is the end of the new document.
848
+ await collection.deleteMany({});
849
+ await collection.insertOne(serializeBucketData(BUCKET, ops, { targetOp: 60n }));
871
850
 
872
851
  const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
873
852
  const checksumResult = result.get(BUCKET)!;
874
853
 
875
- // The total checksum should be: compacted (ops 10,20,30) + partial (ops 40,50,60)
854
+ // The cached checkpoint at 30 is inside the new document. The full result
855
+ // must replace it rather than add the document checksum to it.
876
856
  expect(checksumResult.checksum).toBe(fullChecksum);
877
857
  expect(checksumResult.count).toBe(6);
878
858
  });
879
859
 
880
- test('partial checksum with end straddling multi-op document', async () => {
860
+ test('end straddle invalidates the old checkpoint and a later checkpoint succeeds', async () => {
881
861
  const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
882
862
 
883
863
  // Document with ops 40-60, _id.o=60, min_op=40
@@ -886,49 +866,75 @@ bucket_definitions:
886
866
  makeOp(50, 'E', 'e1', ctx, sourceTableId),
887
867
  makeOp(60, 'F', 'f1', ctx, sourceTableId)
888
868
  ];
889
- const doc = serializeBucketData(BUCKET, ops);
869
+ const doc = serializeBucketData(BUCKET, ops, { targetOp: 60n });
890
870
  await collection.insertMany([doc]);
891
871
 
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
872
  const checksumAllOps = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
902
873
 
903
- // The straddling is real: op 40 is <= 45 but the document's _id.o=60 is > 45
904
- expect(checksumUpTo45).not.toBe(checksumAllOps);
874
+ await bucketStateCollection.insertOne({
875
+ _id: { d: definitionId, b: BUCKET },
876
+ last_op: 0n,
877
+ estimate_since_compact: { count: 0, bytes: 0 }
878
+ });
879
+
880
+ const request = checksumRequest();
905
881
 
882
+ await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
883
+ CheckpointChecksumInvalidatedError
884
+ );
885
+
886
+ const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
887
+ expect(result.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: checksumAllOps, count: 3 });
888
+ });
889
+
890
+ test('end straddle without target_op also invalidates the old checkpoint', async () => {
891
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
892
+ const ops = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'F', 'f1', ctx, sourceTableId)];
893
+ await collection.insertOne(serializeBucketData(BUCKET, ops));
906
894
  await bucketStateCollection.insertOne({
907
895
  _id: { d: definitionId, b: BUCKET },
908
896
  last_op: 0n,
909
897
  estimate_since_compact: { count: 0, bytes: 0 }
910
898
  });
911
899
 
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
- };
900
+ const request = checksumRequest();
901
+ await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
902
+ CheckpointChecksumInvalidatedError
903
+ );
904
+ });
924
905
 
925
- const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request]);
926
- const checksumResult = result.get(BUCKET)!;
906
+ test('has_clear_op replaces a cached checksum', async () => {
907
+ const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
908
+ const beforeClear = makeOp(10, 'A', 'a1', ctx, sourceTableId);
909
+ const clear = { ...makeOp(20, 'clear', '', ctx, sourceTableId), op: 'CLEAR' as const, checksum: 101n, data: null };
910
+ const afterClear = makeOp(30, 'B', 'b1', ctx, sourceTableId);
927
911
 
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);
912
+ await collection.insertOne(serializeBucketData(BUCKET, [beforeClear]));
913
+ await bucketStateCollection.insertOne({
914
+ _id: { d: definitionId, b: BUCKET },
915
+ last_op: 10n,
916
+ estimate_since_compact: { count: 1, bytes: 100 }
917
+ });
918
+
919
+ const request = checksumRequest();
920
+ const before = await bucketStorage.getChecksums(test_utils.testCheckpoint(10n), [request]);
921
+ expect(before.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: 70, count: 1 });
922
+
923
+ // Compaction replaces everything preceding CLEAR, while the checksum cache
924
+ // still contains the old checkpoint.
925
+ await collection.deleteMany({});
926
+ await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
927
+ await bucketStateCollection.updateOne(
928
+ { _id: { d: definitionId, b: BUCKET } },
929
+ { $set: { last_op: 30n, 'estimate_since_compact.count': 2 } }
930
+ );
931
+
932
+ const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
933
+ expect(after.get(BUCKET)).toEqual({
934
+ bucket: BUCKET,
935
+ checksum: addChecksums(Number(clear.checksum), Number(afterClear.checksum)),
936
+ count: 2
937
+ });
932
938
  });
933
939
  });
934
940
 
@@ -986,8 +992,7 @@ bucket_definitions:
986
992
  table: TABLE,
987
993
  row_id: rowId,
988
994
  checksum: BigInt(opId * 7),
989
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
990
- target_op: null
995
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
991
996
  };
992
997
  }
993
998
 
@@ -1010,15 +1015,14 @@ bucket_definitions:
1010
1015
  moveBatchQueryLimit: 10,
1011
1016
  minBucketChanges: 1,
1012
1017
  minChangeRatio: 0,
1013
- maxOpId,
1014
- signal: null as any
1018
+ maxOpId
1015
1019
  });
1016
1020
  }
1017
1021
 
1018
1022
  async function readAllOps(collection: any): Promise<{ row_id: string; o: bigint; op: string }[]> {
1019
1023
  const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1020
1024
  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 }))
1025
+ d.ops!.map((op: any) => ({ row_id: op.row_id!, o: op.o, op: op.op, target_op: op.target_op ?? undefined }))
1022
1026
  );
1023
1027
  }
1024
1028
 
@@ -1255,8 +1259,7 @@ bucket_definitions:
1255
1259
  table: TABLE,
1256
1260
  row_id: rowId,
1257
1261
  checksum: BigInt(opId * 7),
1258
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
1259
- target_op: null
1262
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
1260
1263
  };
1261
1264
  }
1262
1265
 
@@ -1279,8 +1282,7 @@ bucket_definitions:
1279
1282
  moveBatchQueryLimit: 10,
1280
1283
  minBucketChanges: 1,
1281
1284
  minChangeRatio: 0,
1282
- maxOpId,
1283
- signal: null as any
1285
+ maxOpId
1284
1286
  });
1285
1287
  }
1286
1288
 
@@ -1307,7 +1309,7 @@ bucket_definitions:
1307
1309
 
1308
1310
  expect(checksumAfter).toBe(checksumBefore);
1309
1311
 
1310
- const allOpsAfter = docsAfter.flatMap((d) => d.ops);
1312
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
1311
1313
  // Two MOVEs collapsed into one CLEAR
1312
1314
  expect(allOpsAfter.length).toBe(4);
1313
1315
  const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
@@ -1336,7 +1338,7 @@ bucket_definitions:
1336
1338
 
1337
1339
  expect(checksumAfter).toBe(checksumBefore);
1338
1340
 
1339
- const allOpsAfter = docsAfter.flatMap((d) => d.ops);
1341
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
1340
1342
  // Two MOVEs collapsed into one CLEAR
1341
1343
  expect(allOpsAfter.length).toBe(5);
1342
1344
  const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
@@ -1357,7 +1359,7 @@ bucket_definitions:
1357
1359
  await compact(bucketStorage, 30n);
1358
1360
 
1359
1361
  const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1360
- const allOps = docs.flatMap((d) => d.ops);
1362
+ const allOps = docs.flatMap((d) => d.ops!);
1361
1363
  const moveOps = allOps.filter((op) => op.op === 'MOVE');
1362
1364
  expect(moveOps.length).toBe(1);
1363
1365
  expect(moveOps[0].o).toBe(10n);
@@ -1368,8 +1370,9 @@ bucket_definitions:
1368
1370
  expect(putOps.every((op) => op.data != null)).toBe(true);
1369
1371
 
1370
1372
  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
+ // Size calculation is not exact - we just check for a range
1374
+ expect(docs[0].size).toBeGreaterThan(1_000_000);
1375
+ expect(docs[0].size).toBeLessThan(1_001_000);
1373
1376
  });
1374
1377
 
1375
1378
  test('tombstones and survivors end up in same document after rechunking', async () => {
@@ -1394,15 +1397,15 @@ bucket_definitions:
1394
1397
  const checksumAfter = docs.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
1395
1398
  expect(checksumAfter).toBe(checksumBefore);
1396
1399
 
1397
- const allOps = docs.flatMap((d) => d.ops);
1400
+ const allOps = docs.flatMap((d) => d.ops!);
1398
1401
  expect(allOps.length).toBe(5);
1399
1402
  const moveOp = allOps.find((op) => op.op === 'MOVE');
1400
1403
  expect(moveOp).toMatchObject({ o: 10n, data: null });
1401
1404
 
1402
1405
  expect(docs.length).toBe(1);
1403
1406
 
1404
- const moveOpsInDoc = docs[0].ops.filter((op) => op.op === 'MOVE');
1405
- const putOpsInDoc = docs[0].ops.filter((op) => op.op === 'PUT');
1407
+ const moveOpsInDoc = docs[0].ops!.filter((op) => op.op === 'MOVE');
1408
+ const putOpsInDoc = docs[0].ops!.filter((op) => op.op === 'PUT');
1406
1409
  expect(moveOpsInDoc.length).toBe(1);
1407
1410
  expect(putOpsInDoc.length).toBe(4);
1408
1411
  });
@@ -1468,8 +1471,7 @@ bucket_definitions:
1468
1471
  table: TABLE,
1469
1472
  row_id: rowId,
1470
1473
  checksum: BigInt(opId * 7),
1471
- data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data }),
1472
- target_op: null
1474
+ data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
1473
1475
  };
1474
1476
  }
1475
1477
 
@@ -1490,7 +1492,7 @@ bucket_definitions:
1490
1492
  ): Promise<{ row_id: string | undefined; o: bigint; op: string; target_op: bigint | undefined }[]> {
1491
1493
  const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1492
1494
  return docs.flatMap((d: any) =>
1493
- d.ops.map((op: any) => ({
1495
+ d.ops!.map((op: any) => ({
1494
1496
  row_id: op.row_id ?? undefined,
1495
1497
  o: op.o,
1496
1498
  op: op.op,
@@ -1554,8 +1556,7 @@ bucket_definitions:
1554
1556
  moveBatchQueryLimit: 2,
1555
1557
  minBucketChanges: 1,
1556
1558
  minChangeRatio: 0,
1557
- maxOpId: 24n,
1558
- signal: null as any
1559
+ maxOpId: 24n
1559
1560
  });
1560
1561
 
1561
1562
  // Verify checksum preserved
@@ -1604,14 +1605,13 @@ bucket_definitions:
1604
1605
  moveBatchQueryLimit: 2,
1605
1606
  minBucketChanges: 1,
1606
1607
  minChangeRatio: 0,
1607
- maxOpId: 500n,
1608
- signal: null as any
1608
+ maxOpId: 500n
1609
1609
  });
1610
1610
 
1611
1611
  // The document at _id.o=600 must still be present and unmodified after compaction.
1612
1612
  // The streaming compactor should only touch documents containing ops <= maxOpId.
1613
1613
  const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
1614
- const allOpsAfter = docsAfter.flatMap((d) => d.ops);
1614
+ const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
1615
1615
  const op600 = allOpsAfter.find((op) => op.o === 600n);
1616
1616
  expect(op600).toBeDefined();
1617
1617
  expect(op600!.op).toBe('PUT');
@@ -1654,8 +1654,7 @@ bucket_definitions:
1654
1654
  minBucketChanges: 1,
1655
1655
  minChangeRatio: 0,
1656
1656
  maxOpId: 40n,
1657
- memoryLimitMB: 0.001,
1658
- signal: null as any
1657
+ memoryLimitMB: 0.001
1659
1658
  });
1660
1659
 
1661
1660
  // Bucket checksum must be correct even with overflow — old ops that pass through
@@ -1706,8 +1705,7 @@ bucket_definitions:
1706
1705
  moveBatchQueryLimit: 10,
1707
1706
  minBucketChanges: 1,
1708
1707
  minChangeRatio: 0,
1709
- maxOpId: 300n,
1710
- signal: null as any
1708
+ maxOpId: 300n
1711
1709
  });
1712
1710
 
1713
1711
  // The sandwiched document (doc2, ops 340+350) must survive because ALL
@@ -1718,7 +1716,7 @@ bucket_definitions:
1718
1716
  expect(sandwichOps.every((op) => op.op === 'PUT')).toBe(true);
1719
1717
  });
1720
1718
 
1721
- test('6. byte-based batch cutting - respects moveBatchByteLimit', async () => {
1719
+ test('6. byte-based read batches do not constrain output merging', async () => {
1722
1720
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1723
1721
 
1724
1722
  // 6 documents, each with 2 PUT ops. With moveBatchByteLimit=400,
@@ -1734,6 +1732,7 @@ bucket_definitions:
1734
1732
  ])
1735
1733
  );
1736
1734
  }
1735
+ expect(docs[0].size + docs[1].size).toBeGreaterThan(400);
1737
1736
  await insertDocs(collection, docs);
1738
1737
  await insertBucketState(bucketStateCollection, ctx.definitionId, 12n);
1739
1738
 
@@ -1747,8 +1746,7 @@ bucket_definitions:
1747
1746
  moveBatchByteLimit: 400,
1748
1747
  minBucketChanges: 1,
1749
1748
  minChangeRatio: 0,
1750
- maxOpId: 12n,
1751
- signal: null as any
1749
+ maxOpId: 12n
1752
1750
  });
1753
1751
 
1754
1752
  const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
@@ -1760,8 +1758,13 @@ bucket_definitions:
1760
1758
  expect(allOps.length).toBe(12);
1761
1759
  expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
1762
1760
 
1763
- // Multiple documents produced due to byte-based batch cuts
1764
- expect(docsAfter.length).toBeGreaterThan(1);
1761
+ // Read batches are only a memory bound. The pending output group crosses
1762
+ // those boundaries and merges all post-compaction results that fit.
1763
+ expect(docsAfter).toHaveLength(1);
1764
+ expect(docsAfter[0]).toMatchObject({
1765
+ min_op: docs[0].min_op,
1766
+ _id: docs[docs.length - 1]._id
1767
+ });
1765
1768
  });
1766
1769
 
1767
1770
  test('7. cross-batch seen map overflow - dedup continues across batches', async () => {
@@ -1801,8 +1804,7 @@ bucket_definitions:
1801
1804
  minBucketChanges: 1,
1802
1805
  minChangeRatio: 0,
1803
1806
  maxOpId: 20n,
1804
- memoryLimitMB: 0.001,
1805
- signal: null as any
1807
+ memoryLimitMB: 0.001
1806
1808
  });
1807
1809
 
1808
1810
  const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
@@ -1824,7 +1826,7 @@ bucket_definitions:
1824
1826
 
1825
1827
  // MOVEs have target_op stored at the document level (not per-op in V3).
1826
1828
  // Documents containing MOVEs should have non-null target_op.
1827
- const docsWithMoves = docsAfter.filter((d) => d.ops.some((op: any) => op.op === 'MOVE'));
1829
+ const docsWithMoves = docsAfter.filter((d) => d.ops!.some((op: any) => op.op === 'MOVE'));
1828
1830
  expect(docsWithMoves.length).toBeGreaterThan(0);
1829
1831
  for (const doc of docsWithMoves) {
1830
1832
  expect(doc.target_op).toBeDefined();
@@ -1854,8 +1856,7 @@ bucket_definitions:
1854
1856
  moveBatchQueryLimit: 2,
1855
1857
  minBucketChanges: 1,
1856
1858
  minChangeRatio: 0,
1857
- maxOpId: 10n,
1858
- signal: null as any
1859
+ maxOpId: 10n
1859
1860
  });
1860
1861
 
1861
1862
  // All ops are > maxOpId, so processableDocs is always empty.
@@ -1872,7 +1873,7 @@ bucket_definitions:
1872
1873
  expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
1873
1874
  });
1874
1875
 
1875
- test('9. mixed-document maxOpId filtering preserves ops above horizon', async () => {
1876
+ test('9. maxOpId filtering leaves a straddling document untouched', async () => {
1876
1877
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1877
1878
 
1878
1879
  // Single document with two ops: one <= maxOpId, one > maxOpId
@@ -1892,8 +1893,7 @@ bucket_definitions:
1892
1893
  moveBatchQueryLimit: 10,
1893
1894
  minBucketChanges: 1,
1894
1895
  minChangeRatio: 0,
1895
- maxOpId: 300n,
1896
- signal: null as any
1896
+ maxOpId: 300n
1897
1897
  });
1898
1898
 
1899
1899
  const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
@@ -1913,7 +1913,7 @@ bucket_definitions:
1913
1913
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
1914
1914
 
1915
1915
  // Four documents with truly disjoint [min_op, _id.o] ranges.
1916
- // maxOpId=350: doc1+doc2 are processable, doc3+doc4 are not.
1916
+ // maxOpId=350: only doc1 is processable; doc2 straddles the horizon.
1917
1917
  // Compaction must preserve range disjointness after rechunking.
1918
1918
  const doc1 = serializeBucketData(BUCKET, [
1919
1919
  makeOp(100, 'A', 'a1', ctx, sourceTableId),
@@ -1944,8 +1944,7 @@ bucket_definitions:
1944
1944
  moveBatchQueryLimit: 10,
1945
1945
  minBucketChanges: 1,
1946
1946
  minChangeRatio: 0,
1947
- maxOpId: 350n,
1948
- signal: null as any
1947
+ maxOpId: 350n
1949
1948
  });
1950
1949
 
1951
1950
  const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
@@ -1972,8 +1971,7 @@ bucket_definitions:
1972
1971
  moveBatchQueryLimit: 10,
1973
1972
  minBucketChanges: 1,
1974
1973
  minChangeRatio: 0,
1975
- maxOpId,
1976
- signal: null as any
1974
+ maxOpId
1977
1975
  });
1978
1976
  }
1979
1977
 
@@ -2073,8 +2071,7 @@ bucket_definitions:
2073
2071
  moveBatchQueryLimit: 1,
2074
2072
  minBucketChanges: 1,
2075
2073
  minChangeRatio: 0,
2076
- maxOpId: 15n,
2077
- signal: null as any
2074
+ maxOpId: 15n
2078
2075
  });
2079
2076
 
2080
2077
  const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
@@ -2104,12 +2101,12 @@ bucket_definitions:
2104
2101
  await doCompact(bucketStorage, 30n);
2105
2102
 
2106
2103
  const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
2107
- const clearDoc = docsAfter.find((d: any) => d.ops.some((op: any) => op.op === 'CLEAR'));
2104
+ const clearDoc = docsAfter.find((d: any) => d.ops!.some((op: any) => op.op === 'CLEAR'));
2108
2105
  expect(clearDoc).toBeDefined();
2109
2106
  expect(clearDoc!.target_op).toBe(30n);
2110
2107
  });
2111
2108
 
2112
- test('compacted_state.checksum excludes pass-through ops above maxOpId', async () => {
2109
+ test('straddling document is excluded from compacted state', async () => {
2113
2110
  const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
2114
2111
 
2115
2112
  // Row A at 10, 30, 50 — ops 10+30 ≤ maxOpId=40, op 50 > 40 (pass-through)
@@ -2131,19 +2128,12 @@ bucket_definitions:
2131
2128
  _id: { d: ctx.definitionId, b: BUCKET }
2132
2129
  });
2133
2130
  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.
2131
+ expect(state!.compacted_state).toBeUndefined();
2139
2132
 
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)
2133
+ // The document ends beyond maxOpId, so it is not read or modified.
2145
2134
  const allOps = await readAllOps(collection);
2146
2135
  expect(allOps.length).toBe(5);
2136
+ expect(allOps.every((op) => op.op == 'PUT')).toBe(true);
2147
2137
  });
2148
2138
 
2149
2139
  test('upperBound pagination prevents re-reading replacement documents', async () => {
@@ -2167,8 +2157,7 @@ bucket_definitions:
2167
2157
  moveBatchQueryLimit: 1,
2168
2158
  minBucketChanges: 1,
2169
2159
  minChangeRatio: 0,
2170
- maxOpId: 40n,
2171
- signal: null as any
2160
+ maxOpId: 40n
2172
2161
  });
2173
2162
 
2174
2163
  const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();