@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 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 (88) hide show
  1. package/CHANGELOG.md +38 -6
  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 +0 -1
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +10 -2
  16. package/dist/replication/ChangeStream.js +228 -317
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +4 -1
  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 +58 -85
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +1 -1
  39. package/dist/replication/RawChangeStream.js +1 -1
  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/replication-index.d.ts +6 -0
  48. package/dist/replication/replication-index.js +6 -0
  49. package/dist/replication/replication-index.js.map +1 -1
  50. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  51. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  52. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  53. package/dist/test-utils/test-utils-index.d.ts +1 -0
  54. package/dist/test-utils/test-utils-index.js +2 -0
  55. package/dist/test-utils/test-utils-index.js.map +1 -0
  56. package/package.json +8 -8
  57. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  58. package/src/api/infer-collection-schema.ts +153 -0
  59. package/src/index.ts +1 -0
  60. package/src/module/MongoModule.ts +1 -3
  61. package/src/replication/ChangeStream.ts +253 -359
  62. package/src/replication/ChangeStreamReplicationJob.ts +7 -2
  63. package/src/replication/ChangeStreamReplicator.ts +5 -0
  64. package/src/replication/MongoRelation.ts +8 -1
  65. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  66. package/src/replication/MongoReplicationStream.ts +201 -0
  67. package/src/replication/MongoSnapshotQuery.ts +19 -3
  68. package/src/replication/MongoSnapshotter.ts +70 -92
  69. package/src/replication/RawChangeStream.ts +2 -2
  70. package/src/replication/SourceRowConverter.ts +4 -2
  71. package/src/replication/bufferToSqlite.ts +43 -23
  72. package/src/replication/replication-index.ts +6 -0
  73. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  74. package/src/test-utils/test-utils-index.ts +1 -0
  75. package/test/src/buffer_to_sqlite.test.ts +9 -0
  76. package/test/src/change_stream.test.ts +3 -3
  77. package/test/src/change_stream_test_setup.ts +38 -0
  78. package/test/src/chunked_snapshot.test.ts +3 -3
  79. package/test/src/documentdb_mode.test.ts +3 -3
  80. package/test/src/replication_stream.test.ts +311 -0
  81. package/test/src/resume.test.ts +10 -4
  82. package/test/src/resuming_snapshots.test.ts +3 -3
  83. package/test/src/schema.test.ts +223 -0
  84. package/test/src/slow_tests.test.ts +3 -3
  85. package/test/src/snapshot_query_provider.test.ts +95 -0
  86. package/test/src/stream_progress.test.ts +312 -0
  87. package/tsconfig.tsbuildinfo +1 -1
  88. package/test/src/change_stream_utils.ts +0 -375
@@ -27,13 +27,14 @@ import { CheckpointImplementation } from './checkpoints/CheckpointImplementation
27
27
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
28
28
  import { MongoManager } from './MongoManager.js';
29
29
  import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
30
- import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
31
30
  import {
32
- ChangeStreamBatch,
33
- parseChangeDocument,
34
- ProjectedChangeStreamDocument,
35
- rawChangeStream
36
- } from './RawChangeStream.js';
31
+ DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
32
+ MongoReplicationQueryProvider,
33
+ MongoReplicationQueryProviderFactory
34
+ } from './MongoReplicationQueryProvider.js';
35
+ import { MongoReplicationStreamItem, openMongoReplicationStream } from './MongoReplicationStream.js';
36
+ import { MongoSnapshotter, MongoSnapshotterHooks } from './MongoSnapshotter.js';
37
+ import { ChangeStreamBatch, ProjectedChangeStreamDocument } from './RawChangeStream.js';
37
38
  import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
38
39
  import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
39
40
  export interface ChangeStreamOptions {
@@ -61,6 +62,10 @@ export interface ChangeStreamOptions {
61
62
 
62
63
  storageHooks?: storage.StorageHooks;
63
64
  snapshotHooks?: MongoSnapshotterHooks;
65
+ /**
66
+ * Create one adapter per replication attempt, shared with its snapshotter and source probes.
67
+ */
68
+ createReplicationQueryProvider?: MongoReplicationQueryProviderFactory;
64
69
 
65
70
  logger?: Logger;
66
71
  }
@@ -118,6 +123,7 @@ export class ChangeStream {
118
123
  private storageHooks: storage.StorageHooks | undefined;
119
124
 
120
125
  private readonly sourceRowConverter: SourceRowConverter;
126
+ private readonly queryProvider: MongoReplicationQueryProvider;
121
127
 
122
128
  private keepaliveIntervalMs: number;
123
129
 
@@ -150,6 +156,12 @@ export class ChangeStream {
150
156
  this.sync_rules = options.storage.getParsedSyncRules({
151
157
  defaultSchema: this.defaultDb.databaseName
152
158
  });
159
+ this.queryProvider =
160
+ options.createReplicationQueryProvider?.({
161
+ syncConfig: this.sync_rules,
162
+ connectionTag: this.connections.connectionTag,
163
+ defaultSchema: this.defaultDb.databaseName
164
+ }) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
153
165
  this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
154
166
 
155
167
  // The change stream aggregation command should timeout before the socket times out,
@@ -166,7 +178,8 @@ export class ChangeStream {
166
178
  ...options,
167
179
  abortSignal: this.abortSignal,
168
180
  logger: snapshotLogger,
169
- checkpointStreamId: this.checkpointStreamId
181
+ checkpointStreamId: this.checkpointStreamId,
182
+ queryProvider: this.queryProvider
170
183
  });
171
184
 
172
185
  options.abort_signal.addEventListener(
@@ -193,7 +206,9 @@ export class ChangeStream {
193
206
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
194
207
  }
195
208
 
196
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
209
+ /**
210
+ * The active checkpoint strategy. Only valid after ensureDetected().
211
+ */
197
212
  private get checkpointImplementation(): CheckpointImplementation {
198
213
  if (this._checkpointImplementation == null) {
199
214
  throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
@@ -216,6 +231,7 @@ export class ChangeStream {
216
231
  'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
217
232
  );
218
233
  }
234
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
219
235
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
220
236
  client: this.client,
221
237
  db: this.defaultDb,
@@ -512,7 +528,7 @@ export class ChangeStream {
512
528
  if (
513
529
  e instanceof mongo.MongoServerError &&
514
530
  e.codeName == 'NoMatchingDocument' &&
515
- e.errmsg?.includes('post-image was not found')
531
+ (e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))
516
532
  ) {
517
533
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
518
534
  }
@@ -520,86 +536,32 @@ export class ChangeStream {
520
536
  }
521
537
  }
522
538
 
523
- private rawChangeStreamBatches(options: {
539
+ private openChangeStream(options: {
524
540
  lsn: string | null;
525
541
  maxAwaitTimeMS?: number;
526
542
  batchSize?: number;
527
543
  filters: { $match: any; multipleDatabases: boolean };
528
544
  signal?: AbortSignal;
529
545
  tracer?: PerformanceTracer<'changestream'>;
530
- }): AsyncIterableIterator<ChangeStreamBatch> {
531
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
532
- const startAfter = position?.startAfter ?? undefined;
533
- const resumeAfter = position?.resumeAfter ?? undefined;
534
-
535
- const filters = options.filters;
536
-
537
- let fullDocument: 'required' | 'updateLookup';
538
-
539
- if (this.isDocumentDb) {
540
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
541
- fullDocument = 'updateLookup';
542
- } else if (this.usePostImages) {
543
- // 'read_only' or 'auto_configure'
544
- // Configuration happens during snapshot, or when we see new
545
- // collections.
546
- fullDocument = 'required';
547
- } else {
548
- fullDocument = 'updateLookup';
549
- }
550
- const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
551
- fullDocument: fullDocument
552
- };
553
- if (!this.isDocumentDb) {
554
- // DocumentDB does not support showExpandedEvents.
555
- streamOptions.showExpandedEvents = true;
556
- }
557
- const pipeline: mongo.Document[] = [
558
- {
559
- $changeStream: streamOptions
560
- },
561
- {
562
- $match: filters.$match
546
+ onBatch?: (batch: ChangeStreamBatch) => Disposable | void;
547
+ }): AsyncIterableIterator<MongoReplicationStreamItem> {
548
+ return openMongoReplicationStream({
549
+ db: this.defaultDb,
550
+ queryProvider: this.queryProvider,
551
+ namespaceFilter: options.filters,
552
+ isDocumentDb: this.isDocumentDb,
553
+ usePostImages: this.usePostImages,
554
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
555
+ skipInitialTimestamp: true,
556
+ onBatch: options.onBatch,
557
+ options: {
558
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
559
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
560
+ maxTimeMS: this.changeStreamTimeout,
561
+ signal: options.signal,
562
+ logger: this.logger,
563
+ tracer: options.tracer
563
564
  }
564
- ];
565
- if (!this.isDocumentDb) {
566
- // DocumentDB does not support $changeStreamSplitLargeEvent.
567
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
568
- }
569
-
570
- /**
571
- * Only one of these options can be supplied at a time.
572
- */
573
- if (resumeAfter) {
574
- streamOptions.resumeAfter = resumeAfter;
575
- } else if (startAfter != null) {
576
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
577
- // case if we have an old one.
578
- // This is also relevant for getSnapshotLSN().
579
- // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
580
- // opens from "now" with neither option set.
581
- streamOptions.startAtOperationTime = startAfter;
582
- }
583
-
584
- let watchDb: mongo.Db;
585
- if (this.isDocumentDb || filters.multipleDatabases) {
586
- // DocumentDB only supports cluster-level change streams.
587
- watchDb = this.client.db('admin');
588
- streamOptions.allChangesForCluster = true;
589
- } else {
590
- watchDb = this.defaultDb;
591
- }
592
-
593
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
594
-
595
- return rawChangeStream(watchDb, pipeline, {
596
- batchSize: options.batchSize ?? this.snapshotChunkLength,
597
- maxAwaitTimeMS,
598
- maxTimeMS: this.changeStreamTimeout,
599
-
600
- signal: options.signal,
601
- logger: this.logger,
602
- tracer: options.tracer
603
565
  });
604
566
  }
605
567
 
@@ -638,22 +600,30 @@ export class ChangeStream {
638
600
  if (resumeFromLsn == null) {
639
601
  throw new ReplicationAssertionError(`No LSN found to resume from`);
640
602
  }
641
- // Seed the implementation's coordinate state from the stored LSN, and parse
642
- // the legacy startAfter timestamp (timestamp implementation only) for the
643
- // resume-boundary dedupe guard below.
603
+ // Seed the checkpoint strategy's coordinate from the durable source position.
644
604
  this.checkpointImplementation.seedPosition(resumeFromLsn);
645
- const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
646
605
  let outerSpan = tracer.span('batch');
647
606
 
648
607
  this.checkpointImplementation.logResume(resumeFromLsn);
649
608
 
650
609
  const filters = this.getSourceNamespaceFilters();
651
610
  // This is closed when the for loop below returns/breaks/throws
652
- const batchStream = this.rawChangeStreamBatches({
611
+ let processingSpan: ReturnType<typeof tracer.span> | undefined;
612
+ let receivedBytes = 0;
613
+ let changesSinceProgress = 0;
614
+ const stream = this.openChangeStream({
653
615
  lsn: resumeFromLsn,
654
616
  filters,
655
617
  signal: this.abortSignal,
656
- tracer
618
+ tracer,
619
+ onBatch: (batch) => {
620
+ // Count actual transport, even if the adapter drops every envelope or reassembles fragments.
621
+ bytesReplicatedMetric.add(batch.byteSize);
622
+ chunksReplicatedMetric.add(1);
623
+ receivedBytes += batch.byteSize;
624
+ processingSpan = tracer.span('processing');
625
+ return processingSpan;
626
+ }
657
627
  });
658
628
 
659
629
  // Always start with a checkpoint.
@@ -661,305 +631,229 @@ export class ChangeStream {
661
631
  // no data to replicate.
662
632
  let waitForCheckpointLsn: string | null = await this.createBatchCheckpoint();
663
633
 
664
- let splitDocument: ProjectedChangeStreamDocument | null = null;
665
-
666
- let flexDbNameWorkaroundLogged = false;
667
-
668
- let lastEmptyResume = performance.now();
634
+ let lastKeepalive = performance.now();
669
635
  let lastTxnKey: string | null = null;
670
636
 
671
- for await (let eventBatch of batchStream) {
672
- const { events, resumeToken } = eventBatch;
673
- using batchSpan = tracer.span('processing');
674
-
675
- bytesReplicatedMetric.add(eventBatch.byteSize);
676
- chunksReplicatedMetric.add(1);
677
- if (this.abortSignal.aborted) {
678
- break;
679
- }
637
+ for await (const item of stream) {
638
+ if (this.abortSignal.aborted) break;
680
639
  this.touch();
681
- if (events.length == 0) {
682
- // No changes in this batch, but we still want to persist progress.
683
- // We do this by persisting a keepalive checkpoint.
684
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
685
- // with old tokens may cause connection timeouts.
686
- const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
687
- if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
688
- // Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
689
- // and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
690
- // This is the normal case for an idle stream.
691
- // The implementation persists a keepalive (timestamp) or bumps the
692
- // sentinel so a later event commits (sentinel). Logging is handled
693
- // inside the implementation.
694
- await this.checkpointImplementation.keepalive(batch, resumeToken);
695
- this.touch();
696
- lastEmptyResume = performance.now();
697
- this.replicationLag.markStarted();
698
- } else if (hadRecentKeepalive) {
699
- // Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
700
- // We have recently persisted a keepalive.
701
- // Continue waiting.
702
- continue;
703
- } else {
704
- // Case 3: Waiting for a checkpoint; have not had a recent keepalive.
705
- // We cannot call checkpointImplementation.keepalive() here, but we do call
706
- // setResumeLsn() below.
640
+ if (item.type == 'progress') {
641
+ const { resumeToken, filteredCount } = item;
642
+ if (changesSinceProgress == 0) {
643
+ // No retained changes since the previous progress item: this is either idle or filtered-only traffic.
644
+ // Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
645
+ // then flush and save the resume token.
646
+ // Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
647
+ // is pending, to preserve the idle throttle.
648
+ // Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
649
+ // A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
650
+ // progress still saves its token even during the keepalive interval, so excluded work is resumable.
651
+ const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
652
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
653
+ if (filteredCount > 0) {
654
+ // Case 1a: A transaction can span batches: this batch may contain only filtered changes,
655
+ // while retained changes from the same transaction are still unread. They share a timestamp,
656
+ // so publishing a checkpoint here could acknowledge the transaction before its data is saved.
657
+ // Request a source barrier; reaching it ensures those retained changes have been processed.
658
+ // The progress token can still be saved below for recovery without publishing a checkpoint.
659
+ using _ = tracer.span('source_checkpoint');
660
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
661
+ } else {
662
+ // Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
663
+ // the sentinel implementation writes a source marker whose event will commit later.
664
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
665
+ this.replicationLag.markStarted();
666
+ }
667
+ this.touch();
668
+ lastKeepalive = performance.now();
669
+ } else if (filteredCount == 0 && hadRecentKeepalive) {
670
+ // Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
671
+ continue;
672
+ }
707
673
  }
708
- }
709
674
 
710
- this.touch();
675
+ const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
676
+ // Row writes must be durable before their source token. This advances recovery without
677
+ // publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
678
+ await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
679
+ await batch.setResumeLsn(lsn);
680
+ // MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
681
+ this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
682
+ processingSpan?.end();
683
+ const durationsMicroseconds = outerSpan.end();
684
+ this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
685
+ count: changesSinceProgress,
686
+ filteredCount,
687
+ bytes: receivedBytes,
688
+ duration: processingSpan?.durationMillis,
689
+ t: durationsMicroseconds
690
+ });
691
+ changesSinceProgress = 0;
692
+ receivedBytes = 0;
693
+ outerSpan = tracer.span('batch');
694
+ continue;
695
+ }
711
696
 
712
- for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
713
- const rawChangeDocument = events[eventIndex];
714
- const originalChangeDocument = parseChangeDocument(rawChangeDocument);
715
- if (this.abortSignal.aborted) {
716
- break;
697
+ // The shared reader has already reassembled complete events and normalized their namespaces.
698
+ const changeDocument = item.event;
699
+ changesSinceProgress++;
700
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
701
+
702
+ if (ns?.coll == CHECKPOINTS_COLLECTION) {
703
+ /**
704
+ * Dropping the database does not provide an `invalidate` event.
705
+ * We typically would receive `drop` events for the collection which we
706
+ * would process below.
707
+ *
708
+ * However we don't commit the LSN after collections are dropped.
709
+ * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
710
+ * The stream also closes after the drop events.
711
+ * This causes an infinite loop of processing the collection drop events.
712
+ *
713
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
714
+ * is dropped. This allows for detecting when the DB is dropped.
715
+ */
716
+ if (changeDocument.operationType == 'drop') {
717
+ throw new ChangeStreamInvalidatedError(
718
+ 'Internal collections have been dropped',
719
+ new Error('_powersync_checkpoints collection was dropped')
720
+ );
717
721
  }
718
722
 
719
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
723
+ if (
724
+ !(
725
+ changeDocument.operationType == 'insert' ||
726
+ changeDocument.operationType == 'update' ||
727
+ changeDocument.operationType == 'replace'
728
+ )
729
+ ) {
720
730
  continue;
721
731
  }
722
732
 
723
- let changeDocument = originalChangeDocument;
724
- if (originalChangeDocument?.splitEvent != null) {
725
- // Handle split events from $changeStreamSplitLargeEvent.
726
- // This is only relevant for very large update operations.
727
- const splitEvent = originalChangeDocument?.splitEvent;
733
+ // We handle two types of checkpoint events:
734
+ // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
735
+ // immediately, regardless of where they were created.
736
+ // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
737
+ // limiting of commits, so we specifically want to exclude checkpoints from other streams.
738
+ //
739
+ // It may be useful to also throttle commits due to standalone checkpoints in the future.
740
+ // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
728
741
 
729
- if (splitDocument == null) {
730
- splitDocument = originalChangeDocument;
731
- } else {
732
- splitDocument = Object.assign(splitDocument, originalChangeDocument);
733
- }
742
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
734
743
 
735
- if (splitEvent.fragment == splitEvent.of) {
736
- // Got all fragments
737
- changeDocument = splitDocument;
738
- splitDocument = null;
739
- } else {
740
- // Wait for more fragments
744
+ if (kind == 'foreign') {
745
+ // Another stream's barrier - ignore.
746
+ continue;
747
+ } else if (kind == 'standalone') {
748
+ // Standalone / write checkpoint received.
749
+ // When we are caught up, commit immediately to keep write checkpoint latency low.
750
+ // Once there is already a batch checkpoint pending, or the driver has buffered more
751
+ // change stream events, collapse standalone checkpoints into the normal batch
752
+ // checkpoint flow to avoid commit churn under sustained load.
753
+ const hasBufferedChanges = item.hasBufferedChanges;
754
+ if (hasBufferedChanges && waitForCheckpointLsn == null) {
755
+ // Buffered changes - create a new batch checkpoint to rate limit commits
756
+ using _ = tracer.span('source_checkpoint');
757
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
741
758
  continue;
759
+ } else if (waitForCheckpointLsn != null) {
760
+ // Skip this checkpoint - wait for the batch checkpoint.
761
+ continue;
762
+ } else {
763
+ // No buffered changes, and no batch checkpoint pending - commit immediately.
742
764
  }
743
- } else if (splitDocument != null) {
744
- // We were waiting for fragments, but got a different event
745
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
746
765
  }
766
+ // kind == 'own-barrier' falls through to commit.
767
+
768
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
747
769
 
748
770
  if (
749
- !filters.multipleDatabases &&
750
- 'ns' in changeDocument &&
751
- changeDocument.ns.db != this.defaultDb.databaseName &&
752
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
771
+ waitForCheckpointLsn != null &&
772
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
753
773
  ) {
754
- // When all of the following conditions are met:
755
- // 1. We're replicating from an Atlas Flex instance.
756
- // 2. There were changestream events recorded while the PowerSync service is paused.
757
- // 3. We're only replicating from a single database.
758
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
759
- // instead of the expected {db: 'ps'}.
760
- // We correct this.
761
- changeDocument.ns.db = this.defaultDb.databaseName;
762
-
763
- if (!flexDbNameWorkaroundLogged) {
764
- flexDbNameWorkaroundLogged = true;
765
- this.logger.warn(
766
- `Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
767
- );
768
- }
774
+ waitForCheckpointLsn = null;
769
775
  }
776
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
777
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
778
+ });
770
779
 
771
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
772
-
773
- if (ns?.coll == CHECKPOINTS_COLLECTION) {
774
- /**
775
- * Dropping the database does not provide an `invalidate` event.
776
- * We typically would receive `drop` events for the collection which we
777
- * would process below.
778
- *
779
- * However we don't commit the LSN after collections are dropped.
780
- * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
781
- * The stream also closes after the drop events.
782
- * This causes an infinite loop of processing the collection drop events.
783
- *
784
- * This check here invalidates the change stream if our `_powersync_checkpoints` collection
785
- * is dropped. This allows for detecting when the DB is dropped.
786
- */
787
- if (changeDocument.operationType == 'drop') {
788
- throw new ChangeStreamInvalidatedError(
789
- 'Internal collections have been dropped',
790
- new Error('_powersync_checkpoints collection was dropped')
791
- );
792
- }
793
-
794
- if (
795
- !(
796
- changeDocument.operationType == 'insert' ||
797
- changeDocument.operationType == 'update' ||
798
- changeDocument.operationType == 'replace'
799
- )
800
- ) {
801
- continue;
802
- }
803
-
804
- // We handle two types of checkpoint events:
805
- // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
806
- // immediately, regardless of where they were created.
807
- // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
808
- // limiting of commits, so we specifically want to exclude checkpoints from other streams.
809
- //
810
- // It may be useful to also throttle commits due to standalone checkpoints in the future.
811
- // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
812
-
813
- const kind = this.checkpointImplementation.event.observe(changeDocument);
814
-
815
- if (kind == 'foreign') {
816
- // Another stream's barrier - ignore.
817
- continue;
818
- } else if (kind == 'standalone') {
819
- // Standalone / write checkpoint received.
820
- // When we are caught up, commit immediately to keep write checkpoint latency low.
821
- // Once there is already a batch checkpoint pending, or the driver has buffered more
822
- // change stream events, collapse standalone checkpoints into the normal batch
823
- // checkpoint flow to avoid commit churn under sustained load.
824
- const hasBufferedChanges = eventIndex < events.length - 1;
825
- if (hasBufferedChanges && waitForCheckpointLsn == null) {
826
- // Buffered changes - create a new batch checkpoint to rate limit commits
827
- using _ = tracer.span('source_checkpoint');
828
- waitForCheckpointLsn = await this.createBatchCheckpoint();
829
- continue;
830
- } else if (waitForCheckpointLsn != null) {
831
- // Skip this checkpoint - wait for the batch checkpoint.
832
- continue;
833
- } else {
834
- // No buffered changes, and no batch checkpoint pending - commit immediately.
835
- }
836
- }
837
- // kind == 'own-barrier' falls through to commit.
838
-
839
- const lsn = this.checkpointImplementation.event.lsn(changeDocument);
840
-
841
- if (
842
- waitForCheckpointLsn != null &&
843
- this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)
844
- ) {
845
- waitForCheckpointLsn = null;
846
- }
847
- const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
848
- oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
849
- });
780
+ if (!checkpointBlocked || checkpointCreated) {
781
+ this.replicationLag.markCommitted();
782
+ }
783
+ } else if (
784
+ changeDocument.operationType == 'insert' ||
785
+ changeDocument.operationType == 'update' ||
786
+ changeDocument.operationType == 'replace' ||
787
+ changeDocument.operationType == 'delete'
788
+ ) {
789
+ if (waitForCheckpointLsn == null) {
790
+ using _ = tracer.span('source_checkpoint');
791
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
792
+ }
850
793
 
851
- if (!checkpointBlocked || checkpointCreated) {
852
- this.replicationLag.markCommitted();
853
- }
854
- } else if (
855
- changeDocument.operationType == 'insert' ||
856
- changeDocument.operationType == 'update' ||
857
- changeDocument.operationType == 'replace' ||
858
- changeDocument.operationType == 'delete'
859
- ) {
860
- if (waitForCheckpointLsn == null) {
861
- using _ = tracer.span('source_checkpoint');
862
- waitForCheckpointLsn = await this.createBatchCheckpoint();
794
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
795
+ const tables = await this.getRelations(batch, rel, {
796
+ // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
797
+ // for whatever reason, then we do need to snapshot it.
798
+ // This may result in some duplicate operations when a collection is created for the first time after
799
+ // sync config was deployed.
800
+ snapshot: true
801
+ });
802
+ const tablesToReplicate = tables.filter((table) => table.syncAny);
803
+ if (tablesToReplicate.length > 0) {
804
+ this.replicationLag.trackUncommittedChange(
805
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
806
+ // clusterTime, so fall back to wallTime there for the lag metric.
807
+ changeDocument.clusterTime != null
808
+ ? timestampToDate(changeDocument.clusterTime)
809
+ : ((changeDocument as any).wallTime ?? null)
810
+ );
811
+
812
+ const transactionKeyValue = transactionKey(changeDocument);
813
+
814
+ if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
815
+ // Very crude metric for counting transactions replicated.
816
+ // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
817
+ // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
818
+ lastTxnKey = transactionKeyValue;
819
+ transactionsReplicatedMetric.add(1);
863
820
  }
864
821
 
865
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
866
- const tables = await this.getRelations(batch, rel, {
867
- // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
868
- // for whatever reason, then we do need to snapshot it.
869
- // This may result in some duplicate operations when a collection is created for the first time after
870
- // sync config was deployed.
871
- snapshot: true
872
- });
873
- const tablesToReplicate = tables.filter((table) => table.syncAny);
874
- if (tablesToReplicate.length > 0) {
875
- this.replicationLag.trackUncommittedChange(
876
- // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
877
- // clusterTime, so fall back to wallTime there for the lag metric.
878
- changeDocument.clusterTime != null
879
- ? timestampToDate(changeDocument.clusterTime)
880
- : ((changeDocument as any).wallTime ?? null)
881
- );
882
-
883
- const transactionKeyValue = transactionKey(changeDocument);
884
-
885
- if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
886
- // Very crude metric for counting transactions replicated.
887
- // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
888
- // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
889
- lastTxnKey = transactionKeyValue;
890
- transactionsReplicatedMetric.add(1);
891
- }
892
-
893
- for (const table of tablesToReplicate) {
894
- await this.writeChange(batch, table, changeDocument);
895
- }
896
- }
897
- } else if (changeDocument.operationType == 'drop') {
898
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
899
- const tables = await this.getRelations(batch, rel, {
900
- // We're "dropping" this collection, so never snapshot it.
901
- snapshot: false
902
- });
903
- const tablesToDrop = tables.filter((table) => table.syncAny);
904
- if (tablesToDrop.length > 0) {
905
- await batch.drop(tablesToDrop);
822
+ for (const table of tablesToReplicate) {
823
+ await this.writeChange(batch, table, changeDocument);
906
824
  }
907
- this.relationCache.delete(rel);
908
- } else if (changeDocument.operationType == 'rename') {
909
- const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
910
- const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
911
- const tablesFrom = await this.getRelations(batch, relFrom, {
912
- // We're "dropping" this collection, so never snapshot it.
913
- snapshot: false
914
- });
915
- const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
916
- if (tablesToDrop.length > 0) {
917
- await batch.drop(tablesToDrop);
918
- }
919
- this.relationCache.delete(relFrom);
920
- // Here we do need to snapshot the new table
921
- const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
922
- await this.handleRelation(batch, relTo, {
923
- // This is a new (renamed) collection, so always snapshot it.
924
- snapshot: true,
925
- collectionInfo: collection
926
- });
927
825
  }
928
- }
929
-
930
- if (splitDocument == null) {
931
- // We flush and mark progress on every batch of data we receive.
932
- // Batches are generally large (64MB or 6000 events, whichever comes first),
933
- // so this is a good natural point to flush and mark progress.
934
- // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
935
- const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
936
- await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
937
- // TODO: We should consider making this standard behavior of flush().
938
- await batch.setResumeLsn(lsn);
939
-
940
- if (timestamp != null) {
941
- // Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
942
- this.lastPersistedResumeTimestamp = timestamp.getTime();
943
- } else {
944
- // DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
945
- this.lastPersistedResumeTimestamp = Date.now();
826
+ } else if (changeDocument.operationType == 'drop') {
827
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
828
+ const tables = await this.getRelations(batch, rel, {
829
+ // We're "dropping" this collection, so never snapshot it.
830
+ snapshot: false
831
+ });
832
+ const tablesToDrop = tables.filter((table) => table.syncAny);
833
+ if (tablesToDrop.length > 0) {
834
+ await batch.drop(tablesToDrop);
946
835
  }
947
- }
948
-
949
- batchSpan.end();
950
- const durationsMicroseconds = outerSpan.end();
951
- const duration = batchSpan.durationMillis;
952
-
953
- this.logger.info(
954
- `Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`,
955
- {
956
- count: events.length,
957
- bytes: eventBatch.byteSize,
958
- duration,
959
- t: durationsMicroseconds
836
+ this.relationCache.delete(rel);
837
+ } else if (changeDocument.operationType == 'rename') {
838
+ const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
839
+ const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
840
+ const tablesFrom = await this.getRelations(batch, relFrom, {
841
+ // We're "dropping" this collection, so never snapshot it.
842
+ snapshot: false
843
+ });
844
+ const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
845
+ if (tablesToDrop.length > 0) {
846
+ await batch.drop(tablesToDrop);
960
847
  }
961
- );
962
- outerSpan = tracer.span('batch');
848
+ this.relationCache.delete(relFrom);
849
+ // Here we do need to snapshot the new table
850
+ const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
851
+ await this.handleRelation(batch, relTo, {
852
+ // This is a new (renamed) collection, so always snapshot it.
853
+ snapshot: true,
854
+ collectionInfo: collection
855
+ });
856
+ }
963
857
  }
964
858
  }
965
859
  );