@powersync/service-module-mongodb 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +153 -5
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
  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/module/MongoModule.js +2 -1
  9. package/dist/module/MongoModule.js.map +1 -1
  10. package/dist/replication/ChangeStream.d.ts +24 -0
  11. package/dist/replication/ChangeStream.js +203 -67
  12. package/dist/replication/ChangeStream.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicationJob.js +3 -2
  14. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  15. package/dist/replication/ChangeStreamReplicator.js +1 -1
  16. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  17. package/dist/replication/MongoRelation.d.ts +36 -1
  18. package/dist/replication/MongoRelation.js +133 -18
  19. package/dist/replication/MongoRelation.js.map +1 -1
  20. package/dist/replication/MongoSnapshotter.d.ts +9 -1
  21. package/dist/replication/MongoSnapshotter.js +129 -67
  22. package/dist/replication/MongoSnapshotter.js.map +1 -1
  23. package/dist/replication/RawChangeStream.d.ts +4 -1
  24. package/dist/replication/RawChangeStream.js +6 -10
  25. package/dist/replication/RawChangeStream.js.map +1 -1
  26. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +135 -0
  27. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  28. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  29. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +58 -0
  30. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
  31. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  32. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +27 -0
  33. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +116 -0
  34. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  35. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  36. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  37. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  38. package/dist/replication/replication-utils.d.ts +6 -0
  39. package/dist/replication/replication-utils.js +19 -2
  40. package/dist/replication/replication-utils.js.map +1 -1
  41. package/dist/types/types.d.ts +7 -2
  42. package/dist/types/types.js +20 -2
  43. package/dist/types/types.js.map +1 -1
  44. package/package.json +11 -21
  45. package/src/api/MongoRouteAPIAdapter.ts +28 -39
  46. package/src/common/SentinelLSN.ts +78 -0
  47. package/src/module/MongoModule.ts +2 -1
  48. package/src/replication/ChangeStream.ts +223 -78
  49. package/src/replication/ChangeStreamReplicationJob.ts +3 -2
  50. package/src/replication/ChangeStreamReplicator.ts +1 -1
  51. package/src/replication/MongoRelation.ts +153 -21
  52. package/src/replication/MongoSnapshotter.ts +148 -73
  53. package/src/replication/RawChangeStream.ts +27 -32
  54. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  55. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  56. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +145 -0
  57. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  58. package/src/replication/replication-utils.ts +23 -2
  59. package/src/types/types.ts +27 -2
  60. package/test/DOCUMENTDB_TESTING.md +115 -0
  61. package/test/src/DatabaseType.ts +25 -0
  62. package/test/src/change_stream.test.ts +180 -71
  63. package/test/src/change_stream_utils.ts +117 -29
  64. package/test/src/checkpoint_retry.test.ts +134 -0
  65. package/test/src/config.test.ts +34 -0
  66. package/test/src/documentdb_helpers.test.ts +124 -0
  67. package/test/src/documentdb_mode.test.ts +1039 -0
  68. package/test/src/mongo_test.test.ts +15 -5
  69. package/test/src/raw_change_stream.test.ts +147 -126
  70. package/test/src/resume.test.ts +2 -2
  71. package/test/src/resume_token.test.ts +30 -0
  72. package/test/src/slow_tests.test.ts +4 -1
  73. package/test/src/test-timeouts.ts +23 -0
  74. package/test/src/util.ts +14 -32
  75. package/tsconfig.json +1 -4
  76. package/tsconfig.scripts.json +1 -2
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -12,8 +12,13 @@ import { parseDocumentId } from '@module/replication/bufferToSqlite.js';
12
12
  import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from '@module/replication/RawChangeStream.js';
13
13
  import { DirectSourceRowConverter } from '@module/replication/SourceRowConverter.js';
14
14
  import { PostImagesOption } from '@module/types/types.js';
15
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
15
16
  import { clearTestDb, connectMongoData, TEST_CONNECTION_OPTIONS } from './util.js';
16
17
 
18
+ // DocumentDB does not support the (deprecated) mapReduce command, which
19
+ // insertUndefined() relies on to produce BSON `undefined` values. It also does
20
+ // not support database-level change streams (production always uses
21
+ // cluster-level streams on DocumentDB). Tests depending on either are skipped.
17
22
  describe('mongo data types', () => {
18
23
  // These test the full data cycle by writing to mongodb, then checking the change stream and direct collection queries.
19
24
  // More direct tests directly on the BSON values are in buffer_to_sqlite.test.ts.
@@ -244,7 +249,8 @@ describe('mongo data types', () => {
244
249
  });
245
250
  }
246
251
 
247
- test('test direct queries', async () => {
252
+ // DocumentDB: requires mapReduce (insertUndefined)
253
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('test direct queries', async () => {
248
254
  const { db, client } = await connectMongoData();
249
255
 
250
256
  const collection = db.collection('test_data');
@@ -265,7 +271,8 @@ describe('mongo data types', () => {
265
271
  }
266
272
  });
267
273
 
268
- test('test direct queries - arrays', async () => {
274
+ // DocumentDB: requires mapReduce (insertUndefined)
275
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('test direct queries - arrays', async () => {
269
276
  const { db, client } = await connectMongoData();
270
277
  const collection = db.collection('test_data_arrays');
271
278
  try {
@@ -286,7 +293,8 @@ describe('mongo data types', () => {
286
293
  }
287
294
  });
288
295
 
289
- test('test replication', async () => {
296
+ // DocumentDB: requires a database-level change stream and mapReduce
297
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('test replication', async () => {
290
298
  // With MongoDB, replication uses the exact same document format
291
299
  // as normal queries. We test it anyway.
292
300
  const { db, client } = await connectMongoData();
@@ -311,7 +319,8 @@ describe('mongo data types', () => {
311
319
  }
312
320
  });
313
321
 
314
- test('test replication - arrays', async () => {
322
+ // DocumentDB: requires a database-level change stream and mapReduce
323
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('test replication - arrays', async () => {
315
324
  const { db, client } = await connectMongoData();
316
325
  const collection = db.collection('test_data');
317
326
  try {
@@ -334,7 +343,8 @@ describe('mongo data types', () => {
334
343
  }
335
344
  });
336
345
 
337
- test('connection schema', async () => {
346
+ // DocumentDB: requires mapReduce (insertUndefined)
347
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('connection schema', async () => {
338
348
  await using adapter = new MongoRouteAPIAdapter({
339
349
  type: 'mongodb',
340
350
  ...TEST_CONNECTION_OPTIONS
@@ -4,19 +4,27 @@ import { ChangeStreamBatch, namespaceCollection, rawChangeStream } from '@module
4
4
  import { getCursorBatchBytes } from '@module/replication/replication-index.js';
5
5
  import { mongo } from '@powersync/lib-service-mongodb';
6
6
  import { bson } from '@powersync/service-core';
7
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
8
  import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
8
9
 
10
+ // DocumentDB only supports cluster-level change streams — collection- and
11
+ // database-level streams fail with NamespaceNotFound ("Collection not found"),
12
+ // which is why production always uses cluster-level streams on DocumentDB.
13
+ // The cluster-level size tracking test is also skipped: its strict batching
14
+ // assertions (exact batch counts, immediate event delivery per readAll pass)
15
+ // are racy against a remote DocumentDB cluster with multi-second event latency.
16
+ // Byte tracking on DocumentDB is exercised indirectly by documentdb_mode.test.ts.
9
17
  describe('internal mongodb utils', () => {
10
18
  // The implementation relies on internal APIs, so we verify this works as expected for various types of change streams.
11
- test('collection change stream size tracking', async () => {
19
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('collection change stream size tracking', async () => {
12
20
  await testChangeStreamBsonBytes('collection');
13
21
  });
14
22
 
15
- test('db change stream size tracking', async () => {
23
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('db change stream size tracking', async () => {
16
24
  await testChangeStreamBsonBytes('db');
17
25
  });
18
26
 
19
- test('cluster change stream size tracking', async () => {
27
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('cluster change stream size tracking', async () => {
20
28
  await testChangeStreamBsonBytes('cluster');
21
29
  });
22
30
 
@@ -44,144 +52,153 @@ describe('internal mongodb utils', () => {
44
52
  expect(totalBytes).toBeLessThan(1200);
45
53
  });
46
54
 
47
- test('uses separate aggregate and getMore command options', async () => {
48
- const { db, client } = await connectMongoData({ monitorCommands: true });
49
- await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
50
- await clearTestDb(db);
51
- const collection = db.collection('test_data');
52
-
53
- const started: any[] = [];
54
- client.on('commandStarted', (event) => {
55
- if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
56
- started.push(event);
57
- }
58
- });
55
+ // DocumentDB does not support database-level change streams (rawChangeStream on a db); only cluster-level.
56
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
57
+ 'uses separate aggregate and getMore command options',
58
+ async () => {
59
+ const { db, client } = await connectMongoData({ monitorCommands: true });
60
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
61
+ await clearTestDb(db);
62
+ const collection = db.collection('test_data');
63
+
64
+ const started: any[] = [];
65
+ client.on('commandStarted', (event) => {
66
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
67
+ started.push(event);
68
+ }
69
+ });
59
70
 
60
- const stream = rawChangeStream(
61
- db,
62
- [
63
- {
64
- $changeStream: {
65
- fullDocument: 'updateLookup'
71
+ const stream = rawChangeStream(
72
+ db,
73
+ [
74
+ {
75
+ $changeStream: {
76
+ fullDocument: 'updateLookup'
77
+ }
66
78
  }
79
+ ],
80
+ {
81
+ batchSize: 10,
82
+ maxAwaitTimeMS: 50,
83
+ maxTimeMS: 1_000
67
84
  }
68
- ],
69
- {
70
- batchSize: 10,
71
- maxAwaitTimeMS: 50,
72
- maxTimeMS: 1_000
73
- }
74
- );
85
+ );
75
86
 
76
- await stream.next();
77
- await collection.insertOne({ test: 1 });
78
- const nextBatch = await readUntilNonEmptyBatch(stream);
79
- await stream.return?.();
87
+ await stream.next();
88
+ await collection.insertOne({ test: 1 });
89
+ const nextBatch = await readUntilNonEmptyBatch(stream);
90
+ await stream.return?.();
80
91
 
81
- expect(nextBatch.events).toHaveLength(1);
92
+ expect(nextBatch.events).toHaveLength(1);
82
93
 
83
- const aggregate = started.find((event) => event.commandName == 'aggregate');
84
- const getMore = started.find((event) => event.commandName == 'getMore');
94
+ const aggregate = started.find((event) => event.commandName == 'aggregate');
95
+ const getMore = started.find((event) => event.commandName == 'getMore');
85
96
 
86
- expect(aggregate?.command.cursor?.batchSize).toEqual(1);
87
- expect(aggregate?.command.maxTimeMS).toEqual(1_000);
88
- expect(getMore?.command.batchSize).toEqual(10);
89
- expect(getMore?.command.maxTimeMS).toEqual(50);
90
- });
97
+ expect(aggregate?.command.cursor?.batchSize).toEqual(1);
98
+ expect(aggregate?.command.maxTimeMS).toEqual(1_000);
99
+ expect(getMore?.command.batchSize).toEqual(10);
100
+ expect(getMore?.command.maxTimeMS).toEqual(50);
101
+ }
102
+ );
103
+
104
+ // This test uses configureFailPoint to inject a getMore timeout. Azure
105
+ // DocumentDB does not support configureFailPoint.
106
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
107
+ 'should resume on MaxTimeMSExpired from getMore',
108
+ async (ctx) => {
109
+ const { db, client } = await connectMongoData({ monitorCommands: true });
110
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
111
+ await using failCommand = await requireFailCommand(client, ctx);
112
+
113
+ await clearTestDb(db);
114
+ const collection = db.collection('test_data');
115
+
116
+ const started: any[] = [];
117
+ client.on('commandStarted', (event) => {
118
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
119
+ started.push(event);
120
+ }
121
+ });
91
122
 
92
- test('should resume on MaxTimeMSExpired from getMore', async (ctx) => {
93
- const { db, client } = await connectMongoData({ monitorCommands: true });
94
- await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
95
- await using failCommand = await requireFailCommand(client, ctx);
123
+ const stream = rawChangeStream(
124
+ db,
125
+ [
126
+ {
127
+ $changeStream: {
128
+ fullDocument: 'updateLookup'
129
+ }
130
+ }
131
+ ],
132
+ {
133
+ batchSize: 3,
134
+ maxAwaitTimeMS: 50,
135
+ maxTimeMS: 1_000
136
+ // To get more details when debugging, enable the logger:
137
+ // logger: logger
138
+ }
139
+ );
96
140
 
97
- await clearTestDb(db);
98
- const collection = db.collection('test_data');
141
+ await stream.next();
99
142
 
100
- const started: any[] = [];
101
- client.on('commandStarted', (event) => {
102
- if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
103
- started.push(event);
143
+ await failCommand.configure({
144
+ mode: { times: 1 },
145
+ data: {
146
+ failCommands: ['getMore'],
147
+ errorCode: 50 // MaxTimeMSExpired
148
+ }
149
+ });
150
+
151
+ for (let i = 1; i <= 8; i++) {
152
+ await collection.insertOne({ test: i });
104
153
  }
105
- });
106
154
 
107
- const stream = rawChangeStream(
108
- db,
109
- [
155
+ // Test the exponentially-increasing batch size after the retry
156
+ {
157
+ // This will fail the getMore, then retry with aggregate with batchSize 1
158
+ const batch = await readUntilNonEmptyBatch(stream, 10);
159
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
160
+ { test: 1 }
161
+ ]);
162
+ }
163
+ {
164
+ // This will be a getMore with batchSize 2
165
+ const batch = await readUntilNonEmptyBatch(stream, 10);
166
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
167
+ { test: 2 },
168
+ { test: 3 }
169
+ ]);
170
+ }
171
+ {
172
+ // At this point, this batch size is at the original size of 3 again.
173
+ const batch = await readUntilNonEmptyBatch(stream, 10);
174
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
175
+ { test: 4 },
176
+ { test: 5 },
177
+ { test: 6 }
178
+ ]);
179
+ }
180
+ {
181
+ const batch = await readUntilNonEmptyBatch(stream, 10);
182
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
183
+ { test: 7 },
184
+ { test: 8 }
185
+ ]);
186
+ }
187
+
188
+ const aggregateCommands = started.filter((event) => event.commandName == 'aggregate');
189
+ expect(aggregateCommands.length).toBeGreaterThanOrEqual(2);
190
+ expect(aggregateCommands[0].command.pipeline).toEqual([
110
191
  {
111
192
  $changeStream: {
112
193
  fullDocument: 'updateLookup'
113
194
  }
114
195
  }
115
- ],
116
- {
117
- batchSize: 3,
118
- maxAwaitTimeMS: 50,
119
- maxTimeMS: 1_000
120
- // To get more details when debugging, enable the logger:
121
- // logger: logger
122
- }
123
- );
124
-
125
- await stream.next();
126
-
127
- await failCommand.configure({
128
- mode: { times: 1 },
129
- data: {
130
- failCommands: ['getMore'],
131
- errorCode: 50 // MaxTimeMSExpired
132
- }
133
- });
134
-
135
- for (let i = 1; i <= 8; i++) {
136
- await collection.insertOne({ test: i });
137
- }
138
-
139
- // Test the exponentially-increasing batch size after the retry
140
- {
141
- // This will fail the getMore, then retry with aggregate with batchSize 1
142
- const batch = await readUntilNonEmptyBatch(stream, 10);
143
- expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
144
- { test: 1 }
145
- ]);
146
- }
147
- {
148
- // This will be a getMore with batchSize 2
149
- const batch = await readUntilNonEmptyBatch(stream, 10);
150
- expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
151
- { test: 2 },
152
- { test: 3 }
153
- ]);
154
- }
155
- {
156
- // At this point, this batch size is at the original size of 3 again.
157
- const batch = await readUntilNonEmptyBatch(stream, 10);
158
- expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
159
- { test: 4 },
160
- { test: 5 },
161
- { test: 6 }
162
- ]);
163
- }
164
- {
165
- const batch = await readUntilNonEmptyBatch(stream, 10);
166
- expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
167
- { test: 7 },
168
- { test: 8 }
169
196
  ]);
170
- }
197
+ expect(aggregateCommands[1].command.pipeline[0]?.$changeStream?.resumeAfter).toBeDefined();
171
198
 
172
- const aggregateCommands = started.filter((event) => event.commandName == 'aggregate');
173
- expect(aggregateCommands.length).toBeGreaterThanOrEqual(2);
174
- expect(aggregateCommands[0].command.pipeline).toEqual([
175
- {
176
- $changeStream: {
177
- fullDocument: 'updateLookup'
178
- }
179
- }
180
- ]);
181
- expect(aggregateCommands[1].command.pipeline[0]?.$changeStream?.resumeAfter).toBeDefined();
182
-
183
- await stream?.return().catch(() => {});
184
- });
199
+ await stream?.return().catch(() => {});
200
+ }
201
+ );
185
202
 
186
203
  async function testChangeStreamBsonBytes(type: 'db' | 'collection' | 'cluster') {
187
204
  const { db, client } = await connectMongoData();
@@ -260,7 +277,8 @@ describe('internal mongodb utils', () => {
260
277
  expect(totalBytes).toBeLessThan(8000);
261
278
  }
262
279
 
263
- test('should resume on missing cursor (1)', async () => {
280
+ // DocumentDB: database-level change streams and $currentOp are not supported
281
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should resume on missing cursor (1)', async () => {
264
282
  // Many resumable errors are difficult to simulate, but CursorNotFound is easy.
265
283
 
266
284
  const { db, client } = await connectMongoData();
@@ -313,7 +331,8 @@ describe('internal mongodb utils', () => {
313
331
  expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }]);
314
332
  });
315
333
 
316
- test('should resume on missing cursor (2)', async () => {
334
+ // DocumentDB: database-level change streams and $currentOp are not supported
335
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should resume on missing cursor (2)', async () => {
317
336
  const { db, client } = await connectMongoData();
318
337
  await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
319
338
  await clearTestDb(db);
@@ -387,7 +406,8 @@ describe('internal mongodb utils', () => {
387
406
  expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }]);
388
407
  });
389
408
 
390
- test('should cleanly abort a stream between events', async () => {
409
+ // DocumentDB: database-level change streams are not supported
410
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should cleanly abort a stream between events', async () => {
391
411
  const { db, client } = await connectMongoData();
392
412
  const abortController = new AbortController();
393
413
  await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
@@ -437,7 +457,8 @@ describe('internal mongodb utils', () => {
437
457
  expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }]);
438
458
  });
439
459
 
440
- test('should cleanly abort a stream in an event', async () => {
460
+ // DocumentDB: database-level change streams are not supported
461
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should cleanly abort a stream in an event', async () => {
441
462
  const { db, client } = await connectMongoData();
442
463
  const abortController = new AbortController();
443
464
  await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
@@ -67,8 +67,8 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
67
67
 
68
68
  // Create a new context without updating the sync config
69
69
  await using context2 = new ChangeStreamTestContext(factory, connectionManager, {}, storageVersion);
70
- const activeContent = await factory.getActiveSyncRulesContent();
71
- context2.storage = factory.getInstance(activeContent!);
70
+ const active = await factory.getActiveSyncConfig();
71
+ context2.storage = active!.storage;
72
72
 
73
73
  // If this test times out, it likely didn't throw the expected error here.
74
74
  const result = await context2.startStreaming();
@@ -1,4 +1,5 @@
1
1
  import { MongoLSN, parseResumeTokenTimestamp, ZERO_LSN } from '@module/common/MongoLSN.js';
2
+ import { SentinelLSN } from '@module/common/SentinelLSN.js';
2
3
  import { mongo } from '@powersync/lib-service-mongodb';
3
4
  import { describe, expect, it, test } from 'vitest';
4
5
 
@@ -109,3 +110,32 @@ describe('mongo lsn', () => {
109
110
  ).toBe(true);
110
111
  });
111
112
  });
113
+
114
+ describe('documentdb lsn', () => {
115
+ test('uses sentinel as the comparable prefix', () => {
116
+ expect(new SentinelLSN({ sentinel: 9n }).comparable < new SentinelLSN({ sentinel: 10n }).comparable).toBe(true);
117
+ expect(new SentinelLSN({ sentinel: 10n }).comparable > new SentinelLSN({ sentinel: 9n }).comparable).toBe(true);
118
+ });
119
+
120
+ test('resume token round-trips without changing the sentinel', () => {
121
+ const lsn = new SentinelLSN({
122
+ sentinel: 42n,
123
+ resume_token: { _data: 'MzowOjM1MjY4MTIwOTEyOjA6MzUyNjgxMjA5MTI6MTowOg==' }
124
+ });
125
+
126
+ const parsed = SentinelLSN.fromSerialized(lsn.comparable);
127
+
128
+ expect(parsed.sentinel).toEqual(42n);
129
+ expect(parsed.resumeToken).toEqual({ _data: 'MzowOjM1MjY4MTIwOTEyOjA6MzUyNjgxMjA5MTI6MTowOg==' });
130
+ });
131
+
132
+ test('write checkpoint LSN without resume token compares below observed stream LSN for the same sentinel', () => {
133
+ const writeCheckpointHead = new SentinelLSN({ sentinel: 42n }).comparable;
134
+ const observedStreamHead = new SentinelLSN({
135
+ sentinel: 42n,
136
+ resume_token: { _data: 'resume' }
137
+ }).comparable;
138
+
139
+ expect(observedStreamHead >= writeCheckpointHead).toBe(true);
140
+ });
141
+ });
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest';
3
3
 
4
4
  import { mongo } from '@powersync/lib-service-mongodb';
5
5
  import { ChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_utils.js';
6
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
6
7
  import { env } from './env.js';
7
8
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
8
9
 
@@ -15,7 +16,9 @@ function defineSlowTests({ factory, storageVersion }: StorageVersionTestContext)
15
16
  return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
16
17
  };
17
18
 
18
- test('replicating snapshot with lots of data', async () => {
19
+ // This test uses getParameter/setParameter for minSnapshotHistoryWindowInSeconds,
20
+ // which Azure DocumentDB does not support.
21
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('replicating snapshot with lots of data', async () => {
19
22
  await using context = await openContext();
20
23
  // Test with low minSnapshotHistoryWindowInSeconds, to trigger:
21
24
  // > Read timestamp .. is older than the oldest available timestamp.
@@ -0,0 +1,23 @@
1
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
2
+
3
+ /**
4
+ * Scale factor applied to a default test timeout on cloud DocumentDB when no
5
+ * explicit cloud override is given to {@link testTimeout}. Cloud DocumentDB has
6
+ * 10-30s change-stream propagation spikes, so timeouts there need headroom.
7
+ * Override with the `TEST_TIMEOUT_MULTIPLIER` env var; defaults to 6.
8
+ */
9
+ export const TEST_TIMEOUT_MULTIPLIER = Number(process.env.TEST_TIMEOUT_MULTIPLIER ?? '6');
10
+
11
+ /**
12
+ * Resolve a test timeout in milliseconds for the current backend.
13
+ *
14
+ * - Fast backend (local / standard MongoDB): uses `defaultMs` as-is.
15
+ * - Cloud DocumentDB: uses `cloudOverride` when provided, otherwise scales
16
+ * `defaultMs` by {@link TEST_TIMEOUT_MULTIPLIER}.
17
+ */
18
+ export function testTimeout(defaultMs: number, options?: { cloudOverride?: number }): number {
19
+ if (DATABASE_TYPE !== DatabaseType.DOCUMENTDB) {
20
+ return defaultMs;
21
+ }
22
+ return options?.cloudOverride ?? Math.ceil(defaultMs * TEST_TIMEOUT_MULTIPLIER);
23
+ }
package/test/src/util.ts CHANGED
@@ -3,13 +3,9 @@ import * as mongo_storage from '@powersync/service-module-mongodb-storage';
3
3
  import * as postgres_storage from '@powersync/service-module-postgres-storage';
4
4
 
5
5
  import * as types from '@module/types/types.js';
6
- import {
7
- BSON_DESERIALIZE_DATA_OPTIONS,
8
- SUPPORTED_STORAGE_VERSIONS,
9
- TestStorageConfig,
10
- TestStorageFactory
11
- } from '@powersync/service-core';
12
- import { describe, TestContext, TestOptions } from 'vitest';
6
+ import { BSON_DESERIALIZE_DATA_OPTIONS, TestStorageFactory } from '@powersync/service-core';
7
+ import { describeStorageCombinations } from '@powersync/service-core-tests';
8
+ import { TestContext, TestOptions } from 'vitest';
13
9
  import { env } from './env.js';
14
10
 
15
11
  export const TEST_URI = env.MONGO_TEST_DATA_URL;
@@ -21,41 +17,28 @@ export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
21
17
 
22
18
  export const INITIALIZED_MONGO_STORAGE_FACTORY = mongo_storage.test_utils.mongoTestStorageFactoryGenerator({
23
19
  url: env.MONGO_TEST_URL,
24
- isCI: env.CI
20
+ isCI: env.CI,
21
+ supportsMultipleSyncConfigs: true
25
22
  });
26
23
 
27
24
  export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestSetup({
28
25
  url: env.PG_STORAGE_TEST_URL
29
26
  });
30
27
 
31
- export const TEST_STORAGE_VERSIONS = SUPPORTED_STORAGE_VERSIONS;
32
-
33
28
  export interface StorageVersionTestContext {
34
29
  factory: TestStorageFactory;
35
30
  storageVersion: number;
36
31
  }
37
32
 
38
33
  export function describeWithStorage(options: TestOptions, fn: (context: StorageVersionTestContext) => void) {
39
- const describeFactory = (storageName: string, config: TestStorageConfig) => {
40
- describe(`${storageName} storage`, options, function () {
41
- for (const storageVersion of TEST_STORAGE_VERSIONS) {
42
- describe(`storage v${storageVersion}`, function () {
43
- fn({
44
- factory: config.factory,
45
- storageVersion
46
- });
47
- });
48
- }
49
- });
50
- };
51
-
52
- if (env.TEST_MONGO_STORAGE) {
53
- describeFactory('mongodb', INITIALIZED_MONGO_STORAGE_FACTORY);
54
- }
55
-
56
- if (env.TEST_POSTGRES_STORAGE) {
57
- describeFactory('postgres', INITIALIZED_POSTGRES_STORAGE_FACTORY);
58
- }
34
+ describeStorageCombinations(
35
+ {
36
+ mongodb: env.TEST_MONGO_STORAGE ? INITIALIZED_MONGO_STORAGE_FACTORY : undefined,
37
+ postgres: env.TEST_POSTGRES_STORAGE ? INITIALIZED_POSTGRES_STORAGE_FACTORY : undefined
38
+ },
39
+ options,
40
+ fn
41
+ );
59
42
  }
60
43
 
61
44
  export async function clearTestDb(db: mongo.Db) {
@@ -70,8 +53,7 @@ export async function connectMongoData(options: mongo.MongoClientOptions = {}) {
70
53
  ...BSON_DESERIALIZE_DATA_OPTIONS,
71
54
  ...options
72
55
  });
73
- const dbname = new URL(env.MONGO_TEST_DATA_URL).pathname.substring(1);
74
- return { client, db: client.db(dbname) };
56
+ return { client, db: client.db(TEST_CONNECTION_OPTIONS.database) };
75
57
  }
76
58
 
77
59
  /**
package/tsconfig.json CHANGED
@@ -2,10 +2,7 @@
2
2
  "extends": "../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "rootDir": "src",
5
- "outDir": "dist",
6
- "esModuleInterop": true,
7
- "skipLibCheck": true,
8
- "sourceMap": true
5
+ "outDir": "dist"
9
6
  },
10
7
  "include": ["src"],
11
8
  "references": [
@@ -6,8 +6,7 @@
6
6
  "target": "esnext",
7
7
  "noEmit": true,
8
8
  "composite": false,
9
- "types": ["node"],
10
- "skipLibCheck": true
9
+ "types": ["node"]
11
10
  },
12
11
  "include": ["scripts/**/*.mts"]
13
12
  }