@powersync/service-module-mongodb 0.18.1 → 0.19.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 (60) hide show
  1. package/CHANGELOG.md +34 -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/replication/ChangeStream.d.ts +15 -0
  9. package/dist/replication/ChangeStream.js +105 -44
  10. package/dist/replication/ChangeStream.js.map +1 -1
  11. package/dist/replication/MongoRelation.d.ts +36 -1
  12. package/dist/replication/MongoRelation.js +102 -6
  13. package/dist/replication/MongoRelation.js.map +1 -1
  14. package/dist/replication/MongoSnapshotter.d.ts +9 -0
  15. package/dist/replication/MongoSnapshotter.js +108 -42
  16. package/dist/replication/MongoSnapshotter.js.map +1 -1
  17. package/dist/replication/RawChangeStream.d.ts +12 -1
  18. package/dist/replication/RawChangeStream.js +30 -7
  19. package/dist/replication/RawChangeStream.js.map +1 -1
  20. package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
  21. package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
  22. package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
  23. package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
  24. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
  25. package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
  26. package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
  27. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
  28. package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
  29. package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
  30. package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
  31. package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
  32. package/dist/replication/replication-utils.d.ts +6 -0
  33. package/dist/replication/replication-utils.js +19 -2
  34. package/dist/replication/replication-utils.js.map +1 -1
  35. package/package.json +9 -9
  36. package/src/api/MongoRouteAPIAdapter.ts +26 -37
  37. package/src/common/SentinelLSN.ts +78 -0
  38. package/src/replication/ChangeStream.ts +118 -50
  39. package/src/replication/MongoRelation.ts +124 -14
  40. package/src/replication/MongoSnapshotter.ts +122 -47
  41. package/src/replication/RawChangeStream.ts +64 -28
  42. package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
  43. package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
  44. package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
  45. package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
  46. package/src/replication/replication-utils.ts +23 -2
  47. package/test/DOCUMENTDB_TESTING.md +115 -0
  48. package/test/src/DatabaseType.ts +25 -0
  49. package/test/src/change_stream.test.ts +103 -68
  50. package/test/src/change_stream_utils.ts +85 -9
  51. package/test/src/checkpoint_retry.test.ts +5 -2
  52. package/test/src/documentdb_helpers.test.ts +124 -0
  53. package/test/src/documentdb_mode.test.ts +1040 -0
  54. package/test/src/mongo_test.test.ts +15 -5
  55. package/test/src/raw_change_stream.test.ts +209 -125
  56. package/test/src/resume_token.test.ts +30 -0
  57. package/test/src/slow_tests.test.ts +4 -1
  58. package/test/src/test-timeouts.ts +23 -0
  59. package/test/src/util.ts +1 -2
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -14,7 +14,9 @@ import {
14
14
  } from '@powersync/service-sync-rules';
15
15
 
16
16
  import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
17
+ import { ObjectId } from 'bson';
17
18
  import { MongoLSN } from '../common/MongoLSN.js';
19
+ import { SentinelLSN } from '../common/SentinelLSN.js';
18
20
 
19
21
  export function getMongoRelation(
20
22
  source: mongo.ChangeStreamNameSpace,
@@ -173,15 +175,24 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
173
175
  */
174
176
  export const STANDALONE_CHECKPOINT_ID = '_standalone_checkpoint';
175
177
 
176
- export async function createCheckpoint(
177
- client: mongo.MongoClient,
178
- db: mongo.Db,
179
- id: mongo.ObjectId | string
180
- ): Promise<string> {
178
+ /**
179
+ * Id for checkpoint records managed by the {@link SentinelCheckpointImplementation} implementation.
180
+ */
181
+ export const SENTINEL_CHECKPOINT_ID = '_sentinel_checkpoint';
182
+
183
+ /**
184
+ * Create a checkpoint by upserting a document in _powersync_checkpoints, and
185
+ * return a comparable LSN string derived from the write's operationTime.
186
+ *
187
+ * Standard MongoDB only: this requires session.operationTime, which DocumentDB
188
+ * does not provide (it throws PSYNC_S1004 when it is missing). The DocumentDB /
189
+ * sentinel path builds LSNs via {@link createSentinelCheckpointLsn} instead.
190
+ */
191
+ export async function createCheckpoint(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
181
192
  const TRIES = 2;
182
193
  for (let i = 0; i < TRIES; i++) {
183
194
  try {
184
- return await createCheckpointInner(client, db, id);
195
+ return await createCheckpointInner(db, id);
185
196
  } catch (e) {
186
197
  if (i < TRIES - 1) {
187
198
  logger.warn(`Failed to create checkpoint on attempt ${i + 1}`, e);
@@ -193,11 +204,7 @@ export async function createCheckpoint(
193
204
  throw new ServiceAssertionError(`Unreachable code`);
194
205
  }
195
206
 
196
- async function createCheckpointInner(
197
- client: mongo.MongoClient,
198
- db: mongo.Db,
199
- id: mongo.ObjectId | string
200
- ): Promise<string> {
207
+ async function createCheckpointInner(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
201
208
  // We use an unique id per process, and clear documents on startup.
202
209
  // This is so that we can filter events for our own process only, and ignore
203
210
  // events from other processes.
@@ -208,6 +215,8 @@ async function createCheckpointInner(
208
215
  // Instead, we do manual retries, which does not have the same write de-duplication logic.
209
216
  // A sentinal-based approach would be better here, but that is a much bigger change.
210
217
 
218
+ const update: mongo.Document = { $inc: { i: 1 } };
219
+
211
220
  const response = await db.command({
212
221
  findAndModify: '_powersync_checkpoints',
213
222
  query: {
@@ -215,9 +224,7 @@ async function createCheckpointInner(
215
224
  },
216
225
  new: true,
217
226
  upsert: true,
218
- update: {
219
- $inc: { i: 1 }
220
- }
227
+ update
221
228
  });
222
229
 
223
230
  const time = response.operationTime as mongo.Timestamp | undefined;
@@ -227,6 +234,109 @@ async function createCheckpointInner(
227
234
  return new MongoLSN({ timestamp: time }).comparable;
228
235
  }
229
236
 
237
+ /**
238
+ * Create a DocumentDB comparable LSN by advancing the shared sentinel checkpoint
239
+ * document ({@link SENTINEL_CHECKPOINT_ID}). The returned LSN encodes the
240
+ * checkpoint counter in the same 16-hex shape as a MongoDB timestamp LSN (see
241
+ * {@link SentinelLSN}), so the two formats are directly comparable. The counter is
242
+ * seeded in the epoch-seconds range (see below), so a sentinel LSN always sorts
243
+ * above any real-timestamp LSN issued in the past.
244
+ *
245
+ * This counter is intentionally global to the source database. It is used for
246
+ * storage/client checkpoint comparisons and write checkpoint heads, so it must
247
+ * not reset when a new ChangeStream instance or new sync rules start.
248
+ *
249
+ * The document is a single shared record whose `stream_id` field alternates:
250
+ * batch/keepalive bumps stamp it with the calling stream's id (so a stream can
251
+ * recognise its own private barriers), while standalone bumps (write checkpoint
252
+ * heads, snapshot markers) clear it to null. `i` advances globally regardless.
253
+ *
254
+ * @param changeStreamId
255
+ * When provided, the bump is attributed to this stream (a private barrier).
256
+ * When omitted, it is a standalone bump and stream_id is cleared to null.
257
+ */
258
+ export async function createSentinelCheckpointLsn(
259
+ client: mongo.MongoClient,
260
+ db: mongo.Db,
261
+ changeStreamId?: ObjectId
262
+ ): Promise<string> {
263
+ const session = client.startSession();
264
+ try {
265
+ const collection = db.collection('_powersync_checkpoints');
266
+
267
+ for (let attempt = 0; attempt < 3; attempt++) {
268
+ // Common path: increment the existing counter.
269
+ const result = await collection.findOneAndUpdate(
270
+ {
271
+ _id: SENTINEL_CHECKPOINT_ID as any,
272
+ i: { $exists: true }
273
+ },
274
+ {
275
+ $inc: { i: 1 },
276
+ // Standalone bumps (no changeStreamId) must explicitly clear the
277
+ // stream_id left by a previous batch bump — this is a single shared
278
+ // document whose stream_id alternates. Write null rather than relying
279
+ // on `undefined` being serialized (which depends on the driver's
280
+ // ignoreUndefined option, and collapses to an empty $set if enabled).
281
+ $set: {
282
+ stream_id: changeStreamId ?? null
283
+ }
284
+ },
285
+ {
286
+ returnDocument: 'after',
287
+ session
288
+ }
289
+ );
290
+
291
+ if (result != null) {
292
+ // `i` is a bigint: the client is configured with useBigInt64, and the
293
+ // seed exceeds 2^53 so it could not be safely represented as a number.
294
+ return new SentinelLSN({ sentinel: result.i }).comparable;
295
+ }
296
+
297
+ // The counter document does not exist: first run, or a consumer deleted
298
+ // it in their source database. Seed the counter in the epoch-SECONDS range
299
+ // (seconds in the high 32 bits, mirroring a MongoDB timestamp) rather than
300
+ // starting at 1. Two properties follow:
301
+ //
302
+ // - A sentinel LSN sorts above any real-timestamp LSN issued in the past,
303
+ // because its high 32 bits are the current epoch seconds.
304
+ // - A re-created counter jumps forward instead of backward across deletion:
305
+ // the seed advances by 2^32 each wall-clock second, while checkpoints add
306
+ // 1 each, so any later re-seed exceeds the previously issued coordinate
307
+ // (keeping the LSN domain monotonic; otherwise new write checkpoint heads
308
+ // could resolve against old, higher committed LSNs).
309
+ //
310
+ // $setOnInsert cannot be combined with $inc on the same field, so this
311
+ // is a separate upsert; the loop then retries the increment. The
312
+ // $setOnInsert is a no-op if another process created the document
313
+ // concurrently.
314
+ await collection.updateOne(
315
+ {
316
+ _id: SENTINEL_CHECKPOINT_ID as any
317
+ },
318
+ {
319
+ $setOnInsert: {
320
+ i: BigInt(Math.floor(Date.now() / 1000)) << 32n,
321
+ stream_id: changeStreamId ?? null
322
+ }
323
+ },
324
+ {
325
+ upsert: true,
326
+ session
327
+ }
328
+ );
329
+ }
330
+
331
+ throw new ServiceError(
332
+ ErrorCode.PSYNC_S1301,
333
+ `Failed to increment the sentinel checkpoint counter - the checkpoint document may be getting deleted concurrently.`
334
+ );
335
+ } finally {
336
+ await session.endSession();
337
+ }
338
+ }
339
+
230
340
  const mongoTimeOptions: DateTimeSourceOptions = {
231
341
  subSecondPrecision: TimeValuePrecision.milliseconds,
232
342
  defaultSubSecondPrecision: TimeValuePrecision.milliseconds
@@ -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;
@@ -108,6 +113,48 @@ export class MongoSnapshotter {
108
113
  return this.storage.storageConfig.softDeleteCurrentData;
109
114
  }
110
115
 
116
+ /** The active checkpoint strategy. Only valid after ensureDetected(). */
117
+ private get checkpointImplementation(): CheckpointImplementation {
118
+ if (this._checkpointImplementation == null) {
119
+ throw new ServiceError(
120
+ ErrorCode.PSYNC_S1301,
121
+ 'Checkpoint implementation not initialized - call ensureDetected() first'
122
+ );
123
+ }
124
+ return this._checkpointImplementation;
125
+ }
126
+
127
+ /**
128
+ * Detect DocumentDB and select the checkpoint implementation. Idempotent.
129
+ * Also validates server topology (sharded/standalone) for standard MongoDB.
130
+ */
131
+ private async ensureDetected(): Promise<void> {
132
+ if (this._checkpointImplementation != null) {
133
+ return;
134
+ }
135
+ this.isDocumentDb = await detectDocumentDb(this.defaultDb);
136
+ if (!this.isDocumentDb) {
137
+ const hello = await this.defaultDb.command({ hello: 1 });
138
+ if (hello.msg == 'isdbgrid') {
139
+ throw new ServiceError(
140
+ ErrorCode.PSYNC_S1341,
141
+ 'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
142
+ );
143
+ } else if (hello.setName == null) {
144
+ throw new ServiceError(
145
+ ErrorCode.PSYNC_S1342,
146
+ 'Standalone MongoDB instances are not supported - use a replicaset.'
147
+ );
148
+ }
149
+ }
150
+ this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
151
+ client: this.client,
152
+ db: this.defaultDb,
153
+ checkpointStreamId: this.checkpointStreamId,
154
+ logger: this.logger
155
+ });
156
+ }
157
+
111
158
  async checkSlot(): Promise<InitResult> {
112
159
  const status = await this.storage.getStatus();
113
160
  if (status.snapshotDone) {
@@ -119,12 +166,18 @@ export class MongoSnapshotter {
119
166
  }
120
167
 
121
168
  async setupCheckpointsCollection() {
169
+ await this.ensureDetected();
122
170
  const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
123
171
  if (collection == null) {
124
172
  await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
125
- changeStreamPreAndPostImages: { enabled: true }
173
+ // DocumentDB does not support changeStreamPreAndPostImages.
174
+ ...(this.isDocumentDb ? {} : { changeStreamPreAndPostImages: { enabled: true } })
126
175
  });
127
- } else if (this.usePostImages && collection.options?.changeStreamPreAndPostImages?.enabled != true) {
176
+ } else if (
177
+ !this.isDocumentDb &&
178
+ this.usePostImages &&
179
+ collection.options?.changeStreamPreAndPostImages?.enabled != true
180
+ ) {
128
181
  // Drop + create requires less permissions than collMod,
129
182
  // and we don't care about the data in this collection.
130
183
  await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
@@ -135,12 +188,22 @@ export class MongoSnapshotter {
135
188
  // Clear the collection on startup, to keep it clean
136
189
  // We never query this collection directly, and don't want to keep the data around.
137
190
  // We only use this to get data into the oplog/changestream.
138
- await this.defaultDb.collection(CHECKPOINTS_COLLECTION).deleteMany({});
191
+ //
192
+ // The implementation supplies the filter: the sentinel implementation must
193
+ // preserve the sentinel checkpoint document, since its counter is the
194
+ // globally-ordered component of every committed DocumentDB LSN.
195
+ await this.defaultDb
196
+ .collection(CHECKPOINTS_COLLECTION)
197
+ .deleteMany(this.checkpointImplementation.checkpointClearFilter);
139
198
  }
140
199
  }
141
200
 
142
201
  async queueSnapshotTables(snapshotLsn: string | null) {
143
202
  await this.client.connect();
203
+ // Ensure isDocumentDb is set before any getSourceNamespaceFilters() call below
204
+ // (notably the validateSnapshotLsn resume path, which does not go through
205
+ // getSnapshotLsn). Idempotent.
206
+ await this.ensureDetected();
144
207
  await using writer = await this.storage.createWriter({
145
208
  zeroLSN: MongoLSN.ZERO.comparable,
146
209
  defaultSchema: this.defaultDb.databaseName,
@@ -256,7 +319,7 @@ export class MongoSnapshotter {
256
319
  for (const table of tables) {
257
320
  await this.snapshotTable(batch, table);
258
321
  }
259
- const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
322
+ const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
260
323
 
261
324
  await batch.markTableSnapshotDone(tables, noCheckpointBefore);
262
325
  }
@@ -300,7 +363,7 @@ export class MongoSnapshotter {
300
363
 
301
364
  // The checkpoint here is a marker - we need to replicate up to at least this
302
365
  // point before the data can be considered consistent.
303
- const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
366
+ const checkpoint = await this.checkpointImplementation.createStandaloneCheckpoint();
304
367
  if (this.queue.size != 0) {
305
368
  return;
306
369
  }
@@ -312,7 +375,7 @@ export class MongoSnapshotter {
312
375
  // KLUDGE: We need to create an extra checkpoint _after_ marking the snapshot done, to fix
313
376
  // issues with order of processing commits(). This is picked up by tests on postgres storage,
314
377
  // the issue may be specific to that storage engine.
315
- await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
378
+ await this.checkpointImplementation.createStandaloneCheckpoint();
316
379
  }
317
380
 
318
381
  private async replicateTable(tableRequest: SourceTable) {
@@ -336,7 +399,7 @@ export class MongoSnapshotter {
336
399
  }
337
400
 
338
401
  await this.snapshotTable(writer, table);
339
- const noCheckpointBefore = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
402
+ const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
340
403
  await writer.markTableSnapshotDone([table], noCheckpointBefore);
341
404
 
342
405
  // This commit durably records the persisted ops, so a later checkpoint covers them.
@@ -516,19 +579,7 @@ export class MongoSnapshotter {
516
579
  }
517
580
 
518
581
  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
- }
582
+ await this.ensureDetected();
532
583
 
533
584
  // Open a change stream just to get a resume token for later use.
534
585
  // We could use clusterTime from the hello command, but that won't tell us if the
@@ -541,14 +592,19 @@ export class MongoSnapshotter {
541
592
  // Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
542
593
  // To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
543
594
  // periodically until the timeout is reached.
595
+ //
596
+ // For the sentinel implementation (DocumentDB) there is no
597
+ // startAtOperationTime: the stream opens from "now" (lsn null) and the
598
+ // retry loop below re-creates checkpoints until one is observed.
544
599
 
545
600
  const LSN_TIMEOUT_SECONDS = 60;
546
601
  const LSN_CREATE_INTERVAL_SECONDS = 1;
547
602
 
548
- const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
603
+ this.checkpointImplementation.seedPosition(null);
604
+ const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
549
605
  const filters = this.getSourceNamespaceFilters();
550
606
  const iter = this.rawChangeStreamBatches({
551
- lsn: firstCheckpointLsn,
607
+ lsn: startStreamFromLsn,
552
608
  maxAwaitTimeMS: 0,
553
609
  signal: this.abortSignal,
554
610
  filters
@@ -563,7 +619,7 @@ export class MongoSnapshotter {
563
619
  break;
564
620
  }
565
621
  if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
566
- await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
622
+ await this.checkpointImplementation.createBatchCheckpoint();
567
623
  lastCheckpointCreated = performance.now();
568
624
  }
569
625
  batchesSeen += 1;
@@ -573,14 +629,15 @@ export class MongoSnapshotter {
573
629
  const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
574
630
 
575
631
  if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
576
- const checkpointId = changeDocument.documentKey._id as string | mongo.ObjectId;
577
- if (!this.checkpointStreamId.equals(checkpointId)) {
632
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
633
+ if (kind != 'own-barrier') {
634
+ // Standalone events still feed the implementation's coordinate via
635
+ // event.observe above; we only resolve on our own barrier.
578
636
  continue;
579
637
  }
580
- return new MongoLSN({
581
- timestamp: changeDocument.clusterTime!,
582
- resume_token: changeDocument._id
583
- }).comparable;
638
+ // Observing our own barrier has set the coordinate (the barrier event
639
+ // carries the sentinel counter directly), so the LSN is ready.
640
+ return this.checkpointImplementation.event.lsn(changeDocument);
584
641
  }
585
642
 
586
643
  eventsSeen += 1;
@@ -638,7 +695,12 @@ export class MongoSnapshotter {
638
695
  }
639
696
  }
640
697
 
641
- const nsFilter = multipleDatabases
698
+ // DocumentDB always opens a cluster-level change stream, even in single-database
699
+ // mode, so a coll-only filter would match same-named collections in other
700
+ // databases of the cluster. Filter on the full namespace whenever the stream
701
+ // is cluster-scoped. See ChangeStream.getSourceNamespaceFilters for details.
702
+ const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
703
+ const nsFilter = useFullNamespaceFilter
642
704
  ? { ns: { $in: inFilters } }
643
705
  : { 'ns.coll': { $in: inFilters.map((ns) => ns.coll) } };
644
706
  if (regexFilters.length > 0) {
@@ -655,12 +717,15 @@ export class MongoSnapshotter {
655
717
  signal?: AbortSignal;
656
718
  tracer?: PerformanceTracer<'changestream'>;
657
719
  }): AsyncIterableIterator<ChangeStreamBatch> {
658
- const lastLsn = options.lsn ? MongoLSN.fromSerialized(options.lsn) : null;
659
- const startAfter = lastLsn?.timestamp;
660
- const resumeAfter = lastLsn?.resumeToken;
720
+ const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
721
+ const startAfter = position?.startAfter ?? undefined;
722
+ const resumeAfter = position?.resumeAfter ?? undefined;
661
723
 
662
724
  let fullDocument: 'required' | 'updateLookup';
663
- if (this.usePostImages) {
725
+ if (this.isDocumentDb) {
726
+ // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
727
+ fullDocument = 'updateLookup';
728
+ } else if (this.usePostImages) {
664
729
  // 'read_only' or 'auto_configure'
665
730
  // Configuration happens during snapshot, or when we see new
666
731
  // collections.
@@ -669,27 +734,32 @@ export class MongoSnapshotter {
669
734
  fullDocument = 'updateLookup';
670
735
  }
671
736
  const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
672
- showExpandedEvents: true,
673
737
  fullDocument
674
738
  };
675
- const pipeline: mongo.Document[] = [
676
- { $changeStream: streamOptions },
677
- { $match: options.filters.$match },
678
- { $changeStreamSplitLargeEvent: {} }
679
- ];
739
+ if (!this.isDocumentDb) {
740
+ // DocumentDB does not support showExpandedEvents.
741
+ streamOptions.showExpandedEvents = true;
742
+ }
743
+ const pipeline: mongo.Document[] = [{ $changeStream: streamOptions }, { $match: options.filters.$match }];
744
+ if (!this.isDocumentDb) {
745
+ // DocumentDB does not support $changeStreamSplitLargeEvent.
746
+ pipeline.push({ $changeStreamSplitLargeEvent: {} });
747
+ }
680
748
 
681
749
  // Only one of these options can be supplied at a time.
682
750
  if (resumeAfter) {
683
751
  streamOptions.resumeAfter = resumeAfter;
684
- } else {
752
+ } else if (startAfter != null) {
685
753
  // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
686
- // case if we have an old one.
754
+ // case if we have an old one. The sentinel implementation never produces a startAfter,
755
+ // and a fresh DocumentDB stream opens from "now" with neither option set.
687
756
  streamOptions.startAtOperationTime = startAfter;
688
757
  }
689
758
 
690
759
  let watchDb: mongo.Db;
691
- if (options.filters.multipleDatabases) {
692
- // Requires readAnyDatabase@admin on Atlas
760
+ if (this.isDocumentDb || options.filters.multipleDatabases) {
761
+ // DocumentDB only supports cluster-level change streams.
762
+ // Requires readAnyDatabase@admin on Atlas.
693
763
  watchDb = this.client.db('admin');
694
764
  streamOptions.allChangesForCluster = true;
695
765
  } else {
@@ -697,9 +767,14 @@ export class MongoSnapshotter {
697
767
  watchDb = this.defaultDb;
698
768
  }
699
769
 
770
+ const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
771
+
700
772
  return rawChangeStream(watchDb, pipeline, {
701
773
  batchSize: options.batchSize ?? this.snapshotChunkLength,
702
- maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
774
+ maxAwaitTimeMS,
775
+ // maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
776
+ // In that case there is no client-side wait to emulate for DocumentDB.
777
+ clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
703
778
  maxTimeMS: this.changeStreamTimeout,
704
779
  signal: options.signal,
705
780
  logger: this.logger,
@@ -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();
230
- }
231
-
232
- if (isResumableChangeStreamError(e)) {
233
- if (isTimeoutError(e)) {
234
- batchSizer.reduceAfterError();
235
- }
236
- throw new ResumableChangeStreamError(e.message, { cause: e });
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();
237
254
  }
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,8 +426,18 @@ export function parseChangeDocument(buffer: Buffer): ProjectedChangeStreamDocume
400
426
  return doc as any;
401
427
  }
402
428
 
429
+ function isMaxTimeMSExpiredError(e: unknown) {
430
+ return (
431
+ isMongoServerError(e) &&
432
+ (e.codeName == 'MaxTimeMSExpired' ||
433
+ // MongoDB documents code 50 as MaxTimeMSExpired. Azure DocumentDB reports
434
+ // this code with codeName ExceededTimeLimit.
435
+ e.code == 50)
436
+ );
437
+ }
438
+
403
439
  function isTimeoutError(e: unknown) {
404
- return isMongoNetworkTimeoutError(e) || (isMongoServerError(e) && e.codeName == 'MaxTimeMSExpired');
440
+ return isMongoNetworkTimeoutError(e) || isMaxTimeMSExpiredError(e);
405
441
  }
406
442
 
407
443
  function isResumableChangeStreamError(e: unknown) {
@@ -415,8 +451,8 @@ function isResumableChangeStreamError(e: unknown) {
415
451
  } else if (e.hasErrorLabel('ResumableChangeStreamError')) {
416
452
  // For servers with wire version 9 or higher (server version 4.4 or higher), any server error with the ResumableChangeStreamError error label.
417
453
  return true;
418
- } else if (e.codeName == 'MaxTimeMSExpired') {
419
- // Our own exception for MaxTimeMSExpired.
454
+ } else if (isMaxTimeMSExpiredError(e)) {
455
+ // Our own exception for maxTimeMS timeouts.
420
456
  // This can help us retry faster, with a smaller batch size (if initialBatchSize is set to 1), which should hopefully avoid the timeout.
421
457
  return true;
422
458
  } else {