@powersync/service-module-mongodb 0.0.0-dev-20261007110800 → 0.0.0-dev-20261015132441

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 (54) hide show
  1. package/CHANGELOG.md +14 -11
  2. package/benchmarks/env.ts +11 -0
  3. package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
  4. package/dist/api/MongoRouteAPIAdapter.d.ts +5 -0
  5. package/dist/api/MongoRouteAPIAdapter.js +11 -4
  6. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  7. package/dist/module/MongoModule.js +1 -0
  8. package/dist/module/MongoModule.js.map +1 -1
  9. package/dist/replication/ChangeStream.d.ts +2 -1
  10. package/dist/replication/ChangeStream.js +164 -150
  11. package/dist/replication/ChangeStream.js.map +1 -1
  12. package/dist/replication/ChangeStreamReplicationJob.js +17 -9
  13. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  14. package/dist/replication/MongoReplicationQueryProvider.d.ts +9 -11
  15. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -1
  16. package/dist/replication/MongoReplicationStream.d.ts +37 -48
  17. package/dist/replication/MongoReplicationStream.js +157 -30
  18. package/dist/replication/MongoReplicationStream.js.map +1 -1
  19. package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
  20. package/dist/replication/MongoReplicationStreamTypes.js +19 -0
  21. package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
  22. package/dist/replication/MongoSnapshotter.js +59 -30
  23. package/dist/replication/MongoSnapshotter.js.map +1 -1
  24. package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
  25. package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
  26. package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
  27. package/dist/replication/replication-index.d.ts +1 -0
  28. package/dist/replication/replication-index.js +1 -0
  29. package/dist/replication/replication-index.js.map +1 -1
  30. package/package.json +11 -10
  31. package/src/api/MongoRouteAPIAdapter.ts +12 -5
  32. package/src/module/MongoModule.ts +1 -0
  33. package/src/replication/ChangeStream.ts +183 -173
  34. package/src/replication/ChangeStreamReplicationJob.ts +16 -9
  35. package/src/replication/MongoReplicationQueryProvider.ts +9 -11
  36. package/src/replication/MongoReplicationStream.ts +196 -83
  37. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  38. package/src/replication/MongoSnapshotter.ts +64 -33
  39. package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
  40. package/src/replication/replication-index.ts +1 -0
  41. package/test/src/change_event_memory.test.ts +33 -0
  42. package/test/src/change_stream_abort.test.ts +110 -0
  43. package/test/src/filtering_example.test.ts +65 -0
  44. package/test/src/filtering_lifecycle.test.ts +101 -0
  45. package/test/src/query_provider_availability.test.ts +1 -0
  46. package/test/src/query_provider_diagnostics.test.ts +55 -0
  47. package/test/src/replication_stream.test.ts +411 -34
  48. package/test/src/snapshot_count.test.ts +146 -0
  49. package/test/src/snapshot_count_integration.test.ts +70 -0
  50. package/test/src/snapshot_query_provider.test.ts +21 -2
  51. package/test/src/stream_progress.test.ts +62 -44
  52. package/test/tsconfig.json +2 -2
  53. package/tsconfig.tsbuildinfo +1 -1
  54. package/vitest.benchmark.config.ts +15 -0
@@ -1,17 +1,34 @@
1
1
  import { ChangeStreamInvalidatedError } from '@module/replication/ChangeStream.js';
2
2
  import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
3
- import { openMongoReplicationStream, readMongoReplicationStream } from '@module/replication/MongoReplicationStream.js';
4
- import { ChangeStreamBatch, mapChangeStreamError } from '@module/replication/RawChangeStream.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';
5
20
  import { mongo } from '@powersync/lib-service-mongodb';
6
21
  import { DatabaseConnectionError } from '@powersync/lib-services-framework';
7
22
  import { describe, expect, test, vi } from 'vitest';
8
23
  import { env } from './env.js';
9
24
 
10
25
  /**
11
- * Exercise cursor construction and stream decoding without contacting MongoDB. Cursor tests intercept
26
+ * Exercise cursor construction and stream parsing without contacting MongoDB. Cursor tests intercept
12
27
  * the driver's command method; reader tests supply BSON batches with explicit events and resume tokens.
13
28
  */
14
29
  describe('MongoDB replication stream reader', () => {
30
+ const read = (options: MongoReplicationParserOptions & { batches: AsyncIterable<RawMongoReplicationStreamBatch> }) =>
31
+ flattenBatches(parseMongoReplicationStream(options));
15
32
  test.each(['pre', 'post'])('invalidates replication when a required %s-image is missing', (image) => {
16
33
  const error = new mongo.MongoServerError({
17
34
  code: 47,
@@ -48,7 +65,7 @@ describe('MongoDB replication stream reader', () => {
48
65
  const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
49
66
  try {
50
67
  await expect(
51
- collect(
68
+ collectStream(
52
69
  openMongoReplicationStream({
53
70
  db,
54
71
  queryProvider: DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
@@ -96,7 +113,7 @@ describe('MongoDB replication stream reader', () => {
96
113
  const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
97
114
  try {
98
115
  await expect(
99
- collect(
116
+ collectStream(
100
117
  openMongoReplicationStream({
101
118
  db: client.db('app'),
102
119
  queryProvider: {
@@ -136,16 +153,16 @@ describe('MongoDB replication stream reader', () => {
136
153
  test('keeps raw rows, buffered checkpoint information and idle progress', async () => {
137
154
  // Two changes arrive together, followed by an empty response. Preserve raw BSON document bodies
138
155
  // and indicate whether another change is buffered, while exposing a resume boundary for each batch.
139
- const onBatch = vi.fn();
140
156
  const batches = [batch([event('first'), event('second')], 'data'), batch([], 'idle')];
141
- const items = await collect(
142
- readMongoReplicationStream({
157
+ const outputBatches = await collectBatches(
158
+ read({
143
159
  batches: from(batches),
144
160
  defaultSchema: 'app',
145
- multipleDatabases: false,
146
- onBatch
161
+ multipleDatabases: false
147
162
  })
148
163
  );
164
+ expect(outputBatches.map((items) => items.length)).toEqual([3, 1]);
165
+ const items = outputBatches.flat();
149
166
  expect(items.map((item) => item.type)).toEqual(['change', 'change', 'progress', 'progress']);
150
167
  expect(items[0]).toMatchObject({ type: 'change', hasBufferedChanges: true });
151
168
  expect(items[1]).toMatchObject({ type: 'change', hasBufferedChanges: false });
@@ -153,10 +170,9 @@ describe('MongoDB replication stream reader', () => {
153
170
  expect(Buffer.isBuffer(items[0].event.fullDocument)).toBe(true);
154
171
  expect(mongo.BSON.deserialize(items[0].event.fullDocument!)).toEqual({ _id: 'first', body: 'raw row' });
155
172
  expect(items.slice(2)).toEqual([
156
- { type: 'progress', resumeToken: { _data: 'data' }, filteredCount: 0 },
157
- { type: 'progress', resumeToken: { _data: 'idle' }, filteredCount: 0 }
173
+ { type: 'progress', resumeToken: { _data: 'data' }, filteredCount: null },
174
+ { type: 'progress', resumeToken: { _data: 'idle' }, filteredCount: null }
158
175
  ]);
159
- expect(onBatch.mock.calls.map(([value]) => value)).toEqual(batches);
160
176
  });
161
177
 
162
178
  test('withholds progress across split batches, including an idle response between fragments', async () => {
@@ -169,27 +185,26 @@ describe('MongoDB replication stream reader', () => {
169
185
  fullDocument: original.fullDocument,
170
186
  splitEvent: { fragment: 2, of: 2 }
171
187
  };
172
- const onBatch = vi.fn();
173
- const items = await collect(
174
- readMongoReplicationStream({
188
+ const outputBatches = await collectBatches(
189
+ read({
175
190
  batches: from([
176
191
  batch([event('before'), first], 'unsafe'),
177
192
  batch([], 'still-unsafe'),
178
193
  batch([last, event('after')], 'safe')
179
194
  ]),
180
195
  defaultSchema: 'app',
181
- multipleDatabases: false,
182
- onBatch
196
+ multipleDatabases: false
183
197
  })
184
198
  );
199
+ expect(outputBatches.map((items) => items.length)).toEqual([1, 0, 3]);
200
+ const items = outputBatches.flat();
185
201
  // A consumer must never save either partial token, even though all three transport batches are counted.
186
202
  expect(items.map((item) => item.type)).toEqual(['change', 'change', 'change', 'progress']);
187
203
  expect(items[1]).toMatchObject({
188
204
  type: 'change',
189
205
  event: { documentKey: { _id: 'large' }, _id: { _data: 'fragment-2' } }
190
206
  });
191
- expect(items[3]).toEqual({ type: 'progress', resumeToken: { _data: 'safe' }, filteredCount: 0 });
192
- expect(onBatch).toHaveBeenCalledTimes(3);
207
+ expect(items[3]).toEqual({ type: 'progress', resumeToken: { _data: 'safe' }, filteredCount: null });
193
208
  });
194
209
 
195
210
  test.each([
@@ -208,7 +223,7 @@ describe('MongoDB replication stream reader', () => {
208
223
  // Invalid fragment sequences must reject consumption and close the upstream iterator so a caller
209
224
  // cannot mistake a partial document for a complete change or continue from its batch token.
210
225
  const closed = vi.fn();
211
- const stream = readMongoReplicationStream({
226
+ const stream = read({
212
227
  batches: from([batch(events, 'unsafe')], closed),
213
228
  defaultSchema: 'app',
214
229
  multipleDatabases: false
@@ -220,17 +235,20 @@ describe('MongoDB replication stream reader', () => {
220
235
  test.each(['return', 'abort'] as const)(
221
236
  'closes the upstream iterator on %s without acknowledging unread work',
222
237
  async (end) => {
223
- // Stop after the first change while another remains in the same batch. Both consumer return
224
- // and signal cancellation must run upstream cleanup before the batch's progress is yielded.
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.
225
240
  const closed = vi.fn();
226
241
  const controller = new AbortController();
227
- const stream = readMongoReplicationStream({
228
- batches: from([batch([event('first'), event('unread')], 'unsafe')], closed),
242
+ const stream = read({
243
+ batches: from([batch([event('first')], 'safe'), batch([event('unread')], 'unread')], closed),
229
244
  defaultSchema: 'app',
230
245
  multipleDatabases: false,
231
246
  signal: controller.signal
232
247
  });
233
- expect((await stream.next()).value).toMatchObject({ type: 'change' });
248
+ expect([...(await stream.next()).value!]).toMatchObject([
249
+ { type: 'change' },
250
+ { type: 'progress', resumeToken: { _data: 'safe' } }
251
+ ]);
234
252
  if (end == 'return') await stream.return(undefined);
235
253
  else {
236
254
  controller.abort(new Error('cancelled'));
@@ -245,7 +263,7 @@ describe('MongoDB replication stream reader', () => {
245
263
  // The remaining event uses an Atlas Flex database prefix that must be removed before delivery.
246
264
  const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
247
265
  const items = await collect(
248
- readMongoReplicationStream({
266
+ read({
249
267
  batches: from([
250
268
  batch(
251
269
  [
@@ -268,6 +286,335 @@ describe('MongoDB replication stream reader', () => {
268
286
  });
269
287
  });
270
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
+
271
618
  /**
272
619
  * Minimal insert event for the app.documents fixture namespace; callers override fields for each scenario.
273
620
  */
@@ -282,14 +629,12 @@ function event(id: string): mongo.Document {
282
629
  }
283
630
 
284
631
  /**
285
- * Encode events as raw BSON, matching the reader's transport input. Tokens are opaque test labels;
286
- * the fixed byte count supports batch accounting assertions without depending on BSON payload size.
632
+ * Encode events as raw BSON, matching the reader's transport input. Tokens are opaque test labels.
287
633
  */
288
- function batch(events: mongo.Document[], token: string): ChangeStreamBatch {
634
+ function batch(events: mongo.Document[], token: string): RawMongoReplicationStreamBatch {
289
635
  return {
290
636
  events: events.map((event) => Buffer.from(mongo.BSON.serialize(event))),
291
- resumeToken: { _data: token },
292
- byteSize: 1234
637
+ resumeToken: { _data: token }
293
638
  };
294
639
  }
295
640
 
@@ -304,8 +649,40 @@ async function* from<T>(values: T[], closed?: () => void) {
304
649
  }
305
650
  }
306
651
 
307
- async function collect<T>(stream: AsyncIterable<T>): Promise<T[]> {
652
+ async function collect<T>(stream: AsyncIterable<Iterable<T>>): Promise<T[]> {
308
653
  const items: T[] = [];
309
- for await (const item of stream) items.push(item);
654
+ for await (const batch of stream) items.push(...batch);
310
655
  return items;
311
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
+ }