@powersync/service-module-mongodb 0.21.2 → 0.22.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 (90) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/benchmarks/env.ts +11 -0
  3. package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
  4. package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
  5. package/dist/api/MongoRouteAPIAdapter.js +21 -2
  6. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/module/MongoModule.d.ts +1 -1
  11. package/dist/module/MongoModule.js +1 -0
  12. package/dist/module/MongoModule.js.map +1 -1
  13. package/dist/replication/ChangeStream.d.ts +13 -3
  14. package/dist/replication/ChangeStream.js +130 -196
  15. package/dist/replication/ChangeStream.js.map +1 -1
  16. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  17. package/dist/replication/ChangeStreamReplicationJob.js +19 -8
  18. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  19. package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
  20. package/dist/replication/ChangeStreamReplicator.js +6 -0
  21. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  22. package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
  23. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  24. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  25. package/dist/replication/MongoReplicationStream.d.ts +74 -0
  26. package/dist/replication/MongoReplicationStream.js +228 -0
  27. package/dist/replication/MongoReplicationStream.js.map +1 -0
  28. package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
  29. package/dist/replication/MongoReplicationStreamTypes.js +19 -0
  30. package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
  31. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  32. package/dist/replication/MongoSnapshotQuery.js +12 -2
  33. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  34. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  35. package/dist/replication/MongoSnapshotter.js +90 -88
  36. package/dist/replication/MongoSnapshotter.js.map +1 -1
  37. package/dist/replication/RawChangeStream.d.ts +1 -1
  38. package/dist/replication/RawChangeStream.js +1 -1
  39. package/dist/replication/RawChangeStream.js.map +1 -1
  40. package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
  41. package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
  42. package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
  43. package/dist/replication/replication-index.d.ts +7 -0
  44. package/dist/replication/replication-index.js +7 -0
  45. package/dist/replication/replication-index.js.map +1 -1
  46. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  47. package/dist/test-utils/ChangeStreamTestContext.js +390 -0
  48. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  49. package/dist/test-utils/test-utils-index.d.ts +1 -0
  50. package/dist/test-utils/test-utils-index.js +2 -0
  51. package/dist/test-utils/test-utils-index.js.map +1 -0
  52. package/package.json +11 -10
  53. package/src/api/MongoRouteAPIAdapter.ts +26 -2
  54. package/src/index.ts +1 -0
  55. package/src/module/MongoModule.ts +2 -1
  56. package/src/replication/ChangeStream.ts +143 -226
  57. package/src/replication/ChangeStreamReplicationJob.ts +21 -9
  58. package/src/replication/ChangeStreamReplicator.ts +9 -0
  59. package/src/replication/MongoReplicationQueryProvider.ts +106 -0
  60. package/src/replication/MongoReplicationStream.ts +314 -0
  61. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  62. package/src/replication/MongoSnapshotQuery.ts +19 -3
  63. package/src/replication/MongoSnapshotter.ts +101 -92
  64. package/src/replication/RawChangeStream.ts +2 -2
  65. package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
  66. package/src/replication/replication-index.ts +7 -0
  67. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  68. package/src/test-utils/test-utils-index.ts +1 -0
  69. package/test/src/change_event_memory.test.ts +33 -0
  70. package/test/src/change_stream.test.ts +3 -3
  71. package/test/src/change_stream_abort.test.ts +110 -0
  72. package/test/src/change_stream_test_setup.ts +38 -0
  73. package/test/src/chunked_snapshot.test.ts +3 -3
  74. package/test/src/documentdb_mode.test.ts +3 -3
  75. package/test/src/filtering_example.test.ts +65 -0
  76. package/test/src/filtering_lifecycle.test.ts +101 -0
  77. package/test/src/query_provider_availability.test.ts +77 -0
  78. package/test/src/query_provider_diagnostics.test.ts +55 -0
  79. package/test/src/replication_stream.test.ts +688 -0
  80. package/test/src/resume.test.ts +10 -4
  81. package/test/src/resuming_snapshots.test.ts +3 -3
  82. package/test/src/slow_tests.test.ts +3 -3
  83. package/test/src/snapshot_count.test.ts +146 -0
  84. package/test/src/snapshot_count_integration.test.ts +70 -0
  85. package/test/src/snapshot_query_provider.test.ts +122 -0
  86. package/test/src/stream_progress.test.ts +334 -0
  87. package/test/tsconfig.json +2 -2
  88. package/tsconfig.tsbuildinfo +1 -1
  89. package/vitest.benchmark.config.ts +15 -0
  90. package/test/src/change_stream_utils.ts +0 -375
@@ -0,0 +1,688 @@
1
+ import { ChangeStreamInvalidatedError } from '@module/replication/ChangeStream.js';
2
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
3
+ import {
4
+ createMongoReplicationStreamBatch,
5
+ filterMongoReplicationStream,
6
+ openMongoReplicationStream,
7
+ parseMongoReplicationStream
8
+ } from '@module/replication/MongoReplicationStream.js';
9
+ import type {
10
+ MongoReplicationParserOptions,
11
+ MongoReplicationStreamBatch,
12
+ RawMongoReplicationStreamBatch
13
+ } from '@module/replication/MongoReplicationStreamTypes.js';
14
+ import { MongoReplicationFilterResult } from '@module/replication/MongoReplicationStreamTypes.js';
15
+ import {
16
+ mapChangeStreamError,
17
+ parseChangeDocument,
18
+ ProjectedChangeStreamDocument
19
+ } from '@module/replication/RawChangeStream.js';
20
+ import { mongo } from '@powersync/lib-service-mongodb';
21
+ import { DatabaseConnectionError } from '@powersync/lib-services-framework';
22
+ import { describe, expect, test, vi } from 'vitest';
23
+ import { env } from './env.js';
24
+
25
+ /**
26
+ * Exercise cursor construction and stream parsing without contacting MongoDB. Cursor tests intercept
27
+ * the driver's command method; reader tests supply BSON batches with explicit events and resume tokens.
28
+ */
29
+ describe('MongoDB replication stream reader', () => {
30
+ const read = (options: MongoReplicationParserOptions & { batches: AsyncIterable<RawMongoReplicationStreamBatch> }) =>
31
+ flattenBatches(parseMongoReplicationStream(options));
32
+ test.each(['pre', 'post'])('invalidates replication when a required %s-image is missing', (image) => {
33
+ const error = new mongo.MongoServerError({
34
+ code: 47,
35
+ codeName: 'NoMatchingDocument',
36
+ errmsg: `Change stream was configured to require a ${image}-image, but the ${image}-image was not found for event: example`
37
+ });
38
+
39
+ // Invalidated streams trigger a fresh snapshot instead of retrying an unrecoverable position.
40
+ expect(() => mapChangeStreamError(error)).toThrow(ChangeStreamInvalidatedError);
41
+ });
42
+
43
+ test('does not invalidate replication for unrelated NoMatchingDocument errors', () => {
44
+ const error = new mongo.MongoServerError({
45
+ code: 47,
46
+ codeName: 'NoMatchingDocument',
47
+ errmsg: 'No matching document found'
48
+ });
49
+
50
+ expect(() => mapChangeStreamError(error)).toThrow(DatabaseConnectionError);
51
+ });
52
+
53
+ test.each([
54
+ { isDocumentDb: false, multipleDatabases: false, usePostImages: false },
55
+ { isDocumentDb: false, multipleDatabases: false, usePostImages: true },
56
+ { isDocumentDb: false, multipleDatabases: true, usePostImages: true },
57
+ { isDocumentDb: true, multipleDatabases: false, usePostImages: true }
58
+ ])('preserves the base cursor options for %j', async ({ isDocumentDb, multipleDatabases, usePostImages }) => {
59
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL);
60
+ await using clientDisposer = { [Symbol.asyncDispose]: () => client.close() };
61
+ const db = client.db('app');
62
+ const resumeAfter = { _data: 'exact-position' };
63
+ // Observe the real cursor-opening path, stopping before any network operation. An exact token must
64
+ // take precedence even if the stored position also contains its transaction's shared timestamp.
65
+ const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
66
+ try {
67
+ await expect(
68
+ collectStream(
69
+ openMongoReplicationStream({
70
+ db,
71
+ queryProvider: DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
72
+ namespaceFilter: { $match: { 'ns.coll': 'documents' }, multipleDatabases },
73
+ isDocumentDb,
74
+ usePostImages,
75
+ position: { resumeAfter, startAfter: new mongo.Timestamp({ t: 10, i: 1 }) },
76
+ skipInitialTimestamp: true,
77
+ options: { batchSize: 50, maxAwaitTimeMS: 200, maxTimeMS: 1000 }
78
+ })
79
+ )
80
+ ).rejects.toThrow('observed command');
81
+ expect(command).toHaveBeenCalledOnce();
82
+ expect((command.mock.contexts[0] as mongo.Db).databaseName).toBe(
83
+ isDocumentDb || multipleDatabases ? 'admin' : 'app'
84
+ );
85
+ expect(command.mock.calls[0][0]).toEqual({
86
+ aggregate: 1,
87
+ pipeline: [
88
+ {
89
+ $changeStream: {
90
+ fullDocument: !isDocumentDb && usePostImages ? 'required' : 'updateLookup',
91
+ ...(!isDocumentDb ? { showExpandedEvents: true } : {}),
92
+ ...(isDocumentDb || multipleDatabases ? { allChangesForCluster: true } : {}),
93
+ resumeAfter
94
+ }
95
+ },
96
+ { $match: { 'ns.coll': 'documents' } },
97
+ ...(!isDocumentDb ? [{ $changeStreamSplitLargeEvent: {} }] : [])
98
+ ],
99
+ cursor: { batchSize: 1 },
100
+ maxTimeMS: 1000
101
+ });
102
+ } finally {
103
+ command.mockRestore();
104
+ }
105
+ });
106
+
107
+ test('opens adapter stages after namespace selection, with pre-images and the shared resume settings', async () => {
108
+ // A provider requests pre-images and adds a stage. The shared opener must still supply the source
109
+ // position and post-image settings, and run the provider stage before large events are split.
110
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL);
111
+ await using clientDisposer = { [Symbol.asyncDispose]: () => client.close() };
112
+ const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
113
+ const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
114
+ try {
115
+ await expect(
116
+ collectStream(
117
+ openMongoReplicationStream({
118
+ db: client.db('app'),
119
+ queryProvider: {
120
+ ...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
121
+ openChangeStream: ({ open }) =>
122
+ open({
123
+ pipelineStages: [{ $set: { marker: 'keep' } }],
124
+ imageOptions: { fullDocumentBeforeChange: 'whenAvailable' }
125
+ })
126
+ },
127
+ namespaceFilter: { $match: { 'ns.coll': 'documents' }, multipleDatabases: false },
128
+ isDocumentDb: false,
129
+ usePostImages: true,
130
+ position: { startAfter: timestamp },
131
+ options: { batchSize: 50, maxAwaitTimeMS: 200, maxTimeMS: 1000 }
132
+ })
133
+ )
134
+ ).rejects.toThrow('observed command');
135
+ expect(command.mock.calls[0][0].pipeline).toEqual([
136
+ {
137
+ $changeStream: {
138
+ fullDocumentBeforeChange: 'whenAvailable',
139
+ fullDocument: 'required',
140
+ showExpandedEvents: true,
141
+ startAtOperationTime: timestamp
142
+ }
143
+ },
144
+ { $match: { 'ns.coll': 'documents' } },
145
+ { $set: { marker: 'keep' } },
146
+ { $changeStreamSplitLargeEvent: {} }
147
+ ]);
148
+ } finally {
149
+ command.mockRestore();
150
+ }
151
+ });
152
+
153
+ test('keeps raw rows, buffered checkpoint information and idle progress', async () => {
154
+ // Two changes arrive together, followed by an empty response. Preserve raw BSON document bodies
155
+ // and indicate whether another change is buffered, while exposing a resume boundary for each batch.
156
+ const batches = [batch([event('first'), event('second')], 'data'), batch([], 'idle')];
157
+ const outputBatches = await collectBatches(
158
+ read({
159
+ batches: from(batches),
160
+ defaultSchema: 'app',
161
+ multipleDatabases: false
162
+ })
163
+ );
164
+ expect(outputBatches.map((items) => items.length)).toEqual([3, 1]);
165
+ const items = outputBatches.flat();
166
+ expect(items.map((item) => item.type)).toEqual(['change', 'change', 'progress', 'progress']);
167
+ expect(items[0]).toMatchObject({ type: 'change', hasBufferedChanges: true });
168
+ expect(items[1]).toMatchObject({ type: 'change', hasBufferedChanges: false });
169
+ if (items[0].type != 'change' || !('fullDocument' in items[0].event)) throw new Error('Expected a row');
170
+ expect(Buffer.isBuffer(items[0].event.fullDocument)).toBe(true);
171
+ expect(mongo.BSON.deserialize(items[0].event.fullDocument!)).toEqual({ _id: 'first', body: 'raw row' });
172
+ expect(items.slice(2)).toEqual([
173
+ { type: 'progress', resumeToken: { _data: 'data' }, filteredCount: null },
174
+ { type: 'progress', resumeToken: { _data: 'idle' }, filteredCount: null }
175
+ ]);
176
+ });
177
+
178
+ test('withholds progress across split batches, including an idle response between fragments', async () => {
179
+ // A large event spans two nonempty batches with an idle response between them. Saving either
180
+ // intermediate token would lose the in-memory first fragment if replication restarted there.
181
+ const original = event('large');
182
+ const first = { ...original, fullDocument: undefined, splitEvent: { fragment: 1, of: 2 } };
183
+ const last = {
184
+ _id: { _data: 'fragment-2' },
185
+ fullDocument: original.fullDocument,
186
+ splitEvent: { fragment: 2, of: 2 }
187
+ };
188
+ const outputBatches = await collectBatches(
189
+ read({
190
+ batches: from([
191
+ batch([event('before'), first], 'unsafe'),
192
+ batch([], 'still-unsafe'),
193
+ batch([last, event('after')], 'safe')
194
+ ]),
195
+ defaultSchema: 'app',
196
+ multipleDatabases: false
197
+ })
198
+ );
199
+ expect(outputBatches.map((items) => items.length)).toEqual([1, 0, 3]);
200
+ const items = outputBatches.flat();
201
+ // A consumer must never save either partial token, even though all three transport batches are counted.
202
+ expect(items.map((item) => item.type)).toEqual(['change', 'change', 'change', 'progress']);
203
+ expect(items[1]).toMatchObject({
204
+ type: 'change',
205
+ event: { documentKey: { _id: 'large' }, _id: { _data: 'fragment-2' } }
206
+ });
207
+ expect(items[3]).toEqual({ type: 'progress', resumeToken: { _data: 'safe' }, filteredCount: null });
208
+ });
209
+
210
+ test.each([
211
+ // The first fragment is missing.
212
+ [{ ...event('broken'), splitEvent: { fragment: 2, of: 2 } }],
213
+ // A fragment in the middle is missing.
214
+ [
215
+ { ...event('broken'), splitEvent: { fragment: 1, of: 3 } },
216
+ { _id: { _data: 'bad' }, splitEvent: { fragment: 3, of: 3 } }
217
+ ],
218
+ // An ordinary event interrupts reassembly.
219
+ [{ ...event('broken'), splitEvent: { fragment: 1, of: 2 } }, event('unrelated')],
220
+ // The source iterator ends before the final fragment arrives.
221
+ [{ ...event('broken'), splitEvent: { fragment: 1, of: 2 } }]
222
+ ])('fails closed on incomplete or out-of-order fragments %#', async (...events) => {
223
+ // Invalid fragment sequences must reject consumption and close the upstream iterator so a caller
224
+ // cannot mistake a partial document for a complete change or continue from its batch token.
225
+ const closed = vi.fn();
226
+ const stream = read({
227
+ batches: from([batch(events, 'unsafe')], closed),
228
+ defaultSchema: 'app',
229
+ multipleDatabases: false
230
+ });
231
+ await expect(collect(stream)).rejects.toThrow('splitEvent');
232
+ expect(closed).toHaveBeenCalledOnce();
233
+ });
234
+
235
+ test.each(['return', 'abort'] as const)(
236
+ 'closes the upstream iterator on %s without acknowledging unread work',
237
+ async (end) => {
238
+ // Stop after the first batch while another remains unread. Both consumer return
239
+ // and signal cancellation must close the source without delivering the next batch.
240
+ const closed = vi.fn();
241
+ const controller = new AbortController();
242
+ const stream = read({
243
+ batches: from([batch([event('first')], 'safe'), batch([event('unread')], 'unread')], closed),
244
+ defaultSchema: 'app',
245
+ multipleDatabases: false,
246
+ signal: controller.signal
247
+ });
248
+ expect([...(await stream.next()).value!]).toMatchObject([
249
+ { type: 'change' },
250
+ { type: 'progress', resumeToken: { _data: 'safe' } }
251
+ ]);
252
+ if (end == 'return') await stream.return(undefined);
253
+ else {
254
+ controller.abort(new Error('cancelled'));
255
+ await expect(stream.next()).rejects.toThrow('cancelled');
256
+ }
257
+ expect(closed).toHaveBeenCalledOnce();
258
+ }
259
+ );
260
+
261
+ test('normalizes Atlas Flex namespaces and deduplicates legacy positions before adapter evaluation', async () => {
262
+ // Legacy positions contain only a timestamp, so an event at that timestamp is already covered.
263
+ // The remaining event uses an Atlas Flex database prefix that must be removed before delivery.
264
+ const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
265
+ const items = await collect(
266
+ read({
267
+ batches: from([
268
+ batch(
269
+ [
270
+ { ...event('old'), clusterTime: timestamp },
271
+ { ...event('new'), ns: { db: 'prefix_app', coll: 'documents' } }
272
+ ],
273
+ 'safe'
274
+ )
275
+ ]),
276
+ defaultSchema: 'app',
277
+ multipleDatabases: false,
278
+ startAfter: timestamp
279
+ })
280
+ );
281
+ expect(items).toHaveLength(2);
282
+ expect(items[0]).toMatchObject({
283
+ type: 'change',
284
+ event: { ns: { db: 'app', coll: 'documents' }, documentKey: { _id: 'new' } }
285
+ });
286
+ });
287
+ });
288
+
289
+ describe('lazy batch consumption contract', () => {
290
+ test('keeps batch metadata independent of safe tokens and distinguishes unfiltered from zero', () => {
291
+ for (const filteredCount of [null, 0, 2]) {
292
+ const output = createMongoReplicationStreamBatch({
293
+ events: [],
294
+ resolveResumeToken: () => undefined,
295
+ resolveMetadata: () => ({ filteredCount })
296
+ });
297
+ expect(() => output.metadata).toThrow('Consume all batch events');
298
+ expect([...output.events]).toEqual([]);
299
+ expect(output.getResumeToken()).toBeUndefined();
300
+ expect(output.metadata.filteredCount).toBe(filteredCount);
301
+ expect(Object.isFrozen(output.metadata)).toBe(true);
302
+ }
303
+ const unfiltered = createMongoReplicationStreamBatch({ events: [], resolveResumeToken: () => undefined });
304
+ expect([...unfiltered.events]).toEqual([]);
305
+ expect(unfiltered.metadata.filteredCount).toBeNull();
306
+ });
307
+
308
+ test('requires exhaustion even after the last event, and caches progress once', () => {
309
+ const change = { event: parseChangeDocument(batch([event('one')], 'safe').events[0]), hasBufferedChanges: false };
310
+ let filteredCount = 2;
311
+ const progress = vi.fn(() => ({ _data: 'safe' }));
312
+ const output = createMongoReplicationStreamBatch({
313
+ events: [change],
314
+ resolveResumeToken: progress,
315
+ resolveMetadata: () => ({ filteredCount })
316
+ });
317
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
318
+ expect(() => output.metadata).toThrow('Consume all batch events');
319
+ const iterator = output.events[Symbol.iterator]();
320
+ expect(iterator.next().value).toBe(change);
321
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
322
+ expect(() => output.metadata).toThrow('Consume all batch events');
323
+ expect(progress).not.toHaveBeenCalled();
324
+ expect(iterator.next().done).toBe(true);
325
+ const saved = output.getResumeToken();
326
+ filteredCount = 99;
327
+ expect(output.getResumeToken()).toBe(saved);
328
+ expect(output.metadata.filteredCount).toBe(2);
329
+ expect(progress).toHaveBeenCalledOnce();
330
+ expect(() => [...output.events]).toThrow('only be consumed once');
331
+ });
332
+
333
+ test.each(['return', 'failure'] as const)('withholds progress after iteration ends by %s', (end) => {
334
+ const closed = vi.fn();
335
+ const progress = vi.fn(() => ({ _data: 'unsafe' }));
336
+ const output = createMongoReplicationStreamBatch({
337
+ events: (function* () {
338
+ try {
339
+ yield { event: parseChangeDocument(batch([event('one')], 'safe').events[0]), hasBufferedChanges: true };
340
+ throw new Error('filter failure');
341
+ } finally {
342
+ closed();
343
+ }
344
+ })(),
345
+ resolveResumeToken: progress
346
+ });
347
+ const iterator = output.events[Symbol.iterator]();
348
+ iterator.next();
349
+ if (end == 'return') iterator.return?.();
350
+ else expect(() => iterator.next()).toThrow('filter failure');
351
+ expect(closed).toHaveBeenCalledOnce();
352
+ expect(progress).not.toHaveBeenCalled();
353
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
354
+ expect(() => output.metadata).toThrow('Consume all batch events');
355
+ });
356
+
357
+ test('rejects advancing the stream past an unconsumed batch and closes its source', async () => {
358
+ const closed = vi.fn();
359
+ const stream = parseMongoReplicationStream({
360
+ batches: from([batch([event('one')], 'one'), batch([event('two')], 'two')], closed),
361
+ defaultSchema: 'app',
362
+ multipleDatabases: false
363
+ });
364
+ await stream.next();
365
+ await expect(stream.next()).rejects.toThrow('Consume all batch events');
366
+ expect(closed).toHaveBeenCalledOnce();
367
+ });
368
+
369
+ test('an earlier batch keeps its progress result after later fragments complete', async () => {
370
+ const original = event('large');
371
+ const stream = parseMongoReplicationStream({
372
+ batches: from([
373
+ batch([{ ...original, fullDocument: undefined, splitEvent: { fragment: 1, of: 2 } }], 'unsafe'),
374
+ batch(
375
+ [{ _id: { _data: 'last' }, fullDocument: original.fullDocument, splitEvent: { fragment: 2, of: 2 } }],
376
+ 'safe'
377
+ )
378
+ ]),
379
+ defaultSchema: 'app',
380
+ multipleDatabases: false
381
+ });
382
+ const first = (await stream.next()).value!;
383
+ expect([...first.events]).toEqual([]);
384
+ expect(first.getResumeToken()).toBeUndefined();
385
+ const second = (await stream.next()).value!;
386
+ expect([...second.events]).toHaveLength(1);
387
+ expect(second.getResumeToken()).toEqual({ _data: 'safe' });
388
+ expect(first.getResumeToken()).toBeUndefined();
389
+ expect((await stream.next()).done).toBe(true);
390
+ });
391
+
392
+ test('filtering parses on demand and preserves buffered work when trailing events are excluded', async () => {
393
+ const source = batch([event('checkpoint'), event('excluded')], 'safe');
394
+ const stream = parseMongoReplicationStream({
395
+ batches: from([source]),
396
+ defaultSchema: 'app',
397
+ multipleDatabases: false
398
+ });
399
+ const input = (await stream.next()).value!;
400
+ const inspected = vi.fn();
401
+ let filtered = 0;
402
+ const output = createMongoReplicationStreamBatch({
403
+ events: (function* () {
404
+ for (const change of input.events) {
405
+ inspected(change.event);
406
+ if ('documentKey' in change.event && change.event.documentKey._id == 'excluded') {
407
+ filtered++;
408
+ continue;
409
+ }
410
+ yield change;
411
+ }
412
+ })(),
413
+ resolveResumeToken: () => input.getResumeToken(),
414
+ resolveMetadata: () => ({ filteredCount: filtered })
415
+ });
416
+ expect(inspected).not.toHaveBeenCalled();
417
+ const iterator = output.events[Symbol.iterator]();
418
+ expect(iterator.next().value).toMatchObject({ hasBufferedChanges: true });
419
+ expect(inspected).toHaveBeenCalledTimes(1);
420
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
421
+ expect(() => output.metadata).toThrow('Consume all batch events');
422
+ expect(iterator.next().done).toBe(true);
423
+ expect(inspected).toHaveBeenCalledTimes(2);
424
+ expect(output.getResumeToken()).toEqual({ _data: 'safe' });
425
+ expect(output.metadata.filteredCount).toBe(1);
426
+ expect((await stream.next()).done).toBe(true);
427
+ });
428
+
429
+ test('does not finalize again after a failure while resolving the token', () => {
430
+ const resolve = vi.fn(() => {
431
+ throw new Error('resolve failure');
432
+ });
433
+ const output = createMongoReplicationStreamBatch({ events: [], resolveResumeToken: resolve });
434
+ const iterator = output.events[Symbol.iterator]();
435
+ expect(() => iterator.next()).toThrow('resolve failure');
436
+ expect(iterator.next().done).toBe(true);
437
+ expect(resolve).toHaveBeenCalledOnce();
438
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
439
+ });
440
+
441
+ test('ends parsed event iteration on early return without exposing progress', async () => {
442
+ const stream = parseMongoReplicationStream({
443
+ batches: from([batch([event('one'), event('two')], 'safe')]),
444
+ defaultSchema: 'app',
445
+ multipleDatabases: false
446
+ });
447
+ const output = (await stream.next()).value!;
448
+ const iterator = output.events[Symbol.iterator]();
449
+ expect(iterator.next().value).toMatchObject({ event: { documentKey: { _id: 'one' } } });
450
+ expect(iterator.return?.(7)).toEqual({ done: true, value: 7 });
451
+ expect(iterator.next().done).toBe(true);
452
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
453
+ await stream.return(undefined);
454
+ });
455
+
456
+ test('a parse failure ends iteration without parsing later events or exposing progress', async () => {
457
+ const stream = parseMongoReplicationStream({
458
+ batches: from([batch([{ ...event('bad'), splitEvent: { fragment: 2, of: 2 } }, event('after')], 'safe')]),
459
+ defaultSchema: 'app',
460
+ multipleDatabases: false
461
+ });
462
+ const output = (await stream.next()).value!;
463
+ const iterator = output.events[Symbol.iterator]();
464
+ expect(() => iterator.next()).toThrow('Unexpected splitEvent');
465
+ expect(iterator.next().done).toBe(true);
466
+ expect(() => output.getResumeToken()).toThrow('Consume all batch events');
467
+ await stream.return(undefined);
468
+ });
469
+
470
+ test('rejects advancing past an empty batch whose events were never iterated', async () => {
471
+ const closed = vi.fn();
472
+ const stream = parseMongoReplicationStream({
473
+ batches: from([batch([], 'one'), batch([], 'two')], closed),
474
+ defaultSchema: 'app',
475
+ multipleDatabases: false
476
+ });
477
+ await stream.next();
478
+ await expect(stream.next()).rejects.toThrow('Consume all batch events');
479
+ expect(closed).toHaveBeenCalledOnce();
480
+ });
481
+
482
+ test('an abort after the last event does not interrupt finalization, leaving the stop to the consumer', async () => {
483
+ const controller = new AbortController();
484
+ const stream = parseMongoReplicationStream({
485
+ batches: from([batch([event('one')], 'safe'), batch([event('two')], 'later')]),
486
+ defaultSchema: 'app',
487
+ multipleDatabases: false,
488
+ signal: controller.signal
489
+ });
490
+ const output = (await stream.next()).value!;
491
+ const iterator = output.events[Symbol.iterator]();
492
+ expect(iterator.next().done).toBe(false);
493
+ controller.abort(new Error('stopped'));
494
+ // Consumers check the signal themselves before persisting this token.
495
+ expect(iterator.next().done).toBe(true);
496
+ expect(output.getResumeToken()).toEqual({ _data: 'safe' });
497
+ // The next batch pull is where the abort surfaces.
498
+ await expect(stream.next()).rejects.toThrow('stopped');
499
+ });
500
+ });
501
+
502
+ describe('shared replication filter', () => {
503
+ test('filters lazily, preserves control events and creates deletes with source metadata', async () => {
504
+ const checkpoint = { ...event('checkpoint'), ns: { db: 'app', coll: '_powersync_checkpoints' } };
505
+ const drop = { _id: { _data: 'drop' }, operationType: 'drop', ns: { db: 'app', coll: 'documents' } };
506
+ const invalidate = { _id: { _data: 'invalidate' }, operationType: 'invalidate' };
507
+ const leaving: mongo.Document = {
508
+ ...event('leaving'),
509
+ txnNumber: 7n,
510
+ clusterTime: new mongo.Timestamp({ t: 10, i: 1 })
511
+ };
512
+ const filter = vi.fn((change: { documentKey: mongo.Document }) => {
513
+ return change.documentKey._id == 'leaving'
514
+ ? MongoReplicationFilterResult.DELETE
515
+ : MongoReplicationFilterResult.EXCLUDE;
516
+ });
517
+ const stream = filterMongoReplicationStream({
518
+ batches: parseMongoReplicationStream({
519
+ batches: from([batch([checkpoint, drop, invalidate, leaving, event('excluded')], 'safe')]),
520
+ defaultSchema: 'app',
521
+ multipleDatabases: false
522
+ }),
523
+ filter
524
+ });
525
+ const output = (await stream.next()).value!;
526
+ expect(filter).not.toHaveBeenCalled();
527
+ const changes = [...output.events];
528
+ expect(changes.map(({ event }) => event.operationType)).toEqual(['insert', 'drop', 'invalidate', 'delete']);
529
+ expect(filter).toHaveBeenCalledTimes(2);
530
+ expect(changes[3]).toMatchObject({
531
+ hasBufferedChanges: true,
532
+ event: {
533
+ _id: leaving._id,
534
+ documentKey: leaving.documentKey,
535
+ ns: leaving.ns,
536
+ txnNumber: 7n,
537
+ clusterTime: leaving.clusterTime
538
+ }
539
+ });
540
+ expect(output.metadata.filteredCount).toBe(1);
541
+ expect(output.getResumeToken()).toEqual({ _data: 'safe' });
542
+ expect((await stream.next()).done).toBe(true);
543
+ });
544
+
545
+ test('preserves split-event progress and counts fully filtered batches through composed filters', async () => {
546
+ const large = event('large');
547
+ const source = parseMongoReplicationStream({
548
+ batches: from([
549
+ batch([event('excluded'), { ...large, fullDocument: undefined, splitEvent: { fragment: 1, of: 2 } }], 'unsafe'),
550
+ batch(
551
+ [{ _id: { _data: 'last' }, fullDocument: large.fullDocument, splitEvent: { fragment: 2, of: 2 } }],
552
+ 'safe'
553
+ ),
554
+ batch([], 'idle')
555
+ ]),
556
+ defaultSchema: 'app',
557
+ multipleDatabases: false
558
+ });
559
+ const stream = filterMongoReplicationStream({
560
+ batches: filterMongoReplicationStream({ batches: source, filter: () => MongoReplicationFilterResult.EXCLUDE }),
561
+ filter: () => MongoReplicationFilterResult.KEEP
562
+ });
563
+ const first = (await stream.next()).value!;
564
+ expect([...first.events]).toEqual([]);
565
+ expect(first.metadata.filteredCount).toBe(1);
566
+ expect(first.getResumeToken()).toBeUndefined();
567
+ const second = (await stream.next()).value!;
568
+ expect([...second.events]).toEqual([]);
569
+ expect(second.metadata.filteredCount).toBe(1);
570
+ expect(second.getResumeToken()).toEqual({ _data: 'safe' });
571
+ expect(first.getResumeToken()).toBeUndefined();
572
+ const idle = (await stream.next()).value!;
573
+ expect([...idle.events]).toEqual([]);
574
+ expect(idle.metadata.filteredCount).toBe(0);
575
+ expect(idle.getResumeToken()).toEqual({ _data: 'idle' });
576
+ expect((await stream.next()).done).toBe(true);
577
+ });
578
+
579
+ test.each(['failure', 'return', 'unconsumed'] as const)('closes the source without progress on %s', async (end) => {
580
+ const closed = vi.fn();
581
+ const stream = filterMongoReplicationStream({
582
+ batches: parseMongoReplicationStream({
583
+ batches: from([batch([event('first'), event('second')], 'unsafe')], closed),
584
+ defaultSchema: 'app',
585
+ multipleDatabases: false
586
+ }),
587
+ filter: () => {
588
+ if (end == 'failure') {
589
+ throw new Error('filter failed');
590
+ }
591
+ return MongoReplicationFilterResult.KEEP;
592
+ }
593
+ });
594
+ let output: MongoReplicationStreamBatch | undefined;
595
+ if (end == 'unconsumed') {
596
+ output = (await stream.next()).value!;
597
+ await expect(stream.next()).rejects.toThrow('Consume all batch events');
598
+ } else {
599
+ const consume = async () => {
600
+ for await (const batch of stream) {
601
+ output = batch;
602
+ for (const _change of batch.events) {
603
+ return;
604
+ }
605
+ }
606
+ };
607
+ if (end == 'failure') {
608
+ await expect(consume()).rejects.toThrow('filter failed');
609
+ } else {
610
+ await consume();
611
+ }
612
+ }
613
+ expect(closed).toHaveBeenCalledOnce();
614
+ expect(() => output!.getResumeToken()).toThrow('Consume all batch events');
615
+ });
616
+ });
617
+
618
+ /**
619
+ * Minimal insert event for the app.documents fixture namespace; callers override fields for each scenario.
620
+ */
621
+ function event(id: string): mongo.Document {
622
+ return {
623
+ _id: { _data: id },
624
+ operationType: 'insert',
625
+ ns: { db: 'app', coll: 'documents' },
626
+ documentKey: { _id: id },
627
+ fullDocument: { _id: id, body: 'raw row' }
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Encode events as raw BSON, matching the reader's transport input. Tokens are opaque test labels.
633
+ */
634
+ function batch(events: mongo.Document[], token: string): RawMongoReplicationStreamBatch {
635
+ return {
636
+ events: events.map((event) => Buffer.from(mongo.BSON.serialize(event))),
637
+ resumeToken: { _data: token }
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Supply deterministic source batches and expose iterator cleanup to cancellation and failure tests.
643
+ */
644
+ async function* from<T>(values: T[], closed?: () => void) {
645
+ try {
646
+ yield* values;
647
+ } finally {
648
+ closed?.();
649
+ }
650
+ }
651
+
652
+ async function collect<T>(stream: AsyncIterable<Iterable<T>>): Promise<T[]> {
653
+ const items: T[] = [];
654
+ for await (const batch of stream) items.push(...batch);
655
+ return items;
656
+ }
657
+
658
+ async function collectBatches<T>(stream: AsyncIterable<Iterable<T>>): Promise<T[][]> {
659
+ const batches: T[][] = [];
660
+ for await (const batch of stream) batches.push([...batch]);
661
+ return batches;
662
+ }
663
+
664
+ /**
665
+ * Flatten parsed batches only in tests to assert ordered events and progress across both paths.
666
+ */
667
+ type MongoReplicationStreamItem =
668
+ | { type: 'change'; event: ProjectedChangeStreamDocument; hasBufferedChanges: boolean }
669
+ | { type: 'progress'; resumeToken: mongo.ResumeToken; filteredCount: number | null };
670
+
671
+ async function* flattenBatches(
672
+ batches: AsyncIterable<MongoReplicationStreamBatch>
673
+ ): AsyncGenerator<Iterable<MongoReplicationStreamItem>> {
674
+ for await (const batch of batches) yield items(batch);
675
+ function* items(batch: MongoReplicationStreamBatch): Generator<MongoReplicationStreamItem> {
676
+ for (const change of batch.events) yield { type: 'change', ...change };
677
+ const progress = batch.getResumeToken();
678
+ if (progress != null) {
679
+ yield { type: 'progress', resumeToken: progress, filteredCount: batch.metadata.filteredCount };
680
+ }
681
+ }
682
+ }
683
+
684
+ async function collectStream<T>(stream: AsyncIterable<T>): Promise<T[]> {
685
+ const batches: T[] = [];
686
+ for await (const batch of stream) batches.push(batch);
687
+ return batches;
688
+ }