@powersync/service-module-mongodb 0.15.4 → 0.17.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 (64) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/api/MongoRouteAPIAdapter.js +12 -21
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +23 -42
  5. package/dist/replication/ChangeStream.js +363 -600
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.js +2 -2
  8. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  9. package/dist/replication/JsonBufferWriter.d.ts +80 -0
  10. package/dist/replication/JsonBufferWriter.js +342 -0
  11. package/dist/replication/JsonBufferWriter.js.map +1 -0
  12. package/dist/replication/MongoRelation.d.ts +1 -1
  13. package/dist/replication/MongoRelation.js +45 -21
  14. package/dist/replication/MongoRelation.js.map +1 -1
  15. package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
  16. package/dist/replication/MongoSnapshotQuery.js +6 -3
  17. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  18. package/dist/replication/MongoSnapshotter.d.ts +81 -0
  19. package/dist/replication/MongoSnapshotter.js +594 -0
  20. package/dist/replication/MongoSnapshotter.js.map +1 -0
  21. package/dist/replication/RawChangeStream.d.ts +55 -0
  22. package/dist/replication/RawChangeStream.js +322 -0
  23. package/dist/replication/RawChangeStream.js.map +1 -0
  24. package/dist/replication/SourceRowConverter.d.ts +46 -0
  25. package/dist/replication/SourceRowConverter.js +42 -0
  26. package/dist/replication/SourceRowConverter.js.map +1 -0
  27. package/dist/replication/bufferToSqlite.d.ts +43 -0
  28. package/dist/replication/bufferToSqlite.js +740 -0
  29. package/dist/replication/bufferToSqlite.js.map +1 -0
  30. package/dist/replication/internal-mongodb-utils.d.ts +0 -12
  31. package/dist/replication/internal-mongodb-utils.js +0 -54
  32. package/dist/replication/internal-mongodb-utils.js.map +1 -1
  33. package/dist/replication/replication-index.d.ts +2 -0
  34. package/dist/replication/replication-index.js +2 -0
  35. package/dist/replication/replication-index.js.map +1 -1
  36. package/package.json +11 -11
  37. package/scripts/benchmark-change-document-json.mts +358 -0
  38. package/scripts/benchmark-change-document.mts +370 -0
  39. package/src/api/MongoRouteAPIAdapter.ts +13 -21
  40. package/src/replication/ChangeStream.ts +421 -720
  41. package/src/replication/ChangeStreamReplicationJob.ts +2 -2
  42. package/src/replication/JsonBufferWriter.ts +390 -0
  43. package/src/replication/MongoRelation.ts +54 -25
  44. package/src/replication/MongoSnapshotQuery.ts +8 -5
  45. package/src/replication/MongoSnapshotter.ts +729 -0
  46. package/src/replication/RawChangeStream.ts +460 -0
  47. package/src/replication/SourceRowConverter.ts +65 -0
  48. package/src/replication/bufferToSqlite.ts +944 -0
  49. package/src/replication/internal-mongodb-utils.ts +0 -65
  50. package/src/replication/replication-index.ts +2 -0
  51. package/test/src/buffer_to_sqlite.test.ts +1146 -0
  52. package/test/src/change_stream.test.ts +259 -19
  53. package/test/src/change_stream_utils.ts +28 -27
  54. package/test/src/checkpoint_retry.test.ts +131 -0
  55. package/test/src/mongo_test.test.ts +66 -64
  56. package/test/src/parse_document_id.test.ts +54 -0
  57. package/test/src/raw_change_stream.test.ts +547 -0
  58. package/test/src/resume.test.ts +12 -2
  59. package/test/src/resuming_snapshots.test.ts +10 -6
  60. package/test/src/util.ts +56 -3
  61. package/test/tsconfig.json +0 -1
  62. package/tsconfig.scripts.json +13 -0
  63. package/tsconfig.tsbuildinfo +1 -1
  64. package/test/src/internal_mongodb_utils.test.ts +0 -103
@@ -3,7 +3,7 @@ import { setTimeout } from 'node:timers/promises';
3
3
  import { describe, expect, test, vi } from 'vitest';
4
4
 
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
- import { createWriteCheckpoint } from '@powersync/service-core';
6
+ import { createWriteCheckpoint, storage } from '@powersync/service-core';
7
7
  import { test_utils } from '@powersync/service-core-tests';
8
8
 
9
9
  import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
@@ -23,6 +23,8 @@ describe('change stream', () => {
23
23
  });
24
24
 
25
25
  function defineChangeStreamTests({ factory, storageVersion }: StorageVersionTestContext) {
26
+ const supportsConcurrentSnapshots = storageVersion >= 3;
27
+
26
28
  const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
27
29
  return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
28
30
  };
@@ -94,6 +96,77 @@ bucket_definitions:
94
96
  ]);
95
97
  });
96
98
 
99
+ test('does not resurrect rows deleted while snapshotting', async () => {
100
+ // Special case for testing:
101
+ // 1. Row A exists.
102
+ // 2. Start a snapshot and streaming.
103
+ // 3. Row A is deleted.
104
+ // 4. Streaming sees the delete, writes the delete.
105
+ // 5. Snapshot still sees row A and writes it.
106
+ // The streaming delete should win, using skipExistingRows. For that to work, we rely on soft deletes.
107
+ // To trigger this case, we need the delete to trigger and process the delete in between the snapshot read and the snapshot write, which we can do using beforeBatchFlush.
108
+
109
+ let collection!: mongo.Collection;
110
+ let testId!: mongo.ObjectId;
111
+ let interceptedSnapshotFlush = false;
112
+ let sourceDeleteWritten = false;
113
+ let sourceDeleteFlushBatch: storage.BucketStorageBatch | undefined;
114
+ const streamDeleteFlushed = Promise.withResolvers<void>();
115
+
116
+ await using context = await openContext({
117
+ streamOptions: {
118
+ snapshotChunkLength: 1,
119
+ storageHooks: {
120
+ beforeBatchFlush: async (batch) => {
121
+ if (!batch.skipExistingRows && sourceDeleteWritten && sourceDeleteFlushBatch == null) {
122
+ sourceDeleteFlushBatch = batch;
123
+ }
124
+
125
+ // skipExistingRows means we're busy with snapshotting.
126
+ if (!batch.skipExistingRows || interceptedSnapshotFlush) {
127
+ return;
128
+ }
129
+
130
+ interceptedSnapshotFlush = true;
131
+ await collection.deleteOne({ _id: testId });
132
+ sourceDeleteWritten = true;
133
+ if (!supportsConcurrentSnapshots) {
134
+ return;
135
+ }
136
+ await Promise.race([
137
+ streamDeleteFlushed.promise,
138
+ setTimeout(10_000).then(() => {
139
+ throw new Error('Timed out waiting for streamed delete to flush');
140
+ })
141
+ ]);
142
+ },
143
+ afterBatchFlush: async (batch) => {
144
+ if (batch == sourceDeleteFlushBatch) {
145
+ streamDeleteFlushed.resolve();
146
+ }
147
+ }
148
+ }
149
+ }
150
+ });
151
+ const { db } = context;
152
+ await context.updateSyncRules(BASIC_SYNC_RULES);
153
+
154
+ await db.createCollection('test_data', {
155
+ changeStreamPreAndPostImages: { enabled: false }
156
+ });
157
+ testId = new mongo.ObjectId();
158
+ collection = db.collection('test_data');
159
+ await collection.insertOne({ _id: testId, description: 'stale snapshot row' });
160
+
161
+ await context.replicateSnapshot();
162
+
163
+ expect(interceptedSnapshotFlush).toBe(true);
164
+ await context.getCheckpoint();
165
+
166
+ const data = await context.getBucketData('global[]');
167
+ expect(test_utils.reduceBucket(data).slice(1)).toEqual([]);
168
+ });
169
+
97
170
  test('updateLookup - no fullDocument available', async () => {
98
171
  await using context = await openContext({
99
172
  mongoOptions: { postImages: PostImagesOption.OFF }
@@ -257,6 +330,53 @@ bucket_definitions:
257
330
  expect(data).toMatchObject([test_utils.putOp('test_DATA', { id: test_id, description: 'test1' })]);
258
331
  });
259
332
 
333
+ test('replicating from multiple databases in the same cluster', async () => {
334
+ await using context = await openContext();
335
+ const { client, db } = context;
336
+ const otherDb = client.db(`${db.databaseName}_other_${storageVersion}`);
337
+ await otherDb.dropDatabase();
338
+ await using _ = {
339
+ [Symbol.asyncDispose]: async () => {
340
+ await otherDb.dropDatabase();
341
+ }
342
+ };
343
+
344
+ await context.updateSyncRules(`
345
+ bucket_definitions:
346
+ global:
347
+ data:
348
+ - SELECT _id as id, description FROM "${db.databaseName}"."test_data_default"
349
+ - SELECT _id as id, description FROM "${otherDb.databaseName}"."test_data_other"
350
+ `);
351
+
352
+ await db.createCollection('test_data_default');
353
+ await otherDb.createCollection('test_data_other');
354
+ await context.replicateSnapshot();
355
+ context.startStreaming();
356
+
357
+ const defaultResult = await db.collection('test_data_default').insertOne({ description: 'default db' });
358
+ const otherResult = await otherDb.collection('test_data_other').insertOne({ description: 'other db' });
359
+
360
+ const data = await context.getBucketData('global[]');
361
+
362
+ expect(data).toEqual(
363
+ expect.arrayContaining([
364
+ expect.objectContaining(
365
+ test_utils.putOp('test_data_default', {
366
+ id: defaultResult.insertedId.toHexString(),
367
+ description: 'default db'
368
+ })
369
+ ),
370
+ expect.objectContaining(
371
+ test_utils.putOp('test_data_other', {
372
+ id: otherResult.insertedId.toHexString(),
373
+ description: 'other db'
374
+ })
375
+ )
376
+ ])
377
+ );
378
+ });
379
+
260
380
  test('replicating large values', async () => {
261
381
  await using context = await openContext();
262
382
  const { db } = context;
@@ -351,6 +471,119 @@ bucket_definitions:
351
471
  ]);
352
472
  });
353
473
 
474
+ test.runIf(supportsConcurrentSnapshots)(
475
+ 'collection recreated while queued snapshot is waiting does not stall checkpoints',
476
+ async () => {
477
+ // This is a regression test for a specific timing issue in concurrent snapshot logic.
478
+ // Regression flow:
479
+ // 1. Create test_b and wait until streaming has queued a concurrent snapshot for its source table.
480
+ // 2. Pause that queued snapshot before it reads storage state.
481
+ // 3. Drop test_b and wait until streaming has deleted the queued source table row from storage.
482
+ // 4. Recreate the test_b collection, but do not insert replacement data yet. This leaves the collection
483
+ // visible to namespace-based resolution without giving normal streaming an insert event that could
484
+ // discover and queue the replacement source table.
485
+ // 5. Release the stale queued snapshot and wait for a checkpoint. The old implementation re-resolved by
486
+ // namespace here, created a new source table row, then skipped it because its id did not match the
487
+ // queued id. That left snapshot_done=false with no queue item to finish it, stalling checkpoints.
488
+ // 6. After the checkpoint proves the stale snapshot did not orphan a source table, insert replacement data
489
+ // and verify normal streaming still replicates it.
490
+ const testBSnapshotStarted = Promise.withResolvers<void>();
491
+ const releaseTestBSnapshot = Promise.withResolvers<void>();
492
+ let pausedTestBSnapshot = false;
493
+ let queuedTestBTable: storage.SourceTable | null = null;
494
+ let waitForStreamingFlush: PromiseWithResolvers<void> | null = null;
495
+ const waitFor = async (promise: Promise<void>, description: string) => {
496
+ await Promise.race([
497
+ promise,
498
+ setTimeout(10_000).then(() => {
499
+ throw new Error(`Timed out waiting for ${description}`);
500
+ })
501
+ ]);
502
+ };
503
+
504
+ await using context = await openContext({
505
+ streamOptions: {
506
+ snapshotChunkLength: 1,
507
+ snapshotHooks: {
508
+ beforeSnapshotStarted: async (table) => {
509
+ if (table.name != 'test_b' || pausedTestBSnapshot) {
510
+ return;
511
+ }
512
+
513
+ queuedTestBTable = table;
514
+ pausedTestBSnapshot = true;
515
+ testBSnapshotStarted.resolve();
516
+ await releaseTestBSnapshot.promise;
517
+ }
518
+ },
519
+ storageHooks: {
520
+ afterBatchFlush: async (batch) => {
521
+ if (batch.skipExistingRows) {
522
+ return;
523
+ }
524
+
525
+ waitForStreamingFlush?.resolve();
526
+ }
527
+ }
528
+ }
529
+ });
530
+ const { db } = context;
531
+ const waitForSourceTableDropped = async (table: storage.SourceTable) => {
532
+ const deadline = Date.now() + 10_000;
533
+ while (Date.now() < deadline) {
534
+ await using writer = await context.storage!.createWriter(test_utils.BATCH_OPTIONS);
535
+ if ((await writer.getSourceTableStatus(table)) == null) {
536
+ return;
537
+ }
538
+ await setTimeout(50);
539
+ }
540
+ throw new Error('Timed out waiting for test_b source table to be dropped');
541
+ };
542
+ const syncRuleContent = `
543
+ bucket_definitions:
544
+ global:
545
+ data:
546
+ - SELECT _id as id, description FROM "test_%"
547
+ `;
548
+ await context.updateSyncRules(syncRuleContent);
549
+ await context.replicateSnapshot();
550
+ context.startStreaming();
551
+
552
+ try {
553
+ await db.createCollection('test_a');
554
+ const testA = await db.collection('test_a').insertOne({ description: 'test a' });
555
+
556
+ await db.createCollection('test_b');
557
+ waitForStreamingFlush = Promise.withResolvers<void>();
558
+ await db.collection('test_b').insertOne({ description: 'old test b' });
559
+ await waitFor(waitForStreamingFlush.promise, 'old test_b streaming flush');
560
+ await waitFor(testBSnapshotStarted.promise, 'test_b snapshot to start');
561
+
562
+ if (queuedTestBTable == null) {
563
+ throw new Error('test_b snapshot started without a queued source table');
564
+ }
565
+
566
+ await db.collection('test_b').drop();
567
+ await waitForSourceTableDropped(queuedTestBTable);
568
+
569
+ await db.createCollection('test_b');
570
+ releaseTestBSnapshot.resolve();
571
+ await context.getCheckpoint({ timeout: 10_000 });
572
+
573
+ const result = await db.collection('test_b').insertOne({ description: 'new test b' });
574
+
575
+ const data = await context.getBucketData('global[]', undefined, { timeout: 10_000 });
576
+ const reduced = test_utils.reduceBucket(data).slice(1);
577
+ expect(reduced).toMatchObject([
578
+ test_utils.putOp('test_a', { id: testA.insertedId.toHexString(), description: 'test a' }),
579
+ test_utils.putOp('test_b', { id: result.insertedId.toHexString(), description: 'new test b' })
580
+ ]);
581
+ } finally {
582
+ releaseTestBSnapshot.resolve();
583
+ }
584
+ }
585
+ );
586
+
354
587
  test('initial sync', async () => {
355
588
  await using context = await openContext();
356
589
  const { db } = context;
@@ -373,12 +606,6 @@ bucket_definitions:
373
606
  test('coalesces standalone checkpoints when backlog is buffered', async () => {
374
607
  await using context = await openContext();
375
608
  await context.updateSyncRules(BASIC_SYNC_RULES);
376
- await context.replicateSnapshot();
377
- await context.markSnapshotConsistent();
378
- await using api = new MongoRouteAPIAdapter({
379
- type: 'mongodb',
380
- ...TEST_CONNECTION_OPTIONS
381
- });
382
609
 
383
610
  let commitCount = 0;
384
611
  // This relies on internals to count how often checkpoints are committed
@@ -392,6 +619,13 @@ bucket_definitions:
392
619
  }
393
620
  });
394
621
 
622
+ await context.replicateSnapshot();
623
+ await context.markSnapshotConsistent();
624
+ await using api = new MongoRouteAPIAdapter({
625
+ type: 'mongodb',
626
+ ...TEST_CONNECTION_OPTIONS
627
+ });
628
+
395
629
  context.startStreaming();
396
630
 
397
631
  // Wait until the stream is active and caught up, then start counting from zero.
@@ -449,7 +683,7 @@ bucket_definitions:
449
683
  // The field appears twice in the ChangeStream event, so the total size
450
684
  // is > 16MB.
451
685
 
452
- // We don't actually have this description field in the sync rules,
686
+ // We don't actually have this description field in the sync config,
453
687
  // That causes other issues, not relevant for this specific test.
454
688
  const largeDescription = crypto.randomBytes(12000000 / 2).toString('hex');
455
689
 
@@ -478,7 +712,7 @@ bucket_definitions:
478
712
  });
479
713
  });
480
714
 
481
- test('collection not in sync rules', async () => {
715
+ test('collection not in sync config', async () => {
482
716
  await using context = await openContext();
483
717
  const { db } = context;
484
718
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -520,12 +754,19 @@ bucket_definitions:
520
754
  context.startStreaming();
521
755
 
522
756
  const data = await context.getBucketData('global[]');
523
- expect(data).toMatchObject([
524
- // An extra op here, since this triggers a snapshot in addition to getting the event.
525
- test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
526
- test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
527
- test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
528
- ]);
757
+ if (data.length == 3) {
758
+ expect(data).toMatchObject([
759
+ // An extra op here, since this triggers a snapshot in addition to getting the event.
760
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
761
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
762
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
763
+ ]);
764
+ } else {
765
+ expect(data).toMatchObject([
766
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
767
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
768
+ ]);
769
+ }
529
770
  });
530
771
 
531
772
  test('postImages - new collection with postImages disabled', async () => {
@@ -575,7 +816,7 @@ bucket_definitions:
575
816
  await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
576
817
 
577
818
  await context.replicateSnapshot();
578
- await context.markSnapshotConsistent();
819
+ await context.getCheckpoint();
579
820
 
580
821
  // Simulate an error
581
822
  await context.storage!.reportError(new Error('simulated error'));
@@ -583,10 +824,9 @@ bucket_definitions:
583
824
  expect(syncRules).toBeTruthy();
584
825
  expect(syncRules?.last_fatal_error).toEqual('simulated error');
585
826
 
586
- // startStreaming() should automatically clear the error.
587
- context.startStreaming();
827
+ // The next checkpoint should clear the error.
828
+ await context.getCheckpoint();
588
829
 
589
- // getBucketData() creates a checkpoint that clears the error, so we don't do that
590
830
  // Just wait, and check that the error is cleared automatically.
591
831
  await vi.waitUntil(
592
832
  async () => {
@@ -1,4 +1,5 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { ReplicationAbortedError } from '@powersync/lib-services-framework';
2
3
  import {
3
4
  BucketStorageFactory,
4
5
  createCoreReplicationMetrics,
@@ -8,10 +9,11 @@ import {
8
9
  OplogEntry,
9
10
  ProtocolOpId,
10
11
  ReplicationCheckpoint,
12
+ settledPromise,
11
13
  storage,
12
- STORAGE_VERSION_CONFIG,
13
14
  SyncRulesBucketStorage,
14
15
  TestStorageOptions,
16
+ unsettledPromise,
15
17
  updateSyncRulesFromYaml,
16
18
  utils
17
19
  } from '@powersync/service-core';
@@ -27,8 +29,7 @@ import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
27
29
  export class ChangeStreamTestContext {
28
30
  private _walStream?: ChangeStream;
29
31
  private abortController = new AbortController();
30
- private streamPromise?: Promise<PromiseSettledResult<void>>;
31
- private syncRulesId?: number;
32
+ private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
32
33
  private syncRulesContent?: storage.PersistedSyncRulesContent;
33
34
  public storage?: SyncRulesBucketStorage;
34
35
 
@@ -55,17 +56,15 @@ export class ChangeStreamTestContext {
55
56
  }
56
57
 
57
58
  const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
58
- const versionedBuckets = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false;
59
59
 
60
- return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion, versionedBuckets);
60
+ return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion);
61
61
  }
62
62
 
63
63
  constructor(
64
64
  public factory: BucketStorageFactory,
65
65
  public connectionManager: MongoManager,
66
66
  private streamOptions: Partial<ChangeStreamOptions> = {},
67
- private storageVersion: number = LEGACY_STORAGE_VERSION,
68
- private versionedBuckets: boolean = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false
67
+ private storageVersion: number = LEGACY_STORAGE_VERSION
69
68
  ) {
70
69
  createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
71
70
  initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
@@ -74,13 +73,13 @@ export class ChangeStreamTestContext {
74
73
  /**
75
74
  * Abort snapshot and/or replication, without actively closing connections.
76
75
  */
77
- abort() {
78
- this.abortController.abort();
76
+ abort(cause?: Error) {
77
+ this.abortController.abort(cause);
79
78
  }
80
79
 
81
80
  async dispose() {
82
- this.abort();
83
- await this.streamPromise?.catch((e) => e);
81
+ this.abort(new Error('Disposing test context'));
82
+ await this.settledReplicationPromise;
84
83
  await this.factory[Symbol.asyncDispose]();
85
84
  await this.connectionManager.end();
86
85
  }
@@ -105,7 +104,6 @@ export class ChangeStreamTestContext {
105
104
  const syncRules = await this.factory.updateSyncRules(
106
105
  updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
107
106
  );
108
- this.syncRulesId = syncRules.id;
109
107
  this.syncRulesContent = syncRules;
110
108
  this.storage = this.factory.getInstance(syncRules);
111
109
  return this.storage!;
@@ -114,10 +112,9 @@ export class ChangeStreamTestContext {
114
112
  async loadNextSyncRules() {
115
113
  const syncRules = await this.factory.getNextSyncRulesContent();
116
114
  if (syncRules == null) {
117
- throw new Error(`Next sync rules not available`);
115
+ throw new Error(`Next sync config not available`);
118
116
  }
119
117
 
120
- this.syncRulesId = syncRules.id;
121
118
  this.syncRulesContent = syncRules;
122
119
  this.storage = this.factory.getInstance(syncRules);
123
120
  return this.storage!;
@@ -125,7 +122,7 @@ export class ChangeStreamTestContext {
125
122
 
126
123
  private getSyncRulesContent(): storage.PersistedSyncRulesContent {
127
124
  if (this.syncRulesContent == null) {
128
- throw new Error('Sync rules not configured - call updateSyncRules() first');
125
+ throw new Error('Sync config not configured - call updateSyncRules() first');
129
126
  }
130
127
  return this.syncRulesContent;
131
128
  }
@@ -145,14 +142,25 @@ export class ChangeStreamTestContext {
145
142
  // Specifically reduce this from the default for tests on MongoDB <= 6.0, otherwise it can take
146
143
  // a long time to abort the stream.
147
144
  maxAwaitTimeMS: this.streamOptions?.maxAwaitTimeMS ?? 200,
148
- snapshotChunkLength: this.streamOptions?.snapshotChunkLength
145
+ snapshotChunkLength: this.streamOptions?.snapshotChunkLength,
146
+ storageHooks: this.streamOptions?.storageHooks,
147
+ snapshotHooks: this.streamOptions?.snapshotHooks,
148
+ logger: this.streamOptions?.logger
149
149
  };
150
150
  this._walStream = new ChangeStream(options);
151
151
  return this._walStream!;
152
152
  }
153
153
 
154
154
  async replicateSnapshot() {
155
- await this.streamer.initReplication();
155
+ this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
156
+ try {
157
+ await Promise.race([unsettledPromise(this.settledReplicationPromise), this.streamer.waitForInitialSnapshot()]);
158
+ } catch (e) {
159
+ if (e instanceof ReplicationAbortedError && e.cause != null) {
160
+ throw e.cause;
161
+ }
162
+ throw e;
163
+ }
156
164
  }
157
165
 
158
166
  /**
@@ -170,21 +178,14 @@ export class ChangeStreamTestContext {
170
178
  }
171
179
 
172
180
  startStreaming() {
173
- this.streamPromise = this.streamer
174
- .streamChanges()
175
- .then(() => ({ status: 'fulfilled', value: undefined }) satisfies PromiseFulfilledResult<void>)
176
- .catch((reason) => ({ status: 'rejected', reason }) satisfies PromiseRejectedResult);
177
- return this.streamPromise;
181
+ this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
182
+ return this.settledReplicationPromise;
178
183
  }
179
184
 
180
185
  async getCheckpoint(options?: { timeout?: number }) {
181
186
  let checkpoint = await Promise.race([
182
187
  getClientCheckpoint(this.client, this.db, this.factory, { timeout: options?.timeout ?? 15_000 }),
183
- this.streamPromise?.then((e) => {
184
- if (e.status == 'rejected') {
185
- throw e.reason;
186
- }
187
- })
188
+ this.settledReplicationPromise == null ? undefined : unsettledPromise(this.settledReplicationPromise)
188
189
  ]);
189
190
  if (checkpoint == null) {
190
191
  // This indicates an issue with the test setup - streamingPromise completed instead
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { mongo } from '@powersync/lib-service-mongodb';
4
+
5
+ import { MongoLSN } from '@module/common/MongoLSN.js';
6
+ import { createCheckpoint } from '@module/replication/MongoRelation.js';
7
+ import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
8
+
9
+ describe('checkpoint retryable writes', () => {
10
+ test('returns the persisted checkpoint clusterTime after a retryable write', { repeats: 1 }, async (ctx) => {
11
+ // This test relies on very specific timing:
12
+ //
13
+ // 1. _powersync_checkpoints findAndModify command starts and is sent to the server
14
+ // 2. command is written to the server
15
+ // 3. _powersync_checkpoints findAndModify command fails with a retryable error (e.g. connection timeout)
16
+ // 4. command is retried
17
+ // 5. another write lands on the server, increasing the server's operationTime
18
+ // 6. command succeeds with a new operationTime, but without actually writing again (server de-duplicates based on transaction id)
19
+ //
20
+ // It is quite difficult to simulate this, even with failCommand. We currently rely on triggering a socket timeout for the first command,
21
+ // with another write happening right after that.
22
+
23
+ const TIMEOUT = 100;
24
+ const INSERT_COUNT = 5;
25
+
26
+ const { db, client } = await connectMongoData({
27
+ monitorCommands: true,
28
+ socketTimeoutMS: TIMEOUT,
29
+ maxPoolSize: 1
30
+ });
31
+
32
+ await using _client = { [Symbol.asyncDispose]: async () => await client.close() };
33
+ await client.connect();
34
+ await using failCommand = await requireFailCommand(client, ctx);
35
+
36
+ await clearTestDb(db);
37
+
38
+ const checkpointId = new mongo.ObjectId();
39
+ const advanceClusterTime = db.collection('_advance_cluster_time');
40
+
41
+ const changeStream = db.watch([], { maxAwaitTimeMS: TIMEOUT * 0.8 });
42
+ await using _changeStream = { [Symbol.asyncDispose]: async () => await changeStream.close() };
43
+
44
+ const checkpointEventPromise = waitForCheckpointEvent(changeStream, checkpointId, INSERT_COUNT).catch((e) =>
45
+ console.error(e)
46
+ );
47
+
48
+ const commandsStarted: mongo.CommandStartedEvent[] = [];
49
+
50
+ client.on('commandStarted', (event) => {
51
+ commandsStarted.push(event);
52
+ });
53
+
54
+ const DEBUG_COMMANDS = false;
55
+ if (DEBUG_COMMANDS) {
56
+ // For debugging: Log all commands
57
+ client.on('commandStarted', (event) => {
58
+ console.log('commandStarted:', event);
59
+ });
60
+
61
+ client.on('commandSucceeded', (event) => {
62
+ console.log('commandSucceeded:', event);
63
+ });
64
+
65
+ client.on('commandFailed', (event) => {
66
+ console.log('commandFailed:', event);
67
+ });
68
+ }
69
+
70
+ await failCommand.configure({
71
+ mode: { times: 1 },
72
+ data: {
73
+ failCommands: ['commitTransaction', 'findAndModify'],
74
+ blockConnection: true,
75
+ blockTimeMS: TIMEOUT
76
+ }
77
+ });
78
+ const returnedLsnPromise = createCheckpoint(client, db, checkpointId);
79
+
80
+ for (let i = 0; i < INSERT_COUNT; i++) {
81
+ await advanceClusterTime.insertOne({ at: new Date() });
82
+ }
83
+
84
+ const returnedLsn = await returnedLsnPromise;
85
+ const checkpointEvent = await checkpointEventPromise;
86
+ if (checkpointEvent == null) {
87
+ throw new Error('change stream failed');
88
+ }
89
+
90
+ const eventLsn = new MongoLSN({
91
+ timestamp: checkpointEvent.clusterTime!,
92
+ resume_token: checkpointEvent._id
93
+ }).comparable;
94
+
95
+ if (DEBUG_COMMANDS) {
96
+ const commands = commandsStarted
97
+ .filter((c) => ['findAndModify', 'insert'].includes(c.commandName))
98
+ .map((c) => c.commandName);
99
+ // In the failure case, we had findAndModify, insert, findAndModify, potentially with more inserts in between.
100
+ // With updated behavior, we only have a single findAndModify.
101
+ console.log('Command order:', commands);
102
+ }
103
+
104
+ expect(eventLsn >= returnedLsn).toBe(true);
105
+ expect(checkpointEvent.clusterTime).toEqual(MongoLSN.fromSerialized(returnedLsn).timestamp);
106
+ });
107
+ });
108
+
109
+ async function waitForCheckpointEvent(
110
+ changeStream: mongo.ChangeStream,
111
+ checkpointId: mongo.ObjectId,
112
+ insertCount: number
113
+ ): Promise<mongo.ChangeStreamDocument | null> {
114
+ let lastEvent: mongo.ChangeStreamDocument | null = null;
115
+ let seenInserts = 0;
116
+ for await (const event of changeStream) {
117
+ if (
118
+ (event.operationType == 'insert' || event.operationType == 'update' || event.operationType == 'replace') &&
119
+ checkpointId.equals(event.documentKey._id)
120
+ ) {
121
+ lastEvent = event;
122
+ } else if (event.operationType == 'insert' && event.ns.coll === '_advance_cluster_time') {
123
+ seenInserts++;
124
+ if (seenInserts >= insertCount) {
125
+ // We finish when we hit the expected number of inserts, since the number of checkpoint events are unknown.
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ return lastEvent;
131
+ }