@powersync/service-module-mongodb 0.18.1 → 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 (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
  3. package/dist/api/MongoRouteAPIAdapter.js +23 -32
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/SentinelLSN.d.ts +37 -0
  6. package/dist/common/SentinelLSN.js +59 -0
  7. package/dist/common/SentinelLSN.js.map +1 -0
  8. package/dist/replication/ChangeStream.d.ts +15 -0
  9. package/dist/replication/ChangeStream.js +105 -44
  10. package/dist/replication/ChangeStream.js.map +1 -1
  11. package/dist/replication/MongoRelation.d.ts +36 -1
  12. package/dist/replication/MongoRelation.js +102 -6
  13. package/dist/replication/MongoRelation.js.map +1 -1
  14. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  15. package/dist/replication/MongoSnapshotter.js +108 -42
  16. package/dist/replication/MongoSnapshotter.js.map +1 -1
  17. package/dist/replication/RawChangeStream.d.ts +12 -1
  18. package/dist/replication/RawChangeStream.js +30 -7
  19. package/dist/replication/RawChangeStream.js.map +1 -1
  20. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
  21. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  22. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  23. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
  24. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
  25. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  26. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
  27. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
  28. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  29. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  30. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  31. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  32. package/dist/replication/replication-utils.d.ts +6 -0
  33. package/dist/replication/replication-utils.js +19 -2
  34. package/dist/replication/replication-utils.js.map +1 -1
  35. package/package.json +9 -9
  36. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  37. package/src/common/SentinelLSN.ts +78 -0
  38. package/src/replication/ChangeStream.ts +118 -50
  39. package/src/replication/MongoRelation.ts +124 -14
  40. package/src/replication/MongoSnapshotter.ts +122 -47
  41. package/src/replication/RawChangeStream.ts +64 -28
  42. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  43. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
  44. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
  45. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  46. package/src/replication/replication-utils.ts +23 -2
  47. package/test/DOCUMENTDB_TESTING.md +115 -0
  48. package/test/src/DatabaseType.ts +25 -0
  49. package/test/src/change_stream.test.ts +103 -68
  50. package/test/src/change_stream_utils.ts +85 -9
  51. package/test/src/checkpoint_retry.test.ts +5 -2
  52. package/test/src/documentdb_helpers.test.ts +124 -0
  53. package/test/src/documentdb_mode.test.ts +1040 -0
  54. package/test/src/mongo_test.test.ts +15 -5
  55. package/test/src/raw_change_stream.test.ts +209 -125
  56. package/test/src/resume_token.test.ts +30 -0
  57. package/test/src/slow_tests.test.ts +4 -1
  58. package/test/src/test-timeouts.ts +23 -0
  59. package/test/src/util.ts +1 -2
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,115 @@
1
+ # Running Tests Against DocumentDB
2
+
3
+ These instructions cover running the `module-mongodb` test suite against an Azure DocumentDB (formerly Cosmos DB for MongoDB vCore) cluster.
4
+
5
+ ## Prerequisites
6
+
7
+ - An Azure DocumentDB (Cosmos DB for MongoDB vCore) cluster with change stream support
8
+ - Local PostgreSQL for PowerSync's internal storage (not the source database)
9
+ - The connection URI for the DocumentDB cluster
10
+
11
+ > **The open-source `documentdb-local` Docker image cannot be used for these tests.** The
12
+ > open-source DocumentDB engine does not implement change streams (`$changeStream is not
13
+ supported yet in native pipeline`), does not report the `documentdb_versions` `hello` field
14
+ > the suite uses for detection, and presents as `msg: isdbgrid`. An Azure-managed DocumentDB
15
+ > (vCore) cluster is required.
16
+
17
+ ## Environment Variables
18
+
19
+ DocumentDB is detected automatically from the server: the test suite runs
20
+ `detectDocumentDb()` once at startup (see `DatabaseType.ts`) and gates the
21
+ DocumentDB-specific tests on the result. There is no separate enable flag — pointing
22
+ `MONGO_TEST_DATA_URL` at a DocumentDB cluster is what activates the DocumentDB tests.
23
+
24
+ | Variable | Required | Description |
25
+ | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
26
+ | `MONGO_TEST_DATA_URL` | Yes | DocumentDB connection URI. Must include a database name in the path (see below). Pointing this at a DocumentDB cluster enables the DocumentDB tests. |
27
+ | `PG_STORAGE_TEST_URL` | No | PostgreSQL connection for PowerSync storage. Defaults to `postgres://postgres:postgres@localhost:5432/powersync_storage_test`. |
28
+ | `TEST_MONGO_STORAGE` | No | Set to `false` to skip MongoDB storage tests. Recommended when testing against DocumentDB to avoid using it as a storage backend. |
29
+ | `TEST_TIMEOUT_MULTIPLIER` | No | Scale factor `testTimeout()` applies to a default timeout on DocumentDB when no explicit cloud override is given. Defaults to **6**. Increase for a slow/remote cluster. |
30
+
31
+ ### Connection URI format
32
+
33
+ The `MONGO_TEST_DATA_URL` must include a database name in the path. DocumentDB URIs typically don't have one, so you need to add it before the query string:
34
+
35
+ ```
36
+ # Original URI (no database):
37
+ mongodb+srv://user:pass@cluster.mongocluster.cosmos.azure.com/
38
+
39
+ # With database added:
40
+ mongodb+srv://user:pass@cluster.mongocluster.cosmos.azure.com/powersync_test
41
+ ```
42
+
43
+ If your password contains special characters (`=`, `@`, `+`, `/`), they must be URL-encoded in the URI (e.g., `=` becomes `%3D`). DocumentDB auto-generated passwords often contain `=` (base64).
44
+
45
+ ## Commands
46
+
47
+ All commands run from the module directory: `modules/module-mongodb/`
48
+
49
+ ```bash
50
+ # Run all DocumentDB tests (integration + unit helpers):
51
+ MONGO_TEST_DATA_URL="mongodb+srv://user:pass@cluster.mongocluster.cosmos.azure.com/powersync_test" \
52
+ TEST_MONGO_STORAGE=false \
53
+ npx vitest run documentdb --reporter=verbose
54
+
55
+ # Run only integration tests:
56
+ MONGO_TEST_DATA_URL="<uri>" \
57
+ TEST_MONGO_STORAGE=false \
58
+ npx vitest run documentdb_mode --reporter=verbose
59
+
60
+ # Run only unit helper tests (no DocumentDB cluster needed):
61
+ npx vitest run documentdb_helpers --reporter=verbose
62
+
63
+ # Run a specific test by name:
64
+ MONGO_TEST_DATA_URL="<uri>" \
65
+ TEST_MONGO_STORAGE=false \
66
+ npx vitest run documentdb_mode -t "resume after restart" --reporter=verbose
67
+ ```
68
+
69
+ If you have the URI in an environment variable (e.g., `$DOCUMENTDB_URI`), you can construct the test URL inline:
70
+
71
+ ```bash
72
+ DOCUMENTDB_TEST_URL=$(echo "$DOCUMENTDB_URI" | sed 's|\?|powersync_test?|')
73
+ MONGO_TEST_DATA_URL="$DOCUMENTDB_TEST_URL" \
74
+ TEST_MONGO_STORAGE=false \
75
+ npx vitest run documentdb --reporter=verbose
76
+ ```
77
+
78
+ ## GitHub Actions
79
+
80
+ The `.github/workflows/documentdb-integration.yml` workflow runs these tests manually via `workflow_dispatch` only. Add a repository or organization secret named `AZURE_DOCUMENTDB_TEST_DATA_URL`, then dispatch the workflow. The workflow maps that secret to the test suite's `MONGO_TEST_DATA_URL` environment variable.
81
+
82
+ The URI must include a database name in the path. The tests clear/drop this database as part of setup, so use a dedicated test database and cluster.
83
+
84
+ The workflow starts a local MongoDB storage service, then runs the complete `modules/module-mongodb` `test` script against that storage backend. `MONGO_TEST_DATA_URL` is the remote DocumentDB source; `MONGO_TEST_URL` points at the local MongoDB storage test instance.
85
+
86
+ ```bash
87
+ TEST_MONGO_STORAGE=true
88
+ TEST_POSTGRES_STORAGE=false
89
+ ```
90
+
91
+ ## Test Files
92
+
93
+ | File | Requires DocumentDB | Description |
94
+ | ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
95
+ | `documentdb_mode.test.ts` | Yes | Integration tests: replication, sentinel checkpoints, write checkpoints, keepalive, resume. Skipped automatically unless `MONGO_TEST_DATA_URL` points at a DocumentDB cluster. |
96
+ | `documentdb_helpers.test.ts` | No (1 test needs MongoDB) | Unit tests: `getEventTimestamp`, sentinel parsing/matching, detection logic. Runs against any MongoDB or standalone. |
97
+
98
+ ## What the Integration Tests Cover
99
+
100
+ Each integration test runs against 3 storage versions (v1, v2, v3) = 15 integration tests. Plus 15 unit tests in helpers = 30 total.
101
+
102
+ | Test | What it validates |
103
+ | ----------------------- | ----------------------------------------------------------------------------------------------- |
104
+ | basic replication | Insert, update, delete through change stream with wallTime timestamps |
105
+ | sentinel checkpoint | Checkpoint created with `mode: 'sentinel'`, resolved by matching document content in the stream |
106
+ | keepalive | Stream idles past the keepalive interval without crashing on DocumentDB resume tokens |
107
+ | write checkpoint | Full `createReplicationHead` → sentinel → polling flow for client write consistency |
108
+ | data events not dropped | Verifies `.lte()` dedup guard is skipped — events in the same wall-clock second are not lost |
109
+ | resume after restart | Stop streaming, create new context, resume from stored token |
110
+
111
+ There is also a **characterization test**, `does not report collection drop and rename events`,
112
+ that documents the current limitation: it writes DDL plus a post-DDL marker, waits for the marker
113
+ (proving the stream caught up), then asserts no `drop` / `rename` events were delivered. It passes
114
+ today and **fails if a future DocumentDB engine starts delivering DDL events** — a signal to add
115
+ real drop/rename replication support.
@@ -0,0 +1,25 @@
1
+ import { detectDocumentDb } from '@module/replication/replication-utils.js';
2
+ import { logger } from '@powersync/lib-services-framework';
3
+ import { connectMongoData } from './util.js';
4
+
5
+ export enum DatabaseType {
6
+ DOCUMENTDB = 'DOCUMENTDB',
7
+ MONGODB = 'MONGODB'
8
+ }
9
+
10
+ let _databaseType: DatabaseType = DatabaseType.MONGODB;
11
+
12
+ // Detected once at import time. Close the client afterwards so this detection
13
+ // does not leak a connection: this module is imported by many test files, and
14
+ // the client created here is otherwise never closed, accumulating open handles
15
+ // in Vitest.
16
+ const { client, db } = await connectMongoData();
17
+ try {
18
+ _databaseType = (await detectDocumentDb(db)) ? DatabaseType.DOCUMENTDB : DatabaseType.MONGODB;
19
+ } catch (ex) {
20
+ logger.warn(`Could not determine MongoDB database type`, ex);
21
+ } finally {
22
+ await client.close().catch(() => {});
23
+ }
24
+
25
+ export const DATABASE_TYPE = _databaseType;
@@ -3,12 +3,14 @@ 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, storage } from '@powersync/service-core';
6
+ import { createWriteCheckpoint, storage, WriteCheckpointBatcher } 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';
10
10
  import { PostImagesOption } from '@module/types/types.js';
11
11
  import { ChangeStreamTestContext } from './change_stream_utils.js';
12
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
13
+ import { testTimeout } from './test-timeouts.js';
12
14
  import { describeWithStorage, StorageVersionTestContext, TEST_CONNECTION_OPTIONS } from './util.js';
13
15
 
14
16
  const BASIC_SYNC_RULES = `
@@ -19,7 +21,7 @@ bucket_definitions:
19
21
  `;
20
22
 
21
23
  describe('change stream', () => {
22
- describeWithStorage({ timeout: 20_000 }, defineChangeStreamTests);
24
+ describeWithStorage({ timeout: testTimeout(20_000, { cloudOverride: 40_000 }) }, defineChangeStreamTests);
23
25
  });
24
26
 
25
27
  function defineChangeStreamTests({ factory, storageVersion }: StorageVersionTestContext) {
@@ -28,7 +30,8 @@ function defineChangeStreamTests({ factory, storageVersion }: StorageVersionTest
28
30
  const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
29
31
  return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
30
32
  };
31
- test('replicating basic values', async () => {
33
+ // DocumentDB does not support changeStreamPreAndPostImages, which this test requires via postImages: READ_ONLY.
34
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('replicating basic values', async () => {
32
35
  await using context = await openContext({
33
36
  mongoOptions: { postImages: PostImagesOption.READ_ONLY }
34
37
  });
@@ -74,7 +77,10 @@ bucket_definitions:
74
77
  - SELECT _id as id, description, num FROM "test_%"`);
75
78
 
76
79
  await db.createCollection('test_data', {
77
- changeStreamPreAndPostImages: { enabled: false }
80
+ // DocumentDB does not support changeStreamPreAndPostImages, even when
81
+ // explicitly disabled. This test only needs a collection to exercise
82
+ // fatal error recovery, so omit the option in DocumentDB mode.
83
+ ...(DATABASE_TYPE == DatabaseType.MONGODB ? { changeStreamPreAndPostImages: { enabled: false } } : {})
78
84
  });
79
85
  const collection = db.collection('test_data');
80
86
 
@@ -152,7 +158,7 @@ bucket_definitions:
152
158
  await context.updateSyncRules(BASIC_SYNC_RULES);
153
159
 
154
160
  await db.createCollection('test_data', {
155
- changeStreamPreAndPostImages: { enabled: false }
161
+ changeStreamPreAndPostImages: DATABASE_TYPE == DatabaseType.MONGODB ? { enabled: false } : undefined
156
162
  });
157
163
  testId = new mongo.ObjectId();
158
164
  collection = db.collection('test_data');
@@ -167,7 +173,9 @@ bucket_definitions:
167
173
  expect(test_utils.reduceBucket(data).slice(1)).toEqual([]);
168
174
  });
169
175
 
170
- test('updateLookup - no fullDocument available', async () => {
176
+ // DocumentDB always materializes a write-time post-image for updateLookup, so the 'fullDocument unavailable'
177
+ // case this test exercises (update treated as a remove) cannot occur there.
178
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('updateLookup - no fullDocument available', async () => {
171
179
  await using context = await openContext({
172
180
  mongoOptions: { postImages: PostImagesOption.OFF }
173
181
  });
@@ -179,6 +187,9 @@ bucket_definitions:
179
187
  - SELECT _id as id, description, num FROM "test_data"`);
180
188
 
181
189
  await db.createCollection('test_data', {
190
+ // DocumentDB does not support changeStreamPreAndPostImages, even when
191
+ // explicitly disabled. This test only needs a collection to exercise
192
+ // fatal error recovery, so omit the option in DocumentDB mode.
182
193
  changeStreamPreAndPostImages: { enabled: false }
183
194
  });
184
195
  const collection = db.collection('test_data');
@@ -211,7 +222,8 @@ bucket_definitions:
211
222
  ]);
212
223
  });
213
224
 
214
- test('postImages - autoConfigure', async () => {
225
+ // DocumentDB does not support changeStreamPreAndPostImages (post-images).
226
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('postImages - autoConfigure', async () => {
215
227
  // Similar to the above test, but with postImages enabled.
216
228
  // This resolves the consistency issue.
217
229
  await using context = await openContext({
@@ -259,7 +271,8 @@ bucket_definitions:
259
271
  ]);
260
272
  });
261
273
 
262
- test('postImages - on', async () => {
274
+ // DocumentDB does not support changeStreamPreAndPostImages (post-images).
275
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('postImages - on', async () => {
263
276
  // Similar to postImages - autoConfigure, but does not auto-configure.
264
277
  // changeStreamPreAndPostImages must be manually configured.
265
278
  await using context = await openContext({
@@ -408,7 +421,9 @@ bucket_definitions:
408
421
  ]);
409
422
  });
410
423
 
411
- test('replicating dropCollection', async () => {
424
+ // Collection drop events are not currently replicated on DocumentDB: they are not delivered through the
425
+ // cluster-level change stream the way standard MongoDB delivers them, so the drop is not applied as a remove.
426
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('replicating dropCollection', async () => {
412
427
  await using context = await openContext();
413
428
  const { db } = context;
414
429
  const syncRuleContent = `
@@ -440,7 +455,9 @@ bucket_definitions:
440
455
  ]);
441
456
  });
442
457
 
443
- test('replicating renameCollection', async () => {
458
+ // Collection rename events are not currently replicated on DocumentDB: they are not delivered through the
459
+ // cluster-level change stream the way standard MongoDB delivers them.
460
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('replicating renameCollection', async () => {
444
461
  await using context = await openContext();
445
462
  const { db } = context;
446
463
  const syncRuleContent = `
@@ -471,7 +488,9 @@ bucket_definitions:
471
488
  ]);
472
489
  });
473
490
 
474
- test.runIf(supportsConcurrentSnapshots)(
491
+ // Concurrent-snapshot regression test driven by collection drop/recreate DDL events, which DocumentDB does
492
+ // not deliver through the cluster-level change stream the way standard MongoDB does.
493
+ test.runIf(supportsConcurrentSnapshots && DATABASE_TYPE != DatabaseType.DOCUMENTDB)(
475
494
  'collection recreated while queued snapshot is waiting does not stall checkpoints',
476
495
  async () => {
477
496
  // This is a regression test for a specific timing issue in concurrent snapshot logic.
@@ -625,6 +644,10 @@ bucket_definitions:
625
644
  type: 'mongodb',
626
645
  ...TEST_CONNECTION_OPTIONS
627
646
  });
647
+ const writeCheckpointBatcher = new WriteCheckpointBatcher(
648
+ () => api,
649
+ () => context.factory
650
+ );
628
651
 
629
652
  context.startStreaming();
630
653
 
@@ -641,8 +664,7 @@ bucket_definitions:
641
664
  createWriteCheckpoint({
642
665
  userId: 'test_user',
643
666
  clientId: 'test_client' + i,
644
- api,
645
- storage: context.factory
667
+ batcher: writeCheckpointBatcher
646
668
  })
647
669
  )
648
670
  );
@@ -652,12 +674,14 @@ bucket_definitions:
652
674
 
653
675
  // We need at least 1 commit.
654
676
  expect(commitCount).toBeGreaterThan(0);
655
- // The previous implementation greated 1 commit per checkpoint, which is bad for performance.
677
+ // The previous implementation created 1 commit per checkpoint, which is bad for performance.
656
678
  // We expect a small number here - typically 2-10, but allow for anything less than the total number of checkpoints.
657
679
  expect(commitCount).toBeLessThan(checkpointCount + 1);
658
680
  });
659
681
 
660
- test('large record', async () => {
682
+ test
683
+ // DocumentDBDB does not support changeStreamSplitLargeEvent
684
+ .skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('large record', async () => {
661
685
  // Test a large update.
662
686
 
663
687
  // Without $changeStreamSplitLargeEvent, we get this error:
@@ -729,75 +753,83 @@ bucket_definitions:
729
753
  expect(data).toMatchObject([]);
730
754
  });
731
755
 
732
- test('postImages - new collection with postImages enabled', async () => {
733
- await using context = await openContext({
734
- mongoOptions: { postImages: PostImagesOption.AUTO_CONFIGURE }
735
- });
736
- const { db } = context;
737
- await context.updateSyncRules(`
756
+ // DocumentDB does not support changeStreamPreAndPostImages (post-images).
757
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
758
+ 'postImages - new collection with postImages enabled',
759
+ async () => {
760
+ await using context = await openContext({
761
+ mongoOptions: { postImages: PostImagesOption.AUTO_CONFIGURE }
762
+ });
763
+ const { db } = context;
764
+ await context.updateSyncRules(`
738
765
  bucket_definitions:
739
766
  global:
740
767
  data:
741
768
  - SELECT _id as id, description FROM "test_%"`);
742
769
 
743
- await context.replicateSnapshot();
770
+ await context.replicateSnapshot();
744
771
 
745
- await db.createCollection('test_data', {
746
- // enabled: true here - everything should work
747
- changeStreamPreAndPostImages: { enabled: true }
748
- });
749
- const collection = db.collection('test_data');
750
- const result = await collection.insertOne({ description: 'test1' });
751
- const test_id = result.insertedId;
752
- await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
772
+ await db.createCollection('test_data', {
773
+ // enabled: true here - everything should work
774
+ changeStreamPreAndPostImages: { enabled: true }
775
+ });
776
+ const collection = db.collection('test_data');
777
+ const result = await collection.insertOne({ description: 'test1' });
778
+ const test_id = result.insertedId;
779
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
753
780
 
754
- context.startStreaming();
781
+ context.startStreaming();
755
782
 
756
- const data = await context.getBucketData('global[]');
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
- ]);
783
+ const data = await context.getBucketData('global[]');
784
+ if (data.length == 3) {
785
+ expect(data).toMatchObject([
786
+ // An extra op here, since this triggers a snapshot in addition to getting the event.
787
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
788
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
789
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
790
+ ]);
791
+ } else {
792
+ expect(data).toMatchObject([
793
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
794
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
795
+ ]);
796
+ }
769
797
  }
770
- });
798
+ );
771
799
 
772
- test('postImages - new collection with postImages disabled', async () => {
773
- await using context = await openContext({
774
- mongoOptions: { postImages: PostImagesOption.AUTO_CONFIGURE }
775
- });
776
- const { db } = context;
777
- await context.updateSyncRules(`
800
+ // DocumentDB does not support changeStreamPreAndPostImages (post-images).
801
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
802
+ 'postImages - new collection with postImages disabled',
803
+ async () => {
804
+ await using context = await openContext({
805
+ mongoOptions: { postImages: PostImagesOption.AUTO_CONFIGURE }
806
+ });
807
+ const { db } = context;
808
+ await context.updateSyncRules(`
778
809
  bucket_definitions:
779
810
  global:
780
811
  data:
781
812
  - SELECT _id as id, description FROM "test_data%"`);
782
813
 
783
- await context.replicateSnapshot();
814
+ await context.replicateSnapshot();
784
815
 
785
- await db.createCollection('test_data', {
786
- // enabled: false here, but autoConfigure will enable it.
787
- // Unfortunately, that is too late, and replication must be restarted.
788
- changeStreamPreAndPostImages: { enabled: false }
789
- });
790
- const collection = db.collection('test_data');
791
- const result = await collection.insertOne({ description: 'test1' });
792
- const test_id = result.insertedId;
793
- await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
816
+ await db.createCollection('test_data', {
817
+ // enabled: false here, but autoConfigure will enable it.
818
+ // Unfortunately, that is too late, and replication must be restarted.
819
+ changeStreamPreAndPostImages: { enabled: false }
820
+ });
821
+ const collection = db.collection('test_data');
822
+ const result = await collection.insertOne({ description: 'test1' });
823
+ const test_id = result.insertedId;
824
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
794
825
 
795
- context.startStreaming();
826
+ context.startStreaming();
796
827
 
797
- await expect(() => context.getBucketData('global[]')).rejects.toMatchObject({
798
- message: expect.stringContaining('stream was configured to require a post-image for all update events')
799
- });
800
- });
828
+ await expect(() => context.getBucketData('global[]')).rejects.toMatchObject({
829
+ message: expect.stringContaining('stream was configured to require a post-image for all update events')
830
+ });
831
+ }
832
+ );
801
833
 
802
834
  test('recover from error', async () => {
803
835
  await using context = await openContext();
@@ -809,7 +841,10 @@ bucket_definitions:
809
841
  - SELECT _id as id, description, num FROM "test_data"`);
810
842
 
811
843
  await db.createCollection('test_data', {
812
- changeStreamPreAndPostImages: { enabled: false }
844
+ // DocumentDB does not support changeStreamPreAndPostImages, even when
845
+ // explicitly disabled. This test only needs a collection to exercise
846
+ // fatal error recovery, so omit the option in DocumentDB mode.
847
+ changeStreamPreAndPostImages: DATABASE_TYPE == DatabaseType.MONGODB ? { enabled: false } : undefined
813
848
  });
814
849
 
815
850
  const collection = db.collection('test_data');
@@ -834,7 +869,7 @@ bucket_definitions:
834
869
  const error = (await syncRules?.getSyncConfigStatus())?.last_fatal_error;
835
870
  return error == null;
836
871
  },
837
- { timeout: 2_000 }
872
+ { timeout: testTimeout(2_000, { cloudOverride: 5_000 }) }
838
873
  );
839
874
  });
840
875
  }
@@ -19,9 +19,15 @@ import {
19
19
  } from '@powersync/service-core';
20
20
  import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
21
21
 
22
+ import { SentinelLSN } from '@module/common/SentinelLSN.js';
22
23
  import { ChangeStream, ChangeStreamOptions } from '@module/replication/ChangeStream.js';
23
24
  import { MongoManager } from '@module/replication/MongoManager.js';
24
- import { createCheckpoint, STANDALONE_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
25
+ import {
26
+ createCheckpoint,
27
+ createSentinelCheckpointLsn,
28
+ STANDALONE_CHECKPOINT_ID
29
+ } from '@module/replication/MongoRelation.js';
30
+ import { detectDocumentDb } from '@module/replication/replication-utils.js';
25
31
  import { NormalizedMongoConnectionConfig } from '@module/types/types.js';
26
32
 
27
33
  import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
@@ -46,6 +52,11 @@ export class ChangeStreamTestContext {
46
52
  storageVersion?: number;
47
53
  mongoOptions?: Partial<NormalizedMongoConnectionConfig>;
48
54
  streamOptions?: Partial<ChangeStreamOptions>;
55
+ /**
56
+ * Optional override for tests that need to force a mode. By default the
57
+ * test context detects DocumentDB from the hello response.
58
+ */
59
+ documentDbMode?: boolean;
49
60
  }
50
61
  ) {
51
62
  const f = await factory({ doNotClear: options?.doNotClear });
@@ -56,15 +67,17 @@ export class ChangeStreamTestContext {
56
67
  }
57
68
 
58
69
  const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
70
+ const documentDbMode = options?.documentDbMode ?? (await detectDocumentDb(connectionManager.db));
59
71
 
60
- return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion);
72
+ return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion, documentDbMode);
61
73
  }
62
74
 
63
75
  constructor(
64
76
  public factory: BucketStorageFactory,
65
77
  public connectionManager: MongoManager,
66
78
  private streamOptions: Partial<ChangeStreamOptions> = {},
67
- private storageVersion: number = LEGACY_STORAGE_VERSION
79
+ private storageVersion: number = LEGACY_STORAGE_VERSION,
80
+ private documentDbMode: boolean = false
68
81
  ) {
69
82
  createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
70
83
  initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
@@ -120,6 +133,17 @@ export class ChangeStreamTestContext {
120
133
  return this.storage!;
121
134
  }
122
135
 
136
+ async loadActiveSyncRules() {
137
+ const syncConfig = await this.factory.getActiveSyncConfig();
138
+ if (syncConfig == null) {
139
+ throw new Error(`Active sync config not found`);
140
+ }
141
+
142
+ this.syncRulesContent = syncConfig.content;
143
+ this.storage = syncConfig.storage;
144
+ return this.storage!;
145
+ }
146
+
123
147
  private getSyncConfigContent(): storage.PersistedSyncConfigContent {
124
148
  if (this.syncRulesContent == null) {
125
149
  throw new Error('Sync config not configured - call updateSyncRules() first');
@@ -143,6 +167,7 @@ export class ChangeStreamTestContext {
143
167
  // a long time to abort the stream.
144
168
  maxAwaitTimeMS: this.streamOptions?.maxAwaitTimeMS ?? 200,
145
169
  snapshotChunkLength: this.streamOptions?.snapshotChunkLength,
170
+ keepaliveIntervalMs: this.streamOptions?.keepaliveIntervalMs,
146
171
  storageHooks: this.streamOptions?.storageHooks,
147
172
  snapshotHooks: this.streamOptions?.snapshotHooks,
148
173
  logger: this.streamOptions?.logger
@@ -170,7 +195,24 @@ export class ChangeStreamTestContext {
170
195
  * would result in inconsistent data.
171
196
  */
172
197
  async markSnapshotConsistent() {
173
- const checkpoint = await createCheckpoint(this.client, this.db, STANDALONE_CHECKPOINT_ID);
198
+ let checkpoint: string;
199
+ if (this.documentDbMode) {
200
+ const sentinelCheckpoint = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(this.client, this.db));
201
+ const status = await this.storage!.getStatus();
202
+ const resumeFrom = status.resumeLsn;
203
+ const resumeToken = resumeFrom ? SentinelLSN.fromSerialized(resumeFrom).resumeToken : null;
204
+
205
+ // This helper artificially marks the snapshot as consistent without
206
+ // waiting for the stream to observe the sentinel. Keep the sentinel as the
207
+ // comparable position, but carry forward the existing snapshot resume
208
+ // token so later DocumentDB streaming still resumes from a real token.
209
+ checkpoint = new SentinelLSN({
210
+ sentinel: sentinelCheckpoint.sentinel,
211
+ resume_token: resumeToken
212
+ }).comparable;
213
+ } else {
214
+ checkpoint = await createCheckpoint(this.db, STANDALONE_CHECKPOINT_ID);
215
+ }
174
216
 
175
217
  await using writer = await this.storage!.createWriter(test_utils.BATCH_OPTIONS);
176
218
  await writer.keepalive(checkpoint);
@@ -203,12 +245,29 @@ export class ChangeStreamTestContext {
203
245
  }
204
246
 
205
247
  async getBucketData(bucket: string, start?: ProtocolOpId | InternalOpId | undefined, options?: { timeout?: number }) {
248
+ const checkpoint = await this.getCheckpoint(options);
249
+ return this.getBucketDataAtCheckpoint(bucket, checkpoint, start);
250
+ }
251
+
252
+ async getBucketDataAtLatestCheckpoint(bucket: string, start?: ProtocolOpId | InternalOpId | undefined) {
253
+ if (this.storage == null) {
254
+ throw new Error('updateSyncRules() first');
255
+ }
256
+
257
+ const checkpoint = await this.storage.getCheckpoint();
258
+ return this.getBucketDataAtCheckpoint(bucket, checkpoint, start);
259
+ }
260
+
261
+ async getBucketDataAtCheckpoint(
262
+ bucket: string,
263
+ checkpoint: ReplicationCheckpoint,
264
+ start?: ProtocolOpId | InternalOpId | undefined
265
+ ) {
206
266
  start ??= 0n;
207
267
  if (typeof start == 'string') {
208
268
  start = BigInt(start);
209
269
  }
210
270
  const syncConfigContent = this.getSyncConfigContent();
211
- const checkpoint = await this.getCheckpoint(options);
212
271
  let map = [bucketRequest(syncConfigContent, bucket, start)];
213
272
  let data: OplogEntry[] = [];
214
273
  while (true) {
@@ -247,14 +306,25 @@ export async function getClientCheckpoint(
247
306
  client: mongo.MongoClient,
248
307
  db: mongo.Db,
249
308
  storageFactory: BucketStorageFactory,
250
- options?: { timeout?: number }
251
- ): Promise<InternalOpId> {
309
+ options?: { timeout?: number; documentDbMode?: boolean; storage?: SyncRulesBucketStorage }
310
+ ): Promise<ReplicationCheckpoint> {
252
311
  const start = Date.now();
253
- const lsn = await createCheckpoint(client, db, STANDALONE_CHECKPOINT_ID);
312
+ const documentDbMode = options?.documentDbMode ?? (await detectDocumentDb(db));
313
+
314
+ const lsn = documentDbMode
315
+ ? await createSentinelCheckpointLsn(client, db)
316
+ : await createCheckpoint(db, STANDALONE_CHECKPOINT_ID);
254
317
  // This old API needs a persisted checkpoint id.
255
318
  // Since we don't use LSNs anymore, the only way to get that is to wait.
256
319
 
257
320
  const timeout = options?.timeout ?? 50_000;
321
+ // DocumentDB: the streaming loop skips standalone checkpoint events while a
322
+ // batch barrier is pending (see ChangeStream.ts), so a single sentinel bump
323
+ // can be missed on an idle stream with no later event to advance the
324
+ // checkpoint. Periodically re-bump the sentinel so a standalone event
325
+ // eventually commits past `lsn`, mirroring getSnapshotLsn's retry loop.
326
+ const NUDGE_INTERVAL_MS = 1000;
327
+ let lastNudge = Date.now();
258
328
  let lastCp: ReplicationCheckpoint | null = null;
259
329
 
260
330
  while (Date.now() - start < timeout) {
@@ -263,9 +333,15 @@ export async function getClientCheckpoint(
263
333
  if (cp != null) {
264
334
  lastCp = cp;
265
335
  if (cp.lsn && cp.lsn >= lsn) {
266
- return cp.checkpoint;
336
+ return cp;
267
337
  }
268
338
  }
339
+
340
+ if (documentDbMode && Date.now() - lastNudge >= NUDGE_INTERVAL_MS) {
341
+ await createSentinelCheckpointLsn(client, db);
342
+ lastNudge = Date.now();
343
+ }
344
+
269
345
  await new Promise((resolve) => setTimeout(resolve, 30));
270
346
  }
271
347
 
@@ -4,9 +4,12 @@ import { mongo } from '@powersync/lib-service-mongodb';
4
4
 
5
5
  import { MongoLSN } from '@module/common/MongoLSN.js';
6
6
  import { createCheckpoint } from '@module/replication/MongoRelation.js';
7
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
8
  import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
8
9
 
9
- describe('checkpoint retryable writes', () => {
10
+ // This suite uses configureFailPoint to force retry timing. Azure DocumentDB
11
+ // does not support configureFailPoint.
12
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('checkpoint retryable writes', () => {
10
13
  test('returns the persisted checkpoint clusterTime after a retryable write', { repeats: 1 }, async (ctx) => {
11
14
  // This test relies on very specific timing:
12
15
  //
@@ -75,7 +78,7 @@ describe('checkpoint retryable writes', () => {
75
78
  blockTimeMS: TIMEOUT
76
79
  }
77
80
  });
78
- const returnedLsnPromise = createCheckpoint(client, db, checkpointId);
81
+ const returnedLsnPromise = createCheckpoint(db, checkpointId);
79
82
 
80
83
  for (let i = 0; i < INSERT_COUNT; i++) {
81
84
  await advanceClusterTime.insertOne({ at: new Date() });