@powersync/service-module-mongodb 0.0.0-dev-20261005120023 → 0.0.0-dev-20261015071128

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 (50) hide show
  1. package/CHANGELOG.md +38 -7
  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/module/MongoModule.js +1 -0
  8. package/dist/module/MongoModule.js.map +1 -1
  9. package/dist/replication/ChangeStream.d.ts +3 -1
  10. package/dist/replication/ChangeStream.js +173 -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 +45 -25
  23. package/dist/replication/MongoSnapshotter.js.map +1 -1
  24. package/dist/replication/replication-index.d.ts +1 -0
  25. package/dist/replication/replication-index.js +1 -0
  26. package/dist/replication/replication-index.js.map +1 -1
  27. package/dist/test-utils/ChangeStreamTestContext.js +2 -3
  28. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -1
  29. package/package.json +11 -10
  30. package/src/api/MongoRouteAPIAdapter.ts +26 -2
  31. package/src/module/MongoModule.ts +1 -0
  32. package/src/replication/ChangeStream.ts +197 -174
  33. package/src/replication/ChangeStreamReplicationJob.ts +16 -9
  34. package/src/replication/MongoReplicationQueryProvider.ts +9 -11
  35. package/src/replication/MongoReplicationStream.ts +196 -83
  36. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  37. package/src/replication/MongoSnapshotter.ts +49 -28
  38. package/src/replication/replication-index.ts +1 -0
  39. package/src/test-utils/ChangeStreamTestContext.ts +2 -2
  40. package/test/src/change_event_memory.test.ts +33 -0
  41. package/test/src/change_stream_abort.test.ts +110 -0
  42. package/test/src/filtering_example.test.ts +65 -0
  43. package/test/src/filtering_lifecycle.test.ts +101 -0
  44. package/test/src/query_provider_availability.test.ts +77 -0
  45. package/test/src/query_provider_diagnostics.test.ts +55 -0
  46. package/test/src/replication_stream.test.ts +411 -34
  47. package/test/src/stream_progress.test.ts +62 -44
  48. package/test/tsconfig.json +2 -2
  49. package/tsconfig.tsbuildinfo +1 -1
  50. package/vitest.benchmark.config.ts +15 -0
@@ -1,6 +1,16 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { Logger, ReplicationAssertionError } from '@powersync/lib-services-framework';
2
+ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
3
  import { buildMongoChangeStreamPipeline, MongoReplicationQueryProvider } from './MongoReplicationQueryProvider.js';
4
+ import {
5
+ MongoReplicationFilterOptions,
6
+ MongoReplicationFilterResult,
7
+ MongoReplicationParserOptions,
8
+ MongoReplicationStreamBatch,
9
+ MongoReplicationStreamBatchMetadata,
10
+ MongoReplicationStreamChange,
11
+ MongoReplicationStreamOptions,
12
+ RawMongoReplicationStreamBatch
13
+ } from './MongoReplicationStreamTypes.js';
4
14
  import {
5
15
  ChangeStreamBatch,
6
16
  parseChangeDocument,
@@ -8,53 +18,116 @@ import {
8
18
  rawChangeStream,
9
19
  RawChangeStreamOptions
10
20
  } from './RawChangeStream.js';
21
+ import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
11
22
 
12
23
  /**
13
- * Ordered adapter output. Progress advances source recovery, not the client-visible checkpoint.
24
+ * Wraps changes with a single-use iterator. Caches the token and metadata after successful exhaustion.
14
25
  */
15
- export type MongoReplicationStreamItem =
16
- | {
17
- type: 'change';
18
- event: ProjectedChangeStreamDocument;
19
- /**
20
- * More envelopes remain in the received batch, including ones an adapter may exclude.
21
- */
22
- hasBufferedChanges: boolean;
23
- }
24
- | {
25
- type: 'progress';
26
- /**
27
- * Covers only complete events already delivered or intentionally excluded by the adapter.
28
- */
29
- resumeToken: mongo.ResumeToken;
30
- /**
31
- * Excluded complete events since the previous progress item; reset after every boundary.
32
- */
33
- filteredCount: number;
34
- };
35
-
36
- export interface MongoReplicationStreamOptions {
26
+ export function createMongoReplicationStreamBatch({
27
+ events,
28
+ resolveResumeToken,
29
+ resolveMetadata = () => ({ filteredCount: null })
30
+ }: {
37
31
  /**
38
- * Inserted after namespace selection and before the final large-event split stage.
32
+ * Complete changes, optionally produced by a lazy parsing or filtering generator.
39
33
  */
40
- pipelineStages?: mongo.Document[];
34
+ events: Iterable<MongoReplicationStreamChange>;
41
35
  /**
42
- * Request pre-images without overriding the reader's post-image, resume or scope options.
36
+ * Compute a safe resume token once after successful exhaustion, using the final parser state.
43
37
  */
44
- imageOptions?: Pick<mongo.ChangeStreamOptions, 'fullDocumentBeforeChange'>;
45
- }
46
-
47
- export interface MongoReplicationStreamContext {
38
+ resolveResumeToken: () => mongo.ResumeToken | undefined;
48
39
  /**
49
- * Open the source reader with the current position, namespace, deadlines and cancellation already bound.
50
- * The reader returns parsed, reassembled changes followed by safe progress boundaries. A provider can
51
- * add stages here and wrap the result to discard marked events or turn filter exits into deletes.
52
- * Supplying this callback keeps cursor setup shared with snapshot barriers and resume validation.
53
- *
54
- * The default provider simply returns open({}). Wrappers must preserve event/progress ordering and
55
- * close the returned iterator on completion, cancellation or failure (for await handles this).
40
+ * Compute this batch's final statistics after exhaustion. Defaults to no filtering.
56
41
  */
57
- open(options: MongoReplicationStreamOptions): AsyncIterableIterator<MongoReplicationStreamItem>;
42
+ resolveMetadata?: () => MongoReplicationStreamBatchMetadata;
43
+ }): MongoReplicationStreamBatch {
44
+ let started = false;
45
+ let complete = false;
46
+ let resumeToken: mongo.ResumeToken | undefined;
47
+ let metadata: MongoReplicationStreamBatchMetadata;
48
+ function assertComplete() {
49
+ // Progress is only safe to read after every event has been consumed and finalization succeeds.
50
+ if (!complete) {
51
+ throw new ReplicationAssertionError('Consume all batch events before requesting token or metadata');
52
+ }
53
+ }
54
+ return {
55
+ events: {
56
+ [Symbol.iterator](): Iterator<MongoReplicationStreamChange> {
57
+ // A second pass would repeat parsing/filtering against state that has already advanced.
58
+ if (started) {
59
+ throw new ReplicationAssertionError('Batch events can only be consumed once');
60
+ }
61
+ started = true;
62
+ return iterate();
63
+ }
64
+ },
65
+ getResumeToken() {
66
+ assertComplete();
67
+ return resumeToken;
68
+ },
69
+ get metadata() {
70
+ assertComplete();
71
+ return metadata;
72
+ }
73
+ };
74
+
75
+ function* iterate() {
76
+ // Delegate lazily; an error or early return skips the finalization below.
77
+ yield* events;
78
+ // Cache this batch's final results before later batches can change the parser or filter state.
79
+ resumeToken = resolveResumeToken();
80
+ metadata = Object.freeze({ ...resolveMetadata() });
81
+ complete = true;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Filter complete data changes while retaining source batch boundaries and recovery bookkeeping.
87
+ * Return MongoReplicationFilterResult.KEEP to forward a change, EXCLUDE to omit it, or DELETE for an exit.
88
+ * Synthetic deletes retain the source identity, resume token and transaction metadata.
89
+ * The callback runs synchronously and lazily; failures and early exits close the underlying stream.
90
+ */
91
+ export async function* filterMongoReplicationStream({
92
+ batches,
93
+ filter
94
+ }: MongoReplicationFilterOptions): AsyncGenerator<MongoReplicationStreamBatch> {
95
+ for await (const sourceBatch of batches) {
96
+ let filteredCount = 0;
97
+ const batch = createMongoReplicationStreamBatch({
98
+ events: filterEvents(),
99
+ resolveResumeToken: () => sourceBatch.getResumeToken(),
100
+ resolveMetadata: () => ({ filteredCount: (sourceBatch.metadata.filteredCount ?? 0) + filteredCount })
101
+ });
102
+ yield batch;
103
+ // Assert that the consumer exhausted this batch before requesting the next one, even if every event
104
+ // was filtered out. The token value is unused; getResumeToken() throws if consumption is incomplete.
105
+ batch.getResumeToken();
106
+
107
+ function* filterEvents(): Generator<MongoReplicationStreamChange> {
108
+ for (const change of sourceBatch.events) {
109
+ const { event } = change;
110
+ if (
111
+ (event.operationType != 'insert' &&
112
+ event.operationType != 'update' &&
113
+ event.operationType != 'replace' &&
114
+ event.operationType != 'delete') ||
115
+ event.ns.coll == CHECKPOINTS_COLLECTION
116
+ ) {
117
+ yield change;
118
+ continue;
119
+ }
120
+ const result = filter(event);
121
+ if (result == MongoReplicationFilterResult.EXCLUDE) {
122
+ filteredCount++;
123
+ } else if (result == MongoReplicationFilterResult.DELETE) {
124
+ yield { ...change, event: { ...event, operationType: 'delete' } };
125
+ } else {
126
+ yield change;
127
+ }
128
+ }
129
+ }
130
+ }
58
131
  }
59
132
 
60
133
  /**
@@ -83,8 +156,19 @@ export function openMongoReplicationStream({
83
156
  skipInitialTimestamp?: boolean;
84
157
  options: RawChangeStreamOptions;
85
158
  onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
86
- }): AsyncIterableIterator<MongoReplicationStreamItem> {
87
- const open: MongoReplicationStreamContext['open'] = ({ pipelineStages = [], imageOptions = {} }) => {
159
+ }): AsyncIterableIterator<MongoReplicationStreamBatch> {
160
+ const parsing = {
161
+ defaultSchema: db.databaseName,
162
+ multipleDatabases: namespaceFilter.multipleDatabases,
163
+ startAfter: skipInitialTimestamp && position?.resumeAfter == null ? position?.startAfter : undefined,
164
+ signal: options.signal,
165
+ logger: options.logger
166
+ };
167
+
168
+ const openRaw = async function* ({
169
+ pipelineStages = [],
170
+ imageOptions = {}
171
+ }: MongoReplicationStreamOptions): AsyncIterableIterator<RawMongoReplicationStreamBatch> {
88
172
  const clusterScope = isDocumentDb || namespaceFilter.multipleDatabases;
89
173
  const streamOptions: mongo.Document = {
90
174
  ...imageOptions,
@@ -105,51 +189,35 @@ export function openMongoReplicationStream({
105
189
  pipelineStages,
106
190
  includeSplitLargeEvent: !isDocumentDb
107
191
  });
108
- return readMongoReplicationStream({
109
- batches: rawChangeStream(clusterScope ? db.client.db('admin') : db, pipeline, options),
110
- defaultSchema: db.databaseName,
111
- multipleDatabases: namespaceFilter.multipleDatabases,
112
- // An exact token can fall inside a transaction. Later events may share its timestamp and must
113
- // still be delivered. Timestamp deduplication applies only to legacy positions without tokens.
114
- startAfter: skipInitialTimestamp && position?.resumeAfter == null ? position?.startAfter : undefined,
115
- signal: options.signal,
116
- logger: options.logger,
117
- onBatch
118
- });
192
+ for await (const batch of rawChangeStream(clusterScope ? db.client.db('admin') : db, pipeline, options)) {
193
+ options.signal?.throwIfAborted();
194
+ using batchScope = onBatch?.(batch) || undefined;
195
+ yield { events: batch.events, resumeToken: batch.resumeToken };
196
+ }
119
197
  };
120
- return queryProvider.openChangeStream({ open });
198
+
199
+ return queryProvider.openChangeStream({
200
+ open: (options) => parseMongoReplicationStream({ batches: openRaw(options), ...parsing })
201
+ });
121
202
  }
122
203
 
123
204
  /**
124
- * Parse envelopes once, retaining raw BSON row bodies and withholding progress during split reassembly.
205
+ * Creates a stateful parser for one raw MongoDB stream. Parses BSON entries, joins split-event
206
+ * fragments across batches, normalizes namespaces and skips events covered by legacy timestamps.
207
+ * After processing a batch, callers can request safe progress. Check completion on natural EOF,
208
+ * not cancellation.
125
209
  */
126
- export async function* readMongoReplicationStream({
127
- batches,
210
+ export function createMongoReplicationStreamParser({
128
211
  defaultSchema,
129
212
  multipleDatabases,
130
213
  startAfter,
131
214
  signal,
132
- logger,
133
- onBatch
134
- }: {
135
- batches: AsyncIterable<ChangeStreamBatch>;
136
- defaultSchema: string;
137
- multipleDatabases: boolean;
138
- startAfter?: mongo.Timestamp | null;
139
- signal?: AbortSignal;
140
- logger?: Logger;
141
- /**
142
- * Transport accounting happens before parsing/filtering, including partial fragments and empty batches.
143
- */
144
- onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
145
- }): AsyncGenerator<MongoReplicationStreamItem> {
215
+ logger
216
+ }: MongoReplicationParserOptions) {
146
217
  let splitDocument: ProjectedChangeStreamDocument | null = null;
147
218
  let flexDbNameWorkaroundLogged = false;
148
- for await (const batch of batches) {
149
- signal?.throwIfAborted();
150
- // The optional scope measures batch processing, excluding the next network wait.
151
- using batchScope = onBatch?.(batch) || undefined;
152
- for (const [index, rawEvent] of batch.events.entries()) {
219
+ return {
220
+ parse(rawEvent: Buffer): ProjectedChangeStreamDocument | null {
153
221
  signal?.throwIfAborted();
154
222
  let event = parseChangeDocument(rawEvent);
155
223
  const fragment = event.splitEvent;
@@ -165,14 +233,18 @@ export async function* readMongoReplicationStream({
165
233
  throw new ReplicationAssertionError(`Unexpected splitEvent: ${JSON.stringify(fragment)}`);
166
234
  }
167
235
  splitDocument = splitDocument == null ? event : Object.assign(splitDocument, event);
168
- if (fragment.fragment != fragment.of) continue;
236
+ if (fragment.fragment != fragment.of) {
237
+ return null;
238
+ }
169
239
  event = splitDocument;
170
240
  splitDocument = null;
171
241
  } else if (splitDocument != null) {
172
242
  throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
173
243
  }
174
244
 
175
- if (startAfter != null && event.clusterTime?.lte(startAfter)) continue;
245
+ if (startAfter != null && event.clusterTime?.lte(startAfter)) {
246
+ return null;
247
+ }
176
248
 
177
249
  if (
178
250
  !multipleDatabases &&
@@ -189,13 +261,54 @@ export async function* readMongoReplicationStream({
189
261
  logger?.warn(`Incorrect DB name in change stream: ${originalDatabase}. Changed to ${defaultSchema}.`);
190
262
  }
191
263
  }
192
- yield { type: 'change', event, hasBufferedChanges: index < batch.events.length - 1 };
193
- }
194
- if (splitDocument == null) {
195
- yield { type: 'progress', resumeToken: batch.resumeToken, filteredCount: 0 };
264
+ return event;
265
+ },
266
+ /**
267
+ * Returns the given transport token if it is safe to persist, or undefined while a split event is unfinished.
268
+ */
269
+ getSafeResumeToken(resumeToken: mongo.ResumeToken) {
270
+ return splitDocument == null ? resumeToken : undefined;
271
+ },
272
+ assertComplete() {
273
+ if (splitDocument != null) {
274
+ throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
275
+ }
196
276
  }
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Wrap raw batches in lazy event iterables. BSON parsing and split-event assembly happen
282
+ * as consumers request events, without building a parsed array. After exhaustion, each batch retains
283
+ * its own resume-token result even when later batches change the parser state.
284
+ * Consume each batch completely before requesting the next; returning early closes the source.
285
+ */
286
+ export async function* parseMongoReplicationStream({
287
+ batches,
288
+ ...options
289
+ }: MongoReplicationParserOptions & {
290
+ batches: AsyncIterable<RawMongoReplicationStreamBatch>;
291
+ }): AsyncGenerator<MongoReplicationStreamBatch> {
292
+ const parser = createMongoReplicationStreamParser(options);
293
+ for await (const sourceBatch of batches) {
294
+ options.signal?.throwIfAborted();
295
+ const batch = createMongoReplicationStreamBatch({
296
+ events: parseEvents(sourceBatch.events),
297
+ resolveResumeToken: () => parser.getSafeResumeToken(sourceBatch.resumeToken)
298
+ });
299
+ yield batch;
300
+ // Split-event state is shared across batches, so the consumer must finish this one before the next is read.
301
+ // This also covers empty batches.
302
+ batch.getResumeToken();
197
303
  }
198
- if (splitDocument != null) {
199
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
304
+ parser.assertComplete();
305
+
306
+ function* parseEvents(rawEvents: Buffer[]): Generator<MongoReplicationStreamChange> {
307
+ for (let index = 0; index < rawEvents.length; index++) {
308
+ const event = parser.parse(rawEvents[index]);
309
+ if (event != null) {
310
+ yield { event, hasBufferedChanges: index < rawEvents.length - 1 };
311
+ }
312
+ }
200
313
  }
201
314
  }
@@ -0,0 +1,169 @@
1
+ import type { mongo } from '@powersync/lib-service-mongodb';
2
+ import type { Logger } from '@powersync/lib-services-framework';
3
+ import type { ProjectedChangeStreamDocument } from './RawChangeStream.js';
4
+
5
+ /**
6
+ * An unparsed MongoDB response batch. Consumers parse its entries incrementally and retain unfinished
7
+ * split events across batches before deciding whether its resume token is safe to persist.
8
+ */
9
+ export interface RawMongoReplicationStreamBatch {
10
+ /**
11
+ * Original BSON entries in source order, including possible split-event fragments.
12
+ */
13
+ events: Buffer[];
14
+ /**
15
+ * Transport position after this batch. It may cover an incomplete event and is not yet safe progress.
16
+ */
17
+ resumeToken: mongo.ResumeToken;
18
+ }
19
+
20
+ /**
21
+ * A complete event and its position within the original source batch.
22
+ */
23
+ export interface MongoReplicationStreamChange {
24
+ event: ProjectedChangeStreamDocument;
25
+ /**
26
+ * MongoDB returned more entries after this event, even if a provider later excludes them or the
27
+ * parser retains them as unfinished split fragments. For [checkpoint, excluded change], preserve
28
+ * true on the checkpoint so ChangeStream groups commits instead of treating it as caught up.
29
+ * Providers must preserve this value when forwarding or transforming events.
30
+ */
31
+ hasBufferedChanges: boolean;
32
+ }
33
+
34
+ /**
35
+ * Statistics for one fully consumed batch, independent of whether its resume token is safe.
36
+ */
37
+ export interface MongoReplicationStreamBatchMetadata {
38
+ /**
39
+ * Complete events excluded from this batch. Null means no filtering was applied; zero means
40
+ * filtering was applied but no events were excluded. Counts do not carry across batches.
41
+ */
42
+ readonly filteredCount: number | null;
43
+ }
44
+
45
+ /**
46
+ * A single-use batch of complete events, parsed or filtered lazily as the consumer iterates.
47
+ * Exhaust events before reading its resume token, metadata or the next batch.
48
+ */
49
+ export interface MongoReplicationStreamBatch {
50
+ /**
51
+ * Complete changes in source order, parsed or filtered lazily during synchronous iteration.
52
+ * Consume once and fully. It may be empty after filtering or while assembling a split event.
53
+ */
54
+ events: Iterable<MongoReplicationStreamChange>;
55
+ /**
56
+ * Returns the safe resume token after events are fully consumed, or undefined while a split event
57
+ * is unfinished. Throws before exhaustion. The result remains stable after later batches.
58
+ */
59
+ getResumeToken(): mongo.ResumeToken | undefined;
60
+ /**
61
+ * Final statistics for this batch, available even without a safe resume token.
62
+ * Throws before events are fully consumed; the result is immutable and cached afterward.
63
+ */
64
+ readonly metadata: MongoReplicationStreamBatchMetadata;
65
+ }
66
+
67
+ /**
68
+ * Provider-controlled additions to the shared cursor setup. Source scope, resume position, post-image
69
+ * requirements and cancellation remain controlled by the MongoDB replication module.
70
+ */
71
+ export interface MongoReplicationStreamOptions {
72
+ /**
73
+ * Inserted after namespace selection and before the final large-event split stage.
74
+ */
75
+ pipelineStages?: mongo.Document[];
76
+ /**
77
+ * Request pre-images without overriding the reader's post-image, resume or scope options.
78
+ */
79
+ imageOptions?: Pick<mongo.ChangeStreamOptions, 'fullDocumentBeforeChange'>;
80
+ }
81
+
82
+ /**
83
+ * Bound source opener supplied to a query provider for one stream invocation.
84
+ * Providers may wrap the lazy event iterables to filter or transform complete changes.
85
+ */
86
+ export interface MongoReplicationStreamContext {
87
+ /**
88
+ * Open batches whose events are parsed lazily, with cursor scope, position, cancellation and
89
+ * accounting already bound. Each call owns independent split-event assembly and parser state.
90
+ * Events are complete; progress is withheld while a split event is unfinished.
91
+ * Close the iterator on completion, cancellation or failure (for await handles this).
92
+ */
93
+ open(options: MongoReplicationStreamOptions): AsyncIterableIterator<MongoReplicationStreamBatch>;
94
+ }
95
+
96
+ /**
97
+ * Source context for lazy parsing of raw BSON events in default and filtering providers.
98
+ * These options control namespace normalization, legacy timestamp skipping and cancellation; split-event
99
+ * state belongs to each parser instance and must not be shared between independently opened streams.
100
+ */
101
+ export interface MongoReplicationParserOptions {
102
+ /**
103
+ * Expected database name when correcting Atlas Flex's prefixed namespaces in single-database mode.
104
+ */
105
+ defaultSchema: string;
106
+ /**
107
+ * Preserve database names when the stream covers multiple databases instead of normalizing them.
108
+ */
109
+ multipleDatabases: boolean;
110
+ /**
111
+ * Exclude complete events at or before this legacy resume timestamp. Leave unset for exact-token
112
+ * resumes, where later events can share the same timestamp, and for snapshot barrier probes.
113
+ */
114
+ startAfter?: mongo.Timestamp | null;
115
+ /**
116
+ * Abort parsing when the owning replication attempt is cancelled. Resolving a batch's progress is not
117
+ * interrupted, so consumers must check the signal themselves before persisting a token.
118
+ */
119
+ signal?: AbortSignal;
120
+ /**
121
+ * Receives the namespace-correction warning, at most once per parser instance.
122
+ */
123
+ logger?: Logger;
124
+ }
125
+
126
+ /**
127
+ * A complete data change eligible for filtering. Checkpoints and DDL bypass the filter.
128
+ */
129
+ export type MongoReplicationDataChange = Extract<
130
+ ProjectedChangeStreamDocument,
131
+ { operationType: 'insert' | 'update' | 'replace' | 'delete' }
132
+ >;
133
+
134
+ /**
135
+ * The action to apply to a complete data change after evaluating filter membership.
136
+ */
137
+ export enum MongoReplicationFilterResult {
138
+ /**
139
+ * Forward the original change.
140
+ */
141
+ KEEP = 'keep',
142
+ /**
143
+ * Omit the change while retaining its batch's recovery progress.
144
+ */
145
+ EXCLUDE = 'exclude',
146
+ /**
147
+ * Emit a delete because the document left the filtered set.
148
+ */
149
+ DELETE = 'delete'
150
+ }
151
+
152
+ /**
153
+ * Synchronously decide how to replicate a complete data change. Checkpoints and DDL bypass this callback.
154
+ */
155
+ export type MongoReplicationFilter = (event: MongoReplicationDataChange) => MongoReplicationFilterResult;
156
+
157
+ /**
158
+ * Source batches and membership decisions for the shared replication filter.
159
+ */
160
+ export interface MongoReplicationFilterOptions {
161
+ /**
162
+ * Ordered source batches whose boundaries and recovery progress must be preserved.
163
+ */
164
+ batches: AsyncIterable<MongoReplicationStreamBatch>;
165
+ /**
166
+ * Decide whether to keep, exclude or delete each complete data change.
167
+ */
168
+ filter: MongoReplicationFilter;
169
+ }
@@ -22,7 +22,8 @@ import {
22
22
  DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
23
23
  MongoReplicationQueryProvider
24
24
  } from './MongoReplicationQueryProvider.js';
25
- import { MongoReplicationStreamItem, openMongoReplicationStream } from './MongoReplicationStream.js';
25
+ import { openMongoReplicationStream } from './MongoReplicationStream.js';
26
+ import type { MongoReplicationStreamBatch } from './MongoReplicationStreamTypes.js';
26
27
  import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
27
28
  import { ChangeStreamBatch } from './RawChangeStream.js';
28
29
  import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
@@ -646,34 +647,49 @@ export class MongoSnapshotter {
646
647
  let eventsSeen = 0;
647
648
  let batchesSeen = 0;
648
649
 
649
- for await (const item of iter) {
650
+ // Returns false once the timeout is reached. Otherwise creates another checkpoint when one is due.
651
+ const shouldKeepWaiting = async () => {
650
652
  if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
651
- break;
653
+ return false;
652
654
  }
653
655
  if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
654
656
  await this.checkpointImplementation.createBatchCheckpoint();
655
657
  lastCheckpointCreated = performance.now();
656
658
  }
657
- if (item.type == 'progress') {
658
- batchesSeen += 1;
659
- continue;
660
- }
661
- const changeDocument = item.event;
662
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
659
+ return true;
660
+ };
663
661
 
664
- if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
665
- const kind = this.checkpointImplementation.event.observe(changeDocument);
666
- if (kind != 'own-barrier') {
662
+ streamLoop: for await (const sourceBatch of iter) {
663
+ this.abortSignal.throwIfAborted();
664
+ for (const { event: changeDocument } of sourceBatch.events) {
665
+ this.abortSignal.throwIfAborted();
666
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
667
+
668
+ if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
667
669
  // Standalone events still feed the implementation's coordinate via
668
- // event.observe above; we only resolve on our own barrier.
669
- continue;
670
+ // event.observe; we only resolve on our own barrier.
671
+ if (this.checkpointImplementation.event.observe(changeDocument) == 'own-barrier') {
672
+ // Observing our own barrier has set the coordinate (the barrier event
673
+ // carries the sentinel counter directly), so the LSN is ready.
674
+ // This is checked before the timeout, so a barrier we already hold is never discarded.
675
+ return this.checkpointImplementation.event.lsn(changeDocument);
676
+ }
677
+ } else {
678
+ eventsSeen += 1;
670
679
  }
671
- // Observing our own barrier has set the coordinate (the barrier event
672
- // carries the sentinel counter directly), so the LSN is ready.
673
- return this.checkpointImplementation.event.lsn(changeDocument);
674
- }
675
680
 
676
- eventsSeen += 1;
681
+ if (!(await shouldKeepWaiting())) {
682
+ break streamLoop;
683
+ }
684
+ }
685
+ const resumeToken = sourceBatch.getResumeToken();
686
+ if (resumeToken != null) {
687
+ batchesSeen++;
688
+ }
689
+ // Empty batches have no events to trigger the per-event check.
690
+ if (!(await shouldKeepWaiting())) {
691
+ break;
692
+ }
677
693
  }
678
694
 
679
695
  // Could happen if there is a very large replication lag?
@@ -687,14 +703,19 @@ export class MongoSnapshotter {
687
703
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
688
704
  */
689
705
  private async validateSnapshotLsn(lsn: string) {
690
- const stream = this.openChangeStream({
691
- lsn,
692
- maxAwaitTimeMS: 0,
693
- filters: this.getSourceNamespaceFilters(),
694
- signal: this.abortSignal
695
- });
696
- for await (const _item of stream) {
697
- break;
706
+ const filters = this.getSourceNamespaceFilters();
707
+ const stream = this.openChangeStream({ lsn, maxAwaitTimeMS: 0, filters, signal: this.abortSignal });
708
+ for await (const batch of stream) {
709
+ this.abortSignal.throwIfAborted();
710
+ // Reading one complete event validates the position; closing the stream abandons batch progress.
711
+ for (const _change of batch.events) {
712
+ return;
713
+ }
714
+ // An empty or fully filtered batch can also validate the position through safe progress.
715
+ // If a split event is unfinished, progress is undefined: keep reading until it is complete.
716
+ if (batch.getResumeToken() != null) {
717
+ return;
718
+ }
698
719
  }
699
720
  }
700
721
 
@@ -750,7 +771,7 @@ export class MongoSnapshotter {
750
771
  signal?: AbortSignal;
751
772
  tracer?: PerformanceTracer<'changestream'>;
752
773
  onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
753
- }): AsyncIterableIterator<MongoReplicationStreamItem> {
774
+ }): AsyncIterableIterator<MongoReplicationStreamBatch> {
754
775
  return openMongoReplicationStream({
755
776
  db: this.defaultDb,
756
777
  queryProvider: this.queryProvider,
@@ -7,6 +7,7 @@ export * from './MongoErrorRateLimiter.js';
7
7
  export * from './MongoRelation.js';
8
8
  export * from './MongoReplicationQueryProvider.js';
9
9
  export * from './MongoReplicationStream.js';
10
+ export * from './MongoReplicationStreamTypes.js';
10
11
  export * from './MongoSnapshotter.js';
11
12
  export * from './RawChangeStream.js';
12
13
  export { CHECKPOINTS_COLLECTION } from './replication-utils.js';
@@ -19,6 +19,7 @@ import {
19
19
  updateSyncRulesFromConfig,
20
20
  utils
21
21
  } from '@powersync/service-core';
22
+ import { SqlSyncRules } from '@powersync/service-sync-rules';
22
23
  import { setTimeout } from 'node:timers/promises';
23
24
 
24
25
  import { SentinelLSN } from '../common/SentinelLSN.js';
@@ -192,9 +193,8 @@ export class ChangeStreamTestContext implements AsyncDisposable {
192
193
 
193
194
  async updateSyncRules(content: string) {
194
195
  const replicationStream = await this.factory.updateSyncRules(
195
- // Deployment and resume must use the same parser, including caller-registered extensions.
196
196
  updateSyncRulesFromConfig(
197
- this.factory.syncConfigParser.parseContent(content, {
197
+ SqlSyncRules.fromYaml(content, {
198
198
  defaultSchema: this.db.databaseName,
199
199
  throwOnError: true
200
200
  }),