@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.
- package/CHANGELOG.md +14 -11
- package/benchmarks/env.ts +11 -0
- package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +5 -0
- package/dist/api/MongoRouteAPIAdapter.js +11 -4
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/module/MongoModule.js +1 -0
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +2 -1
- package/dist/replication/ChangeStream.js +164 -150
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +17 -9
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +9 -11
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -1
- package/dist/replication/MongoReplicationStream.d.ts +37 -48
- package/dist/replication/MongoReplicationStream.js +157 -30
- package/dist/replication/MongoReplicationStream.js.map +1 -1
- package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
- package/dist/replication/MongoReplicationStreamTypes.js +19 -0
- package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
- package/dist/replication/MongoSnapshotter.js +59 -30
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
- package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
- package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
- package/dist/replication/replication-index.d.ts +1 -0
- package/dist/replication/replication-index.js +1 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/package.json +11 -10
- package/src/api/MongoRouteAPIAdapter.ts +12 -5
- package/src/module/MongoModule.ts +1 -0
- package/src/replication/ChangeStream.ts +183 -173
- package/src/replication/ChangeStreamReplicationJob.ts +16 -9
- package/src/replication/MongoReplicationQueryProvider.ts +9 -11
- package/src/replication/MongoReplicationStream.ts +196 -83
- package/src/replication/MongoReplicationStreamTypes.ts +169 -0
- package/src/replication/MongoSnapshotter.ts +64 -33
- package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
- package/src/replication/replication-index.ts +1 -0
- package/test/src/change_event_memory.test.ts +33 -0
- package/test/src/change_stream_abort.test.ts +110 -0
- package/test/src/filtering_example.test.ts +65 -0
- package/test/src/filtering_lifecycle.test.ts +101 -0
- package/test/src/query_provider_availability.test.ts +1 -0
- package/test/src/query_provider_diagnostics.test.ts +55 -0
- package/test/src/replication_stream.test.ts +411 -34
- package/test/src/snapshot_count.test.ts +146 -0
- package/test/src/snapshot_count_integration.test.ts +70 -0
- package/test/src/snapshot_query_provider.test.ts +21 -2
- package/test/src/stream_progress.test.ts +62 -44
- package/test/tsconfig.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- 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 {
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
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:
|
|
157
|
-
{ type: 'progress', resumeToken: { _data: 'idle' }, filteredCount:
|
|
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
|
|
173
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
224
|
-
// and signal cancellation must
|
|
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 =
|
|
228
|
-
batches: from([batch([event('first'), event('unread')], '
|
|
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(
|
|
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
|
-
|
|
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):
|
|
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
|
|
652
|
+
async function collect<T>(stream: AsyncIterable<Iterable<T>>): Promise<T[]> {
|
|
308
653
|
const items: T[] = [];
|
|
309
|
-
for await (const
|
|
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
|
+
}
|