@powersync/service-core-tests 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +112 -4
  2. package/dist/test-utils/AbstractStreamTestContext.d.ts +7 -6
  3. package/dist/test-utils/AbstractStreamTestContext.js +28 -25
  4. package/dist/test-utils/AbstractStreamTestContext.js.map +1 -1
  5. package/dist/test-utils/StorageDataHelpers.d.ts +6 -5
  6. package/dist/test-utils/StorageDataHelpers.js +40 -2
  7. package/dist/test-utils/StorageDataHelpers.js.map +1 -1
  8. package/dist/test-utils/bucket-validation.d.ts +3 -3
  9. package/dist/test-utils/bucket-validation.js +3 -3
  10. package/dist/test-utils/general-utils.d.ts +16 -3
  11. package/dist/test-utils/general-utils.js +37 -4
  12. package/dist/test-utils/general-utils.js.map +1 -1
  13. package/dist/test-utils/storage-combinations.d.ts +13 -0
  14. package/dist/test-utils/storage-combinations.js +26 -0
  15. package/dist/test-utils/storage-combinations.js.map +1 -0
  16. package/dist/test-utils/stream_utils.d.ts +0 -1
  17. package/dist/test-utils/stream_utils.js +0 -10
  18. package/dist/test-utils/stream_utils.js.map +1 -1
  19. package/dist/test-utils/test-utils-index.d.ts +1 -0
  20. package/dist/test-utils/test-utils-index.js +1 -0
  21. package/dist/test-utils/test-utils-index.js.map +1 -1
  22. package/dist/tests/register-compacting-tests.js +36 -29
  23. package/dist/tests/register-compacting-tests.js.map +1 -1
  24. package/dist/tests/register-data-storage-checkpoint-tests.js +138 -14
  25. package/dist/tests/register-data-storage-checkpoint-tests.js.map +1 -1
  26. package/dist/tests/register-data-storage-data-tests.js +205 -138
  27. package/dist/tests/register-data-storage-data-tests.js.map +1 -1
  28. package/dist/tests/register-data-storage-parameter-tests.js +44 -42
  29. package/dist/tests/register-data-storage-parameter-tests.js.map +1 -1
  30. package/dist/tests/register-parameter-compacting-tests.js +3 -2
  31. package/dist/tests/register-parameter-compacting-tests.js.map +1 -1
  32. package/dist/tests/register-sync-tests.js +404 -86
  33. package/dist/tests/register-sync-tests.js.map +1 -1
  34. package/dist/tests/util.d.ts +8 -2
  35. package/dist/tests/util.js +12 -0
  36. package/dist/tests/util.js.map +1 -1
  37. package/package.json +5 -6
  38. package/src/test-utils/AbstractStreamTestContext.ts +31 -27
  39. package/src/test-utils/StorageDataHelpers.ts +52 -8
  40. package/src/test-utils/bucket-validation.ts +3 -3
  41. package/src/test-utils/general-utils.ts +62 -10
  42. package/src/test-utils/storage-combinations.ts +51 -0
  43. package/src/test-utils/stream_utils.ts +0 -11
  44. package/src/test-utils/test-utils-index.ts +1 -0
  45. package/src/tests/register-compacting-tests.ts +55 -32
  46. package/src/tests/register-data-storage-checkpoint-tests.ts +171 -14
  47. package/src/tests/register-data-storage-data-tests.ts +251 -150
  48. package/src/tests/register-data-storage-parameter-tests.ts +58 -42
  49. package/src/tests/register-parameter-compacting-tests.ts +3 -2
  50. package/src/tests/register-sync-tests.ts +443 -88
  51. package/src/tests/util.ts +15 -2
  52. package/tsconfig.json +1 -4
  53. package/tsconfig.tsbuildinfo +1 -1
@@ -12,9 +12,10 @@ import { JSONBig } from '@powersync/service-jsonbig';
12
12
  import path from 'path';
13
13
  import * as timers from 'timers/promises';
14
14
  import { fileURLToPath } from 'url';
15
- import { expect, test } from 'vitest';
15
+ import { expect, test, vi } from 'vitest';
16
16
  import * as test_utils from '../test-utils/test-utils-index.js';
17
17
  import { bucketRequest, METRICS_HELPER } from '../test-utils/test-utils-index.js';
18
+ import { compactActive } from './util.js';
18
19
 
19
20
  const __filename = fileURLToPath(import.meta.url);
20
21
  const __dirname = path.dirname(__filename);
@@ -251,29 +252,36 @@ streams:
251
252
  expect(lines).toMatchSnapshot();
252
253
  });
253
254
 
254
- test('sync interrupts low-priority buckets on new checkpoints', async () => {
255
+ test('carries pending low-priority buckets into a new checkpoint', async () => {
255
256
  await using f = await factory();
256
257
 
257
258
  const syncRules = await updateSyncRules(f, {
258
259
  content: `
259
260
  bucket_definitions:
260
- b0:
261
+ b0a:
261
262
  priority: 2
262
263
  data:
263
- - SELECT * FROM test WHERE LENGTH(id) <= 5;
264
+ - SELECT * FROM test WHERE substring(id, 1, 6) = 'first-';
265
+ b0b:
266
+ priority: 3
267
+ data:
268
+ - SELECT * FROM test WHERE substring(id, 1, 4) = 'low-';
264
269
  b1:
265
270
  priority: 1
266
271
  data:
267
- - SELECT * FROM test WHERE LENGTH(id) > 5;
272
+ - SELECT * FROM test WHERE substring(id, 1, 8) = 'highprio';
268
273
  `
269
274
  });
270
275
 
271
276
  const bucketStorage = f.getInstance(syncRules);
272
277
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
273
278
  const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
279
+ const syncRulesContent = syncRules.syncConfigContent[0];
280
+ const b0aBucket = test_utils.bucketRequest(syncRulesContent, 'b0a[]').bucket;
281
+ const b0bBucket = test_utils.bucketRequest(syncRulesContent, 'b0b[]').bucket;
282
+ const b1Bucket = test_utils.bucketRequest(syncRulesContent, 'b1[]').bucket;
274
283
 
275
284
  await writer.markAllSnapshotDone('0/1');
276
- // Initial data: Add one priority row and 10k low-priority rows.
277
285
  await writer.save({
278
286
  sourceTable: testTable,
279
287
  tag: storage.SaveOperationTag.INSERT,
@@ -283,15 +291,26 @@ bucket_definitions:
283
291
  },
284
292
  afterReplicaId: 'highprio'
285
293
  });
286
- for (let i = 0; i < 10_000; i++) {
294
+ for (let i = 0; i < 999; i++) {
287
295
  await writer.save({
288
296
  sourceTable: testTable,
289
297
  tag: storage.SaveOperationTag.INSERT,
290
298
  after: {
291
- id: `${i}`,
292
- description: 'low prio'
299
+ id: `first-${i}`,
300
+ description: 'first low-priority bucket'
293
301
  },
294
- afterReplicaId: `${i}`
302
+ afterReplicaId: `first-${i}`
303
+ });
304
+ }
305
+ for (let i = 0; i < 9_001; i++) {
306
+ await writer.save({
307
+ sourceTable: testTable,
308
+ tag: storage.SaveOperationTag.INSERT,
309
+ after: {
310
+ id: `low-${i}`,
311
+ description: 'last low-priority bucket'
312
+ },
313
+ afterReplicaId: `low-${i}`
295
314
  });
296
315
  }
297
316
 
@@ -311,8 +330,12 @@ bucket_definitions:
311
330
  isEncodingAsBson: false
312
331
  });
313
332
 
314
- let sentCheckpoints = 0;
315
- let sentRows = 0;
333
+ const dataLines: Array<{ bucket: string; objectIds: string[]; afterCheckpointDiff: boolean }> = [];
334
+ const partialCheckpoints: Array<{ priority: number; afterCheckpointDiff: boolean }> = [];
335
+ const checkpoints: any[] = [];
336
+ const checkpointCompletes: any[] = [];
337
+ let afterCheckpointDiff = false;
338
+ let interruptionTriggered = false;
316
339
 
317
340
  for await (let next of stream) {
318
341
  if (typeof next == 'string') {
@@ -320,10 +343,13 @@ bucket_definitions:
320
343
  }
321
344
  if (typeof next === 'object' && next !== null) {
322
345
  if ('partial_checkpoint_complete' in next) {
323
- if (sentCheckpoints == 1) {
324
- // Save new data to interrupt the low-priority sync.
346
+ partialCheckpoints.push({
347
+ priority: next.partial_checkpoint_complete.priority,
348
+ afterCheckpointDiff
349
+ });
325
350
 
326
- // Add another high-priority row. This should interrupt the long-running low-priority sync.
351
+ if (next.partial_checkpoint_complete.priority == 2 && !interruptionTriggered) {
352
+ interruptionTriggered = true;
327
353
  await writer.save({
328
354
  sourceTable: testTable,
329
355
  tag: storage.SaveOperationTag.INSERT,
@@ -335,28 +361,76 @@ bucket_definitions:
335
361
  });
336
362
 
337
363
  await writer.commit('0/2');
338
- } else {
339
- // Low-priority sync from the first checkpoint was interrupted. This should not happen before
340
- // 1000 low-priority items were synchronized.
341
- expect(sentCheckpoints).toBe(2);
342
- expect(sentRows).toBeGreaterThan(1000);
364
+ // Let the checkpoint watcher observe the update before requesting the next priority.
365
+ await timers.setTimeout(50);
343
366
  }
344
367
  }
345
368
  if ('checkpoint' in next || 'checkpoint_diff' in next) {
346
- sentCheckpoints += 1;
369
+ checkpoints.push(next);
370
+ afterCheckpointDiff = 'checkpoint_diff' in next;
347
371
  }
348
372
 
349
373
  if ('data' in next) {
350
- sentRows += next.data.data.length;
374
+ dataLines.push({
375
+ bucket: next.data.bucket,
376
+ objectIds: next.data.data.flatMap((entry: any) => (entry.object_id == null ? [] : [entry.object_id])),
377
+ afterCheckpointDiff
378
+ });
351
379
  }
352
380
  if ('checkpoint_complete' in next) {
381
+ checkpointCompletes.push(next);
353
382
  break;
354
383
  }
355
384
  }
356
385
  }
357
386
 
358
- expect(sentCheckpoints).toBe(2);
359
- expect(sentRows).toBe(10002);
387
+ // Expected flow (data may be split into any number of chunks):
388
+ //
389
+ // checkpoint
390
+ // data: b1 contains highprio
391
+ // partial_checkpoint_complete: priority 1
392
+ // data: b0a contains all 999 first-* rows (1,000 operations including highprio)
393
+ // partial_checkpoint_complete: priority 2
394
+ // ## add highprio2, interrupting before b0b starts
395
+ // checkpoint_diff
396
+ // data: b1 contains highprio2
397
+ // partial_checkpoint_complete: priority 1
398
+ // data: b0b contains all 9,001 low-* rows carried over from the interrupted checkpoint
399
+ // checkpoint_complete: only for the new checkpoint
400
+ expect(interruptionTriggered).toBe(true);
401
+ expect(checkpoints).toHaveLength(2);
402
+ expect(checkpoints[0]).toHaveProperty('checkpoint');
403
+ expect(checkpoints[1]).toHaveProperty('checkpoint_diff');
404
+ expect(partialCheckpoints).toEqual([
405
+ { priority: 1, afterCheckpointDiff: false },
406
+ { priority: 2, afterCheckpointDiff: false },
407
+ { priority: 1, afterCheckpointDiff: true }
408
+ ]);
409
+ expect(checkpointCompletes).toEqual([
410
+ {
411
+ checkpoint_complete: {
412
+ last_op_id: checkpoints[1].checkpoint_diff.last_op_id
413
+ }
414
+ }
415
+ ]);
416
+
417
+ const objectIdsFor = (bucket: string, afterDiff: boolean) =>
418
+ dataLines
419
+ .filter((line) => line.bucket == bucket && line.afterCheckpointDiff == afterDiff)
420
+ .flatMap((line) => line.objectIds);
421
+
422
+ expect(objectIdsFor(b1Bucket, false)).toEqual(['highprio']);
423
+ const b0aObjectIds = objectIdsFor(b0aBucket, false);
424
+ expect(new Set(b0aObjectIds)).toEqual(new Set(Array.from({ length: 999 }, (_, i) => `first-${i}`)));
425
+ expect(b0aObjectIds).toHaveLength(999);
426
+ expect(objectIdsFor(b0bBucket, false)).toEqual([]);
427
+
428
+ expect(objectIdsFor(b1Bucket, true)).toEqual(['highprio2']);
429
+ expect(objectIdsFor(b0aBucket, true)).toEqual([]);
430
+
431
+ const b0bObjectIds = objectIdsFor(b0bBucket, true);
432
+ expect(new Set(b0bObjectIds)).toEqual(new Set(Array.from({ length: 9_001 }, (_, i) => `low-${i}`)));
433
+ expect(b0bObjectIds).toHaveLength(9_001);
360
434
  });
361
435
 
362
436
  test('sync interruptions with unrelated data', async () => {
@@ -490,37 +564,36 @@ bucket_definitions:
490
564
  expect(sentRows).toBe(10002);
491
565
  });
492
566
 
493
- test('sync interrupts low-priority buckets on new checkpoints (2)', async () => {
567
+ test('restarts updated low-priority buckets at a new checkpoint', async () => {
494
568
  await using f = await factory();
495
569
 
496
- // bucket0a -> send all data
497
- // then interrupt checkpoint with new data for all buckets
498
- // -> data for all buckets should be sent in the new checkpoint
499
-
500
570
  const syncRules = await updateSyncRules(f, {
501
571
  content: `
502
572
  bucket_definitions:
503
573
  b0a:
504
574
  priority: 2
505
575
  data:
506
- - SELECT * FROM test WHERE LENGTH(id) <= 5;
576
+ - SELECT * FROM test WHERE substring(id, 1, 6) = 'first-';
507
577
  b0b:
508
- priority: 2
578
+ priority: 3
509
579
  data:
510
- - SELECT * FROM test WHERE LENGTH(id) <= 5;
580
+ - SELECT * FROM test WHERE substring(id, 1, 4) = 'low-';
511
581
  b1:
512
582
  priority: 1
513
583
  data:
514
- - SELECT * FROM test WHERE LENGTH(id) > 5;
584
+ - SELECT * FROM test WHERE substring(id, 1, 8) = 'highprio';
515
585
  `
516
586
  });
517
587
 
518
588
  const bucketStorage = f.getInstance(syncRules);
519
589
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
520
590
  const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
591
+ const syncRulesContent = syncRules.syncConfigContent[0];
592
+ const b0aBucket = test_utils.bucketRequest(syncRulesContent, 'b0a[]').bucket;
593
+ const b0bBucket = test_utils.bucketRequest(syncRulesContent, 'b0b[]').bucket;
594
+ const b1Bucket = test_utils.bucketRequest(syncRulesContent, 'b1[]').bucket;
521
595
 
522
596
  await writer.markAllSnapshotDone('0/1');
523
- // Initial data: Add one priority row and 10k low-priority rows.
524
597
  await writer.save({
525
598
  sourceTable: testTable,
526
599
  tag: storage.SaveOperationTag.INSERT,
@@ -530,15 +603,26 @@ bucket_definitions:
530
603
  },
531
604
  afterReplicaId: 'highprio'
532
605
  });
606
+ for (let i = 0; i < 999; i++) {
607
+ await writer.save({
608
+ sourceTable: testTable,
609
+ tag: storage.SaveOperationTag.INSERT,
610
+ after: {
611
+ id: `first-${i}`,
612
+ description: 'first low-priority bucket'
613
+ },
614
+ afterReplicaId: `first-${i}`
615
+ });
616
+ }
533
617
  for (let i = 0; i < 2_000; i++) {
534
618
  await writer.save({
535
619
  sourceTable: testTable,
536
620
  tag: storage.SaveOperationTag.INSERT,
537
621
  after: {
538
- id: `${i}`,
622
+ id: `low-${i}`,
539
623
  description: 'low prio'
540
624
  },
541
- afterReplicaId: `${i}`
625
+ afterReplicaId: `low-${i}`
542
626
  });
543
627
  }
544
628
 
@@ -558,8 +642,12 @@ bucket_definitions:
558
642
  isEncodingAsBson: false
559
643
  });
560
644
 
561
- let sentRows = 0;
562
- let lines: any[] = [];
645
+ const dataLines: Array<{ bucket: string; objectIds: string[]; afterCheckpointDiff: boolean }> = [];
646
+ const partialCheckpoints: Array<{ priority: number; afterCheckpointDiff: boolean }> = [];
647
+ const checkpoints: any[] = [];
648
+ const checkpointCompletes: any[] = [];
649
+ let afterCheckpointDiff = false;
650
+ let interruptionTriggered = false;
563
651
 
564
652
  for await (let next of stream) {
565
653
  if (typeof next == 'string') {
@@ -567,19 +655,13 @@ bucket_definitions:
567
655
  }
568
656
  if (typeof next === 'object' && next !== null) {
569
657
  if ('partial_checkpoint_complete' in next) {
570
- lines.push(next);
571
- }
572
- if ('checkpoint' in next || 'checkpoint_diff' in next) {
573
- lines.push(next);
574
- }
575
-
576
- if ('data' in next) {
577
- lines.push({ data: { ...next.data, data: undefined } });
578
- sentRows += next.data.data.length;
658
+ partialCheckpoints.push({
659
+ priority: next.partial_checkpoint_complete.priority,
660
+ afterCheckpointDiff
661
+ });
579
662
 
580
- if (sentRows == 1001) {
581
- // Save new data to interrupt the low-priority sync.
582
- // Add another high-priority row. This should interrupt the long-running low-priority sync.
663
+ if (next.partial_checkpoint_complete.priority == 2 && !interruptionTriggered) {
664
+ interruptionTriggered = true;
583
665
  await writer.save({
584
666
  sourceTable: testTable,
585
667
  tag: storage.SaveOperationTag.INSERT,
@@ -590,51 +672,87 @@ bucket_definitions:
590
672
  afterReplicaId: 'highprio2'
591
673
  });
592
674
 
593
- // Also add a low-priority row
594
675
  await writer.save({
595
676
  sourceTable: testTable,
596
677
  tag: storage.SaveOperationTag.INSERT,
597
678
  after: {
598
- id: '2001',
679
+ id: 'low-2000',
599
680
  description: 'Another low-priority row'
600
681
  },
601
- afterReplicaId: '2001'
682
+ afterReplicaId: 'low-2000'
602
683
  });
603
684
 
604
685
  await writer.commit('0/2');
605
- }
606
-
607
- if (sentRows >= 1000 && sentRows <= 2001) {
608
- // pause for a bit to give the stream time to process interruptions.
609
- // This covers the data batch above and the next one.
686
+ // Let the checkpoint watcher observe the update before requesting the next priority.
610
687
  await timers.setTimeout(50);
611
688
  }
612
689
  }
690
+ if ('checkpoint' in next || 'checkpoint_diff' in next) {
691
+ checkpoints.push(next);
692
+ afterCheckpointDiff = 'checkpoint_diff' in next;
693
+ }
694
+
695
+ if ('data' in next) {
696
+ dataLines.push({
697
+ bucket: next.data.bucket,
698
+ objectIds: next.data.data.flatMap((entry: any) => (entry.object_id == null ? [] : [entry.object_id])),
699
+ afterCheckpointDiff
700
+ });
701
+ }
613
702
  if ('checkpoint_complete' in next) {
614
- lines.push(next);
703
+ checkpointCompletes.push(next);
615
704
  break;
616
705
  }
617
706
  }
618
707
  }
619
708
 
620
- // Expected lines (full details in snapshot):
709
+ // Expected flow (data may be split into any number of chunks):
621
710
  //
622
- // checkpoint (4001)
623
- // data (b1[] 0 -> 1)
624
- // partial_checkpoint_complete (4001, priority 1)
625
- // data (b0a[], 0 -> 2000)
626
- // ## adds new data, interrupting the checkpoint
627
- // data (b0a[], 2000 -> 4000) # expected - stream is already busy with this by the time it receives the interruption
628
- // checkpoint_diff (4004)
629
- // data (b1[], 1 -> 4002)
630
- // partial_checkpoint_complete (4004, priority 1)
631
- // data (b0a[], 4000 -> 4003)
632
- // data (b0b[], 0 -> 1999)
633
- // data (b0b[], 1999 -> 3999)
634
- // data (b0b[], 3999 -> 4004)
635
- // checkpoint_complete (4004)
636
- expect(lines).toMatchSnapshot();
637
- expect(sentRows).toBe(4004);
711
+ // checkpoint
712
+ // data: b1 contains highprio
713
+ // partial_checkpoint_complete: priority 1
714
+ // data: b0a contains all 999 first-* rows (1,000 operations including highprio)
715
+ // partial_checkpoint_complete: priority 2
716
+ // ## add highprio2 and low-2000, interrupting before b0b starts
717
+ // checkpoint_diff
718
+ // data: b1 contains highprio2
719
+ // partial_checkpoint_complete: priority 1
720
+ // data: b0b contains all 2,001 low-* rows
721
+ // checkpoint_complete: only for the new checkpoint
722
+ expect(interruptionTriggered).toBe(true);
723
+ expect(checkpoints).toHaveLength(2);
724
+ expect(checkpoints[0]).toHaveProperty('checkpoint');
725
+ expect(checkpoints[1]).toHaveProperty('checkpoint_diff');
726
+ expect(partialCheckpoints).toEqual([
727
+ { priority: 1, afterCheckpointDiff: false },
728
+ { priority: 2, afterCheckpointDiff: false },
729
+ { priority: 1, afterCheckpointDiff: true }
730
+ ]);
731
+ expect(checkpointCompletes).toEqual([
732
+ {
733
+ checkpoint_complete: {
734
+ last_op_id: checkpoints[1].checkpoint_diff.last_op_id
735
+ }
736
+ }
737
+ ]);
738
+
739
+ const objectIdsFor = (bucket: string, afterDiff: boolean) =>
740
+ dataLines
741
+ .filter((line) => line.bucket == bucket && line.afterCheckpointDiff == afterDiff)
742
+ .flatMap((line) => line.objectIds);
743
+
744
+ expect(objectIdsFor(b1Bucket, false)).toEqual(['highprio']);
745
+ const b0aObjectIds = objectIdsFor(b0aBucket, false);
746
+ expect(new Set(b0aObjectIds)).toEqual(new Set(Array.from({ length: 999 }, (_, i) => `first-${i}`)));
747
+ expect(b0aObjectIds).toHaveLength(999);
748
+ expect(objectIdsFor(b0bBucket, false)).toEqual([]);
749
+
750
+ expect(objectIdsFor(b1Bucket, true)).toEqual(['highprio2']);
751
+ expect(objectIdsFor(b0aBucket, true)).toEqual([]);
752
+
753
+ const b0bObjectIds = objectIdsFor(b0bBucket, true);
754
+ expect(new Set(b0bObjectIds)).toEqual(new Set(Array.from({ length: 2_001 }, (_, i) => `low-${i}`)));
755
+ expect(b0bObjectIds).toHaveLength(2_001);
638
756
  });
639
757
 
640
758
  test('sends checkpoint complete line for empty checkpoint', async () => {
@@ -687,7 +805,7 @@ bucket_definitions:
687
805
  receivedCompletions++;
688
806
  if (receivedCompletions == 1) {
689
807
  // Trigger an empty bucket update.
690
- await bucketStorage.createManagedWriteCheckpoint({ user_id: '', heads: { '1': '1/0' } });
808
+ await bucketStorage.createManagedWriteCheckpoints([{ user_id: '', heads: { '1': '1/0' } }]);
691
809
  await writer.commit('1/0');
692
810
  } else {
693
811
  break;
@@ -892,7 +1010,8 @@ bucket_definitions:
892
1010
 
893
1011
  const checkpoint2 = await getCheckpointLines(iter);
894
1012
 
895
- const { bucket } = test_utils.bucketRequest(syncRules, 'by_user["user1"]');
1013
+ const syncRulesContent = syncRules.syncConfigContent[0];
1014
+ const { bucket } = test_utils.bucketRequest(syncRulesContent, 'by_user["user1"]');
896
1015
  expect(
897
1016
  (checkpoint2[0] as StreamingSyncCheckpointDiff).checkpoint_diff?.updated_buckets?.map((b) => b.bucket)
898
1017
  ).toEqual([bucket]);
@@ -947,7 +1066,8 @@ bucket_definitions:
947
1066
  iter.return?.();
948
1067
  });
949
1068
 
950
- const { bucket } = bucketRequest(syncRules, 'by_user["user1"]');
1069
+ const syncRulesContent = syncRules.syncConfigContent[0];
1070
+ const { bucket } = bucketRequest(syncRulesContent, 'by_user["user1"]');
951
1071
  const checkpoint1 = await getCheckpointLines(iter);
952
1072
 
953
1073
  expect((checkpoint1[0] as StreamingSyncCheckpoint).checkpoint?.buckets?.map((b) => b.bucket)).toEqual([bucket]);
@@ -1038,7 +1158,8 @@ bucket_definitions:
1038
1158
 
1039
1159
  await writer.commit('0/1');
1040
1160
 
1041
- const { bucket } = test_utils.bucketRequest(syncRules, 'by_user["user1"]');
1161
+ const syncRulesContent = syncRules.syncConfigContent[0];
1162
+ const { bucket } = test_utils.bucketRequest(syncRulesContent, 'by_user["user1"]');
1042
1163
 
1043
1164
  const checkpoint2 = await getCheckpointLines(iter);
1044
1165
  expect(
@@ -1087,6 +1208,92 @@ bucket_definitions:
1087
1208
  expect(expLines).toMatchSnapshot();
1088
1209
  });
1089
1210
 
1211
+ test('checksum invalidation skips the candidate checkpoint', async (context) => {
1212
+ await using f = await factory();
1213
+
1214
+ const syncRules = await updateSyncRules(f, {
1215
+ content: BASIC_SYNC_RULES
1216
+ });
1217
+
1218
+ const bucketStorage = await f.getInstance(syncRules);
1219
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
1220
+ const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
1221
+
1222
+ await writer.markAllSnapshotDone('0/1');
1223
+ await writer.save({
1224
+ sourceTable: testTable,
1225
+ tag: storage.SaveOperationTag.INSERT,
1226
+ after: {
1227
+ id: 't1',
1228
+ description: 'Test 1'
1229
+ },
1230
+ afterReplicaId: 't1'
1231
+ });
1232
+ await writer.commit('0/1');
1233
+
1234
+ let resolveInvalidatedChecksum!: () => void;
1235
+ const invalidatedChecksum = new Promise<void>((resolve) => {
1236
+ resolveInvalidatedChecksum = resolve;
1237
+ });
1238
+ const checksumSpy = vi.spyOn(bucketStorage, 'getChecksums').mockImplementationOnce(async (checkpoint, buckets) => {
1239
+ resolveInvalidatedChecksum();
1240
+ throw new storage.CheckpointChecksumInvalidatedError(checkpoint.checkpoint, buckets[0].bucket);
1241
+ });
1242
+
1243
+ const stream = sync.streamResponse({
1244
+ syncContext,
1245
+ bucketStorage,
1246
+ syncRules: bucketStorage.getParsedSyncRules(test_utils.PARSE_OPTIONS),
1247
+ params: {
1248
+ buckets: [],
1249
+ include_checksum: true,
1250
+ raw_data: true
1251
+ },
1252
+ tracker,
1253
+ token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
1254
+ isEncodingAsBson: false
1255
+ });
1256
+
1257
+ const iter = stream[Symbol.asyncIterator]();
1258
+ context.onTestFinished(() => {
1259
+ iter.return?.();
1260
+ });
1261
+
1262
+ const linesPromise = getCheckpointLines(iter, { consume: true });
1263
+ await invalidatedChecksum;
1264
+
1265
+ // The first candidate was discarded before CheckpointLine.advance(). A later
1266
+ // checkpoint must therefore be calculated from the original connection state.
1267
+ await writer.save({
1268
+ sourceTable: testTable,
1269
+ tag: storage.SaveOperationTag.INSERT,
1270
+ after: {
1271
+ id: 't2',
1272
+ description: 'Test 2'
1273
+ },
1274
+ afterReplicaId: 't2'
1275
+ });
1276
+ await writer.commit('0/2');
1277
+
1278
+ const lines = await linesPromise;
1279
+ expect(checksumSpy).toHaveBeenCalledTimes(2);
1280
+ expect(lines[0]).toEqual({
1281
+ checkpoint: expect.objectContaining({
1282
+ last_op_id: '2'
1283
+ })
1284
+ });
1285
+ expect(lines).not.toContainEqual({
1286
+ checkpoint: expect.objectContaining({
1287
+ last_op_id: '1'
1288
+ })
1289
+ });
1290
+ expect(lines.at(-1)).toEqual({
1291
+ checkpoint_complete: expect.objectContaining({
1292
+ last_op_id: '2'
1293
+ })
1294
+ });
1295
+ });
1296
+
1090
1297
  test('compacting data - invalidate checkpoint', async (context) => {
1091
1298
  // This tests a case of a compact operation invalidating a checkpoint in the
1092
1299
  // middle of syncing data.
@@ -1102,6 +1309,7 @@ bucket_definitions:
1102
1309
  const bucketStorage = await f.getInstance(syncRules);
1103
1310
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
1104
1311
  const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
1312
+ const bucket = bucketRequest(syncRules.syncConfigContent[0], 'mybucket[]').bucket;
1105
1313
 
1106
1314
  await writer.markAllSnapshotDone('0/1');
1107
1315
  await writer.save({
@@ -1180,10 +1388,7 @@ bucket_definitions:
1180
1388
 
1181
1389
  await writer.commit('0/2');
1182
1390
 
1183
- await bucketStorage.compact({
1184
- minBucketChanges: 1,
1185
- minChangeRatio: 0
1186
- });
1391
+ await compactActive(f, { compactBuckets: [bucket] });
1187
1392
 
1188
1393
  const lines2 = await getCheckpointLines(iter, { consume: true });
1189
1394
 
@@ -1235,6 +1440,152 @@ bucket_definitions:
1235
1440
  });
1236
1441
  });
1237
1442
 
1443
+ test('compacting high-priority data invalidates checkpoint before lower priorities', async (context) => {
1444
+ // Scenario:
1445
+ // 1. Bucket priorities are used.
1446
+ // 2. Client syncs a high-priority bucket.
1447
+ // 3. Due to a concurrent compaction, the checkpoint is invalidated (via target_op).
1448
+ // 4. The service picks up that invalidation, and the batch stops, without emitting a "partial_checkpoint_complete". The intention is continuing with the next checkpoint.
1449
+ // 5. However, the loop handling priorities does not see that invalidation, it only sees "bucket data for this priority is done".
1450
+ // 6. The priority loop incorrectly continues with the next priority.
1451
+ // 7. The service emits the data, as well as a final checkpoint_complete.
1452
+ // 8. The client now received a checkpoint_complete without the full data for that checkpoint. It would pick up the missing data in the checksum check, requiring a full re-download of the affected buckets.
1453
+ await using f = await factory();
1454
+
1455
+ const syncRules = await updateSyncRules(f, {
1456
+ content: `
1457
+ bucket_definitions:
1458
+ high_priority:
1459
+ priority: 1
1460
+ data:
1461
+ - SELECT * FROM test WHERE substring(id, 1, 4) = 'high';
1462
+ low_priority:
1463
+ priority: 2
1464
+ data:
1465
+ - SELECT * FROM test WHERE id = 'low';
1466
+ `
1467
+ });
1468
+
1469
+ const bucketStorage = await f.getInstance(syncRules);
1470
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
1471
+ const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
1472
+ const highPriorityBucket = bucketRequest(syncRules.syncConfigContent[0], 'high_priority[]').bucket;
1473
+
1474
+ await writer.markAllSnapshotDone('0/1');
1475
+ await writer.save({
1476
+ sourceTable: testTable,
1477
+ tag: storage.SaveOperationTag.INSERT,
1478
+ after: {
1479
+ id: 'high1',
1480
+ description: 'High priority 1'
1481
+ },
1482
+ afterReplicaId: 'high1'
1483
+ });
1484
+ await writer.save({
1485
+ sourceTable: testTable,
1486
+ tag: storage.SaveOperationTag.INSERT,
1487
+ after: {
1488
+ id: 'high2',
1489
+ description: 'High priority 2'
1490
+ },
1491
+ afterReplicaId: 'high2'
1492
+ });
1493
+ await writer.save({
1494
+ sourceTable: testTable,
1495
+ tag: storage.SaveOperationTag.INSERT,
1496
+ after: {
1497
+ id: 'low',
1498
+ description: 'Low priority'
1499
+ },
1500
+ afterReplicaId: 'low'
1501
+ });
1502
+ await writer.commit('0/1');
1503
+
1504
+ const stream = sync.streamResponse({
1505
+ syncContext,
1506
+ bucketStorage,
1507
+ syncRules: bucketStorage.getParsedSyncRules(test_utils.PARSE_OPTIONS),
1508
+ params: {
1509
+ buckets: [],
1510
+ include_checksum: true,
1511
+ raw_data: true
1512
+ },
1513
+ tracker,
1514
+ token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
1515
+ isEncodingAsBson: false
1516
+ });
1517
+
1518
+ const iter = stream[Symbol.asyncIterator]();
1519
+ await using _ = {
1520
+ async [Symbol.asyncDispose]() {
1521
+ await iter.return?.();
1522
+ }
1523
+ };
1524
+
1525
+ const checkpoint = await consumeIterator(iter, {
1526
+ consume: false,
1527
+ isDone: (line) => (line as any)?.checkpoint != null
1528
+ });
1529
+ expect(checkpoint[0]).toEqual({
1530
+ checkpoint: expect.objectContaining({
1531
+ last_op_id: '3'
1532
+ })
1533
+ });
1534
+
1535
+ await writer.save({
1536
+ sourceTable: testTable,
1537
+ tag: storage.SaveOperationTag.UPDATE,
1538
+ after: {
1539
+ id: 'high1',
1540
+ description: 'Updated high priority 1'
1541
+ },
1542
+ afterReplicaId: 'high1'
1543
+ });
1544
+ await writer.save({
1545
+ sourceTable: testTable,
1546
+ tag: storage.SaveOperationTag.UPDATE,
1547
+ after: {
1548
+ id: 'high2',
1549
+ description: 'Updated high priority 2'
1550
+ },
1551
+ afterReplicaId: 'high2'
1552
+ });
1553
+ await writer.commit('0/2');
1554
+
1555
+ await compactActive(f, {
1556
+ // Explicitly compact the high-priority bucket: V3 schedules background
1557
+ // compaction, while this test needs compaction at this exact point.
1558
+ compactBuckets: [highPriorityBucket]
1559
+ });
1560
+
1561
+ const lines = await getCheckpointLines(iter, { consume: true });
1562
+ const nextCheckpointIndex = lines.findIndex((line) => (line as any)?.checkpoint_diff != null);
1563
+ expect(nextCheckpointIndex).toBeGreaterThan(0);
1564
+
1565
+ const invalidatedCheckpointLines = lines.slice(0, nextCheckpointIndex);
1566
+ expect(invalidatedCheckpointLines).not.toContainEqual(
1567
+ expect.objectContaining({ checkpoint_complete: expect.anything() })
1568
+ );
1569
+ expect(invalidatedCheckpointLines).not.toContainEqual(
1570
+ expect.objectContaining({
1571
+ data: expect.objectContaining({
1572
+ bucket: expect.stringContaining('low_priority')
1573
+ })
1574
+ })
1575
+ );
1576
+
1577
+ expect(lines[nextCheckpointIndex]).toEqual({
1578
+ checkpoint_diff: expect.objectContaining({
1579
+ last_op_id: '5'
1580
+ })
1581
+ });
1582
+ expect(lines.at(-1)).toEqual({
1583
+ checkpoint_complete: expect.objectContaining({
1584
+ last_op_id: '5'
1585
+ })
1586
+ });
1587
+ });
1588
+
1238
1589
  test('write checkpoint', async () => {
1239
1590
  await using f = await factory();
1240
1591
 
@@ -1249,10 +1600,14 @@ bucket_definitions:
1249
1600
  // <= the managed write checkpoint LSN below
1250
1601
  await writer.commit('0/1');
1251
1602
 
1252
- const checkpoint = await bucketStorage.createManagedWriteCheckpoint({
1253
- user_id: 'test',
1254
- heads: { '1': '1/0' }
1255
- });
1603
+ const checkpoint = (
1604
+ await bucketStorage.createManagedWriteCheckpoints([
1605
+ {
1606
+ user_id: 'test',
1607
+ heads: { '1': '1/0' }
1608
+ }
1609
+ ])
1610
+ ).writeCheckpoints.get('test')!;
1256
1611
 
1257
1612
  const params: sync.SyncStreamParameters = {
1258
1613
  syncContext,