@powersync/service-core-tests 0.17.2 → 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 (46) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/test-utils/AbstractStreamTestContext.d.ts +1 -1
  3. package/dist/test-utils/AbstractStreamTestContext.js +2 -3
  4. package/dist/test-utils/AbstractStreamTestContext.js.map +1 -1
  5. package/dist/test-utils/StorageDataHelpers.d.ts +3 -2
  6. package/dist/test-utils/StorageDataHelpers.js +40 -2
  7. package/dist/test-utils/StorageDataHelpers.js.map +1 -1
  8. package/dist/test-utils/general-utils.d.ts +3 -1
  9. package/dist/test-utils/general-utils.js +13 -1
  10. package/dist/test-utils/general-utils.js.map +1 -1
  11. package/dist/test-utils/storage-combinations.d.ts +13 -0
  12. package/dist/test-utils/storage-combinations.js +26 -0
  13. package/dist/test-utils/storage-combinations.js.map +1 -0
  14. package/dist/test-utils/stream_utils.d.ts +0 -1
  15. package/dist/test-utils/stream_utils.js +0 -10
  16. package/dist/test-utils/stream_utils.js.map +1 -1
  17. package/dist/test-utils/test-utils-index.d.ts +1 -0
  18. package/dist/test-utils/test-utils-index.js +1 -0
  19. package/dist/test-utils/test-utils-index.js.map +1 -1
  20. package/dist/tests/register-compacting-tests.js +15 -15
  21. package/dist/tests/register-compacting-tests.js.map +1 -1
  22. package/dist/tests/register-data-storage-checkpoint-tests.js +132 -14
  23. package/dist/tests/register-data-storage-checkpoint-tests.js.map +1 -1
  24. package/dist/tests/register-data-storage-data-tests.js +116 -96
  25. package/dist/tests/register-data-storage-data-tests.js.map +1 -1
  26. package/dist/tests/register-parameter-compacting-tests.js +3 -2
  27. package/dist/tests/register-parameter-compacting-tests.js.map +1 -1
  28. package/dist/tests/register-sync-tests.js +267 -82
  29. package/dist/tests/register-sync-tests.js.map +1 -1
  30. package/dist/tests/util.d.ts +6 -0
  31. package/dist/tests/util.js +12 -0
  32. package/dist/tests/util.js.map +1 -1
  33. package/package.json +4 -4
  34. package/src/test-utils/AbstractStreamTestContext.ts +2 -3
  35. package/src/test-utils/StorageDataHelpers.ts +45 -2
  36. package/src/test-utils/general-utils.ts +27 -3
  37. package/src/test-utils/storage-combinations.ts +51 -0
  38. package/src/test-utils/stream_utils.ts +0 -11
  39. package/src/test-utils/test-utils-index.ts +1 -0
  40. package/src/tests/register-compacting-tests.ts +15 -15
  41. package/src/tests/register-data-storage-checkpoint-tests.ts +161 -14
  42. package/src/tests/register-data-storage-data-tests.ts +132 -134
  43. package/src/tests/register-parameter-compacting-tests.ts +3 -2
  44. package/src/tests/register-sync-tests.ts +288 -84
  45. package/src/tests/util.ts +13 -0
  46. 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
- }
658
+ partialCheckpoints.push({
659
+ priority: next.partial_checkpoint_complete.priority,
660
+ afterCheckpointDiff
661
+ });
575
662
 
576
- if ('data' in next) {
577
- lines.push({ data: { ...next.data, data: undefined } });
578
- sentRows += next.data.data.length;
579
-
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 () => {
@@ -1090,6 +1208,92 @@ bucket_definitions:
1090
1208
  expect(expLines).toMatchSnapshot();
1091
1209
  });
1092
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
+
1093
1297
  test('compacting data - invalidate checkpoint', async (context) => {
1094
1298
  // This tests a case of a compact operation invalidating a checkpoint in the
1095
1299
  // middle of syncing data.
@@ -1105,6 +1309,7 @@ bucket_definitions:
1105
1309
  const bucketStorage = await f.getInstance(syncRules);
1106
1310
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
1107
1311
  const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
1312
+ const bucket = bucketRequest(syncRules.syncConfigContent[0], 'mybucket[]').bucket;
1108
1313
 
1109
1314
  await writer.markAllSnapshotDone('0/1');
1110
1315
  await writer.save({
@@ -1183,10 +1388,7 @@ bucket_definitions:
1183
1388
 
1184
1389
  await writer.commit('0/2');
1185
1390
 
1186
- await bucketStorage.compact({
1187
- minBucketChanges: 1,
1188
- minChangeRatio: 0
1189
- });
1391
+ await compactActive(f, { compactBuckets: [bucket] });
1190
1392
 
1191
1393
  const lines2 = await getCheckpointLines(iter, { consume: true });
1192
1394
 
@@ -1267,6 +1469,7 @@ bucket_definitions:
1267
1469
  const bucketStorage = await f.getInstance(syncRules);
1268
1470
  await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
1269
1471
  const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
1472
+ const highPriorityBucket = bucketRequest(syncRules.syncConfigContent[0], 'high_priority[]').bucket;
1270
1473
 
1271
1474
  await writer.markAllSnapshotDone('0/1');
1272
1475
  await writer.save({
@@ -1349,9 +1552,10 @@ bucket_definitions:
1349
1552
  });
1350
1553
  await writer.commit('0/2');
1351
1554
 
1352
- await bucketStorage.compact({
1353
- minBucketChanges: 1,
1354
- minChangeRatio: 0
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]
1355
1559
  });
1356
1560
 
1357
1561
  const lines = await getCheckpointLines(iter, { consume: true });
@@ -1403,7 +1607,7 @@ bucket_definitions:
1403
1607
  heads: { '1': '1/0' }
1404
1608
  }
1405
1609
  ])
1406
- ).get('test')!;
1610
+ ).writeCheckpoints.get('test')!;
1407
1611
 
1408
1612
  const params: sync.SyncStreamParameters = {
1409
1613
  syncContext,
package/src/tests/util.ts CHANGED
@@ -8,6 +8,19 @@ import {
8
8
  } from '@powersync/service-sync-rules';
9
9
  import { bucketRequest } from '../test-utils/general-utils.js';
10
10
 
11
+ /**
12
+ * Resolve storage again after test writes activate the sync config. The storage
13
+ * instance used by the writer retains its original PROCESSING stream snapshot,
14
+ * while compact() expects an instance constructed with the current state.
15
+ */
16
+ export async function compactActive(factory: storage.BucketStorageFactory, options: storage.CompactOptions) {
17
+ const active = await factory.getActiveSyncConfig();
18
+ if (active == null) {
19
+ throw new Error('Expected an active sync config before compacting');
20
+ }
21
+ await active.storage.compact(options);
22
+ }
23
+
11
24
  export function bucketRequestMap(
12
25
  syncRules: storage.PersistedSyncConfigContent,
13
26
  buckets: Iterable<readonly [string, bigint]>