@powersync/service-module-mongodb 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
- package/dist/api/MongoRouteAPIAdapter.js +23 -32
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/common/SentinelLSN.d.ts +37 -0
- package/dist/common/SentinelLSN.js +59 -0
- package/dist/common/SentinelLSN.js.map +1 -0
- package/dist/replication/ChangeStream.d.ts +15 -0
- package/dist/replication/ChangeStream.js +105 -44
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +36 -1
- package/dist/replication/MongoRelation.js +102 -6
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +9 -0
- package/dist/replication/MongoSnapshotter.js +108 -42
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +12 -1
- package/dist/replication/RawChangeStream.js +30 -7
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
- package/dist/replication/replication-utils.d.ts +6 -0
- package/dist/replication/replication-utils.js +19 -2
- package/dist/replication/replication-utils.js.map +1 -1
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +26 -37
- package/src/common/SentinelLSN.ts +78 -0
- package/src/replication/ChangeStream.ts +118 -50
- package/src/replication/MongoRelation.ts +124 -14
- package/src/replication/MongoSnapshotter.ts +122 -47
- package/src/replication/RawChangeStream.ts +64 -28
- package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
- package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
- package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
- package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
- package/src/replication/replication-utils.ts +23 -2
- package/test/DOCUMENTDB_TESTING.md +115 -0
- package/test/src/DatabaseType.ts +25 -0
- package/test/src/change_stream.test.ts +97 -65
- package/test/src/change_stream_utils.ts +83 -7
- package/test/src/checkpoint_retry.test.ts +5 -2
- package/test/src/documentdb_helpers.test.ts +124 -0
- package/test/src/documentdb_mode.test.ts +1040 -0
- package/test/src/mongo_test.test.ts +15 -5
- package/test/src/raw_change_stream.test.ts +209 -125
- package/test/src/resume_token.test.ts +30 -0
- package/test/src/slow_tests.test.ts +4 -1
- package/test/src/test-timeouts.ts +23 -0
- package/test/src/util.ts +1 -2
- 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;
|
|
@@ -9,6 +9,8 @@ import { test_utils } from '@powersync/service-core-tests';
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -655,12 +674,14 @@ bucket_definitions:
|
|
|
655
674
|
|
|
656
675
|
// We need at least 1 commit.
|
|
657
676
|
expect(commitCount).toBeGreaterThan(0);
|
|
658
|
-
// The previous implementation
|
|
677
|
+
// The previous implementation created 1 commit per checkpoint, which is bad for performance.
|
|
659
678
|
// We expect a small number here - typically 2-10, but allow for anything less than the total number of checkpoints.
|
|
660
679
|
expect(commitCount).toBeLessThan(checkpointCount + 1);
|
|
661
680
|
});
|
|
662
681
|
|
|
663
|
-
test
|
|
682
|
+
test
|
|
683
|
+
// DocumentDBDB does not support changeStreamSplitLargeEvent
|
|
684
|
+
.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('large record', async () => {
|
|
664
685
|
// Test a large update.
|
|
665
686
|
|
|
666
687
|
// Without $changeStreamSplitLargeEvent, we get this error:
|
|
@@ -732,75 +753,83 @@ bucket_definitions:
|
|
|
732
753
|
expect(data).toMatchObject([]);
|
|
733
754
|
});
|
|
734
755
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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(`
|
|
741
765
|
bucket_definitions:
|
|
742
766
|
global:
|
|
743
767
|
data:
|
|
744
768
|
- SELECT _id as id, description FROM "test_%"`);
|
|
745
769
|
|
|
746
|
-
|
|
770
|
+
await context.replicateSnapshot();
|
|
747
771
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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' } });
|
|
756
780
|
|
|
757
|
-
|
|
781
|
+
context.startStreaming();
|
|
758
782
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
+
}
|
|
772
797
|
}
|
|
773
|
-
|
|
798
|
+
);
|
|
774
799
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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(`
|
|
781
809
|
bucket_definitions:
|
|
782
810
|
global:
|
|
783
811
|
data:
|
|
784
812
|
- SELECT _id as id, description FROM "test_data%"`);
|
|
785
813
|
|
|
786
|
-
|
|
814
|
+
await context.replicateSnapshot();
|
|
787
815
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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' } });
|
|
797
825
|
|
|
798
|
-
|
|
826
|
+
context.startStreaming();
|
|
799
827
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
+
);
|
|
804
833
|
|
|
805
834
|
test('recover from error', async () => {
|
|
806
835
|
await using context = await openContext();
|
|
@@ -812,7 +841,10 @@ bucket_definitions:
|
|
|
812
841
|
- SELECT _id as id, description, num FROM "test_data"`);
|
|
813
842
|
|
|
814
843
|
await db.createCollection('test_data', {
|
|
815
|
-
|
|
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
|
|
816
848
|
});
|
|
817
849
|
|
|
818
850
|
const collection = db.collection('test_data');
|
|
@@ -837,7 +869,7 @@ bucket_definitions:
|
|
|
837
869
|
const error = (await syncRules?.getSyncConfigStatus())?.last_fatal_error;
|
|
838
870
|
return error == null;
|
|
839
871
|
},
|
|
840
|
-
{ timeout: 2_000 }
|
|
872
|
+
{ timeout: testTimeout(2_000, { cloudOverride: 5_000 }) }
|
|
841
873
|
);
|
|
842
874
|
});
|
|
843
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 {
|
|
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
|
-
|
|
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 }
|
|
309
|
+
options?: { timeout?: number; documentDbMode?: boolean; storage?: SyncRulesBucketStorage }
|
|
251
310
|
): Promise<ReplicationCheckpoint> {
|
|
252
311
|
const start = Date.now();
|
|
253
|
-
const
|
|
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) {
|
|
@@ -266,6 +336,12 @@ export async function getClientCheckpoint(
|
|
|
266
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
|
-
|
|
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(
|
|
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() });
|