@powersync/service-module-mongodb 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716

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 (105) hide show
  1. package/CHANGELOG.md +95 -7
  2. package/ci/test-connection.yaml +12 -0
  3. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  4. package/dist/api/MongoRouteAPIAdapter.js +73 -143
  5. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  6. package/dist/api/infer-collection-schema.d.ts +12 -0
  7. package/dist/api/infer-collection-schema.js +142 -0
  8. package/dist/api/infer-collection-schema.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module/MongoModule.d.ts +1 -2
  13. package/dist/module/MongoModule.js +2 -2
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +18 -2
  16. package/dist/replication/ChangeStream.js +275 -307
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +6 -2
  20. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  21. package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
  22. package/dist/replication/ChangeStreamReplicator.js +3 -0
  23. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  24. package/dist/replication/MongoRelation.js +5 -2
  25. package/dist/replication/MongoRelation.js.map +1 -1
  26. package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
  27. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  28. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  29. package/dist/replication/MongoReplicationStream.d.ts +85 -0
  30. package/dist/replication/MongoReplicationStream.js +101 -0
  31. package/dist/replication/MongoReplicationStream.js.map +1 -0
  32. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  33. package/dist/replication/MongoSnapshotQuery.js +12 -2
  34. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  35. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  36. package/dist/replication/MongoSnapshotter.js +64 -91
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +4 -12
  39. package/dist/replication/RawChangeStream.js +1 -18
  40. package/dist/replication/RawChangeStream.js.map +1 -1
  41. package/dist/replication/SourceRowConverter.d.ts +1 -0
  42. package/dist/replication/SourceRowConverter.js +4 -2
  43. package/dist/replication/SourceRowConverter.js.map +1 -1
  44. package/dist/replication/bufferToSqlite.d.ts +3 -1
  45. package/dist/replication/bufferToSqlite.js +35 -23
  46. package/dist/replication/bufferToSqlite.js.map +1 -1
  47. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +4 -1
  48. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +4 -1
  49. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +1 -1
  50. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -1
  51. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +4 -1
  52. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +2 -1
  53. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -1
  54. package/dist/replication/replication-index.d.ts +6 -0
  55. package/dist/replication/replication-index.js +6 -0
  56. package/dist/replication/replication-index.js.map +1 -1
  57. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  58. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  59. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  60. package/dist/test-utils/test-utils-index.d.ts +1 -0
  61. package/dist/test-utils/test-utils-index.js +2 -0
  62. package/dist/test-utils/test-utils-index.js.map +1 -0
  63. package/dist/types/types.d.ts +5 -0
  64. package/dist/types/types.js +20 -2
  65. package/dist/types/types.js.map +1 -1
  66. package/package.json +9 -9
  67. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  68. package/src/api/infer-collection-schema.ts +153 -0
  69. package/src/index.ts +1 -0
  70. package/src/module/MongoModule.ts +3 -4
  71. package/src/replication/ChangeStream.ts +303 -350
  72. package/src/replication/ChangeStreamReplicationJob.ts +9 -3
  73. package/src/replication/ChangeStreamReplicator.ts +5 -0
  74. package/src/replication/MongoRelation.ts +8 -1
  75. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  76. package/src/replication/MongoReplicationStream.ts +201 -0
  77. package/src/replication/MongoSnapshotQuery.ts +19 -3
  78. package/src/replication/MongoSnapshotter.ts +76 -98
  79. package/src/replication/RawChangeStream.ts +5 -32
  80. package/src/replication/SourceRowConverter.ts +4 -2
  81. package/src/replication/bufferToSqlite.ts +43 -23
  82. package/src/replication/checkpoints/CheckpointImplementation.ts +1 -1
  83. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +2 -2
  84. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +3 -2
  85. package/src/replication/replication-index.ts +6 -0
  86. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  87. package/src/test-utils/test-utils-index.ts +1 -0
  88. package/src/types/types.ts +27 -2
  89. package/test/src/buffer_to_sqlite.test.ts +9 -0
  90. package/test/src/change_stream.test.ts +76 -3
  91. package/test/src/change_stream_test_setup.ts +38 -0
  92. package/test/src/chunked_snapshot.test.ts +3 -3
  93. package/test/src/config.test.ts +34 -0
  94. package/test/src/documentdb_mode.test.ts +4 -5
  95. package/test/src/raw_change_stream.test.ts +0 -63
  96. package/test/src/replication_stream.test.ts +311 -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 +223 -0
  100. package/test/src/slow_tests.test.ts +3 -3
  101. package/test/src/snapshot_query_provider.test.ts +95 -0
  102. package/test/src/stream_progress.test.ts +312 -0
  103. package/test/src/util.ts +11 -29
  104. package/tsconfig.tsbuildinfo +1 -1
  105. package/test/src/change_stream_utils.ts +0 -375
@@ -18,8 +18,13 @@ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation
18
18
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
19
19
  import { MongoManager } from './MongoManager.js';
20
20
  import { getMongoRelation } from './MongoRelation.js';
21
+ import {
22
+ DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
23
+ MongoReplicationQueryProvider
24
+ } from './MongoReplicationQueryProvider.js';
25
+ import { MongoReplicationStreamItem, openMongoReplicationStream } from './MongoReplicationStream.js';
21
26
  import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
22
- import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
27
+ import { ChangeStreamBatch } from './RawChangeStream.js';
23
28
  import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
24
29
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
25
30
 
@@ -34,6 +39,7 @@ export interface MongoSnapshotterOptions {
34
39
  checkpointStreamId: mongo.ObjectId;
35
40
  storageHooks?: storage.StorageHooks;
36
41
  snapshotHooks?: MongoSnapshotterHooks;
42
+ queryProvider?: MongoReplicationQueryProvider;
37
43
  }
38
44
 
39
45
  export interface MongoSnapshotterHooks {
@@ -59,6 +65,7 @@ export class MongoSnapshotter {
59
65
  private readonly defaultDb: mongo.Db;
60
66
  private readonly syncRules: HydratedSyncConfig;
61
67
  private readonly sourceRowConverter: SourceRowConverter;
68
+ private readonly queryProvider: MongoReplicationQueryProvider;
62
69
  private readonly maxAwaitTimeMS: number;
63
70
  private readonly snapshotChunkLength: number;
64
71
  private readonly abortSignal: AbortSignal;
@@ -90,6 +97,7 @@ export class MongoSnapshotter {
90
97
  this.checkpointStreamId = options.checkpointStreamId;
91
98
  this.storageHooks = options.storageHooks;
92
99
  this.snapshotHooks = options.snapshotHooks;
100
+ this.queryProvider = options.queryProvider ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
93
101
  this.changeStreamTimeout = Math.ceil(this.client.options.socketTimeoutMS * 0.9);
94
102
  this.syncRules = options.storage.getParsedSyncRules({
95
103
  defaultSchema: this.defaultDb.databaseName
@@ -116,7 +124,9 @@ export class MongoSnapshotter {
116
124
  return this.storage.storageConfig.softDeleteCurrentData;
117
125
  }
118
126
 
119
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
127
+ /**
128
+ * The active checkpoint strategy. Only valid after ensureDetected().
129
+ */
120
130
  private get checkpointImplementation(): CheckpointImplementation {
121
131
  if (this._checkpointImplementation == null) {
122
132
  throw new ServiceError(
@@ -150,6 +160,7 @@ export class MongoSnapshotter {
150
160
  );
151
161
  }
152
162
  }
163
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
153
164
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
154
165
  client: this.client,
155
166
  db: this.defaultDb,
@@ -212,6 +223,7 @@ export class MongoSnapshotter {
212
223
  defaultSchema: this.defaultDb.databaseName,
213
224
  storeCurrentData: false,
214
225
  skipExistingRows: true,
226
+ signal: this.abortSignal,
215
227
  tracer: new PerformanceTracer('MongoDB initial snapshot setup')
216
228
  });
217
229
  if (snapshotLsn == null) {
@@ -243,9 +255,7 @@ export class MongoSnapshotter {
243
255
  totalEstimatedCount: count
244
256
  });
245
257
  this.queueTable(updated);
246
- this.logger.info(
247
- `To replicate: ${updated.qualifiedName}: ${updated.snapshotStatus?.replicatedCount}/~${updated.snapshotStatus?.totalEstimatedCount}`
248
- );
258
+ this.logger.info(`To replicate: ${updated.qualifiedName} ${updated.formatSnapshotProgress()}`);
249
259
  }
250
260
  }
251
261
 
@@ -345,10 +355,10 @@ export class MongoSnapshotter {
345
355
  return;
346
356
  }
347
357
 
348
- // Populate the cache _after_ initial replication, but _before_ we switch to this replication stream.
358
+ // Compact storage _after_ initial replication, but _before_ we switch to this replication stream.
349
359
  // Keeping snapshot_done false until this completes makes this resumable after interruption.
350
360
  // No checkpoint exists yet - storage defaults to its highest persisted op id.
351
- await this.storage.populatePersistentChecksumCache({
361
+ await this.storage.compactInitialReplication({
352
362
  signal: this.abortSignal
353
363
  });
354
364
 
@@ -361,7 +371,8 @@ export class MongoSnapshotter {
361
371
  zeroLSN: MongoLSN.ZERO.comparable,
362
372
  defaultSchema: this.defaultDb.databaseName,
363
373
  storeCurrentData: false,
364
- skipExistingRows: true
374
+ skipExistingRows: true,
375
+ signal: this.abortSignal
365
376
  });
366
377
 
367
378
  // The checkpoint here is a marker - we need to replicate up to at least this
@@ -391,6 +402,7 @@ export class MongoSnapshotter {
391
402
  storeCurrentData: false,
392
403
  skipExistingRows: true,
393
404
  hooks: this.storageHooks,
405
+ signal: this.abortSignal,
394
406
  tracer: new PerformanceTracer('MongoDB snapshot table')
395
407
  });
396
408
  // Get fresh table info, in case it was updated while queuing.
@@ -462,13 +474,15 @@ export class MongoSnapshotter {
462
474
  const bytesReplicatedMetric = this.metrics.getCounter(ReplicationMetric.DATA_REPLICATED_BYTES);
463
475
  const chunksReplicatedMetric = this.metrics.getCounter(ReplicationMetric.CHUNKS_REPLICATED);
464
476
 
465
- const totalEstimatedCount = await this.estimatedCountNumber(table);
477
+ const filter = this.queryProvider.getSnapshotFilter(table);
478
+ const totalEstimatedCount = await this.estimatedCountNumber(table, filter);
466
479
  let at = table.snapshotStatus?.replicatedCount ?? 0;
467
480
  const collection = this.client.db(table.schema).collection(table.name);
468
481
  await using query = new ChunkedSnapshotQuery({
469
482
  collection,
470
483
  key: table.snapshotStatus?.lastKey,
471
- batchSize: this.snapshotChunkLength
484
+ batchSize: this.snapshotChunkLength,
485
+ filter
472
486
  });
473
487
  if (query.lastKey != null) {
474
488
  this.logger.info(
@@ -562,7 +576,15 @@ export class MongoSnapshotter {
562
576
  return result.tables;
563
577
  }
564
578
 
565
- private async estimatedCountNumber(table: storage.SourceTable): Promise<number> {
579
+ private async estimatedCountNumber(
580
+ table: storage.SourceTable,
581
+ filter = this.queryProvider.getSnapshotFilter(table)
582
+ ): Promise<number> {
583
+ // Collection metadata cannot estimate a filtered result. An exact count could require another
584
+ // expensive scan, so use the shared unknown-total sentinel and report only the replicated count.
585
+ if (filter != null) {
586
+ return -1;
587
+ }
566
588
  return await this.client.db(table.schema).collection(table.name).estimatedDocumentCount();
567
589
  }
568
590
 
@@ -613,7 +635,7 @@ export class MongoSnapshotter {
613
635
  this.checkpointImplementation.seedPosition(null);
614
636
  const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
615
637
  const filters = this.getSourceNamespaceFilters();
616
- const iter = this.rawChangeStreamBatches({
638
+ const iter = this.openChangeStream({
617
639
  lsn: startStreamFromLsn,
618
640
  maxAwaitTimeMS: 0,
619
641
  signal: this.abortSignal,
@@ -624,7 +646,7 @@ export class MongoSnapshotter {
624
646
  let eventsSeen = 0;
625
647
  let batchesSeen = 0;
626
648
 
627
- for await (const { events } of iter) {
649
+ for await (const item of iter) {
628
650
  if (performance.now() - startTime >= LSN_TIMEOUT_SECONDS * 1000) {
629
651
  break;
630
652
  }
@@ -632,26 +654,26 @@ export class MongoSnapshotter {
632
654
  await this.checkpointImplementation.createBatchCheckpoint();
633
655
  lastCheckpointCreated = performance.now();
634
656
  }
635
- batchesSeen += 1;
636
-
637
- for (const rawChangeDocument of events) {
638
- const changeDocument = parseChangeDocument(rawChangeDocument);
639
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
640
-
641
- if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
642
- const kind = this.checkpointImplementation.event.observe(changeDocument);
643
- if (kind != 'own-barrier') {
644
- // Standalone events still feed the implementation's coordinate via
645
- // event.observe above; we only resolve on our own barrier.
646
- continue;
647
- }
648
- // Observing our own barrier has set the coordinate (the barrier event
649
- // carries the sentinel counter directly), so the LSN is ready.
650
- return this.checkpointImplementation.event.lsn(changeDocument);
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;
663
+
664
+ if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
665
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
666
+ if (kind != 'own-barrier') {
667
+ // Standalone events still feed the implementation's coordinate via
668
+ // event.observe above; we only resolve on our own barrier.
669
+ continue;
651
670
  }
652
-
653
- eventsSeen += 1;
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);
654
674
  }
675
+
676
+ eventsSeen += 1;
655
677
  }
656
678
 
657
679
  // Could happen if there is a very large replication lag?
@@ -665,12 +687,13 @@ export class MongoSnapshotter {
665
687
  * Given a snapshot LSN, validate that we can read from it, by opening a change stream.
666
688
  */
667
689
  private async validateSnapshotLsn(lsn: string) {
668
- const stream = this.rawChangeStreamBatches({
690
+ const stream = this.openChangeStream({
669
691
  lsn,
670
692
  maxAwaitTimeMS: 0,
671
- filters: this.getSourceNamespaceFilters()
693
+ filters: this.getSourceNamespaceFilters(),
694
+ signal: this.abortSignal
672
695
  });
673
- for await (const _batch of stream) {
696
+ for await (const _item of stream) {
674
697
  break;
675
698
  }
676
699
  }
@@ -719,76 +742,31 @@ export class MongoSnapshotter {
719
742
  return { $match: nsFilter, multipleDatabases };
720
743
  }
721
744
 
722
- private rawChangeStreamBatches(options: {
745
+ private openChangeStream(options: {
723
746
  lsn: string | null;
724
747
  maxAwaitTimeMS?: number;
725
748
  batchSize?: number;
726
749
  filters: { $match: any; multipleDatabases: boolean };
727
750
  signal?: AbortSignal;
728
751
  tracer?: PerformanceTracer<'changestream'>;
729
- }): AsyncIterableIterator<ChangeStreamBatch> {
730
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
731
- const startAfter = position?.startAfter ?? undefined;
732
- const resumeAfter = position?.resumeAfter ?? undefined;
733
-
734
- let fullDocument: 'required' | 'updateLookup';
735
- if (this.isDocumentDb) {
736
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
737
- fullDocument = 'updateLookup';
738
- } else if (this.usePostImages) {
739
- // 'read_only' or 'auto_configure'
740
- // Configuration happens during snapshot, or when we see new
741
- // collections.
742
- fullDocument = 'required';
743
- } else {
744
- fullDocument = 'updateLookup';
745
- }
746
- const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
747
- fullDocument
748
- };
749
- if (!this.isDocumentDb) {
750
- // DocumentDB does not support showExpandedEvents.
751
- streamOptions.showExpandedEvents = true;
752
- }
753
- const pipeline: mongo.Document[] = [{ $changeStream: streamOptions }, { $match: options.filters.$match }];
754
- if (!this.isDocumentDb) {
755
- // DocumentDB does not support $changeStreamSplitLargeEvent.
756
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
757
- }
758
-
759
- // Only one of these options can be supplied at a time.
760
- if (resumeAfter) {
761
- streamOptions.resumeAfter = resumeAfter;
762
- } else if (startAfter != null) {
763
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
764
- // case if we have an old one. The sentinel implementation never produces a startAfter,
765
- // and a fresh DocumentDB stream opens from "now" with neither option set.
766
- streamOptions.startAtOperationTime = startAfter;
767
- }
768
-
769
- let watchDb: mongo.Db;
770
- if (this.isDocumentDb || options.filters.multipleDatabases) {
771
- // DocumentDB only supports cluster-level change streams.
772
- // Requires readAnyDatabase@admin on Atlas.
773
- watchDb = this.client.db('admin');
774
- streamOptions.allChangesForCluster = true;
775
- } else {
776
- // Same general result, but requires less permissions than the above
777
- watchDb = this.defaultDb;
778
- }
779
-
780
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
781
-
782
- return rawChangeStream(watchDb, pipeline, {
783
- batchSize: options.batchSize ?? this.snapshotChunkLength,
784
- maxAwaitTimeMS,
785
- // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
786
- // In that case there is no client-side wait to emulate for DocumentDB.
787
- clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
788
- maxTimeMS: this.changeStreamTimeout,
789
- signal: options.signal,
790
- logger: this.logger,
791
- tracer: options.tracer
752
+ onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
753
+ }): AsyncIterableIterator<MongoReplicationStreamItem> {
754
+ return openMongoReplicationStream({
755
+ db: this.defaultDb,
756
+ queryProvider: this.queryProvider,
757
+ namespaceFilter: options.filters,
758
+ isDocumentDb: this.isDocumentDb,
759
+ usePostImages: this.usePostImages,
760
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
761
+ onBatch: options.onBatch,
762
+ options: {
763
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
764
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
765
+ maxTimeMS: this.changeStreamTimeout,
766
+ signal: options.signal,
767
+ logger: this.logger,
768
+ tracer: options.tracer
769
+ }
792
770
  });
793
771
  }
794
772
 
@@ -6,14 +6,8 @@ import {
6
6
  ReplicationAssertionError
7
7
  } from '@powersync/lib-services-framework';
8
8
  import { PerformanceTracer } from '@powersync/service-core';
9
- import { performance } from 'node:perf_hooks';
10
- import { setTimeout as delay } from 'node:timers/promises';
11
9
  import { ChangeStreamInvalidatedError } from './ChangeStream.js';
12
10
 
13
- // Keep the DocumentDB idle-poll workaround from adding the full maxAwaitTimeMS
14
- // as local latency when an update arrives just after an empty batch.
15
- const CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS = 1_000;
16
-
17
11
  export interface RawChangeStreamOptions {
18
12
  signal?: AbortSignal;
19
13
 
@@ -21,21 +15,12 @@ export interface RawChangeStreamOptions {
21
15
  * How long to wait for new data per batch (max time for long-polling).
22
16
  * This is sent as maxTimeMS for the getMore command.
23
17
  *
24
- * A value of 0 is allowed for probe-style streams that do not want an idle
25
- * wait; in that case PowerSync also skips the local empty-batch delay.
18
+ * A value of 0 removes the explicit await limit and leaves the server's
19
+ * default awaitData behavior in effect; it does not make getMore return
20
+ * immediately. Snapshot probes use 0 for this purpose.
26
21
  */
27
22
  maxAwaitTimeMS: number;
28
23
 
29
- /**
30
- * Also enforce maxAwaitTimeMS on the client for empty getMore batches.
31
- *
32
- * Azure DocumentDB currently returns idle getMore calls before maxTimeMS. When
33
- * this is enabled, empty batches are delayed locally (capped at 1s) to avoid
34
- * tight polling. We still send maxTimeMS so this remains compatible with
35
- * servers that handle maxAwaitTimeMS correctly.
36
- */
37
- clientSideMaxAwaitTimeMS?: boolean;
38
-
39
24
  /**
40
25
  * Timeout for the initial aggregate command.
41
26
  */
@@ -230,17 +215,12 @@ async function* rawChangeStreamInner(
230
215
  options.signal?.throwIfAborted();
231
216
 
232
217
  using commandSpan = options.tracer?.span('changestream', 'getmore');
233
- const getMoreStartedAt = performance.now();
234
218
  const getMoreCommand: mongo.Document = {
235
219
  getMore: cursorId,
236
220
  collection: nsCollection,
237
221
  batchSize: batchSizer.next(),
238
222
  maxTimeMS: options.maxAwaitTimeMS
239
223
  };
240
- // Azure DocumentDB currently returns empty getMore batches before
241
- // maxTimeMS expires. Keep maxTimeMS for forward compatibility with the
242
- // server-side behavior, and when client-side mode is enabled, enforce the
243
- // capped idle wait locally for empty batches below.
244
224
  const getMoreResult: mongo.Document = await db.command(getMoreCommand, { session, raw: true }).catch((e) => {
245
225
  if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
246
226
  // This may be due to the killCursors command issued when aborting.
@@ -268,13 +248,6 @@ async function* rawChangeStreamInner(
268
248
  // postBatchResumeToken is returned in MongoDB 4.0.7 and later, and we support 6.0+
269
249
  throw new ReplicationAssertionError(`postBatchResumeToken from aggregate response`);
270
250
  }
271
- if (options.clientSideMaxAwaitTimeMS && nextBatch.length == 0) {
272
- const remainingMaxAwaitTimeMS = Math.ceil(options.maxAwaitTimeMS - (performance.now() - getMoreStartedAt));
273
- if (remainingMaxAwaitTimeMS > 0) {
274
- const clientSideDelayMs = Math.min(remainingMaxAwaitTimeMS, CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS);
275
- await delay(clientSideDelayMs, undefined, { signal: options.signal });
276
- }
277
- }
278
251
  yield {
279
252
  events: nextBatch,
280
253
  resumeToken: cursor.postBatchResumeToken,
@@ -391,7 +364,7 @@ type MapRawDocument<T> = T extends unknown ? { [K in keyof T]: Rawify<T[K]> } :
391
364
  export type ProjectedChangeStreamDocument =
392
365
  | Omit<mongo.ChangeStreamDropDocument, 'wallTime' | 'collectionUUID'>
393
366
  | Omit<mongo.ChangeStreamRenameDocument, 'wallTime'>
394
- | Omit<mongo.ChangeStreamDeleteDocument<Buffer>, 'wallTime'>
367
+ | Omit<mongo.ChangeStreamDeleteDocument<Buffer>, 'wallTime' | 'collectionUUID'>
395
368
  | Omit<mongo.ChangeStreamInsertDocument<Buffer>, 'wallTime'>
396
369
  | Omit<mongo.ChangeStreamUpdateDocument<Buffer>, 'wallTime' | 'updateDescription'>
397
370
  | Omit<mongo.ChangeStreamReplaceDocument<Buffer>, 'wallTime'>;
@@ -459,7 +432,7 @@ export function mapChangeStreamError(e: unknown) {
459
432
  } else if (
460
433
  isMongoServerError(e) &&
461
434
  e.codeName == 'NoMatchingDocument' &&
462
- 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'))
463
436
  ) {
464
437
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
465
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
  }
@@ -80,12 +80,16 @@ const SHARED_WRITER = new JsonBufferWriter();
80
80
  *
81
81
  * @param bytes the source BSON bytes
82
82
  * @param dateRenderMode derive using getDateRenderMode(compatibilityContext)
83
+ * @param fixedBooleansInJson whether to serialize booleans in nested JSON as booleans, derived from compatibility
84
+ * context.
83
85
  *
84
86
  * @returns a SqliteRow
85
87
  */
86
- export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode): SqliteRow {
88
+ export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode, fixedBooleansInJson: boolean): SqliteRow {
87
89
  const row: SqliteRow = {};
88
90
  const jsonWriter = SHARED_WRITER;
91
+ const options: SerializeToJsonOptions = { dateRenderMode, fixedBooleans: fixedBooleansInJson };
92
+
89
93
  // BSON documents are length-prefixed and null-terminated. We parse directly
90
94
  // from raw bytes, so structural validation happens here rather than in the
91
95
  // upstream BSON decoder.
@@ -112,14 +116,14 @@ export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode): S
112
116
  }
113
117
  case BSON_TYPE_ARRAY: {
114
118
  jsonWriter.reset();
115
- const result = serializeNestedArrayToJson(bytes, offset, 0, jsonWriter, dateRenderMode);
119
+ const result = serializeNestedArrayToJson(bytes, offset, 0, jsonWriter, options);
116
120
  row[key] = jsonWriter.toString();
117
121
  offset = result.nextOffset;
118
122
  break;
119
123
  }
120
124
  case BSON_TYPE_DOCUMENT: {
121
125
  jsonWriter.reset();
122
- const result = serializeNestedObjectToJson(bytes, offset, 0, jsonWriter, dateRenderMode);
126
+ const result = serializeNestedObjectToJson(bytes, offset, 0, jsonWriter, options);
123
127
  row[key] = jsonWriter.toString();
124
128
  offset = result.nextOffset;
125
129
  break;
@@ -195,7 +199,7 @@ export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode): S
195
199
  }
196
200
  case BSON_TYPE_CODE_WITH_SCOPE: {
197
201
  jsonWriter.reset();
198
- const nextOffset = writeCodeWithScopeJson(bytes, offset, 0, jsonWriter, dateRenderMode);
202
+ const nextOffset = writeCodeWithScopeJson(bytes, offset, 0, jsonWriter, options);
199
203
  row[key] = jsonWriter.toString();
200
204
  offset = nextOffset;
201
205
  break;
@@ -437,12 +441,17 @@ function skipBsonValue(bytes: Buffer, offset: number, type: number) {
437
441
  }
438
442
  }
439
443
 
444
+ interface SerializeToJsonOptions {
445
+ dateRenderMode: DateRenderMode;
446
+ fixedBooleans: boolean;
447
+ }
448
+
440
449
  function serializeNestedObjectToJson(
441
450
  bytes: Buffer,
442
451
  offset: number,
443
452
  depth: number,
444
453
  writer: JsonBufferWriter,
445
- dateRenderMode: DateRenderMode
454
+ options: SerializeToJsonOptions
446
455
  ): { nextOffset: number } {
447
456
  if (depth > NESTED_DEPTH_LIMIT) {
448
457
  throw new Error(`json nested object depth exceeds the limit of ${NESTED_DEPTH_LIMIT}`);
@@ -475,7 +484,7 @@ function serializeNestedObjectToJson(
475
484
  type,
476
485
  depth,
477
486
  writer,
478
- dateRenderMode
487
+ options
479
488
  );
480
489
  cursor = afterValue;
481
490
  // Malformed BSON must fail fast instead of getting the parser stuck on the
@@ -499,7 +508,7 @@ function serializeNestedArrayToJson(
499
508
  offset: number,
500
509
  depth: number,
501
510
  writer: JsonBufferWriter,
502
- dateRenderMode: DateRenderMode
511
+ options: SerializeToJsonOptions
503
512
  ): { nextOffset: number } {
504
513
  if (depth > NESTED_DEPTH_LIMIT) {
505
514
  throw new Error(`json nested object depth exceeds the limit of ${NESTED_DEPTH_LIMIT}`);
@@ -527,7 +536,7 @@ function serializeNestedArrayToJson(
527
536
  type,
528
537
  depth,
529
538
  writer,
530
- dateRenderMode
539
+ options
531
540
  );
532
541
  cursor = afterValue;
533
542
  assertAdvanced(previousCursor, cursor);
@@ -547,7 +556,7 @@ function serializeNestedElementValue(
547
556
  type: number,
548
557
  depth: number,
549
558
  writer: JsonBufferWriter,
550
- dateRenderMode: DateRenderMode
559
+ options: SerializeToJsonOptions
551
560
  ): { nextOffset: number; defined: boolean } {
552
561
  switch (type) {
553
562
  case BSON_TYPE_DOUBLE: // Double
@@ -555,9 +564,9 @@ function serializeNestedElementValue(
555
564
  case BSON_TYPE_STRING: // String
556
565
  return serializeNestedStringElement(bytes, offset, writer);
557
566
  case BSON_TYPE_DOCUMENT: // Embedded document
558
- return serializeNestedObjectElement(bytes, offset, depth, writer, dateRenderMode);
567
+ return serializeNestedObjectElement(bytes, offset, depth, writer, options);
559
568
  case BSON_TYPE_ARRAY: // Array
560
- return serializeNestedArrayElement(bytes, offset, depth, writer, dateRenderMode);
569
+ return serializeNestedArrayElement(bytes, offset, depth, writer, options);
561
570
  case BSON_TYPE_BINARY: // Binary
562
571
  return serializeNestedBinaryElement(bytes, offset, writer);
563
572
  case BSON_TYPE_UNDEFINED: // Undefined
@@ -567,11 +576,22 @@ function serializeNestedElementValue(
567
576
  writer.writeQuotedHexLower(bytes, offset, 12);
568
577
  return { nextOffset: offset + 12, defined: true };
569
578
  }
570
- case BSON_TYPE_BOOLEAN: // Boolean
571
- writer.writeByte(bytes[offset] ? BYTE_ONE : BYTE_ZERO);
579
+ case BSON_TYPE_BOOLEAN: {
580
+ // Boolean
581
+ const value = !!bytes[offset];
582
+
583
+ if (options.fixedBooleans) {
584
+ const str = value ? 'true' : 'false';
585
+
586
+ writer.writeAscii(str);
587
+ } else {
588
+ writer.writeByte(value ? BYTE_ONE : BYTE_ZERO);
589
+ }
590
+
572
591
  return { nextOffset: offset + 1, defined: true };
592
+ }
573
593
  case BSON_TYPE_UTC_DATETIME: // UTC datetime
574
- return serializeNestedDateTimeElement(bytes, offset, writer, dateRenderMode);
594
+ return serializeNestedDateTimeElement(bytes, offset, writer, options.dateRenderMode);
575
595
  case BSON_TYPE_NULL: // Null
576
596
  case BSON_TYPE_MIN_KEY: // MinKey
577
597
  case BSON_TYPE_MAX_KEY: // MaxKey
@@ -586,7 +606,7 @@ function serializeNestedElementValue(
586
606
  case BSON_TYPE_SYMBOL: // Symbol
587
607
  return serializeNestedSymbolElement(bytes, offset, writer);
588
608
  case BSON_TYPE_CODE_WITH_SCOPE: // JavaScript code with scope
589
- return serializeNestedCodeWithScopeElement(bytes, offset, depth, writer, dateRenderMode);
609
+ return serializeNestedCodeWithScopeElement(bytes, offset, depth, writer, options);
590
610
  case BSON_TYPE_INT32: {
591
611
  // Int32
592
612
  writer.writeAscii(String(readInt32LE(bytes, offset)));
@@ -641,9 +661,9 @@ function serializeNestedObjectElement(
641
661
  offset: number,
642
662
  depth: number,
643
663
  writer: JsonBufferWriter,
644
- dateRenderMode: DateRenderMode
664
+ options: SerializeToJsonOptions
645
665
  ): { nextOffset: number; defined: boolean } {
646
- const result = serializeNestedObjectToJson(bytes, offset, depth + 1, writer, dateRenderMode);
666
+ const result = serializeNestedObjectToJson(bytes, offset, depth + 1, writer, options);
647
667
  return { nextOffset: result.nextOffset, defined: true };
648
668
  }
649
669
 
@@ -652,9 +672,9 @@ function serializeNestedArrayElement(
652
672
  offset: number,
653
673
  depth: number,
654
674
  writer: JsonBufferWriter,
655
- dateRenderMode: DateRenderMode
675
+ options: SerializeToJsonOptions
656
676
  ): { nextOffset: number; defined: boolean } {
657
- const result = serializeNestedArrayToJson(bytes, offset, depth + 1, writer, dateRenderMode);
677
+ const result = serializeNestedArrayToJson(bytes, offset, depth + 1, writer, options);
658
678
  return { nextOffset: result.nextOffset, defined: true };
659
679
  }
660
680
 
@@ -791,14 +811,14 @@ function writeCodeWithScopeJson(
791
811
  offset: number,
792
812
  depth: number,
793
813
  writer: JsonBufferWriter,
794
- dateRenderMode: DateRenderMode
814
+ options: SerializeToJsonOptions
795
815
  ) {
796
816
  const totalLength = readInt32LE(bytes, offset);
797
817
  const { value: code, nextOffset: afterCode } = readBsonString(bytes, offset + 4);
798
818
  writer.writeAscii('{"code":');
799
819
  writer.writeQuotedJsonString(code);
800
820
  writer.writeAscii(',"scope":');
801
- serializeNestedObjectToJson(bytes, afterCode, depth + 1, writer, dateRenderMode);
821
+ serializeNestedObjectToJson(bytes, afterCode, depth + 1, writer, options);
802
822
  writer.writeByte(BYTE_RBRACE);
803
823
  // code_w_scope carries its own total byte length, so we trust that wrapper
804
824
  // rather than reconstructing the end position from the nested scope.
@@ -885,10 +905,10 @@ function serializeNestedCodeWithScopeElement(
885
905
  offset: number,
886
906
  depth: number,
887
907
  writer: JsonBufferWriter,
888
- dateRenderMode: DateRenderMode
908
+ options: SerializeToJsonOptions
889
909
  ): { nextOffset: number; defined: boolean } {
890
910
  return {
891
- nextOffset: writeCodeWithScopeJson(bytes, offset, depth, writer, dateRenderMode),
911
+ nextOffset: writeCodeWithScopeJson(bytes, offset, depth, writer, options),
892
912
  defined: true
893
913
  };
894
914
  }
@@ -128,7 +128,7 @@ export interface CheckpointImplementation {
128
128
  * The timestamp implementation parses the timestamp embedded in the token.
129
129
  * The sentinel implementation pairs the token with the current coordinate.
130
130
  */
131
- lsnFromResumeToken(resumeToken: mongo.ResumeToken): string;
131
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken): { lsn: string; timestamp: Date | null };
132
132
 
133
133
  /**
134
134
  * Source-side replication head for write checkpoints. The LSN passed to the
@@ -108,11 +108,11 @@ export class SentinelCheckpointImplementation implements CheckpointImplementatio
108
108
  );
109
109
  }
110
110
 
111
- lsnFromResumeToken(resumeToken: mongo.ResumeToken): string {
111
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken) {
112
112
  // Pair the bare token with the current coordinate. The coordinate is
113
113
  // frozen between checkpoint events, so a per-batch resume marker only
114
114
  // advances the token; that is all resumption needs (see the design doc).
115
- return new SentinelLSN({ sentinel: this.position, resume_token: resumeToken }).comparable;
115
+ return { lsn: new SentinelLSN({ sentinel: this.position, resume_token: resumeToken }).comparable, timestamp: null };
116
116
  }
117
117
 
118
118
  async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
@@ -67,9 +67,10 @@ export class TimestampCheckpointImplementation implements CheckpointImplementati
67
67
  );
68
68
  }
69
69
 
70
- lsnFromResumeToken(resumeToken: mongo.ResumeToken): string {
70
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken) {
71
71
  // The timestamp is embedded in the resume token.
72
- return MongoLSN.fromResumeToken(resumeToken).comparable;
72
+ const lsn = MongoLSN.fromResumeToken(resumeToken);
73
+ return { lsn: lsn.comparable, timestamp: timestampToDate(lsn.timestamp) };
73
74
  }
74
75
 
75
76
  async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {