@powersync/service-module-mongodb 0.15.3 → 0.16.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 (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/api/MongoRouteAPIAdapter.js +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +6 -6
  5. package/dist/replication/ChangeStream.js +300 -322
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.js +2 -2
  8. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  9. package/dist/replication/ChangeStreamReplicator.d.ts +1 -1
  10. package/dist/replication/ChangeStreamReplicator.js +1 -1
  11. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  12. package/dist/replication/JsonBufferWriter.d.ts +80 -0
  13. package/dist/replication/JsonBufferWriter.js +342 -0
  14. package/dist/replication/JsonBufferWriter.js.map +1 -0
  15. package/dist/replication/MongoManager.d.ts +1 -1
  16. package/dist/replication/MongoManager.js +1 -1
  17. package/dist/replication/MongoManager.js.map +1 -1
  18. package/dist/replication/MongoRelation.js +4 -0
  19. package/dist/replication/MongoRelation.js.map +1 -1
  20. package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
  21. package/dist/replication/MongoSnapshotQuery.js +6 -3
  22. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  23. package/dist/replication/RawChangeStream.d.ts +55 -0
  24. package/dist/replication/RawChangeStream.js +322 -0
  25. package/dist/replication/RawChangeStream.js.map +1 -0
  26. package/dist/replication/SourceRowConverter.d.ts +46 -0
  27. package/dist/replication/SourceRowConverter.js +42 -0
  28. package/dist/replication/SourceRowConverter.js.map +1 -0
  29. package/dist/replication/bufferToSqlite.d.ts +43 -0
  30. package/dist/replication/bufferToSqlite.js +740 -0
  31. package/dist/replication/bufferToSqlite.js.map +1 -0
  32. package/dist/replication/internal-mongodb-utils.d.ts +0 -12
  33. package/dist/replication/internal-mongodb-utils.js +0 -54
  34. package/dist/replication/internal-mongodb-utils.js.map +1 -1
  35. package/dist/replication/replication-index.d.ts +4 -2
  36. package/dist/replication/replication-index.js +4 -2
  37. package/dist/replication/replication-index.js.map +1 -1
  38. package/dist/replication/replication-utils.d.ts +1 -1
  39. package/dist/types/types.js.map +1 -1
  40. package/package.json +11 -11
  41. package/scripts/benchmark-change-document-json.mts +358 -0
  42. package/scripts/benchmark-change-document.mts +370 -0
  43. package/src/api/MongoRouteAPIAdapter.ts +2 -2
  44. package/src/replication/ChangeStream.ts +348 -371
  45. package/src/replication/ChangeStreamReplicationJob.ts +2 -2
  46. package/src/replication/ChangeStreamReplicator.ts +2 -5
  47. package/src/replication/JsonBufferWriter.ts +390 -0
  48. package/src/replication/MongoManager.ts +2 -2
  49. package/src/replication/MongoRelation.ts +5 -2
  50. package/src/replication/MongoSnapshotQuery.ts +8 -5
  51. package/src/replication/RawChangeStream.ts +460 -0
  52. package/src/replication/SourceRowConverter.ts +65 -0
  53. package/src/replication/bufferToSqlite.ts +944 -0
  54. package/src/replication/internal-mongodb-utils.ts +0 -66
  55. package/src/replication/replication-index.ts +4 -2
  56. package/src/replication/replication-utils.ts +2 -2
  57. package/src/types/types.ts +1 -1
  58. package/test/src/buffer_to_sqlite.test.ts +1146 -0
  59. package/test/src/change_stream.test.ts +49 -3
  60. package/test/src/change_stream_utils.ts +4 -10
  61. package/test/src/mongo_test.test.ts +66 -64
  62. package/test/src/parse_document_id.test.ts +54 -0
  63. package/test/src/raw_change_stream.test.ts +547 -0
  64. package/test/src/resume.test.ts +12 -2
  65. package/test/src/util.ts +56 -3
  66. package/test/tsconfig.json +0 -1
  67. package/tsconfig.scripts.json +13 -0
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/test/src/internal_mongodb_utils.test.ts +0 -103
@@ -0,0 +1,547 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { ChangeStreamBatch, namespaceCollection, rawChangeStream } from '@module/replication/RawChangeStream.js';
4
+ import { getCursorBatchBytes } from '@module/replication/replication-index.js';
5
+ import { mongo } from '@powersync/lib-service-mongodb';
6
+ import { bson } from '@powersync/service-core';
7
+ import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
8
+
9
+ describe('internal mongodb utils', () => {
10
+ // 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 () => {
12
+ await testChangeStreamBsonBytes('collection');
13
+ });
14
+
15
+ test('db change stream size tracking', async () => {
16
+ await testChangeStreamBsonBytes('db');
17
+ });
18
+
19
+ test('cluster change stream size tracking', async () => {
20
+ await testChangeStreamBsonBytes('cluster');
21
+ });
22
+
23
+ test('cursor batch size tracking', async () => {
24
+ const { db, client } = await connectMongoData();
25
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
26
+ await clearTestDb(db);
27
+ const collection = db.collection('test_data');
28
+ await collection.insertMany([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }, { test: 5 }]);
29
+
30
+ const cursor = collection.find({}, { batchSize: 2 });
31
+ let batchBytes: number[] = [];
32
+ let totalBytes = 0;
33
+ // We use this in the same way as ChunkedSnapshotQuery
34
+ while (await cursor.hasNext()) {
35
+ batchBytes.push(getCursorBatchBytes(cursor));
36
+ totalBytes += batchBytes[batchBytes.length - 1];
37
+ cursor.readBufferedDocuments();
38
+ }
39
+
40
+ // 3 batches: [2, 2, 1] documents. Should not change
41
+ expect(batchBytes.length).toEqual(3);
42
+ // Current tests show 839, but this may change depending on the MongoDB version and other conditions.
43
+ expect(totalBytes).toBeGreaterThan(400);
44
+ expect(totalBytes).toBeLessThan(1200);
45
+ });
46
+
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
+ });
59
+
60
+ const stream = rawChangeStream(
61
+ db,
62
+ [
63
+ {
64
+ $changeStream: {
65
+ fullDocument: 'updateLookup'
66
+ }
67
+ }
68
+ ],
69
+ {
70
+ batchSize: 10,
71
+ maxAwaitTimeMS: 50,
72
+ maxTimeMS: 1_000
73
+ }
74
+ );
75
+
76
+ await stream.next();
77
+ await collection.insertOne({ test: 1 });
78
+ const nextBatch = await readUntilNonEmptyBatch(stream);
79
+ await stream.return?.();
80
+
81
+ expect(nextBatch.events).toHaveLength(1);
82
+
83
+ const aggregate = started.find((event) => event.commandName == 'aggregate');
84
+ const getMore = started.find((event) => event.commandName == 'getMore');
85
+
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
+ });
91
+
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);
96
+
97
+ await clearTestDb(db);
98
+ const collection = db.collection('test_data');
99
+
100
+ const started: any[] = [];
101
+ client.on('commandStarted', (event) => {
102
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
103
+ started.push(event);
104
+ }
105
+ });
106
+
107
+ const stream = rawChangeStream(
108
+ db,
109
+ [
110
+ {
111
+ $changeStream: {
112
+ fullDocument: 'updateLookup'
113
+ }
114
+ }
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
+ ]);
170
+ }
171
+
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
+ });
185
+
186
+ async function testChangeStreamBsonBytes(type: 'db' | 'collection' | 'cluster') {
187
+ const { db, client } = await connectMongoData();
188
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
189
+ await clearTestDb(db);
190
+ const collection = db.collection('test_data');
191
+
192
+ let stream: AsyncIterableIterator<ChangeStreamBatch>;
193
+ const pipeline = [
194
+ {
195
+ $changeStream: {
196
+ fullDocument: 'updateLookup',
197
+ allChangesForCluster: type == 'cluster'
198
+ }
199
+ }
200
+ ];
201
+ if (type === 'collection') {
202
+ stream = rawChangeStream(db, pipeline, {
203
+ batchSize: 10,
204
+ maxAwaitTimeMS: 5,
205
+ maxTimeMS: 1_000,
206
+ collection: collection.collectionName
207
+ });
208
+ } else if (type === 'db') {
209
+ stream = rawChangeStream(db, pipeline, {
210
+ batchSize: 10,
211
+ maxAwaitTimeMS: 5,
212
+ maxTimeMS: 1_000
213
+ });
214
+ } else {
215
+ stream = rawChangeStream(client.db('admin'), pipeline, {
216
+ batchSize: 10,
217
+ maxAwaitTimeMS: 5,
218
+ maxTimeMS: 1_000
219
+ });
220
+ }
221
+
222
+ let batchBytes: number[] = [];
223
+ let totalBytes = 0;
224
+
225
+ const readAll = async () => {
226
+ while (true) {
227
+ const next = await stream.next();
228
+ if (next.done) {
229
+ break;
230
+ }
231
+ const bytes = next.value.byteSize;
232
+ batchBytes.push(bytes);
233
+ totalBytes += bytes;
234
+
235
+ if (next.value.events.length == 0) {
236
+ break;
237
+ }
238
+ }
239
+ };
240
+
241
+ await readAll();
242
+
243
+ await collection.insertOne({ test: 1 });
244
+ await readAll();
245
+ await collection.insertOne({ test: 2 });
246
+ await readAll();
247
+ await collection.insertOne({ test: 3 });
248
+ await readAll();
249
+
250
+ await stream.return?.();
251
+
252
+ // The exact length by vary based on exact batching logic, but we do want to know when it changes.
253
+ // Note: If this causes unstable tests, we can relax this check.
254
+ expect(batchBytes.length).toEqual(7);
255
+
256
+ // Current tests show 4464-4576 bytes for the size, depending on the type of change stream.
257
+ // This can easily vary based on the mongodb version and general conditions, so we just check the general range.
258
+ // For the most part, if any bytes are reported, the tracking is working.
259
+ expect(totalBytes).toBeGreaterThan(2000);
260
+ expect(totalBytes).toBeLessThan(8000);
261
+ }
262
+
263
+ test('should resume on missing cursor (1)', async () => {
264
+ // Many resumable errors are difficult to simulate, but CursorNotFound is easy.
265
+
266
+ const { db, client } = await connectMongoData();
267
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
268
+ await clearTestDb(db);
269
+ const collection = db.collection('test_data');
270
+
271
+ const pipeline = [
272
+ {
273
+ $changeStream: {
274
+ fullDocument: 'updateLookup'
275
+ }
276
+ }
277
+ ];
278
+ const stream = rawChangeStream(db, pipeline, {
279
+ batchSize: 10,
280
+ maxAwaitTimeMS: 5,
281
+ maxTimeMS: 1_000
282
+ });
283
+
284
+ let readDocs: any[] = [];
285
+ const readAll = async () => {
286
+ while (true) {
287
+ const next = await stream.next();
288
+ if (next.done) {
289
+ break;
290
+ }
291
+
292
+ if (next.value.events.length == 0) {
293
+ break;
294
+ }
295
+
296
+ readDocs.push(...next.value.events.map((e) => bson.deserialize(e, { useBigInt64: true })));
297
+ }
298
+ };
299
+
300
+ await readAll();
301
+
302
+ await collection.insertOne({ test: 1 });
303
+ await readAll();
304
+ await collection.insertOne({ test: 2 });
305
+ await readAll();
306
+ await collection.insertOne({ test: 3 });
307
+ await killChangeStreamCursor(db, client);
308
+ await collection.insertOne({ test: 4 });
309
+ await readAll();
310
+
311
+ await stream.return?.();
312
+
313
+ expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }]);
314
+ });
315
+
316
+ test('should resume on missing cursor (2)', async () => {
317
+ const { db, client } = await connectMongoData();
318
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
319
+ await clearTestDb(db);
320
+ const collection = db.collection('test_data');
321
+
322
+ const currentOpStream = rawChangeStream(
323
+ db,
324
+ [
325
+ {
326
+ $changeStream: {
327
+ fullDocument: 'updateLookup'
328
+ }
329
+ }
330
+ ],
331
+ {
332
+ batchSize: 10,
333
+ maxAwaitTimeMS: 5,
334
+ maxTimeMS: 1_000
335
+ }
336
+ );
337
+ const firstBatch = await currentOpStream.next();
338
+ await currentOpStream.return();
339
+ const resumeAfter = firstBatch.value!.resumeToken;
340
+
341
+ const stream = rawChangeStream(
342
+ db,
343
+ [
344
+ {
345
+ $changeStream: {
346
+ fullDocument: 'updateLookup',
347
+ resumeAfter
348
+ }
349
+ }
350
+ ],
351
+ {
352
+ batchSize: 10,
353
+ maxAwaitTimeMS: 5,
354
+ maxTimeMS: 1_000
355
+ }
356
+ );
357
+
358
+ let readDocs: any[] = [];
359
+ const readAll = async () => {
360
+ while (true) {
361
+ const next = await stream.next();
362
+ if (next.done) {
363
+ break;
364
+ }
365
+
366
+ if (next.value.events.length == 0) {
367
+ break;
368
+ }
369
+
370
+ readDocs.push(...next.value.events.map((e) => bson.deserialize(e, { useBigInt64: true })));
371
+ }
372
+ };
373
+
374
+ await readAll();
375
+
376
+ await collection.insertOne({ test: 1 });
377
+ await readAll();
378
+ await collection.insertOne({ test: 2 });
379
+ await readAll();
380
+ await collection.insertOne({ test: 3 });
381
+ await killChangeStreamCursor(db, client);
382
+ await collection.insertOne({ test: 4 });
383
+ await readAll();
384
+
385
+ await stream.return?.();
386
+
387
+ expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }, { test: 3 }, { test: 4 }]);
388
+ });
389
+
390
+ test('should cleanly abort a stream between events', async () => {
391
+ const { db, client } = await connectMongoData();
392
+ const abortController = new AbortController();
393
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
394
+ await clearTestDb(db);
395
+ const collection = db.collection('test_data');
396
+
397
+ const pipeline = [
398
+ {
399
+ $changeStream: {
400
+ fullDocument: 'updateLookup'
401
+ }
402
+ }
403
+ ];
404
+ const stream = rawChangeStream(db, pipeline, {
405
+ batchSize: 10,
406
+ maxAwaitTimeMS: 5,
407
+ maxTimeMS: 1_000,
408
+ signal: abortController.signal
409
+ });
410
+
411
+ let readDocs: any[] = [];
412
+ const readAll = async () => {
413
+ while (true) {
414
+ const next = await stream.next();
415
+ if (next.done) {
416
+ break;
417
+ }
418
+
419
+ if (next.value.events.length == 0) {
420
+ break;
421
+ }
422
+
423
+ readDocs.push(...next.value.events.map((e) => bson.deserialize(e, { useBigInt64: true })));
424
+ }
425
+ };
426
+
427
+ await readAll();
428
+
429
+ await collection.insertOne({ test: 1 });
430
+ await readAll();
431
+ await collection.insertOne({ test: 2 });
432
+ await readAll();
433
+ abortController.abort(new Error('test abort'));
434
+ await collection.insertOne({ test: 3 });
435
+ await expect(readAll()).rejects.toMatchObject({ message: 'test abort' });
436
+
437
+ expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }, { test: 2 }]);
438
+ });
439
+
440
+ test('should cleanly abort a stream in an event', async () => {
441
+ const { db, client } = await connectMongoData();
442
+ const abortController = new AbortController();
443
+ await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
444
+ await clearTestDb(db);
445
+ const collection = db.collection('test_data');
446
+
447
+ const pipeline = [
448
+ {
449
+ $changeStream: {
450
+ fullDocument: 'updateLookup'
451
+ }
452
+ }
453
+ ];
454
+ const stream = rawChangeStream(db, pipeline, {
455
+ batchSize: 10,
456
+ maxAwaitTimeMS: 200,
457
+ maxTimeMS: 1_000,
458
+ signal: abortController.signal
459
+ });
460
+
461
+ let readDocs: any[] = [];
462
+ const readAll = async () => {
463
+ while (true) {
464
+ const next = await stream.next();
465
+ if (next.done) {
466
+ break;
467
+ }
468
+
469
+ if (next.value.events.length == 0) {
470
+ break;
471
+ }
472
+
473
+ readDocs.push(...next.value.events.map((e) => bson.deserialize(e, { useBigInt64: true })));
474
+ }
475
+ };
476
+
477
+ await readAll();
478
+
479
+ await collection.insertOne({ test: 1 });
480
+ await readAll();
481
+ // This is specifically a readAll() without an insert in between, to trigger the longer await
482
+ // period.
483
+ let readPromise = readAll();
484
+ abortController.abort(new Error('test abort'));
485
+ await expect(readPromise).rejects.toMatchObject({ message: 'test abort' });
486
+
487
+ expect(readDocs.map((doc) => doc.fullDocument)).toMatchObject([{ test: 1 }]);
488
+ });
489
+ });
490
+
491
+ async function killChangeStreamCursor(db: mongo.Db, client: mongo.MongoClient) {
492
+ const ops = await client
493
+ .db('admin')
494
+ .aggregate<CurrentOpIdleCursor>([{ $currentOp: { idleCursors: true } }, { $match: { type: 'idleCursor' } }])
495
+ .toArray();
496
+
497
+ const ns = `${db.databaseName}.$cmd.aggregate`;
498
+ const op = ops.find((op) => {
499
+ const command = op.cursor?.originatingCommand;
500
+ return op.ns == ns && Array.isArray(command?.pipeline) && command.pipeline[0]?.$changeStream != null;
501
+ });
502
+
503
+ if (op?.cursor == null) {
504
+ throw new Error(
505
+ `Could not find change stream cursor. Idle cursors: ${JSON.stringify(
506
+ ops.map((op) => ({
507
+ ns: op.ns,
508
+ type: op.type,
509
+ cursorId: op.cursor?.cursorId?.toString(),
510
+ aggregate: op.cursor?.originatingCommand?.aggregate,
511
+ pipeline: op.cursor?.originatingCommand?.pipeline
512
+ }))
513
+ )}`
514
+ );
515
+ }
516
+
517
+ await db.command({
518
+ killCursors: namespaceCollection(op.ns),
519
+ cursors: [op.cursor.cursorId]
520
+ });
521
+ }
522
+
523
+ type CurrentOpIdleCursor = {
524
+ ns: string;
525
+ type: string;
526
+ cursor?: {
527
+ cursorId: bigint;
528
+ originatingCommand?: {
529
+ aggregate?: unknown;
530
+ pipeline?: any[];
531
+ };
532
+ };
533
+ };
534
+
535
+ async function readUntilNonEmptyBatch(stream: AsyncIterableIterator<ChangeStreamBatch>, maxEmptyBatches: number = 5) {
536
+ for (let i = 0; i < maxEmptyBatches; i++) {
537
+ const next = await stream.next();
538
+ if (next.done) {
539
+ throw new Error('Change stream ended unexpectedly');
540
+ }
541
+ if (next.value.events.length > 0) {
542
+ return next.value;
543
+ }
544
+ }
545
+
546
+ throw new Error(`Did not receive a non-empty batch after ${maxEmptyBatches} empty batches`);
547
+ }
@@ -15,7 +15,17 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
15
15
  return ChangeStreamTestContext.open(factoryGenerator, { ...options, storageVersion });
16
16
  };
17
17
 
18
- test('resuming with a different source database', async () => {
18
+ test.skip('resuming with a different source database', async () => {
19
+ // This test is not functioning anymore.
20
+ // Previously, we mostly used individual change stream _id's for resumeTokens. Those would become invalid
21
+ // when the database is changed.
22
+ // Now, we mostly use postBatchResumeToken when we can, which do not become invalidated in this case.
23
+ // This is recommended by the change stream spec.
24
+ // The old behavior wasn't 100% consistent either - it _could_ use postBatchResumeToken, in which
25
+ // case it would similarly not be invalidated.
26
+ // We can revisit the logic to invalidate the change stream at a later point, potentially by
27
+ // keeping track of the source database name.
28
+
19
29
  await using context = await openContext();
20
30
  const { db } = context;
21
31
 
@@ -55,7 +65,7 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
55
65
  );
56
66
  const factory = await factoryGenerator({ doNotClear: true });
57
67
 
58
- // Create a new context without updating the sync rules
68
+ // Create a new context without updating the sync config
59
69
  await using context2 = new ChangeStreamTestContext(factory, connectionManager, {}, storageVersion);
60
70
  const activeContent = await factory.getActiveSyncRulesContent();
61
71
  context2.storage = factory.getInstance(activeContent!);
package/test/src/util.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  TestStorageConfig,
10
10
  TestStorageFactory
11
11
  } from '@powersync/service-core';
12
- import { describe, TestOptions } from 'vitest';
12
+ import { describe, TestContext, TestOptions } from 'vitest';
13
13
  import { env } from './env.js';
14
14
 
15
15
  export const TEST_URI = env.MONGO_TEST_DATA_URL;
@@ -62,13 +62,66 @@ export async function clearTestDb(db: mongo.Db) {
62
62
  await db.dropDatabase();
63
63
  }
64
64
 
65
- export async function connectMongoData() {
65
+ export async function connectMongoData(options: mongo.MongoClientOptions = {}) {
66
66
  const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL, {
67
67
  connectTimeoutMS: env.CI ? 15_000 : 5_000,
68
68
  socketTimeoutMS: env.CI ? 15_000 : 5_000,
69
69
  serverSelectionTimeoutMS: env.CI ? 15_000 : 2_500,
70
- ...BSON_DESERIALIZE_DATA_OPTIONS
70
+ ...BSON_DESERIALIZE_DATA_OPTIONS,
71
+ ...options
71
72
  });
72
73
  const dbname = new URL(env.MONGO_TEST_DATA_URL).pathname.substring(1);
73
74
  return { client, db: client.db(dbname) };
74
75
  }
76
+
77
+ /**
78
+ * This allows us to inject custom failures into commands on the mongodb server.
79
+ *
80
+ * For this to work, mongodb must be started with `--setParameter enableTestCommands=1`.
81
+ *
82
+ * We require this in CI, but in local development we skip the test if it's not configured.
83
+ *
84
+ * https://github.com/mongodb/mongo/wiki/The-failCommand-fail-point
85
+ */
86
+ export async function requireFailCommand(client: mongo.MongoClient, ctx: TestContext) {
87
+ try {
88
+ await client.db('admin').command({ configureFailPoint: 'failCommand', mode: 'off' });
89
+ } catch (e: any) {
90
+ const codeName = e?.codeName;
91
+ const message = e?.message ?? String(e);
92
+
93
+ if (
94
+ codeName == 'CommandNotFound' ||
95
+ codeName == 'Unauthorized' ||
96
+ message.includes('no such command') ||
97
+ message.includes('enableTestCommands')
98
+ ) {
99
+ if (process.env.CI) {
100
+ // In CI we want to fail if failCommand is not supported, as that likely means something is wrong with the test environment setup.
101
+ throw e;
102
+ }
103
+ // In local development, we skip the test if failCommand is not supported, as developers may be running against a variety of MongoDB versions and configurations.
104
+ ctx.skip(`failCommand not supported: ${codeName ?? message}`);
105
+ }
106
+
107
+ throw e;
108
+ }
109
+
110
+ return {
111
+ async configure(data: Omit<mongo.Document, 'configureFailPoint'>) {
112
+ await client.db('admin').command({
113
+ configureFailPoint: 'failCommand',
114
+ ...data
115
+ });
116
+ },
117
+ async [Symbol.asyncDispose]() {
118
+ await client
119
+ .db('admin')
120
+ .command({
121
+ configureFailPoint: 'failCommand',
122
+ mode: 'off'
123
+ })
124
+ .catch(() => {});
125
+ }
126
+ };
127
+ }
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.tests.json",
3
3
  "compilerOptions": {
4
- "baseUrl": "./",
5
4
  "paths": {
6
5
  "@/*": ["../../../packages/service-core/src/*"],
7
6
  "@module/*": ["../src/*"],
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "target": "esnext",
7
+ "noEmit": true,
8
+ "composite": false,
9
+ "types": ["node"],
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["scripts/**/*.mts"]
13
+ }