@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
@@ -7,8 +7,9 @@ import { PostImagesOption } from '../types/types.js';
7
7
  import { escapeRegExp } from '../utils.js';
8
8
  import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
9
9
  import { getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
10
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from './MongoReplicationQueryProvider.js';
11
+ import { openMongoReplicationStream } from './MongoReplicationStream.js';
10
12
  import { MongoSnapshotter } from './MongoSnapshotter.js';
11
- import { parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
12
13
  import { CHECKPOINTS_COLLECTION, detectDocumentDb, timestampToDate } from './replication-utils.js';
13
14
  import { DirectSourceRowConverter } from './SourceRowConverter.js';
14
15
  /**
@@ -49,6 +50,7 @@ export class ChangeStream {
49
50
  changeStreamTimeout;
50
51
  storageHooks;
51
52
  sourceRowConverter;
53
+ queryProvider;
52
54
  keepaliveIntervalMs;
53
55
  isDocumentDb = false;
54
56
  _checkpointImplementation = null;
@@ -79,6 +81,12 @@ export class ChangeStream {
79
81
  this.sync_rules = options.storage.getParsedSyncRules({
80
82
  defaultSchema: this.defaultDb.databaseName
81
83
  });
84
+ this.queryProvider =
85
+ options.createReplicationQueryProvider?.({
86
+ syncConfig: this.sync_rules,
87
+ connectionTag: this.connections.connectionTag,
88
+ defaultSchema: this.defaultDb.databaseName
89
+ }) ?? DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER;
82
90
  this.sourceRowConverter = new DirectSourceRowConverter(this.sync_rules.compatibility);
83
91
  // The change stream aggregation command should timeout before the socket times out,
84
92
  // so we use 90% of the socket timeout value.
@@ -92,7 +100,8 @@ export class ChangeStream {
92
100
  ...options,
93
101
  abortSignal: this.abortSignal,
94
102
  logger: snapshotLogger,
95
- checkpointStreamId: this.checkpointStreamId
103
+ checkpointStreamId: this.checkpointStreamId,
104
+ queryProvider: this.queryProvider
96
105
  });
97
106
  options.abort_signal.addEventListener('abort', () => {
98
107
  this.abortController.abort(options.abort_signal.reason);
@@ -110,7 +119,9 @@ export class ChangeStream {
110
119
  get configurePostImages() {
111
120
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
112
121
  }
113
- /** The active checkpoint strategy. Only valid after ensureDetected(). */
122
+ /**
123
+ * The active checkpoint strategy. Only valid after ensureDetected().
124
+ */
114
125
  get checkpointImplementation() {
115
126
  if (this._checkpointImplementation == null) {
116
127
  throw new ReplicationAssertionError('Checkpoint implementation not initialized - call ensureDetected() first');
@@ -130,6 +141,7 @@ export class ChangeStream {
130
141
  if (this.isDocumentDb) {
131
142
  this.logger.warn('Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.');
132
143
  }
144
+ await this.queryProvider.validateSource?.({ connectionManager: this.connections, isDocumentDb: this.isDocumentDb });
133
145
  this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
134
146
  client: this.client,
135
147
  db: this.defaultDb,
@@ -390,81 +402,30 @@ export class ChangeStream {
390
402
  catch (e) {
391
403
  if (e instanceof mongo.MongoServerError &&
392
404
  e.codeName == 'NoMatchingDocument' &&
393
- e.errmsg?.includes('post-image was not found')) {
405
+ (e.errmsg?.includes('post-image was not found') || e.errmsg?.includes('pre-image was not found'))) {
394
406
  throw new ChangeStreamInvalidatedError(e.errmsg, e);
395
407
  }
396
408
  throw e;
397
409
  }
398
410
  }
399
- rawChangeStreamBatches(options) {
400
- const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
401
- const startAfter = position?.startAfter ?? undefined;
402
- const resumeAfter = position?.resumeAfter ?? undefined;
403
- const filters = options.filters;
404
- let fullDocument;
405
- if (this.isDocumentDb) {
406
- // DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
407
- fullDocument = 'updateLookup';
408
- }
409
- else if (this.usePostImages) {
410
- // 'read_only' or 'auto_configure'
411
- // Configuration happens during snapshot, or when we see new
412
- // collections.
413
- fullDocument = 'required';
414
- }
415
- else {
416
- fullDocument = 'updateLookup';
417
- }
418
- const streamOptions = {
419
- fullDocument: fullDocument
420
- };
421
- if (!this.isDocumentDb) {
422
- // DocumentDB does not support showExpandedEvents.
423
- streamOptions.showExpandedEvents = true;
424
- }
425
- const pipeline = [
426
- {
427
- $changeStream: streamOptions
428
- },
429
- {
430
- $match: filters.$match
411
+ openChangeStream(options) {
412
+ return openMongoReplicationStream({
413
+ db: this.defaultDb,
414
+ queryProvider: this.queryProvider,
415
+ namespaceFilter: options.filters,
416
+ isDocumentDb: this.isDocumentDb,
417
+ usePostImages: this.usePostImages,
418
+ position: options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null,
419
+ skipInitialTimestamp: true,
420
+ onBatch: options.onBatch,
421
+ options: {
422
+ batchSize: options.batchSize ?? this.snapshotChunkLength,
423
+ maxAwaitTimeMS: options.maxAwaitTimeMS ?? this.maxAwaitTimeMS,
424
+ maxTimeMS: this.changeStreamTimeout,
425
+ signal: options.signal,
426
+ logger: this.logger,
427
+ tracer: options.tracer
431
428
  }
432
- ];
433
- if (!this.isDocumentDb) {
434
- // DocumentDB does not support $changeStreamSplitLargeEvent.
435
- pipeline.push({ $changeStreamSplitLargeEvent: {} });
436
- }
437
- /**
438
- * Only one of these options can be supplied at a time.
439
- */
440
- if (resumeAfter) {
441
- streamOptions.resumeAfter = resumeAfter;
442
- }
443
- else if (startAfter != null) {
444
- // Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
445
- // case if we have an old one.
446
- // This is also relevant for getSnapshotLSN().
447
- // The sentinel implementation never produces a startAfter, and a fresh DocumentDB stream
448
- // opens from "now" with neither option set.
449
- streamOptions.startAtOperationTime = startAfter;
450
- }
451
- let watchDb;
452
- if (this.isDocumentDb || filters.multipleDatabases) {
453
- // DocumentDB only supports cluster-level change streams.
454
- watchDb = this.client.db('admin');
455
- streamOptions.allChangesForCluster = true;
456
- }
457
- else {
458
- watchDb = this.defaultDb;
459
- }
460
- const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
461
- return rawChangeStream(watchDb, pipeline, {
462
- batchSize: options.batchSize ?? this.snapshotChunkLength,
463
- maxAwaitTimeMS,
464
- maxTimeMS: this.changeStreamTimeout,
465
- signal: options.signal,
466
- logger: this.logger,
467
- tracer: options.tracer
468
429
  });
469
430
  }
470
431
  rawToSqliteRow(row) {
@@ -495,288 +456,238 @@ export class ChangeStream {
495
456
  if (resumeFromLsn == null) {
496
457
  throw new ReplicationAssertionError(`No LSN found to resume from`);
497
458
  }
498
- // Seed the implementation's coordinate state from the stored LSN, and parse
499
- // the legacy startAfter timestamp (timestamp implementation only) for the
500
- // resume-boundary dedupe guard below.
459
+ // Seed the checkpoint strategy's coordinate from the durable source position.
501
460
  this.checkpointImplementation.seedPosition(resumeFromLsn);
502
- const { startAfter } = this.checkpointImplementation.parseResumePosition(resumeFromLsn);
503
461
  let outerSpan = tracer.span('batch');
504
462
  this.checkpointImplementation.logResume(resumeFromLsn);
505
463
  const filters = this.getSourceNamespaceFilters();
506
464
  // This is closed when the for loop below returns/breaks/throws
507
- const batchStream = this.rawChangeStreamBatches({
465
+ let processingSpan;
466
+ let receivedBytes = 0;
467
+ let changesSinceProgress = 0;
468
+ const stream = this.openChangeStream({
508
469
  lsn: resumeFromLsn,
509
470
  filters,
510
471
  signal: this.abortSignal,
511
- tracer
472
+ tracer,
473
+ onBatch: (batch) => {
474
+ // Count actual transport, even if the adapter drops every envelope or reassembles fragments.
475
+ bytesReplicatedMetric.add(batch.byteSize);
476
+ chunksReplicatedMetric.add(1);
477
+ receivedBytes += batch.byteSize;
478
+ processingSpan = tracer.span('processing');
479
+ return processingSpan;
480
+ }
512
481
  });
513
482
  // Always start with a checkpoint.
514
483
  // This helps us to clear errors when restarting, even if there is
515
484
  // no data to replicate.
516
485
  let waitForCheckpointLsn = await this.createBatchCheckpoint();
517
- let splitDocument = null;
518
- let flexDbNameWorkaroundLogged = false;
519
- let lastEmptyResume = performance.now();
486
+ let lastKeepalive = performance.now();
520
487
  let lastTxnKey = null;
521
- for await (let eventBatch of batchStream) {
522
- const { events, resumeToken } = eventBatch;
523
- using batchSpan = tracer.span('processing');
524
- bytesReplicatedMetric.add(eventBatch.byteSize);
525
- chunksReplicatedMetric.add(1);
526
- if (this.abortSignal.aborted) {
488
+ for await (const item of stream) {
489
+ if (this.abortSignal.aborted)
527
490
  break;
528
- }
529
- this.touch();
530
- if (events.length == 0) {
531
- // No changes in this batch, but we still want to persist progress.
532
- // We do this by persisting a keepalive checkpoint.
533
- // If we don't update it on empty events, we do keep consistency, but resuming the stream
534
- // with old tokens may cause connection timeouts.
535
- const hadRecentKeepalive = performance.now() - lastEmptyResume < this.keepaliveIntervalMs;
536
- if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
537
- // Case 1: We have no changes, and we are not waiting for a checkpoint to be created,
538
- // and we have not recently persisted a keepalive. Persist one now, and call setResumeLsn() below.
539
- // This is the normal case for an idle stream.
540
- // The implementation persists a keepalive (timestamp) or bumps the
541
- // sentinel so a later event commits (sentinel). Logging is handled
542
- // inside the implementation.
543
- await this.checkpointImplementation.keepalive(batch, resumeToken);
544
- this.touch();
545
- lastEmptyResume = performance.now();
546
- this.replicationLag.markStarted();
547
- }
548
- else if (hadRecentKeepalive) {
549
- // Case 2: We have no changes, and may or may not be waiting for a checkpoint to be created.
550
- // We have recently persisted a keepalive.
551
- // Continue waiting.
552
- continue;
553
- }
554
- else {
555
- // Case 3: Waiting for a checkpoint; have not had a recent keepalive.
556
- // We cannot call checkpointImplementation.keepalive() here, but we do call
557
- // setResumeLsn() below.
558
- }
559
- }
560
491
  this.touch();
561
- for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
562
- const rawChangeDocument = events[eventIndex];
563
- const originalChangeDocument = parseChangeDocument(rawChangeDocument);
564
- if (this.abortSignal.aborted) {
565
- break;
566
- }
567
- if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
568
- continue;
569
- }
570
- let changeDocument = originalChangeDocument;
571
- if (originalChangeDocument?.splitEvent != null) {
572
- // Handle split events from $changeStreamSplitLargeEvent.
573
- // This is only relevant for very large update operations.
574
- const splitEvent = originalChangeDocument?.splitEvent;
575
- if (splitDocument == null) {
576
- splitDocument = originalChangeDocument;
577
- }
578
- else {
579
- splitDocument = Object.assign(splitDocument, originalChangeDocument);
580
- }
581
- if (splitEvent.fragment == splitEvent.of) {
582
- // Got all fragments
583
- changeDocument = splitDocument;
584
- splitDocument = null;
585
- }
586
- else {
587
- // Wait for more fragments
588
- continue;
589
- }
590
- }
591
- else if (splitDocument != null) {
592
- // We were waiting for fragments, but got a different event
593
- throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
594
- }
595
- if (!filters.multipleDatabases &&
596
- 'ns' in changeDocument &&
597
- changeDocument.ns.db != this.defaultDb.databaseName &&
598
- changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)) {
599
- // When all of the following conditions are met:
600
- // 1. We're replicating from an Atlas Flex instance.
601
- // 2. There were changestream events recorded while the PowerSync service is paused.
602
- // 3. We're only replicating from a single database.
603
- // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
604
- // instead of the expected {db: 'ps'}.
605
- // We correct this.
606
- changeDocument.ns.db = this.defaultDb.databaseName;
607
- if (!flexDbNameWorkaroundLogged) {
608
- flexDbNameWorkaroundLogged = true;
609
- this.logger.warn(`Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`);
610
- }
611
- }
612
- const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
613
- if (ns?.coll == CHECKPOINTS_COLLECTION) {
614
- /**
615
- * Dropping the database does not provide an `invalidate` event.
616
- * We typically would receive `drop` events for the collection which we
617
- * would process below.
618
- *
619
- * However we don't commit the LSN after collections are dropped.
620
- * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
621
- * The stream also closes after the drop events.
622
- * This causes an infinite loop of processing the collection drop events.
623
- *
624
- * This check here invalidates the change stream if our `_powersync_checkpoints` collection
625
- * is dropped. This allows for detecting when the DB is dropped.
626
- */
627
- if (changeDocument.operationType == 'drop') {
628
- throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
629
- }
630
- if (!(changeDocument.operationType == 'insert' ||
631
- changeDocument.operationType == 'update' ||
632
- changeDocument.operationType == 'replace')) {
633
- continue;
634
- }
635
- // We handle two types of checkpoint events:
636
- // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
637
- // immediately, regardless of where they were created.
638
- // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
639
- // limiting of commits, so we specifically want to exclude checkpoints from other streams.
640
- //
641
- // It may be useful to also throttle commits due to standalone checkpoints in the future.
642
- // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
643
- const kind = this.checkpointImplementation.event.observe(changeDocument);
644
- if (kind == 'foreign') {
645
- // Another stream's barrier - ignore.
646
- continue;
647
- }
648
- else if (kind == 'standalone') {
649
- // Standalone / write checkpoint received.
650
- // When we are caught up, commit immediately to keep write checkpoint latency low.
651
- // Once there is already a batch checkpoint pending, or the driver has buffered more
652
- // change stream events, collapse standalone checkpoints into the normal batch
653
- // checkpoint flow to avoid commit churn under sustained load.
654
- const hasBufferedChanges = eventIndex < events.length - 1;
655
- if (hasBufferedChanges && waitForCheckpointLsn == null) {
656
- // Buffered changes - create a new batch checkpoint to rate limit commits
492
+ if (item.type == 'progress') {
493
+ const { resumeToken, filteredCount } = item;
494
+ if (changesSinceProgress == 0) {
495
+ // No retained changes since the previous progress item: this is either idle or filtered-only traffic.
496
+ // Case 1: No pending barrier and the keepalive interval has elapsed. Advance checkpoints as below,
497
+ // then flush and save the resume token.
498
+ // Case 2: Idle traffic with a recent keepalive. Skip this resume update, whether or not a barrier
499
+ // is pending, to preserve the idle throttle.
500
+ // Case 3: All other combinations. Flush and save the resume token without requesting a checkpoint.
501
+ // A pending barrier will provide the checkpoint boundary once replication reaches it. Filtered
502
+ // progress still saves its token even during the keepalive interval, so excluded work is resumable.
503
+ const hadRecentKeepalive = performance.now() - lastKeepalive < this.keepaliveIntervalMs;
504
+ if (waitForCheckpointLsn == null && !hadRecentKeepalive) {
505
+ if (filteredCount > 0) {
506
+ // Case 1a: A transaction can span batches: this batch may contain only filtered changes,
507
+ // while retained changes from the same transaction are still unread. They share a timestamp,
508
+ // so publishing a checkpoint here could acknowledge the transaction before its data is saved.
509
+ // Request a source barrier; reaching it ensures those retained changes have been processed.
510
+ // The progress token can still be saved below for recovery without publishing a checkpoint.
657
511
  using _ = tracer.span('source_checkpoint');
658
512
  waitForCheckpointLsn = await this.createBatchCheckpoint();
659
- continue;
660
- }
661
- else if (waitForCheckpointLsn != null) {
662
- // Skip this checkpoint - wait for the batch checkpoint.
663
- continue;
664
513
  }
665
514
  else {
666
- // No buffered changes, and no batch checkpoint pending - commit immediately.
515
+ // Case 1b: Idle traffic. The timestamp implementation persists a keepalive directly;
516
+ // the sentinel implementation writes a source marker whose event will commit later.
517
+ await this.checkpointImplementation.keepalive(batch, resumeToken);
518
+ this.replicationLag.markStarted();
667
519
  }
520
+ this.touch();
521
+ lastKeepalive = performance.now();
668
522
  }
669
- // kind == 'own-barrier' falls through to commit.
670
- const lsn = this.checkpointImplementation.event.lsn(changeDocument);
671
- if (waitForCheckpointLsn != null &&
672
- this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
673
- waitForCheckpointLsn = null;
674
- }
675
- const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
676
- oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
677
- });
678
- if (!checkpointBlocked || checkpointCreated) {
679
- this.replicationLag.markCommitted();
523
+ else if (filteredCount == 0 && hadRecentKeepalive) {
524
+ // Case 2: Only idle progress skips resume persistence. Cases 1 and 3 fall through below.
525
+ continue;
680
526
  }
681
527
  }
682
- else if (changeDocument.operationType == 'insert' ||
528
+ const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
529
+ // Row writes must be durable before their source token. This advances recovery without
530
+ // publishing a checkpoint ahead of an outstanding barrier, transaction or snapshot.
531
+ await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
532
+ await batch.setResumeLsn(lsn);
533
+ // MongoDB's token timestamp may lag by about 10 seconds. DocumentDB tokens have no timestamp.
534
+ this.lastPersistedResumeTimestamp = timestamp?.getTime() ?? Date.now();
535
+ processingSpan?.end();
536
+ const durationsMicroseconds = outerSpan.end();
537
+ this.logger.info(`Processed ${changesSinceProgress} changes and ${filteredCount} filtered events`, {
538
+ count: changesSinceProgress,
539
+ filteredCount,
540
+ bytes: receivedBytes,
541
+ duration: processingSpan?.durationMillis,
542
+ t: durationsMicroseconds
543
+ });
544
+ changesSinceProgress = 0;
545
+ receivedBytes = 0;
546
+ outerSpan = tracer.span('batch');
547
+ continue;
548
+ }
549
+ // The shared reader has already reassembled complete events and normalized their namespaces.
550
+ const changeDocument = item.event;
551
+ changesSinceProgress++;
552
+ const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
553
+ if (ns?.coll == CHECKPOINTS_COLLECTION) {
554
+ /**
555
+ * Dropping the database does not provide an `invalidate` event.
556
+ * We typically would receive `drop` events for the collection which we
557
+ * would process below.
558
+ *
559
+ * However we don't commit the LSN after collections are dropped.
560
+ * This prevents the `startAfter` or `resumeToken` from advancing past the drop events.
561
+ * The stream also closes after the drop events.
562
+ * This causes an infinite loop of processing the collection drop events.
563
+ *
564
+ * This check here invalidates the change stream if our `_powersync_checkpoints` collection
565
+ * is dropped. This allows for detecting when the DB is dropped.
566
+ */
567
+ if (changeDocument.operationType == 'drop') {
568
+ throw new ChangeStreamInvalidatedError('Internal collections have been dropped', new Error('_powersync_checkpoints collection was dropped'));
569
+ }
570
+ if (!(changeDocument.operationType == 'insert' ||
683
571
  changeDocument.operationType == 'update' ||
684
- changeDocument.operationType == 'replace' ||
685
- changeDocument.operationType == 'delete') {
686
- if (waitForCheckpointLsn == null) {
572
+ changeDocument.operationType == 'replace')) {
573
+ continue;
574
+ }
575
+ // We handle two types of checkpoint events:
576
+ // 1. "Standalone" checkpoints, typically write checkpoints. We want to process these
577
+ // immediately, regardless of where they were created.
578
+ // 2. "Batch" checkpoints for the current stream. This is used as a form of dynamic rate
579
+ // limiting of commits, so we specifically want to exclude checkpoints from other streams.
580
+ //
581
+ // It may be useful to also throttle commits due to standalone checkpoints in the future.
582
+ // However, these typically have a much lower rate than batch checkpoints, so we don't do that for now.
583
+ const kind = this.checkpointImplementation.event.observe(changeDocument);
584
+ if (kind == 'foreign') {
585
+ // Another stream's barrier - ignore.
586
+ continue;
587
+ }
588
+ else if (kind == 'standalone') {
589
+ // Standalone / write checkpoint received.
590
+ // When we are caught up, commit immediately to keep write checkpoint latency low.
591
+ // Once there is already a batch checkpoint pending, or the driver has buffered more
592
+ // change stream events, collapse standalone checkpoints into the normal batch
593
+ // checkpoint flow to avoid commit churn under sustained load.
594
+ const hasBufferedChanges = item.hasBufferedChanges;
595
+ if (hasBufferedChanges && waitForCheckpointLsn == null) {
596
+ // Buffered changes - create a new batch checkpoint to rate limit commits
687
597
  using _ = tracer.span('source_checkpoint');
688
598
  waitForCheckpointLsn = await this.createBatchCheckpoint();
599
+ continue;
689
600
  }
690
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
691
- const tables = await this.getRelations(batch, rel, {
692
- // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
693
- // for whatever reason, then we do need to snapshot it.
694
- // This may result in some duplicate operations when a collection is created for the first time after
695
- // sync config was deployed.
696
- snapshot: true
697
- });
698
- const tablesToReplicate = tables.filter((table) => table.syncAny);
699
- if (tablesToReplicate.length > 0) {
700
- this.replicationLag.trackUncommittedChange(
701
- // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
702
- // clusterTime, so fall back to wallTime there for the lag metric.
703
- changeDocument.clusterTime != null
704
- ? timestampToDate(changeDocument.clusterTime)
705
- : (changeDocument.wallTime ?? null));
706
- const transactionKeyValue = transactionKey(changeDocument);
707
- if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
708
- // Very crude metric for counting transactions replicated.
709
- // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
710
- // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
711
- lastTxnKey = transactionKeyValue;
712
- transactionsReplicatedMetric.add(1);
713
- }
714
- for (const table of tablesToReplicate) {
715
- await this.writeChange(batch, table, changeDocument);
716
- }
601
+ else if (waitForCheckpointLsn != null) {
602
+ // Skip this checkpoint - wait for the batch checkpoint.
603
+ continue;
717
604
  }
718
- }
719
- else if (changeDocument.operationType == 'drop') {
720
- const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
721
- const tables = await this.getRelations(batch, rel, {
722
- // We're "dropping" this collection, so never snapshot it.
723
- snapshot: false
724
- });
725
- const tablesToDrop = tables.filter((table) => table.syncAny);
726
- if (tablesToDrop.length > 0) {
727
- await batch.drop(tablesToDrop);
605
+ else {
606
+ // No buffered changes, and no batch checkpoint pending - commit immediately.
728
607
  }
729
- this.relationCache.delete(rel);
730
608
  }
731
- else if (changeDocument.operationType == 'rename') {
732
- const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
733
- const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
734
- const tablesFrom = await this.getRelations(batch, relFrom, {
735
- // We're "dropping" this collection, so never snapshot it.
736
- snapshot: false
737
- });
738
- const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
739
- if (tablesToDrop.length > 0) {
740
- await batch.drop(tablesToDrop);
609
+ // kind == 'own-barrier' falls through to commit.
610
+ const lsn = this.checkpointImplementation.event.lsn(changeDocument);
611
+ if (waitForCheckpointLsn != null &&
612
+ this.checkpointImplementation.event.resolvesBarrier(waitForCheckpointLsn, changeDocument)) {
613
+ waitForCheckpointLsn = null;
614
+ }
615
+ const { checkpointBlocked, checkpointCreated } = await batch.commit(lsn, {
616
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
617
+ });
618
+ if (!checkpointBlocked || checkpointCreated) {
619
+ this.replicationLag.markCommitted();
620
+ }
621
+ }
622
+ else if (changeDocument.operationType == 'insert' ||
623
+ changeDocument.operationType == 'update' ||
624
+ changeDocument.operationType == 'replace' ||
625
+ changeDocument.operationType == 'delete') {
626
+ if (waitForCheckpointLsn == null) {
627
+ using _ = tracer.span('source_checkpoint');
628
+ waitForCheckpointLsn = await this.createBatchCheckpoint();
629
+ }
630
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
631
+ const tables = await this.getRelations(batch, rel, {
632
+ // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
633
+ // for whatever reason, then we do need to snapshot it.
634
+ // This may result in some duplicate operations when a collection is created for the first time after
635
+ // sync config was deployed.
636
+ snapshot: true
637
+ });
638
+ const tablesToReplicate = tables.filter((table) => table.syncAny);
639
+ if (tablesToReplicate.length > 0) {
640
+ this.replicationLag.trackUncommittedChange(
641
+ // Standard MongoDB uses clusterTime, unchanged. DocumentDB has no
642
+ // clusterTime, so fall back to wallTime there for the lag metric.
643
+ changeDocument.clusterTime != null
644
+ ? timestampToDate(changeDocument.clusterTime)
645
+ : (changeDocument.wallTime ?? null));
646
+ const transactionKeyValue = transactionKey(changeDocument);
647
+ if (transactionKeyValue == null || lastTxnKey != transactionKeyValue) {
648
+ // Very crude metric for counting transactions replicated.
649
+ // We ignore operations other than basic CRUD, and ignore changes to _powersync_checkpoints.
650
+ // Individual writes may not have a txnNumber, in which case we count them as separate transactions.
651
+ lastTxnKey = transactionKeyValue;
652
+ transactionsReplicatedMetric.add(1);
653
+ }
654
+ for (const table of tablesToReplicate) {
655
+ await this.writeChange(batch, table, changeDocument);
741
656
  }
742
- this.relationCache.delete(relFrom);
743
- // Here we do need to snapshot the new table
744
- const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
745
- await this.handleRelation(batch, relTo, {
746
- // This is a new (renamed) collection, so always snapshot it.
747
- snapshot: true,
748
- collectionInfo: collection
749
- });
750
657
  }
751
658
  }
752
- if (splitDocument == null) {
753
- // We flush and mark progress on every batch of data we receive.
754
- // Batches are generally large (64MB or 6000 events, whichever comes first),
755
- // so this is a good natural point to flush and mark progress.
756
- // We avoid this when splitDocument is set, since we cannot resume in the middle of a split event.
757
- const { lsn, timestamp } = this.checkpointImplementation.lsnFromResumeToken(resumeToken);
758
- await batch.flush({ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange });
759
- // TODO: We should consider making this standard behavior of flush().
760
- await batch.setResumeLsn(lsn);
761
- if (timestamp != null) {
762
- // Note that this timestamp provided by MongoDB is not exact - it can be around 10s behind.
763
- this.lastPersistedResumeTimestamp = timestamp.getTime();
659
+ else if (changeDocument.operationType == 'drop') {
660
+ const rel = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
661
+ const tables = await this.getRelations(batch, rel, {
662
+ // We're "dropping" this collection, so never snapshot it.
663
+ snapshot: false
664
+ });
665
+ const tablesToDrop = tables.filter((table) => table.syncAny);
666
+ if (tablesToDrop.length > 0) {
667
+ await batch.drop(tablesToDrop);
764
668
  }
765
- else {
766
- // DocumentDB: No timestamp associated with the resumeToken. Just use the current time.
767
- this.lastPersistedResumeTimestamp = Date.now();
669
+ this.relationCache.delete(rel);
670
+ }
671
+ else if (changeDocument.operationType == 'rename') {
672
+ const relFrom = getMongoRelation(changeDocument.ns, this.connections.connectionTag);
673
+ const relTo = getMongoRelation(changeDocument.to, this.connections.connectionTag);
674
+ const tablesFrom = await this.getRelations(batch, relFrom, {
675
+ // We're "dropping" this collection, so never snapshot it.
676
+ snapshot: false
677
+ });
678
+ const tablesToDrop = tablesFrom.filter((table) => table.syncAny);
679
+ if (tablesToDrop.length > 0) {
680
+ await batch.drop(tablesToDrop);
768
681
  }
682
+ this.relationCache.delete(relFrom);
683
+ // Here we do need to snapshot the new table
684
+ const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
685
+ await this.handleRelation(batch, relTo, {
686
+ // This is a new (renamed) collection, so always snapshot it.
687
+ snapshot: true,
688
+ collectionInfo: collection
689
+ });
769
690
  }
770
- batchSpan.end();
771
- const durationsMicroseconds = outerSpan.end();
772
- const duration = batchSpan.durationMillis;
773
- this.logger.info(`Processed batch of ${events.length} changes / ${eventBatch.byteSize} bytes in ${duration}ms`, {
774
- count: events.length,
775
- bytes: eventBatch.byteSize,
776
- duration,
777
- t: durationsMicroseconds
778
- });
779
- outerSpan = tracer.span('batch');
780
691
  }
781
692
  });
782
693
  throw new ReplicationAbortedError(`Replication stream aborted`, this.abortSignal.reason);