@powersync/service-module-mongodb 0.18.2 → 0.20.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 (71) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
  3. package/dist/api/MongoRouteAPIAdapter.js +23 -32
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/SentinelLSN.d.ts +37 -0
  6. package/dist/common/SentinelLSN.js +59 -0
  7. package/dist/common/SentinelLSN.js.map +1 -0
  8. package/dist/module/MongoModule.js +2 -1
  9. package/dist/module/MongoModule.js.map +1 -1
  10. package/dist/replication/ChangeStream.d.ts +23 -0
  11. package/dist/replication/ChangeStream.js +167 -47
  12. package/dist/replication/ChangeStream.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicationJob.js +2 -1
  14. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  15. package/dist/replication/MongoRelation.d.ts +36 -1
  16. package/dist/replication/MongoRelation.js +102 -6
  17. package/dist/replication/MongoRelation.js.map +1 -1
  18. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  19. package/dist/replication/MongoSnapshotter.js +113 -42
  20. package/dist/replication/MongoSnapshotter.js.map +1 -1
  21. package/dist/replication/RawChangeStream.d.ts +12 -1
  22. package/dist/replication/RawChangeStream.js +23 -10
  23. package/dist/replication/RawChangeStream.js.map +1 -1
  24. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +135 -0
  25. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  26. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  27. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +58 -0
  28. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +224 -0
  29. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  30. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +27 -0
  31. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +116 -0
  32. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  33. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  34. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  35. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  36. package/dist/replication/replication-utils.d.ts +6 -0
  37. package/dist/replication/replication-utils.js +19 -2
  38. package/dist/replication/replication-utils.js.map +1 -1
  39. package/dist/types/types.d.ts +5 -0
  40. package/dist/types/types.js +20 -2
  41. package/dist/types/types.js.map +1 -1
  42. package/package.json +9 -9
  43. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  44. package/src/common/SentinelLSN.ts +78 -0
  45. package/src/module/MongoModule.ts +2 -1
  46. package/src/replication/ChangeStream.ts +184 -55
  47. package/src/replication/ChangeStreamReplicationJob.ts +2 -1
  48. package/src/replication/MongoRelation.ts +124 -14
  49. package/src/replication/MongoSnapshotter.ts +132 -47
  50. package/src/replication/RawChangeStream.ts +54 -32
  51. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  52. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +269 -0
  53. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +145 -0
  54. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  55. package/src/replication/replication-utils.ts +23 -2
  56. package/src/types/types.ts +27 -2
  57. package/test/DOCUMENTDB_TESTING.md +115 -0
  58. package/test/src/DatabaseType.ts +25 -0
  59. package/test/src/change_stream.test.ts +97 -65
  60. package/test/src/change_stream_utils.ts +99 -11
  61. package/test/src/checkpoint_retry.test.ts +5 -2
  62. package/test/src/config.test.ts +34 -0
  63. package/test/src/documentdb_helpers.test.ts +124 -0
  64. package/test/src/documentdb_mode.test.ts +1040 -0
  65. package/test/src/mongo_test.test.ts +15 -5
  66. package/test/src/raw_change_stream.test.ts +209 -125
  67. package/test/src/resume_token.test.ts +30 -0
  68. package/test/src/slow_tests.test.ts +4 -1
  69. package/test/src/test-timeouts.ts +23 -0
  70. package/test/src/util.ts +1 -2
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -14,11 +14,13 @@ import { performance } from 'node:perf_hooks';
14
14
  import { MongoLSN } from '../common/MongoLSN.js';
15
15
  import { PostImagesOption } from '../types/types.js';
16
16
  import { escapeRegExp } from '../utils.js';
17
+ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
18
+ import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
17
19
  import { MongoManager } from './MongoManager.js';
18
- import { createCheckpoint, getMongoRelation, STANDALONE_CHECKPOINT_ID } from './MongoRelation.js';
20
+ import { getMongoRelation } from './MongoRelation.js';
19
21
  import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
20
22
  import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
21
- import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
23
+ import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
22
24
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
23
25
 
24
26
  export interface MongoSnapshotterOptions {
@@ -72,6 +74,9 @@ export class MongoSnapshotter {
72
74
  private nextItemQueued: PromiseWithResolvers<void> | null = null;
73
75
  private lastTouchedAt = performance.now();
74
76
 
77
+ private isDocumentDb = false;
78
+ private _checkpointImplementation: CheckpointImplementation | null = null;
79
+
75
80
  constructor(options: MongoSnapshotterOptions) {
76
81
  this.storage = options.storage;
77
82
  this.metrics = options.metrics;
@@ -94,6 +99,9 @@ export class MongoSnapshotter {
94
99
  this.abortSignal.addEventListener('abort', () => {
95
100
  this.nextItemQueued?.resolve();
96
101
  });
102
+
103
+ // Errors here should not result in uncaught rejection - calling waitForInitialSnapshot is optional.
104
+ void this.initialSnapshotDone.promise.catch(() => {});
97
105
  }
98
106
 
99
107
  private get usePostImages() {
@@ -108,6 +116,48 @@ export class MongoSnapshotter {
108
116
  return this.storage.storageConfig.softDeleteCurrentData;
109
117
  }
110
118
 
119
+ /** The active checkpoint strategy. Only valid after ensureDetected(). */
120
+ private get checkpointImplementation(): CheckpointImplementation {
121
+ if (this._checkpointImplementation == null) {
122
+ throw new ServiceError(
123
+ ErrorCode.PSYNC_S1301,
124
+ 'Checkpoint implementation not initialized - call ensureDetected() first'
125
+ );
126
+ }
127
+ return this._checkpointImplementation;
128
+ }
129
+
130
+ /**
131
+ * Detect DocumentDB and select the checkpoint implementation. Idempotent.
132
+ * Also validates server topology (sharded/standalone) for standard MongoDB.
133
+ */
134
+ private async ensureDetected(): Promise<void> {
135
+ if (this._checkpointImplementation != null) {
136
+ return;
137
+ }
138
+ this.isDocumentDb = await detectDocumentDb(this.defaultDb);
139
+ if (!this.isDocumentDb) {
140
+ const hello = await this.defaultDb.command({ hello: 1 });
141
+ if (hello.msg == 'isdbgrid') {
142
+ throw new ServiceError(
143
+ ErrorCode.PSYNC_S1341,
144
+ 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
145
+ );
146
+ } else if (hello.setName == null) {
147
+ throw new ServiceError(
148
+ ErrorCode.PSYNC_S1342,
149
+ 'Standalone MongoDB instances are not supported - use a replicaset.'
150
+ );
151
+ }
152
+ }
153
+ this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
154
+ client: this.client,
155
+ db: this.defaultDb,
156
+ checkpointStreamId: this.checkpointStreamId,
157
+ logger: this.logger
158
+ });
159
+ }
160
+
111
161
  async checkSlot(): Promise<InitResult> {
112
162
  const status = await this.storage.getStatus();
113
163
  if (status.snapshotDone) {
@@ -119,12 +169,18 @@ export class MongoSnapshotter {
119
169
  }
120
170
 
121
171
  async setupCheckpointsCollection() {
172
+ await this.ensureDetected();
122
173
  const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
123
174
  if (collection == null) {
124
175
  await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
125
- changeStreamPreAndPostImages: { enabled: true }
176
+ // DocumentDB does not support changeStreamPreAndPostImages.
177
+ ...(this.isDocumentDb ? {} : { changeStreamPreAndPostImages: { enabled: true } })
126
178
  });
127
- } else if (this.usePostImages && collection.options?.changeStreamPreAndPostImages?.enabled != true) {
179
+ } else if (
180
+ !this.isDocumentDb &&
181
+ this.usePostImages &&
182
+ collection.options?.changeStreamPreAndPostImages?.enabled != true
183
+ ) {
128
184
  // Drop + create requires less permissions than collMod,
129
185
  // and we don't care about the data in this collection.
130
186
  await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
@@ -135,12 +191,22 @@ export class MongoSnapshotter {
135
191
  // Clear the collection on startup, to keep it clean
136
192
  // We never query this collection directly, and don't want to keep the data around.
137
193
  // We only use this to get data into the oplog/changestream.
138
- await this.defaultDb.collection(CHECKPOINTS_COLLECTION).deleteMany({});
194
+ //
195
+ // The implementation supplies the filter: the sentinel implementation must
196
+ // preserve the sentinel checkpoint document, since its counter is the
197
+ // globally-ordered component of every committed DocumentDB LSN.
198
+ await this.defaultDb
199
+ .collection(CHECKPOINTS_COLLECTION)
200
+ .deleteMany(this.checkpointImplementation.checkpointClearFilter);
139
201
  }
140
202
  }
141
203
 
142
204
  async queueSnapshotTables(snapshotLsn: string | null) {
143
205
  await this.client.connect();
206
+ // Ensure isDocumentDb is set before any getSourceNamespaceFilters() call below
207
+ // (notably the validateSnapshotLsn resume path, which does not go through
208
+ // getSnapshotLsn). Idempotent.
209
+ await this.ensureDetected();
144
210
  await using writer = await this.storage.createWriter({
145
211
  zeroLSN: MongoLSN.ZERO.comparable,
146
212
  defaultSchema: this.defaultDb.databaseName,
@@ -256,7 +322,7 @@ export class MongoSnapshotter {
256
322
  for (const table of tables) {
257
323
  await this.snapshotTable(batch, table);
258
324
  }
259
- const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
325
+ const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
260
326
 
261
327
  await batch.markTableSnapshotDone(tables, noCheckpointBefore);
262
328
  }
@@ -300,7 +366,7 @@ export class MongoSnapshotter {
300
366
 
301
367
  // The checkpoint here is a marker - we need to replicate up to at least this
302
368
  // point before the data can be considered consistent.
303
- const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
369
+ const checkpoint = await this.checkpointImplementation.createStandaloneCheckpoint();
304
370
  if (this.queue.size != 0) {
305
371
  return;
306
372
  }
@@ -312,7 +378,7 @@ export class MongoSnapshotter {
312
378
  // KLUDGE: We need to create an extra checkpoint _after_ marking the snapshot done, to fix
313
379
  // issues with order of processing commits(). This is picked up by tests on postgres storage,
314
380
  // the issue may be specific to that storage engine.
315
- await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
381
+ await this.checkpointImplementation.createStandaloneCheckpoint();
316
382
  }
317
383
 
318
384
  private async replicateTable(tableRequest: SourceTable) {
@@ -336,7 +402,7 @@ export class MongoSnapshotter {
336
402
  }
337
403
 
338
404
  await this.snapshotTable(writer, table);
339
- const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
405
+ const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
340
406
  await writer.markTableSnapshotDone([table], noCheckpointBefore);
341
407
 
342
408
  // This commit durably records the persisted ops, so a later checkpoint covers them.
@@ -355,6 +421,13 @@ export class MongoSnapshotter {
355
421
  return [];
356
422
  }
357
423
 
424
+ if (tablePattern.isSchemaWildcard) {
425
+ throw new ServiceError(
426
+ ErrorCode.PSYNC_R2201,
427
+ 'Schema wildcards ("%") in table patterns are not supported for MongoDB connections.'
428
+ );
429
+ }
430
+
358
431
  const nameFilter = tablePattern.isWildcard
359
432
  ? new RegExp('^' + escapeRegExp(tablePattern.tablePrefix))
360
433
  : tablePattern.name;
@@ -516,19 +589,7 @@ export class MongoSnapshotter {
516
589
  }
517
590
 
518
591
  private async getSnapshotLsn(): Promise<string> {
519
- const hello = await this.defaultDb.command({ hello: 1 });
520
- // Basic sanity check
521
- if (hello.msg == 'isdbgrid') {
522
- throw new ServiceError(
523
- ErrorCode.PSYNC_S1341,
524
- 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
525
- );
526
- } else if (hello.setName == null) {
527
- throw new ServiceError(
528
- ErrorCode.PSYNC_S1342,
529
- 'Standalone MongoDB instances are not supported - use a replicaset.'
530
- );
531
- }
592
+ await this.ensureDetected();
532
593
 
533
594
  // Open a change stream just to get a resume token for later use.
534
595
  // We could use clusterTime from the hello command, but that won't tell us if the
@@ -541,14 +602,19 @@ export class MongoSnapshotter {
541
602
  // Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
542
603
  // To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
543
604
  // periodically until the timeout is reached.
605
+ //
606
+ // For the sentinel implementation (DocumentDB) there is no
607
+ // startAtOperationTime: the stream opens from "now" (lsn null) and the
608
+ // retry loop below re-creates checkpoints until one is observed.
544
609
 
545
610
  const LSN_TIMEOUT_SECONDS = 60;
546
611
  const LSN_CREATE_INTERVAL_SECONDS = 1;
547
612
 
548
- const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
613
+ this.checkpointImplementation.seedPosition(null);
614
+ const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
549
615
  const filters = this.getSourceNamespaceFilters();
550
616
  const iter = this.rawChangeStreamBatches({
551
- lsn: firstCheckpointLsn,
617
+ lsn: startStreamFromLsn,
552
618
  maxAwaitTimeMS: 0,
553
619
  signal: this.abortSignal,
554
620
  filters
@@ -563,7 +629,7 @@ export class MongoSnapshotter {
563
629
  break;
564
630
  }
565
631
  if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
566
- await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
632
+ await this.checkpointImplementation.createBatchCheckpoint();
567
633
  lastCheckpointCreated = performance.now();
568
634
  }
569
635
  batchesSeen += 1;
@@ -573,14 +639,15 @@ export class MongoSnapshotter {
573
639
  const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
574
640
 
575
641
  if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
576
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
577
- if (!this.checkpointStreamId.equals(checkpointId)) {
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.
578
646
  continue;
579
647
  }
580
- return new MongoLSN({
581
- timestamp: changeDocument.clusterTime!,
582
- resume_token: changeDocument._id
583
- }).comparable;
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);
584
651
  }
585
652
 
586
653
  eventsSeen += 1;
@@ -638,7 +705,12 @@ export class MongoSnapshotter {
638
705
  }
639
706
  }
640
707
 
641
- const nsFilter = multipleDatabases
708
+ // DocumentDB always opens a cluster-level change stream, even in single-database
709
+ // mode, so a coll-only filter would match same-named collections in other
710
+ // databases of the cluster. Filter on the full namespace whenever the stream
711
+ // is cluster-scoped. See ChangeStream.getSourceNamespaceFilters for details.
712
+ const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
713
+ const nsFilter = useFullNamespaceFilter
642
714
  ? { ns: { $in: inFilters } }
643
715
  : { 'ns.coll': { $in: inFilters.map((ns) => ns.coll) } };
644
716
  if (regexFilters.length > 0) {
@@ -655,12 +727,15 @@ export class MongoSnapshotter {
655
727
  signal?: AbortSignal;
656
728
  tracer?: PerformanceTracer<'changestream'>;
657
729
  }): AsyncIterableIterator<ChangeStreamBatch> {
658
- const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
659
- const startAfter = lastLsn?.timestamp;
660
- const resumeAfter = lastLsn?.resumeToken;
730
+ const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
731
+ const startAfter = position?.startAfter ?? undefined;
732
+ const resumeAfter = position?.resumeAfter ?? undefined;
661
733
 
662
734
  let fullDocument: 'required' | 'updateLookup';
663
- if (this.usePostImages) {
735
+ if (this.isDocumentDb) {
736
+ // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
737
+ fullDocument = 'updateLookup';
738
+ } else if (this.usePostImages) {
664
739
  // 'read_only' or 'auto_configure'
665
740
  // Configuration happens during snapshot, or when we see new
666
741
  // collections.
@@ -669,27 +744,32 @@ export class MongoSnapshotter {
669
744
  fullDocument = 'updateLookup';
670
745
  }
671
746
  const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
672
- showExpandedEvents: true,
673
747
  fullDocument
674
748
  };
675
- const pipeline: mongo.Document[] = [
676
- { $changeStream: streamOptions },
677
- { $match: options.filters.$match },
678
- { $changeStreamSplitLargeEvent: {} }
679
- ];
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
+ }
680
758
 
681
759
  // Only one of these options can be supplied at a time.
682
760
  if (resumeAfter) {
683
761
  streamOptions.resumeAfter = resumeAfter;
684
- } else {
762
+ } else if (startAfter != null) {
685
763
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
686
- // case if we have an old one.
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.
687
766
  streamOptions.startAtOperationTime = startAfter;
688
767
  }
689
768
 
690
769
  let watchDb: mongo.Db;
691
- if (options.filters.multipleDatabases) {
692
- // Requires readAnyDatabase@admin on Atlas
770
+ if (this.isDocumentDb || options.filters.multipleDatabases) {
771
+ // DocumentDB only supports cluster-level change streams.
772
+ // Requires readAnyDatabase@admin on Atlas.
693
773
  watchDb = this.client.db('admin');
694
774
  streamOptions.allChangesForCluster = true;
695
775
  } else {
@@ -697,9 +777,14 @@ export class MongoSnapshotter {
697
777
  watchDb = this.defaultDb;
698
778
  }
699
779
 
780
+ const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
781
+
700
782
  return rawChangeStream(watchDb, pipeline, {
701
783
  batchSize: options.batchSize ?? this.snapshotChunkLength,
702
- maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
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,
703
788
  maxTimeMS: this.changeStreamTimeout,
704
789
  signal: options.signal,
705
790
  logger: this.logger,
@@ -1,4 +1,4 @@
1
- import { isMongoNetworkTimeoutError, isMongoServerError, mongo } from '@powersync/lib-service-mongodb';
1
+ import { isMaxTimeMSExpiredError, isMongoServerError, isTimeoutError, mongo } from '@powersync/lib-service-mongodb';
2
2
  import {
3
3
  DatabaseConnectionError,
4
4
  ErrorCode,
@@ -6,18 +6,36 @@ 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';
9
11
  import { ChangeStreamInvalidatedError } from './ChangeStream.js';
10
12
 
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
+
11
17
  export interface RawChangeStreamOptions {
12
18
  signal?: AbortSignal;
13
19
 
14
20
  /**
15
21
  * How long to wait for new data per batch (max time for long-polling).
22
+ * This is sent as maxTimeMS for the getMore command.
16
23
  *
17
- * This is used for maxTimeMS for the getMore command.
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
26
  */
19
27
  maxAwaitTimeMS: number;
20
28
 
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
+
21
39
  /**
22
40
  * Timeout for the initial aggregate command.
23
41
  */
@@ -212,31 +230,32 @@ async function* rawChangeStreamInner(
212
230
  options.signal?.throwIfAborted();
213
231
 
214
232
  using commandSpan = options.tracer?.span('changestream', 'getmore');
215
- const getMoreResult: mongo.Document = await db
216
- .command(
217
- {
218
- getMore: cursorId,
219
- collection: nsCollection,
220
- batchSize: batchSizer.next(),
221
- maxTimeMS: options.maxAwaitTimeMS
222
- },
223
- { session, raw: true }
224
- )
225
- .catch((e) => {
226
- if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
227
- // This may be due to the killCursors command issued when aborting.
228
- // In that case, use the abort error instead.
229
- options.signal?.throwIfAborted();
233
+ const getMoreStartedAt = performance.now();
234
+ const getMoreCommand: mongo.Document = {
235
+ getMore: cursorId,
236
+ collection: nsCollection,
237
+ batchSize: batchSizer.next(),
238
+ maxTimeMS: options.maxAwaitTimeMS
239
+ };
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
+ const getMoreResult: mongo.Document = await db.command(getMoreCommand, { session, raw: true }).catch((e) => {
245
+ if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
246
+ // This may be due to the killCursors command issued when aborting.
247
+ // In that case, use the abort error instead.
248
+ options.signal?.throwIfAborted();
249
+ }
250
+
251
+ if (isResumableChangeStreamError(e)) {
252
+ if (isTimeoutError(e)) {
253
+ batchSizer.reduceAfterError();
230
254
  }
231
-
232
- if (isResumableChangeStreamError(e)) {
233
- if (isTimeoutError(e)) {
234
- batchSizer.reduceAfterError();
235
- }
236
- throw new ResumableChangeStreamError(e.message, { cause: e });
237
- }
238
- throw mapChangeStreamError(e);
239
- });
255
+ throw new ResumableChangeStreamError(e.message, { cause: e });
256
+ }
257
+ throw mapChangeStreamError(e);
258
+ });
240
259
 
241
260
  commandSpan?.end();
242
261
 
@@ -249,6 +268,13 @@ async function* rawChangeStreamInner(
249
268
  // postBatchResumeToken is returned in MongoDB 4.0.7 and later, and we support 6.0+
250
269
  throw new ReplicationAssertionError(`postBatchResumeToken from aggregate response`);
251
270
  }
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
+ }
252
278
  yield {
253
279
  events: nextBatch,
254
280
  resumeToken: cursor.postBatchResumeToken,
@@ -400,10 +426,6 @@ export function parseChangeDocument(buffer: Buffer): ProjectedChangeStreamDocume
400
426
  return doc as any;
401
427
  }
402
428
 
403
- function isTimeoutError(e: unknown) {
404
- return isMongoNetworkTimeoutError(e) || (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired');
405
- }
406
-
407
429
  function isResumableChangeStreamError(e: unknown) {
408
430
  // See: https://github.com/mongodb/specifications/blob/master/source/change-streams/change-streams.md#resumable-error
409
431
  if (!isMongoServerError(e)) {
@@ -415,8 +437,8 @@ function isResumableChangeStreamError(e: unknown) {
415
437
  } else if (e.hasErrorLabel('ResumableChangeStreamError')) {
416
438
  // For servers with wire version 9 or higher (server version 4.4 or higher), any server error with the ResumableChangeStreamError error label.
417
439
  return true;
418
- } else if (e.codeName == 'MaxTimeMSExpired') {
419
- // Our own exception for MaxTimeMSExpired.
440
+ } else if (isMaxTimeMSExpiredError(e)) {
441
+ // Our own exception for maxTimeMS timeouts.
420
442
  // This can help us retry faster, with a smaller batch size (if initialBatchSize is set to 1), which should hopefully avoid the timeout.
421
443
  return true;
422
444
  } else {
@@ -0,0 +1,167 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { Logger } from '@powersync/lib-services-framework';
3
+ import { ReplicationHeadCallback, storage } from '@powersync/service-core';
4
+
5
+ import { ProjectedChangeStreamDocument } from '../RawChangeStream.js';
6
+
7
+ /**
8
+ * Classification of an event on the `_powersync_checkpoints` collection.
9
+ *
10
+ * - 'standalone': source-wide checkpoint (write checkpoints, keepalive bumps,
11
+ * snapshot markers). Processed regardless of which process created it.
12
+ * - 'own-barrier': this stream's private batch barrier document.
13
+ * - 'foreign': another stream's barrier document — ignore.
14
+ */
15
+ export type CheckpointEventKind = 'standalone' | 'own-barrier' | 'foreign';
16
+
17
+ export interface StreamResumePosition {
18
+ resumeAfter: mongo.ResumeToken | null;
19
+ /**
20
+ * Legacy startAtOperationTime fallback. Only produced by the timestamp implementation,
21
+ * and only for old LSNs persisted without a resume token.
22
+ */
23
+ startAfter: mongo.Timestamp | null;
24
+ }
25
+
26
+ export interface CheckpointImplementationContext {
27
+ client: mongo.MongoClient;
28
+ db: mongo.Db;
29
+ checkpointStreamId: mongo.ObjectId;
30
+ logger: Logger;
31
+ }
32
+
33
+ /**
34
+ * Event-interpretation surface of a {@link CheckpointImplementation}. Every method
35
+ * operates on a single raw change event.
36
+ *
37
+ * Ordering contract: {@link observe} must be called before {@link lsn} for a given
38
+ * event, so the sentinel implementation's coordinate is current.
39
+ */
40
+ export interface CheckpointEventApi {
41
+ /**
42
+ * Classify a checkpoint-collection event and absorb any coordinate it
43
+ * carries (sentinel implementation). Must be called for every checkpoint event, in
44
+ * stream order.
45
+ */
46
+ observe(doc: ProjectedChangeStreamDocument): CheckpointEventKind;
47
+
48
+ /**
49
+ * Comparable LSN for committing/resuming at this event.
50
+ *
51
+ * Note this is not purely a function of `doc`. The timestamp implementation
52
+ * derives the whole LSN from the event (its coordinate is the event's
53
+ * clusterTime). The sentinel implementation pairs the *tracked* coordinate
54
+ * (updated by {@link observe}) with the event's resume token, because data
55
+ * events carry no coordinate of their own — hence the observe-before-lsn
56
+ * contract.
57
+ */
58
+ lsn(doc: ProjectedChangeStreamDocument): string;
59
+
60
+ /** Whether a barrier marker from {@link CheckpointImplementation.createBatchCheckpoint} is resolved by this event. */
61
+ resolvesBarrier(marker: string, doc: ProjectedChangeStreamDocument): boolean;
62
+ }
63
+
64
+ /**
65
+ * Strategy for producing and interpreting replication checkpoints.
66
+ *
67
+ * Two implementations exist:
68
+ *
69
+ * - {@link TimestampCheckpointImplementation}: standard MongoDB. The ordered LSN
70
+ * coordinate is the oplog clusterTime, which is unique per operation and
71
+ * parseable from resume tokens.
72
+ * - {@link SentinelCheckpointImplementation}: for sources without a usable clusterTime
73
+ * (DocumentDB, and technically usable on any MongoDB). The ordered coordinate
74
+ * is a shared monotonic counter document, observed through the change
75
+ * stream itself.
76
+ *
77
+ * An implementation instance belongs to a single ChangeStream (or API adapter) and may
78
+ * hold per-stream coordinate state; call {@link seedPosition} at the start of
79
+ * each streaming loop.
80
+ */
81
+ export interface CheckpointImplementation {
82
+ /** LSN representing "before any data". */
83
+ readonly zeroLsn: string;
84
+
85
+ /** Parse a stored LSN into change stream resume options. Pure. */
86
+ parseResumePosition(lsn: string): StreamResumePosition;
87
+
88
+ /** Reset/seed the implementation's coordinate state for a new stream loop. */
89
+ seedPosition(lsn: string | null): void;
90
+
91
+ /** Log the resume position at the start of a streaming loop. */
92
+ logResume(lsn: string): void;
93
+
94
+ /**
95
+ * Create a source-wide consistency checkpoint (snapshot boundaries,
96
+ * `no_checkpoint_before` markers). Returns a comparable LSN.
97
+ */
98
+ createStandaloneCheckpoint(): Promise<string>;
99
+
100
+ /**
101
+ * Create a batch barrier for this stream. Returns a marker that is resolved
102
+ * by a later change stream event via {@link CheckpointEventApi.resolvesBarrier}.
103
+ */
104
+ createBatchCheckpoint(): Promise<string>;
105
+
106
+ /**
107
+ * Create the first batch barrier for snapshot-LSN acquisition and return the
108
+ * LSN to open the change stream from, or null to open from "now".
109
+ *
110
+ * The timestamp implementation's barrier marker is itself a comparable LSN, so
111
+ * the stream resumes from it. The sentinel implementation's marker is opaque
112
+ * (content-matched) and carries no resume position, so it returns null and the
113
+ * stream opens from the current point.
114
+ */
115
+ createFirstBarrier(): Promise<string | null>;
116
+
117
+ /**
118
+ * Idle keepalive for an empty change stream batch. May persist a checkpoint
119
+ * directly (timestamp implementation) or nudge the source so that a later event
120
+ * commits (sentinel implementation).
121
+ */
122
+ keepalive(batch: storage.BucketStorageBatch, resumeToken: mongo.ResumeToken): Promise<void>;
123
+
124
+ /**
125
+ * Build a comparable LSN from a bare batch-level resume token (no change
126
+ * event). Used for the per-batch `setResumeLsn` progress marker.
127
+ *
128
+ * The timestamp implementation parses the timestamp embedded in the token.
129
+ * The sentinel implementation pairs the token with the current coordinate.
130
+ */
131
+ lsnFromResumeToken(resumeToken: mongo.ResumeToken): { lsn: string; timestamp: Date | null };
132
+
133
+ /**
134
+ * Source-side replication head for write checkpoints. The LSN passed to the
135
+ * callback must compare at or below any LSN committed after the caller's
136
+ * preceding writes.
137
+ */
138
+ createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T>;
139
+
140
+ /** Event-interpretation methods, all operating on a single raw change event. */
141
+ readonly event: CheckpointEventApi;
142
+
143
+ /** Filter for clearing the checkpoints collection on startup. */
144
+ readonly checkpointClearFilter: mongo.Filter<mongo.Document>;
145
+ }
146
+
147
+ /**
148
+ * Extract the event timestamp: clusterTime when present, otherwise wallTime
149
+ * truncated to second precision.
150
+ */
151
+ export function getEventTimestamp(changeDocument: ProjectedChangeStreamDocument): mongo.Timestamp {
152
+ if (changeDocument.clusterTime) {
153
+ return changeDocument.clusterTime;
154
+ }
155
+ const wallTime = (changeDocument as any).wallTime as Date | undefined;
156
+ if (wallTime != null) {
157
+ return mongo.Timestamp.fromBits(0, Math.floor(wallTime.getTime() / 1000));
158
+ }
159
+ throw new Error('Change event has neither clusterTime nor wallTime');
160
+ }
161
+
162
+ export function getCheckpointId(doc: ProjectedChangeStreamDocument): string | mongo.ObjectId | null {
163
+ if (!('documentKey' in doc)) {
164
+ return null;
165
+ }
166
+ return doc.documentKey._id as string | mongo.ObjectId;
167
+ }