@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +16 -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 +8 -8
  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 +97 -65
  50. package/test/src/change_stream_utils.ts +83 -7
  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
@@ -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,28 @@ 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';
8
+ import { testTimeout } from './test-timeouts.js';
7
9
  import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
8
10
 
11
+ // DocumentDB only supports cluster-level change streams — collection- and
12
+ // database-level streams fail with NamespaceNotFound ("Collection not found"),
13
+ // which is why production always uses cluster-level streams on DocumentDB.
14
+ // The cluster-level size tracking test is also skipped: its strict batching
15
+ // assertions (exact batch counts, immediate event delivery per readAll pass)
16
+ // are racy against a remote DocumentDB cluster with multi-second event latency.
17
+ // Byte tracking on DocumentDB is exercised indirectly by documentdb_mode.test.ts.
9
18
  describe('internal mongodb utils', () => {
10
19
  // 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 () => {
20
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('collection change stream size tracking', async () => {
12
21
  await testChangeStreamBsonBytes('collection');
13
22
  });
14
23
 
15
- test('db change stream size tracking', async () => {
24
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('db change stream size tracking', async () => {
16
25
  await testChangeStreamBsonBytes('db');
17
26
  });
18
27
 
19
- test('cluster change stream size tracking', async () => {
28
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('cluster change stream size tracking', async () => {
20
29
  await testChangeStreamBsonBytes('cluster');
21
30
  });
22
31
 
@@ -44,144 +53,215 @@ describe('internal mongodb utils', () => {
44
53
  expect(totalBytes).toBeLessThan(1200);
45
54
  });
46
55
 
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
- });
56
+ // DocumentDB does not support database-level change streams (rawChangeStream on a db); only cluster-level.
57
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
58
+ 'uses separate aggregate and getMore command options',
59
+ async () => {
60
+ const { db, client } = await connectMongoData({ monitorCommands: true });
61
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
62
+ await clearTestDb(db);
63
+ const collection = db.collection('test_data');
64
+
65
+ const started: any[] = [];
66
+ client.on('commandStarted', (event) => {
67
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
68
+ started.push(event);
69
+ }
70
+ });
59
71
 
60
- const stream = rawChangeStream(
61
- db,
62
- [
63
- {
64
- $changeStream: {
65
- fullDocument: 'updateLookup'
72
+ const stream = rawChangeStream(
73
+ db,
74
+ [
75
+ {
76
+ $changeStream: {
77
+ fullDocument: 'updateLookup'
78
+ }
66
79
  }
80
+ ],
81
+ {
82
+ batchSize: 10,
83
+ maxAwaitTimeMS: 50,
84
+ maxTimeMS: 1_000
67
85
  }
68
- ],
69
- {
70
- batchSize: 10,
71
- maxAwaitTimeMS: 50,
72
- maxTimeMS: 1_000
73
- }
74
- );
86
+ );
75
87
 
76
- await stream.next();
77
- await collection.insertOne({ test: 1 });
78
- const nextBatch = await readUntilNonEmptyBatch(stream);
79
- await stream.return?.();
88
+ await stream.next();
89
+ await collection.insertOne({ test: 1 });
90
+ const nextBatch = await readUntilNonEmptyBatch(stream);
91
+ await stream.return?.();
80
92
 
81
- expect(nextBatch.events).toHaveLength(1);
93
+ expect(nextBatch.events).toHaveLength(1);
82
94
 
83
- const aggregate = started.find((event) => event.commandName == 'aggregate');
84
- const getMore = started.find((event) => event.commandName == 'getMore');
95
+ const aggregate = started.find((event) => event.commandName == 'aggregate');
96
+ const getMore = started.find((event) => event.commandName == 'getMore');
85
97
 
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
- });
98
+ expect(aggregate?.command.cursor?.batchSize).toEqual(1);
99
+ expect(aggregate?.command.maxTimeMS).toEqual(1_000);
100
+ expect(getMore?.command.batchSize).toEqual(10);
101
+ expect(getMore?.command.maxTimeMS).toEqual(50);
102
+ }
103
+ );
104
+
105
+ test(
106
+ 'keeps getMore maxTimeMS when client-side maxAwaitTimeMS is enabled',
107
+ { timeout: testTimeout(30_000, { cloudOverride: 150_000 }) },
108
+ async () => {
109
+ const { db, client } = await connectMongoData({ monitorCommands: true });
110
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
111
+ await clearTestDb(db);
112
+ const collection = db.collection('test_data');
113
+ // Keep the local Mongo path fast, but give Azure DocumentDB enough time
114
+ // for slow cloud change-stream delivery when maxTimeMS is sent to getMore.
115
+ const maxAwaitTimeMS = testTimeout(50, { cloudOverride: 10_000 });
116
+
117
+ const started: any[] = [];
118
+ client.on('commandStarted', (event) => {
119
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
120
+ started.push(event);
121
+ }
122
+ });
91
123
 
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);
124
+ const stream = rawChangeStream(
125
+ DATABASE_TYPE == DatabaseType.DOCUMENTDB ? client.db('admin') : db,
126
+ [
127
+ {
128
+ $changeStream: {
129
+ fullDocument: 'updateLookup',
130
+ ...(DATABASE_TYPE == DatabaseType.DOCUMENTDB ? { allChangesForCluster: true } : {})
131
+ }
132
+ },
133
+ ...(DATABASE_TYPE == DatabaseType.DOCUMENTDB
134
+ ? [
135
+ {
136
+ $match: {
137
+ 'ns.db': db.databaseName,
138
+ 'ns.coll': collection.collectionName
139
+ }
140
+ }
141
+ ]
142
+ : [])
143
+ ],
144
+ {
145
+ batchSize: 10,
146
+ maxAwaitTimeMS,
147
+ clientSideMaxAwaitTimeMS: true,
148
+ maxTimeMS: 1_000
149
+ }
150
+ );
96
151
 
97
- await clearTestDb(db);
98
- const collection = db.collection('test_data');
152
+ await stream.next();
153
+ await collection.insertOne({ test: 1 });
154
+ const nextBatch = await readUntilNonEmptyBatch(stream);
155
+ await stream.return?.();
99
156
 
100
- const started: any[] = [];
101
- client.on('commandStarted', (event) => {
102
- if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
103
- started.push(event);
104
- }
105
- });
157
+ expect(nextBatch.events).toHaveLength(1);
106
158
 
107
- const stream = rawChangeStream(
108
- db,
109
- [
110
- {
111
- $changeStream: {
112
- fullDocument: 'updateLookup'
113
- }
159
+ const aggregate = started.find((event) => event.commandName == 'aggregate');
160
+ const getMore = started.find((event) => event.commandName == 'getMore');
161
+
162
+ expect(aggregate?.command.maxTimeMS).toEqual(1_000);
163
+ expect(getMore?.command.maxTimeMS).toEqual(maxAwaitTimeMS);
164
+ }
165
+ );
166
+
167
+ // This test uses configureFailPoint to inject a getMore timeout. Azure
168
+ // DocumentDB does not support configureFailPoint.
169
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
170
+ 'should resume on MaxTimeMSExpired from getMore',
171
+ async (ctx) => {
172
+ const { db, client } = await connectMongoData({ monitorCommands: true });
173
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
174
+ await using failCommand = await requireFailCommand(client, ctx);
175
+
176
+ await clearTestDb(db);
177
+ const collection = db.collection('test_data');
178
+
179
+ const started: any[] = [];
180
+ client.on('commandStarted', (event) => {
181
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
182
+ started.push(event);
114
183
  }
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
- );
184
+ });
124
185
 
125
- await stream.next();
186
+ const stream = rawChangeStream(
187
+ db,
188
+ [
189
+ {
190
+ $changeStream: {
191
+ fullDocument: 'updateLookup'
192
+ }
193
+ }
194
+ ],
195
+ {
196
+ batchSize: 3,
197
+ maxAwaitTimeMS: 50,
198
+ maxTimeMS: 1_000
199
+ // To get more details when debugging, enable the logger:
200
+ // logger: logger
201
+ }
202
+ );
126
203
 
127
- await failCommand.configure({
128
- mode: { times: 1 },
129
- data: {
130
- failCommands: ['getMore'],
131
- errorCode: 50 // MaxTimeMSExpired
132
- }
133
- });
204
+ await stream.next();
134
205
 
135
- for (let i = 1; i <= 8; i++) {
136
- await collection.insertOne({ test: i });
137
- }
206
+ await failCommand.configure({
207
+ mode: { times: 1 },
208
+ data: {
209
+ failCommands: ['getMore'],
210
+ errorCode: 50 // MaxTimeMSExpired
211
+ }
212
+ });
138
213
 
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
- ]);
170
- }
214
+ for (let i = 1; i <= 8; i++) {
215
+ await collection.insertOne({ test: i });
216
+ }
171
217
 
172
- const aggregateCommands = started.filter((event) => event.commandName == 'aggregate');
173
- expect(aggregateCommands.length).toBeGreaterThanOrEqual(2);
174
- expect(aggregateCommands[0].command.pipeline).toEqual([
218
+ // Test the exponentially-increasing batch size after the retry
175
219
  {
176
- $changeStream: {
177
- fullDocument: 'updateLookup'
178
- }
220
+ // This will fail the getMore, then retry with aggregate with batchSize 1
221
+ const batch = await readUntilNonEmptyBatch(stream, 10);
222
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
223
+ { test: 1 }
224
+ ]);
225
+ }
226
+ {
227
+ // This will be a getMore with batchSize 2
228
+ const batch = await readUntilNonEmptyBatch(stream, 10);
229
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
230
+ { test: 2 },
231
+ { test: 3 }
232
+ ]);
233
+ }
234
+ {
235
+ // At this point, this batch size is at the original size of 3 again.
236
+ const batch = await readUntilNonEmptyBatch(stream, 10);
237
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
238
+ { test: 4 },
239
+ { test: 5 },
240
+ { test: 6 }
241
+ ]);
242
+ }
243
+ {
244
+ const batch = await readUntilNonEmptyBatch(stream, 10);
245
+ expect(batch.events.map((e) => bson.deserialize(e, { useBigInt64: true }).fullDocument)).toMatchObject([
246
+ { test: 7 },
247
+ { test: 8 }
248
+ ]);
179
249
  }
180
- ]);
181
- expect(aggregateCommands[1].command.pipeline[0]?.$changeStream?.resumeAfter).toBeDefined();
182
250
 
183
- await stream?.return().catch(() => {});
184
- });
251
+ const aggregateCommands = started.filter((event) => event.commandName == 'aggregate');
252
+ expect(aggregateCommands.length).toBeGreaterThanOrEqual(2);
253
+ expect(aggregateCommands[0].command.pipeline).toEqual([
254
+ {
255
+ $changeStream: {
256
+ fullDocument: 'updateLookup'
257
+ }
258
+ }
259
+ ]);
260
+ expect(aggregateCommands[1].command.pipeline[0]?.$changeStream?.resumeAfter).toBeDefined();
261
+
262
+ await stream?.return().catch(() => {});
263
+ }
264
+ );
185
265
 
186
266
  async function testChangeStreamBsonBytes(type: 'db' | 'collection' | 'cluster') {
187
267
  const { db, client } = await connectMongoData();
@@ -260,7 +340,8 @@ describe('internal mongodb utils', () => {
260
340
  expect(totalBytes).toBeLessThan(8000);
261
341
  }
262
342
 
263
- test('should resume on missing cursor (1)', async () => {
343
+ // DocumentDB: database-level change streams and $currentOp are not supported
344
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should resume on missing cursor (1)', async () => {
264
345
  // Many resumable errors are difficult to simulate, but CursorNotFound is easy.
265
346
 
266
347
  const { db, client } = await connectMongoData();
@@ -313,7 +394,8 @@ describe('internal mongodb utils', () => {
313
394
  expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }]);
314
395
  });
315
396
 
316
- test('should resume on missing cursor (2)', async () => {
397
+ // DocumentDB: database-level change streams and $currentOp are not supported
398
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should resume on missing cursor (2)', async () => {
317
399
  const { db, client } = await connectMongoData();
318
400
  await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
319
401
  await clearTestDb(db);
@@ -387,7 +469,8 @@ describe('internal mongodb utils', () => {
387
469
  expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }]);
388
470
  });
389
471
 
390
- test('should cleanly abort a stream between events', async () => {
472
+ // DocumentDB: database-level change streams are not supported
473
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should cleanly abort a stream between events', async () => {
391
474
  const { db, client } = await connectMongoData();
392
475
  const abortController = new AbortController();
393
476
  await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
@@ -437,7 +520,8 @@ describe('internal mongodb utils', () => {
437
520
  expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }]);
438
521
  });
439
522
 
440
- test('should cleanly abort a stream in an event', async () => {
523
+ // DocumentDB: database-level change streams are not supported
524
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('should cleanly abort a stream in an event', async () => {
441
525
  const { db, client } = await connectMongoData();
442
526
  const abortController = new AbortController();
443
527
  await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
@@ -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
@@ -71,8 +71,7 @@ export async function connectMongoData(options: mongo.MongoClientOptions = {}) {
71
71
  ...BSON_DESERIALIZE_DATA_OPTIONS,
72
72
  ...options
73
73
  });
74
- const dbname = new URL(env.MONGO_TEST_DATA_URL).pathname.substring(1);
75
- return { client, db: client.db(dbname) };
74
+ return { client, db: client.db(TEST_CONNECTION_OPTIONS.database) };
76
75
  }
77
76
 
78
77
  /**