@powersync/service-module-mongodb 0.6.0 → 0.6.1

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.
@@ -14,7 +14,7 @@ import { MongoLSN } from '../common/MongoLSN.js';
14
14
  import { PostImagesOption } from '../types/types.js';
15
15
  import { escapeRegExp } from '../utils.js';
16
16
  import { MongoManager } from './MongoManager.js';
17
- import { constructAfterRecord, createCheckpoint, getMongoRelation } from './MongoRelation.js';
17
+ import { constructAfterRecord, createCheckpoint, getCacheIdentifier, getMongoRelation } from './MongoRelation.js';
18
18
  import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
19
19
 
20
20
  export interface ChangeStreamOptions {
@@ -89,6 +89,10 @@ export class ChangeStream {
89
89
  return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
90
90
  }
91
91
 
92
+ private get logPrefix() {
93
+ return `[powersync_${this.group_id}]`;
94
+ }
95
+
92
96
  /**
93
97
  * This resolves a pattern, persists the related metadata, and returns
94
98
  * the resulting SourceTables.
@@ -124,18 +128,13 @@ export class ChangeStream {
124
128
  .toArray();
125
129
 
126
130
  if (!tablePattern.isWildcard && collections.length == 0) {
127
- logger.warn(`Collection ${schema}.${tablePattern.name} not found`);
131
+ logger.warn(`${this.logPrefix} Collection ${schema}.${tablePattern.name} not found`);
128
132
  }
129
133
 
130
134
  for (let collection of collections) {
131
135
  const table = await this.handleRelation(
132
136
  batch,
133
- {
134
- name: collection.name,
135
- schema,
136
- objectId: collection.name,
137
- replicationColumns: [{ name: '_id' }]
138
- } as SourceEntityDescriptor,
137
+ getMongoRelation({ db: schema, coll: collection.name }),
139
138
  // This is done as part of the initial setup - snapshot is handled elsewhere
140
139
  { snapshot: false, collectionInfo: collection }
141
140
  );
@@ -149,7 +148,7 @@ export class ChangeStream {
149
148
  async initSlot(): Promise<InitResult> {
150
149
  const status = await this.storage.getStatus();
151
150
  if (status.snapshot_done && status.checkpoint_lsn) {
152
- logger.info(`Initial replication already done`);
151
+ logger.info(`${this.logPrefix} Initial replication already done`);
153
152
  return { needsInitialSync: false };
154
153
  }
155
154
 
@@ -220,7 +219,7 @@ export class ChangeStream {
220
219
  }
221
220
 
222
221
  const { comparable: lsn } = new MongoLSN({ timestamp: snapshotTime });
223
- logger.info(`Snapshot commit at ${snapshotTime.inspect()} / ${lsn}`);
222
+ logger.info(`${this.logPrefix} Snapshot commit at ${snapshotTime.inspect()} / ${lsn}`);
224
223
  await batch.commit(lsn);
225
224
  }
226
225
  );
@@ -289,7 +288,7 @@ export class ChangeStream {
289
288
  table: storage.SourceTable,
290
289
  session?: mongo.ClientSession
291
290
  ) {
292
- logger.info(`Replicating ${table.qualifiedName}`);
291
+ logger.info(`${this.logPrefix} Replicating ${table.qualifiedName}`);
293
292
  const estimatedCount = await this.estimatedCount(table);
294
293
  let at = 0;
295
294
  let lastLogIndex = 0;
@@ -319,7 +318,7 @@ export class ChangeStream {
319
318
 
320
319
  at += 1;
321
320
  if (at - lastLogIndex >= 5000) {
322
- logger.info(`[${this.group_id}] Replicating ${table.qualifiedName} ${at}/${estimatedCount}`);
321
+ logger.info(`${this.logPrefix} Replicating ${table.qualifiedName} ${at}/${estimatedCount}`);
323
322
  lastLogIndex = at;
324
323
  }
325
324
  Metrics.getInstance().rows_replicated_total.add(1);
@@ -328,14 +327,16 @@ export class ChangeStream {
328
327
  }
329
328
 
330
329
  await batch.flush();
331
- logger.info(`Replicated ${at} documents for ${table.qualifiedName}`);
330
+ logger.info(`${this.logPrefix} Replicated ${at} documents for ${table.qualifiedName}`);
332
331
  }
333
332
 
334
333
  private async getRelation(
335
334
  batch: storage.BucketStorageBatch,
336
- descriptor: SourceEntityDescriptor
335
+ descriptor: SourceEntityDescriptor,
336
+ options: { snapshot: boolean }
337
337
  ): Promise<SourceTable> {
338
- const existing = this.relation_cache.get(descriptor.objectId);
338
+ const cacheId = getCacheIdentifier(descriptor);
339
+ const existing = this.relation_cache.get(cacheId);
339
340
  if (existing != null) {
340
341
  return existing;
341
342
  }
@@ -344,7 +345,7 @@ export class ChangeStream {
344
345
  // missing values.
345
346
  const collection = await this.getCollectionInfo(descriptor.schema, descriptor.name);
346
347
 
347
- return this.handleRelation(batch, descriptor, { snapshot: false, collectionInfo: collection });
348
+ return this.handleRelation(batch, descriptor, { snapshot: options.snapshot, collectionInfo: collection });
348
349
  }
349
350
 
350
351
  private async getCollectionInfo(db: string, name: string): Promise<mongo.CollectionInfo | undefined> {
@@ -375,7 +376,7 @@ export class ChangeStream {
375
376
  collMod: collectionInfo.name,
376
377
  changeStreamPreAndPostImages: { enabled: true }
377
378
  });
378
- logger.info(`Enabled postImages on ${db}.${collectionInfo.name}`);
379
+ logger.info(`${this.logPrefix} Enabled postImages on ${db}.${collectionInfo.name}`);
379
380
  } else if (!enabled) {
380
381
  throw new ServiceError(ErrorCode.PSYNC_S1343, `postImages not enabled on ${db}.${collectionInfo.name}`);
381
382
  }
@@ -394,9 +395,6 @@ export class ChangeStream {
394
395
  }
395
396
 
396
397
  const snapshot = options.snapshot;
397
- if (!descriptor.objectId && typeof descriptor.objectId != 'string') {
398
- throw new ReplicationAssertionError('MongoDB replication - objectId expected');
399
- }
400
398
  const result = await this.storage.resolveTable({
401
399
  group_id: this.group_id,
402
400
  connection_id: this.connection_id,
@@ -404,10 +402,16 @@ export class ChangeStream {
404
402
  entity_descriptor: descriptor,
405
403
  sync_rules: this.sync_rules
406
404
  });
407
- this.relation_cache.set(descriptor.objectId, result.table);
405
+ this.relation_cache.set(getCacheIdentifier(descriptor), result.table);
408
406
 
409
- // Drop conflicting tables. This includes for example renamed tables.
410
- await batch.drop(result.dropTables);
407
+ // Drop conflicting collections.
408
+ // This is generally not expected for MongoDB source dbs, so we log an error.
409
+ if (result.dropTables.length > 0) {
410
+ logger.error(
411
+ `Conflicting collections found for ${JSON.stringify(descriptor)}. Dropping: ${result.dropTables.map((t) => t.id).join(', ')}`
412
+ );
413
+ await batch.drop(result.dropTables);
414
+ }
411
415
 
412
416
  // Snapshot if:
413
417
  // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
@@ -415,6 +419,7 @@ export class ChangeStream {
415
419
  // 3. The table is used in sync rules.
416
420
  const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
417
421
  if (shouldSnapshot) {
422
+ logger.info(`${this.logPrefix} New collection: ${descriptor.schema}.${descriptor.name}`);
418
423
  // Truncate this table, in case a previous snapshot was interrupted.
419
424
  await batch.truncate([result.table]);
420
425
 
@@ -434,7 +439,7 @@ export class ChangeStream {
434
439
  change: mongo.ChangeStreamDocument
435
440
  ): Promise<storage.FlushedResult | null> {
436
441
  if (!table.syncAny) {
437
- logger.debug(`Collection ${table.qualifiedName} not used in sync rules - skipping`);
442
+ logger.debug(`${this.logPrefix} Collection ${table.qualifiedName} not used in sync rules - skipping`);
438
443
  return null;
439
444
  }
440
445
 
@@ -528,7 +533,7 @@ export class ChangeStream {
528
533
  const startAfter = lastLsn?.timestamp;
529
534
  const resumeAfter = lastLsn?.resumeToken;
530
535
 
531
- logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn}`);
536
+ logger.info(`${this.logPrefix} Resume streaming at ${startAfter?.inspect()} / ${lastLsn}`);
532
537
 
533
538
  const filters = this.getSourceNamespaceFilters();
534
539
 
@@ -590,13 +595,14 @@ export class ChangeStream {
590
595
 
591
596
  let splitDocument: mongo.ChangeStreamDocument | null = null;
592
597
 
598
+ let flexDbNameWorkaroundLogged = false;
599
+
593
600
  while (true) {
594
601
  if (this.abort_signal.aborted) {
595
602
  break;
596
603
  }
597
604
 
598
605
  const originalChangeDocument = await stream.tryNext();
599
-
600
606
  // The stream was closed, we will only ever receive `null` from it
601
607
  if (!originalChangeDocument && stream.closed) {
602
608
  break;
@@ -636,6 +642,29 @@ export class ChangeStream {
636
642
  throw new ReplicationAssertionError(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
637
643
  }
638
644
 
645
+ if (
646
+ !filters.multipleDatabases &&
647
+ 'ns' in changeDocument &&
648
+ changeDocument.ns.db != this.defaultDb.databaseName &&
649
+ changeDocument.ns.db.endsWith(`_${this.defaultDb.databaseName}`)
650
+ ) {
651
+ // When all of the following conditions are met:
652
+ // 1. We're replicating from an Atlas Flex instance.
653
+ // 2. There were changestream events recorded while the PowerSync service is paused.
654
+ // 3. We're only replicating from a single database.
655
+ // Then we've observed an ns with for example {db: '67b83e86cd20730f1e766dde_ps'},
656
+ // instead of the expected {db: 'ps'}.
657
+ // We correct this.
658
+ changeDocument.ns.db = this.defaultDb.databaseName;
659
+
660
+ if (!flexDbNameWorkaroundLogged) {
661
+ flexDbNameWorkaroundLogged = true;
662
+ logger.warn(
663
+ `${this.logPrefix} Incorrect DB name in change stream: ${changeDocument.ns.db}. Changed to ${this.defaultDb.databaseName}.`
664
+ );
665
+ }
666
+ }
667
+
639
668
  if (
640
669
  (changeDocument.operationType == 'insert' ||
641
670
  changeDocument.operationType == 'update' ||
@@ -682,28 +711,44 @@ export class ChangeStream {
682
711
  waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb);
683
712
  }
684
713
  const rel = getMongoRelation(changeDocument.ns);
685
- const table = await this.getRelation(batch, rel);
714
+ const table = await this.getRelation(batch, rel, {
715
+ // In most cases, we should not need to snapshot this. But if this is the first time we see the collection
716
+ // for whatever reason, then we do need to snapshot it.
717
+ // This may result in some duplicate operations when a collection is created for the first time after
718
+ // sync rules was deployed.
719
+ snapshot: true
720
+ });
686
721
  if (table.syncAny) {
687
722
  await this.writeChange(batch, table, changeDocument);
688
723
  }
689
724
  } else if (changeDocument.operationType == 'drop') {
690
725
  const rel = getMongoRelation(changeDocument.ns);
691
- const table = await this.getRelation(batch, rel);
726
+ const table = await this.getRelation(batch, rel, {
727
+ // We're "dropping" this collection, so never snapshot it.
728
+ snapshot: false
729
+ });
692
730
  if (table.syncAny) {
693
731
  await batch.drop([table]);
694
- this.relation_cache.delete(table.objectId);
732
+ this.relation_cache.delete(getCacheIdentifier(rel));
695
733
  }
696
734
  } else if (changeDocument.operationType == 'rename') {
697
735
  const relFrom = getMongoRelation(changeDocument.ns);
698
736
  const relTo = getMongoRelation(changeDocument.to);
699
- const tableFrom = await this.getRelation(batch, relFrom);
737
+ const tableFrom = await this.getRelation(batch, relFrom, {
738
+ // We're "dropping" this collection, so never snapshot it.
739
+ snapshot: false
740
+ });
700
741
  if (tableFrom.syncAny) {
701
742
  await batch.drop([tableFrom]);
702
- this.relation_cache.delete(tableFrom.objectId);
743
+ this.relation_cache.delete(getCacheIdentifier(relFrom));
703
744
  }
704
745
  // Here we do need to snapshot the new table
705
746
  const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
706
- await this.handleRelation(batch, relTo, { snapshot: true, collectionInfo: collection });
747
+ await this.handleRelation(batch, relTo, {
748
+ // This is a new (renamed) collection, so always snapshot it.
749
+ snapshot: true,
750
+ collectionInfo: collection
751
+ });
707
752
  }
708
753
  }
709
754
  }
@@ -40,8 +40,8 @@ export class ChangeStreamReplicationJob extends replication.AbstractReplicationJ
40
40
  this.logger.error(`Replication failed`, e);
41
41
 
42
42
  if (e instanceof ChangeStreamInvalidatedError) {
43
- // This stops replication on this slot, and creates a new slot
44
- await this.options.storage.factory.slotRemoved(this.slotName);
43
+ // This stops replication and restarts with a new instance
44
+ await this.options.storage.factory.restartReplication(this.storage.group_id);
45
45
  }
46
46
  } finally {
47
47
  this.abortController.abort();
@@ -11,11 +11,19 @@ export function getMongoRelation(source: mongo.ChangeStreamNameSpace): storage.S
11
11
  return {
12
12
  name: source.coll,
13
13
  schema: source.db,
14
- objectId: source.coll,
14
+ // Not relevant for MongoDB - we use db + coll name as the identifier
15
+ objectId: undefined,
15
16
  replicationColumns: [{ name: '_id' }]
16
17
  } satisfies storage.SourceEntityDescriptor;
17
18
  }
18
19
 
20
+ /**
21
+ * For in-memory cache only.
22
+ */
23
+ export function getCacheIdentifier(source: storage.SourceEntityDescriptor): string {
24
+ return `${source.schema}.${source.name}`;
25
+ }
26
+
19
27
  export function constructAfterRecord(document: mongo.Document): SqliteRow {
20
28
  let record: SqliteRow = {};
21
29
  for (let key of Object.keys(document)) {
@@ -239,6 +239,7 @@ bucket_definitions:
239
239
  - SELECT _id as id, description FROM "test_DATA"
240
240
  `);
241
241
 
242
+ await db.createCollection('test_DATA');
242
243
  await context.replicateSnapshot();
243
244
 
244
245
  context.startStreaming();
@@ -261,6 +262,7 @@ bucket_definitions:
261
262
  data:
262
263
  - SELECT _id as id, name, description FROM "test_data"
263
264
  `);
265
+ await db.createCollection('test_data');
264
266
 
265
267
  await context.replicateSnapshot();
266
268
  context.startStreaming();
@@ -371,6 +373,8 @@ bucket_definitions:
371
373
  - SELECT _id as id, name, other FROM "test_data"`);
372
374
  const { db } = context;
373
375
 
376
+ await db.createCollection('test_data');
377
+
374
378
  await context.replicateSnapshot();
375
379
 
376
380
  const collection = db.collection('test_data');
@@ -451,6 +455,8 @@ bucket_definitions:
451
455
 
452
456
  const data = await context.getBucketData('global[]');
453
457
  expect(data).toMatchObject([
458
+ // An extra op here, since this triggers a snapshot in addition to getting the event.
459
+ test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' }),
454
460
  test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
455
461
  test_utils.putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
456
462
  ]);