11import { mongo } from '@powersync/lib-service-mongodb' ;
22import {
33 container ,
4+ DatabaseConnectionError ,
45 ErrorCode ,
56 logger ,
67 ReplicationAbortedError ,
@@ -9,20 +10,13 @@ import {
910} from '@powersync/lib-services-framework' ;
1011import { Metrics , SaveOperationTag , SourceEntityDescriptor , SourceTable , storage } from '@powersync/service-core' ;
1112import { DatabaseInputRow , SqliteRow , SqlSyncRules , TablePattern } from '@powersync/service-sync-rules' ;
13+ import { MongoLSN } from '../common/MongoLSN.js' ;
1214import { PostImagesOption } from '../types/types.js' ;
1315import { escapeRegExp } from '../utils.js' ;
1416import { MongoManager } from './MongoManager.js' ;
15- import {
16- constructAfterRecord ,
17- createCheckpoint ,
18- getMongoLsn ,
19- getMongoRelation ,
20- mongoLsnToTimestamp
21- } from './MongoRelation.js' ;
17+ import { constructAfterRecord , createCheckpoint , getMongoRelation } from './MongoRelation.js' ;
2218import { CHECKPOINTS_COLLECTION } from './replication-utils.js' ;
2319
24- export const ZERO_LSN = '0000000000000000' ;
25-
2620export interface ChangeStreamOptions {
2721 connections : MongoManager ;
2822 storage : storage . SyncRulesBucketStorage ;
@@ -41,9 +35,9 @@ interface InitResult {
4135 * * Some change stream documents do not have postImages.
4236 * * startAfter/resumeToken is not valid anymore.
4337 */
44- export class ChangeStreamInvalidatedError extends Error {
45- constructor ( message : string ) {
46- super ( message ) ;
38+ export class ChangeStreamInvalidatedError extends DatabaseConnectionError {
39+ constructor ( message : string , cause : any ) {
40+ super ( ErrorCode . PSYNC_S1344 , message , cause ) ;
4741 }
4842}
4943
@@ -207,7 +201,7 @@ export class ChangeStream {
207201 const session = await this . client . startSession ( ) ;
208202 try {
209203 await this . storage . startBatch (
210- { zeroLSN : ZERO_LSN , defaultSchema : this . defaultDb . databaseName , storeCurrentData : false } ,
204+ { zeroLSN : MongoLSN . ZERO . comparable , defaultSchema : this . defaultDb . databaseName , storeCurrentData : false } ,
211205 async ( batch ) => {
212206 // Start by resolving all tables.
213207 // This checks postImage configuration, and that should fail as
@@ -220,12 +214,12 @@ export class ChangeStream {
220214
221215 for ( let table of allSourceTables ) {
222216 await this . snapshotTable ( batch , table , session ) ;
223- await batch . markSnapshotDone ( [ table ] , ZERO_LSN ) ;
217+ await batch . markSnapshotDone ( [ table ] , MongoLSN . ZERO . comparable ) ;
224218
225219 await touch ( ) ;
226220 }
227221
228- const lsn = getMongoLsn ( snapshotTime ) ;
222+ const { comparable : lsn } = new MongoLSN ( { timestamp : snapshotTime } ) ;
229223 logger . info ( `Snapshot commit at ${ snapshotTime . inspect ( ) } / ${ lsn } ` ) ;
230224 await batch . commit ( lsn ) ;
231225 }
@@ -516,7 +510,7 @@ export class ChangeStream {
516510 e . codeName == 'NoMatchingDocument' &&
517511 e . errmsg ?. includes ( 'post-image was not found' )
518512 ) {
519- throw new ChangeStreamInvalidatedError ( e . errmsg ) ;
513+ throw new ChangeStreamInvalidatedError ( e . errmsg , e ) ;
520514 }
521515 throw e ;
522516 }
@@ -527,10 +521,13 @@ export class ChangeStream {
527521 await this . storage . autoActivate ( ) ;
528522
529523 await this . storage . startBatch (
530- { zeroLSN : ZERO_LSN , defaultSchema : this . defaultDb . databaseName , storeCurrentData : false } ,
524+ { zeroLSN : MongoLSN . ZERO . comparable , defaultSchema : this . defaultDb . databaseName , storeCurrentData : false } ,
531525 async ( batch ) => {
532- const lastLsn = batch . lastCheckpointLsn ;
533- const startAfter = mongoLsnToTimestamp ( lastLsn ) ?? undefined ;
526+ const { lastCheckpointLsn } = batch ;
527+ const lastLsn = lastCheckpointLsn ? MongoLSN . fromSerialized ( lastCheckpointLsn ) : null ;
528+ const startAfter = lastLsn ?. timestamp ;
529+ const resumeAfter = lastLsn ?. resumeToken ;
530+
534531 logger . info ( `Resume streaming at ${ startAfter ?. inspect ( ) } / ${ lastLsn } ` ) ;
535532
536533 const filters = this . getSourceNamespaceFilters ( ) ;
@@ -554,12 +551,21 @@ export class ChangeStream {
554551 }
555552
556553 const streamOptions : mongo . ChangeStreamOptions = {
557- startAtOperationTime : startAfter ,
558554 showExpandedEvents : true ,
559555 useBigInt64 : true ,
560556 maxAwaitTimeMS : 200 ,
561557 fullDocument : fullDocument
562558 } ;
559+
560+ /**
561+ * Only one of these options can be supplied at a time.
562+ */
563+ if ( resumeAfter ) {
564+ streamOptions . resumeAfter = resumeAfter ;
565+ } else {
566+ streamOptions . startAtOperationTime = startAfter ;
567+ }
568+
563569 let stream : mongo . ChangeStream < mongo . Document > ;
564570 if ( filters . multipleDatabases ) {
565571 // Requires readAnyDatabase@admin on Atlas
@@ -579,7 +585,7 @@ export class ChangeStream {
579585 } ) ;
580586
581587 // Always start with a checkpoint.
582- // This helps us to clear erorrs when restarting, even if there is
588+ // This helps us to clear errors when restarting, even if there is
583589 // no data to replicate.
584590 let waitForCheckpointLsn : string | null = await createCheckpoint ( this . client , this . defaultDb ) ;
585591
@@ -592,6 +598,11 @@ export class ChangeStream {
592598
593599 const originalChangeDocument = await stream . tryNext ( ) ;
594600
601+ // The stream was closed, we will only ever receive `null` from it
602+ if ( ! originalChangeDocument && stream . closed ) {
603+ break ;
604+ }
605+
595606 if ( originalChangeDocument == null || this . abort_signal . aborted ) {
596607 continue ;
597608 }
@@ -626,15 +637,38 @@ export class ChangeStream {
626637 throw new ReplicationAssertionError ( `Incomplete splitEvent: ${ JSON . stringify ( splitDocument . splitEvent ) } ` ) ;
627638 }
628639
629- // console.log('event', changeDocument);
630-
631640 if (
632641 ( changeDocument . operationType == 'insert' ||
633642 changeDocument . operationType == 'update' ||
634- changeDocument . operationType == 'replace' ) &&
643+ changeDocument . operationType == 'replace' ||
644+ changeDocument . operationType == 'drop' ) &&
635645 changeDocument . ns . coll == CHECKPOINTS_COLLECTION
636646 ) {
637- const lsn = getMongoLsn ( changeDocument . clusterTime ! ) ;
647+ /**
648+ * Dropping the database does not provide an `invalidate` event.
649+ * We typically would receive `drop` events for the collection which we
650+ * would process below.
651+ *
652+ * However we don't commit the LSN after collections are dropped.
653+ * The prevents the `startAfter` or `resumeToken` from advancing past the drop events.
654+ * The stream also closes after the drop events.
655+ * This causes an infinite loop of processing the collection drop events.
656+ *
657+ * This check here invalidates the change stream if our `_checkpoints` collection
658+ * is dropped. This allows for detecting when the DB is dropped.
659+ */
660+ if ( changeDocument . operationType == 'drop' ) {
661+ throw new ChangeStreamInvalidatedError (
662+ 'Internal collections have been dropped' ,
663+ new Error ( '_checkpoints collection was dropped' )
664+ ) ;
665+ }
666+
667+ const { comparable : lsn } = new MongoLSN ( {
668+ timestamp : changeDocument . clusterTime ! ,
669+ resume_token : changeDocument . _id
670+ } ) ;
671+
638672 if ( waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn ) {
639673 waitForCheckpointLsn = null ;
640674 }
0 commit comments