@powersync/service-module-mongodb 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/benchmarks/env.ts +11 -0
  3. package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
  4. package/ci/test-connection.yaml +12 -0
  5. package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
  6. package/dist/api/MongoRouteAPIAdapter.js +33 -4
  7. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  8. package/dist/api/infer-collection-schema.d.ts +5 -0
  9. package/dist/api/infer-collection-schema.js +24 -0
  10. package/dist/api/infer-collection-schema.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/module/MongoModule.d.ts +1 -2
  15. package/dist/module/MongoModule.js +1 -1
  16. package/dist/module/MongoModule.js.map +1 -1
  17. package/dist/replication/ChangeStream.d.ts +13 -3
  18. package/dist/replication/ChangeStream.js +130 -196
  19. package/dist/replication/ChangeStream.js.map +1 -1
  20. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  21. package/dist/replication/ChangeStreamReplicationJob.js +19 -8
  22. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  23. package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
  24. package/dist/replication/ChangeStreamReplicator.js +6 -0
  25. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  26. package/dist/replication/MongoRelation.js +5 -2
  27. package/dist/replication/MongoRelation.js.map +1 -1
  28. package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
  29. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  30. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  31. package/dist/replication/MongoReplicationStream.d.ts +74 -0
  32. package/dist/replication/MongoReplicationStream.js +228 -0
  33. package/dist/replication/MongoReplicationStream.js.map +1 -0
  34. package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
  35. package/dist/replication/MongoReplicationStreamTypes.js +19 -0
  36. package/dist/replication/MongoReplicationStreamTypes.js.map +1 -0
  37. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  38. package/dist/replication/MongoSnapshotQuery.js +12 -2
  39. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  40. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  41. package/dist/replication/MongoSnapshotter.js +90 -88
  42. package/dist/replication/MongoSnapshotter.js.map +1 -1
  43. package/dist/replication/RawChangeStream.d.ts +1 -1
  44. package/dist/replication/RawChangeStream.js +1 -1
  45. package/dist/replication/RawChangeStream.js.map +1 -1
  46. package/dist/replication/SourceRowConverter.d.ts +1 -0
  47. package/dist/replication/SourceRowConverter.js +4 -2
  48. package/dist/replication/SourceRowConverter.js.map +1 -1
  49. package/dist/replication/bufferToSqlite.d.ts +3 -1
  50. package/dist/replication/bufferToSqlite.js +35 -23
  51. package/dist/replication/bufferToSqlite.js.map +1 -1
  52. package/dist/replication/estimateFilteredSnapshotCount.d.ts +12 -0
  53. package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
  54. package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
  55. package/dist/replication/replication-index.d.ts +7 -0
  56. package/dist/replication/replication-index.js +7 -0
  57. package/dist/replication/replication-index.js.map +1 -1
  58. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  59. package/dist/test-utils/ChangeStreamTestContext.js +390 -0
  60. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  61. package/dist/test-utils/test-utils-index.d.ts +1 -0
  62. package/dist/test-utils/test-utils-index.js +2 -0
  63. package/dist/test-utils/test-utils-index.js.map +1 -0
  64. package/package.json +11 -10
  65. package/src/api/MongoRouteAPIAdapter.ts +38 -4
  66. package/src/api/infer-collection-schema.ts +26 -0
  67. package/src/index.ts +1 -0
  68. package/src/module/MongoModule.ts +2 -3
  69. package/src/replication/ChangeStream.ts +143 -226
  70. package/src/replication/ChangeStreamReplicationJob.ts +21 -9
  71. package/src/replication/ChangeStreamReplicator.ts +9 -0
  72. package/src/replication/MongoRelation.ts +8 -1
  73. package/src/replication/MongoReplicationQueryProvider.ts +106 -0
  74. package/src/replication/MongoReplicationStream.ts +314 -0
  75. package/src/replication/MongoReplicationStreamTypes.ts +169 -0
  76. package/src/replication/MongoSnapshotQuery.ts +19 -3
  77. package/src/replication/MongoSnapshotter.ts +101 -92
  78. package/src/replication/RawChangeStream.ts +2 -2
  79. package/src/replication/SourceRowConverter.ts +4 -2
  80. package/src/replication/bufferToSqlite.ts +43 -23
  81. package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
  82. package/src/replication/replication-index.ts +7 -0
  83. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  84. package/src/test-utils/test-utils-index.ts +1 -0
  85. package/test/src/buffer_to_sqlite.test.ts +9 -0
  86. package/test/src/change_event_memory.test.ts +33 -0
  87. package/test/src/change_stream.test.ts +3 -3
  88. package/test/src/change_stream_abort.test.ts +110 -0
  89. package/test/src/change_stream_test_setup.ts +38 -0
  90. package/test/src/chunked_snapshot.test.ts +3 -3
  91. package/test/src/documentdb_mode.test.ts +3 -3
  92. package/test/src/filtering_example.test.ts +65 -0
  93. package/test/src/filtering_lifecycle.test.ts +101 -0
  94. package/test/src/query_provider_availability.test.ts +77 -0
  95. package/test/src/query_provider_diagnostics.test.ts +55 -0
  96. package/test/src/replication_stream.test.ts +688 -0
  97. package/test/src/resume.test.ts +10 -4
  98. package/test/src/resuming_snapshots.test.ts +3 -3
  99. package/test/src/schema.test.ts +62 -19
  100. package/test/src/slow_tests.test.ts +3 -3
  101. package/test/src/snapshot_count.test.ts +146 -0
  102. package/test/src/snapshot_count_integration.test.ts +70 -0
  103. package/test/src/snapshot_query_provider.test.ts +122 -0
  104. package/test/src/stream_progress.test.ts +334 -0
  105. package/test/tsconfig.json +2 -2
  106. package/tsconfig.tsbuildinfo +1 -1
  107. package/vitest.benchmark.config.ts +15 -0
  108. package/test/src/change_stream_utils.ts +0 -375
@@ -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
+ }
@@ -15,12 +15,19 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
15
15
  private lastCursor: mongo.FindCursor | null = null;
16
16
  private collection: mongo.Collection;
17
17
  private batchSize: number;
18
+ private readonly filter: mongo.Filter<mongo.Document> | null;
18
19
 
19
- public constructor(options: { collection: mongo.Collection; batchSize: number; key?: Uint8Array | null }) {
20
+ public constructor(options: {
21
+ collection: mongo.Collection;
22
+ batchSize: number;
23
+ key?: Uint8Array | null;
24
+ filter?: mongo.Filter<mongo.Document> | null;
25
+ }) {
20
26
  this.lastKey = options.key ? bson.deserialize(options.key, { useBigInt64: true })._id : null;
21
27
  this.lastCursor = null;
22
28
  this.collection = options.collection;
23
29
  this.batchSize = options.batchSize;
30
+ this.filter = options.filter ?? null;
24
31
  }
25
32
 
26
33
  async nextChunk(): Promise<
@@ -39,8 +46,14 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
39
46
  // any parsing as an operator.
40
47
  // Starting in MongoDB 5.0, this filter can use the _id index. Source:
41
48
  // https://www.mongodb.com/docs/manual/release-notes/5.0/#general-aggregation-improvements
42
- const filter: mongo.Filter<mongo.Document> =
49
+ const continuationFilter: mongo.Filter<mongo.Document> =
43
50
  this.lastKey == null ? {} : { $expr: { $gt: ['$_id', { $literal: this.lastKey }] } };
51
+ const filter: mongo.Filter<mongo.Document> =
52
+ this.filter == null
53
+ ? continuationFilter
54
+ : this.lastKey == null
55
+ ? this.filter
56
+ : { $and: [this.filter, continuationFilter] };
44
57
  cursor = this.collection.find(filter, {
45
58
  readConcern: 'majority',
46
59
  limit: this.batchSize,
@@ -48,7 +61,10 @@ export class ChunkedSnapshotQuery implements AsyncDisposable {
48
61
  // See https://github.com/mongodb/node-mongodb-native/pull/4580
49
62
  batchSize: this.batchSize + 1,
50
63
  sort: { _id: 1 },
51
- raw: true
64
+ raw: true,
65
+ // Replication predicates have stable binary semantics independent of a
66
+ // collection's default collation.
67
+ ...(this.filter == null ? {} : { collation: { locale: 'simple' } })
52
68
  });
53
69
  newCursor = true;
54
70
  }
@@ -16,10 +16,17 @@ import { PostImagesOption } from '../types/types.js';
16
16
  import { escapeRegExp } from '../utils.js';
17
17
  import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
18
18
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
19
+ import { estimateFilteredSnapshotCount } from './estimateFilteredSnapshotCount.js';
19
20
  import { MongoManager } from './MongoManager.js';
20
21
  import { getMongoRelation } from './MongoRelation.js';
22
+ import {
23
+ DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
24
+ MongoReplicationQueryProvider
25
+ } from './MongoReplicationQueryProvider.js';
26
+ import { openMongoReplicationStream } from './MongoReplicationStream.js';
27
+ import type { MongoReplicationStreamBatch } from './MongoReplicationStreamTypes.js';
21
28
  import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
22
- import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
29
+ import { ChangeStreamBatch } from './RawChangeStream.js';
23
30
  import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
24
31
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
25
32
 
@@ -34,6 +41,7 @@ export interface MongoSnapshotterOptions {
34
41
  checkpointStreamId: mongo.ObjectId;
35
42
  storageHooks?: storage.StorageHooks;
36
43
  snapshotHooks?: MongoSnapshotterHooks;
44
+ queryProvider?: MongoReplicationQueryProvider;
37
45
  }
38
46
 
39
47
  export interface MongoSnapshotterHooks {
@@ -59,6 +67,7 @@ export class MongoSnapshotter {
59
67
  private readonly defaultDb: mongo.Db;
60
68
  private readonly syncRules: HydratedSyncConfig;
61
69
  private readonly sourceRowConverter: SourceRowConverter;
70
+ private readonly queryProvider: MongoReplicationQueryProvider;
62
71
  private readonly maxAwaitTimeMS: number;
63
72
  private readonly snapshotChunkLength: number;
64
73
  private readonly abortSignal: AbortSignal;
@@ -90,6 +99,7 @@ export class MongoSnapshotter {
90
99
  this.checkpointStreamId = options.checkpointStreamId;
91
100
  this.storageHooks = options.storageHooks;
92
101
  this.snapshotHooks = options.snapshotHooks;
102
+ this.queryProvider = options.queryProvider ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
93
103
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
94
104
  this.syncRules = options.storage.getParsedSyncRules({
95
105
  defaultSchema: this.defaultDb.databaseName
@@ -116,7 +126,9 @@ export class MongoSnapshotter {
116
126
  return this.storage.storageConfig.softDeleteCurrentData;
117
127
  }
118
128
 
119
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
129
+ /**
130
+ * The active checkpoint strategy. Only valid after ensureDetected().
131
+ */
120
132
  private get checkpointImplementation(): CheckpointImplementation {
121
133
  if (this._checkpointImplementation == null) {
122
134
  throw new ServiceError(
@@ -150,6 +162,7 @@ export class MongoSnapshotter {
150
162
  );
151
163
  }
152
164
  }
165
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
153
166
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
154
167
  client: this.client,
155
168
  db: this.defaultDb,
@@ -244,9 +257,7 @@ export class MongoSnapshotter {
244
257
  totalEstimatedCount: count
245
258
  });
246
259
  this.queueTable(updated);
247
- this.logger.info(
248
- `To replicate: ${updated.qualifiedName}: ${updated.snapshotStatus?.replicatedCount}/~${updated.snapshotStatus?.totalEstimatedCount}`
249
- );
260
+ this.logger.info(`To replicate: ${updated.qualifiedName} ${updated.formatSnapshotProgress()}`);
250
261
  }
251
262
  }
252
263
 
@@ -465,13 +476,20 @@ export class MongoSnapshotter {
465
476
  const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
466
477
  const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
467
478
 
468
- const totalEstimatedCount = await this.estimatedCountNumber(table);
479
+ const filter = this.queryProvider.getSnapshotFilter(table);
480
+ // Reuse known filtered estimates to avoid repeating an expensive count and sample. Unknown totals
481
+ // (-1) can also represent uninitialized progress, so retry them. Unfiltered metadata counts are cheap
482
+ // and refreshed before copying rows.
483
+ const previousEstimate = table.snapshotStatus?.totalEstimatedCount ?? -1;
484
+ const totalEstimatedCount =
485
+ filter != null && previousEstimate >= 0 ? previousEstimate : await this.estimatedCountNumber(table, filter);
469
486
  let at = table.snapshotStatus?.replicatedCount ?? 0;
470
487
  const collection = this.client.db(table.schema).collection(table.name);
471
488
  await using query = new ChunkedSnapshotQuery({
472
489
  collection,
473
490
  key: table.snapshotStatus?.lastKey,
474
- batchSize: this.snapshotChunkLength
491
+ batchSize: this.snapshotChunkLength,
492
+ filter
475
493
  });
476
494
  if (query.lastKey != null) {
477
495
  this.logger.info(
@@ -565,8 +583,20 @@ export class MongoSnapshotter {
565
583
  return result.tables;
566
584
  }
567
585
 
568
- private async estimatedCountNumber(table: storage.SourceTable): Promise<number> {
569
- return await this.client.db(table.schema).collection(table.name).estimatedDocumentCount();
586
+ private async estimatedCountNumber(
587
+ table: storage.SourceTable,
588
+ filter = this.queryProvider.getSnapshotFilter(table)
589
+ ): Promise<number> {
590
+ const collection = this.client.db(table.schema).collection(table.name);
591
+ if (filter != null) {
592
+ return await estimateFilteredSnapshotCount({
593
+ collection,
594
+ filter,
595
+ signal: this.abortSignal,
596
+ logger: this.logger
597
+ });
598
+ }
599
+ return await collection.estimatedDocumentCount();
570
600
  }
571
601
 
572
602
  private async getCollectionInfo(db: string, name: string): Promise<mongo.CollectionInfo | undefined> {
@@ -616,7 +646,7 @@ export class MongoSnapshotter {
616
646
  this.checkpointImplementation.seedPosition(null);
617
647
  const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
618
648
  const filters = this.getSourceNamespaceFilters();
619
- const iter = this.rawChangeStreamBatches({
649
+ const iter = this.openChangeStream({
620
650
  lsn: startStreamFromLsn,
621
651
  maxAwaitTimeMS: 0,
622
652
  signal: this.abortSignal,
@@ -627,33 +657,48 @@ export class MongoSnapshotter {
627
657
  let eventsSeen = 0;
628
658
  let batchesSeen = 0;
629
659
 
630
- for await (const { events } of iter) {
660
+ // Returns false once the timeout is reached. Otherwise creates another checkpoint when one is due.
661
+ const shouldKeepWaiting = async () => {
631
662
  if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
632
- break;
663
+ return false;
633
664
  }
634
665
  if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
635
666
  await this.checkpointImplementation.createBatchCheckpoint();
636
667
  lastCheckpointCreated = performance.now();
637
668
  }
638
- batchesSeen += 1;
669
+ return true;
670
+ };
639
671
 
640
- for (const rawChangeDocument of events) {
641
- const changeDocument = parseChangeDocument(rawChangeDocument);
672
+ streamLoop: for await (const sourceBatch of iter) {
673
+ this.abortSignal.throwIfAborted();
674
+ for (const { event: changeDocument } of sourceBatch.events) {
675
+ this.abortSignal.throwIfAborted();
642
676
  const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
643
677
 
644
678
  if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
645
- const kind = this.checkpointImplementation.event.observe(changeDocument);
646
- if (kind != 'own-barrier') {
647
- // Standalone events still feed the implementation's coordinate via
648
- // event.observe above; we only resolve on our own barrier.
649
- continue;
679
+ // Standalone events still feed the implementation's coordinate via
680
+ // event.observe; we only resolve on our own barrier.
681
+ if (this.checkpointImplementation.event.observe(changeDocument) == 'own-barrier') {
682
+ // Observing our own barrier has set the coordinate (the barrier event
683
+ // carries the sentinel counter directly), so the LSN is ready.
684
+ // This is checked before the timeout, so a barrier we already hold is never discarded.
685
+ return this.checkpointImplementation.event.lsn(changeDocument);
650
686
  }
651
- // Observing our own barrier has set the coordinate (the barrier event
652
- // carries the sentinel counter directly), so the LSN is ready.
653
- return this.checkpointImplementation.event.lsn(changeDocument);
687
+ } else {
688
+ eventsSeen += 1;
654
689
  }
655
690
 
656
- eventsSeen += 1;
691
+ if (!(await shouldKeepWaiting())) {
692
+ break streamLoop;
693
+ }
694
+ }
695
+ const resumeToken = sourceBatch.getResumeToken();
696
+ if (resumeToken != null) {
697
+ batchesSeen++;
698
+ }
699
+ // Empty batches have no events to trigger the per-event check.
700
+ if (!(await shouldKeepWaiting())) {
701
+ break;
657
702
  }
658
703
  }
659
704
 
@@ -668,13 +713,19 @@ export class MongoSnapshotter {
668
713
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
669
714
  */
670
715
  private async validateSnapshotLsn(lsn: string) {
671
- const stream = this.rawChangeStreamBatches({
672
- lsn,
673
- maxAwaitTimeMS: 0,
674
- filters: this.getSourceNamespaceFilters()
675
- });
676
- for await (const _batch of stream) {
677
- break;
716
+ const filters = this.getSourceNamespaceFilters();
717
+ const stream = this.openChangeStream({ lsn, maxAwaitTimeMS: 0, filters, signal: this.abortSignal });
718
+ for await (const batch of stream) {
719
+ this.abortSignal.throwIfAborted();
720
+ // Reading one complete event validates the position; closing the stream abandons batch progress.
721
+ for (const _change of batch.events) {
722
+ return;
723
+ }
724
+ // An empty or fully filtered batch can also validate the position through safe progress.
725
+ // If a split event is unfinished, progress is undefined: keep reading until it is complete.
726
+ if (batch.getResumeToken() != null) {
727
+ return;
728
+ }
678
729
  }
679
730
  }
680
731
 
@@ -722,73 +773,31 @@ export class MongoSnapshotter {
722
773
  return { $match: nsFilter, multipleDatabases };
723
774
  }
724
775
 
725
- private rawChangeStreamBatches(options: {
776
+ private openChangeStream(options: {
726
777
  lsn: string | null;
727
778
  maxAwaitTimeMS?: number;
728
779
  batchSize?: number;
729
780
  filters: { $match: any; multipleDatabases: boolean };
730
781
  signal?: AbortSignal;
731
782
  tracer?: PerformanceTracer<'changestream'>;
732
- }): AsyncIterableIterator<ChangeStreamBatch> {
733
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
734
- const startAfter = position?.startAfter ?? undefined;
735
- const resumeAfter = position?.resumeAfter ?? undefined;
736
-
737
- let fullDocument: 'required' | 'updateLookup';
738
- if (this.isDocumentDb) {
739
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
740
- fullDocument = 'updateLookup';
741
- } else if (this.usePostImages) {
742
- // 'read_only' or 'auto_configure'
743
- // Configuration happens during snapshot, or when we see new
744
- // collections.
745
- fullDocument = 'required';
746
- } else {
747
- fullDocument = 'updateLookup';
748
- }
749
- const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
750
- fullDocument
751
- };
752
- if (!this.isDocumentDb) {
753
- // DocumentDB does not support showExpandedEvents.
754
- streamOptions.showExpandedEvents = true;
755
- }
756
- const pipeline: mongo.Document[] = [{ $changeStream: streamOptions }, { $match: options.filters.$match }];
757
- if (!this.isDocumentDb) {
758
- // DocumentDB does not support $changeStreamSplitLargeEvent.
759
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
760
- }
761
-
762
- // Only one of these options can be supplied at a time.
763
- if (resumeAfter) {
764
- streamOptions.resumeAfter = resumeAfter;
765
- } else if (startAfter != null) {
766
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
767
- // case if we have an old one. The sentinel implementation never produces a startAfter,
768
- // and a fresh DocumentDB stream opens from "now" with neither option set.
769
- streamOptions.startAtOperationTime = startAfter;
770
- }
771
-
772
- let watchDb: mongo.Db;
773
- if (this.isDocumentDb || options.filters.multipleDatabases) {
774
- // DocumentDB only supports cluster-level change streams.
775
- // Requires readAnyDatabase@admin on Atlas.
776
- watchDb = this.client.db('admin');
777
- streamOptions.allChangesForCluster = true;
778
- } else {
779
- // Same general result, but requires less permissions than the above
780
- watchDb = this.defaultDb;
781
- }
782
-
783
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
784
-
785
- return rawChangeStream(watchDb, pipeline, {
786
- batchSize: options.batchSize ?? this.snapshotChunkLength,
787
- maxAwaitTimeMS,
788
- maxTimeMS: this.changeStreamTimeout,
789
- signal: options.signal,
790
- logger: this.logger,
791
- tracer: options.tracer
783
+ onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
784
+ }): AsyncIterableIterator<MongoReplicationStreamBatch> {
785
+ return openMongoReplicationStream({
786
+ db: this.defaultDb,
787
+ queryProvider: this.queryProvider,
788
+ namespaceFilter: options.filters,
789
+ isDocumentDb: this.isDocumentDb,
790
+ usePostImages: this.usePostImages,
791
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
792
+ onBatch: options.onBatch,
793
+ options: {
794
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
795
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
796
+ maxTimeMS: this.changeStreamTimeout,
797
+ signal: options.signal,
798
+ logger: this.logger,
799
+ tracer: options.tracer
800
+ }
792
801
  });
793
802
  }
794
803
 
@@ -364,7 +364,7 @@ type MapRawDocument<T> = T extends unknown ? { [K in keyof T]: Rawify<T[K]> } :
364
364
  export type ProjectedChangeStreamDocument =
365
365
  | Omit<mongo.ChangeStreamDropDocument, 'wallTime' | 'collectionUUID'>
366
366
  | Omit<mongo.ChangeStreamRenameDocument, 'wallTime'>
367
- | Omit<mongo.ChangeStreamDeleteDocument<Buffer>, 'wallTime'>
367
+ | Omit<mongo.ChangeStreamDeleteDocument<Buffer>, 'wallTime' | 'collectionUUID'>
368
368
  | Omit<mongo.ChangeStreamInsertDocument<Buffer>, 'wallTime'>
369
369
  | Omit<mongo.ChangeStreamUpdateDocument<Buffer>, 'wallTime' | 'updateDescription'>
370
370
  | Omit<mongo.ChangeStreamReplaceDocument<Buffer>, 'wallTime'>;
@@ -432,7 +432,7 @@ export function mapChangeStreamError(e: unknown) {
432
432
  } else if (
433
433
  isMongoServerError(e) &&
434
434
  e.codeName == 'NoMatchingDocument' &&
435
- e.errmsg?.includes('post-image was not found')
435
+ (e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
436
436
  ) {
437
437
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
438
438
  } else if (isMongoServerError(e) && e.hasErrorLabel('NonResumableChangeStreamError')) {
@@ -1,5 +1,5 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
- import { applyRowContext, CompatibilityContext, SqliteRow } from '@powersync/service-sync-rules';
2
+ import { applyRowContext, CompatibilityContext, CompatibilityOption, SqliteRow } from '@powersync/service-sync-rules';
3
3
  import { bufferToSqlite, DateRenderMode, getDateRenderMode, parseDocumentId } from './bufferToSqlite.js';
4
4
  import { constructAfterRecord } from './MongoRelation.js';
5
5
 
@@ -46,13 +46,15 @@ export class LegacySourceRowConverter implements SourceRowConverter {
46
46
 
47
47
  export class DirectSourceRowConverter implements SourceRowConverter {
48
48
  private readonly dateRenderMode: DateRenderMode;
49
+ private readonly fixedBooleansInJson: boolean;
49
50
 
50
51
  constructor(compatibilityContext: CompatibilityContext) {
51
52
  this.dateRenderMode = getDateRenderMode(compatibilityContext);
53
+ this.fixedBooleansInJson = compatibilityContext.isEnabled(CompatibilityOption.fixedBooleanInJson);
52
54
  }
53
55
 
54
56
  rawToSqliteRow(source: Buffer): { row: SqliteRow; replicaId: any } {
55
- const row = bufferToSqlite(source, this.dateRenderMode);
57
+ const row = bufferToSqlite(source, this.dateRenderMode, this.fixedBooleansInJson);
56
58
  const replicaId = parseDocumentId(source).id;
57
59
  return { row, replicaId };
58
60
  }