@powersync/service-module-mssql 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (64) hide show
  1. package/CHANGELOG.md +209 -4
  2. package/dist/api/MSSQLRouteAPIAdapter.d.ts +4 -5
  3. package/dist/api/MSSQLRouteAPIAdapter.js +33 -39
  4. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/MSSQLSourceTable.d.ts +12 -2
  6. package/dist/common/MSSQLSourceTable.js +30 -4
  7. package/dist/common/MSSQLSourceTable.js.map +1 -1
  8. package/dist/module/MSSQLModule.js +2 -1
  9. package/dist/module/MSSQLModule.js.map +1 -1
  10. package/dist/replication/CDCPoller.d.ts +13 -36
  11. package/dist/replication/CDCPoller.js +173 -129
  12. package/dist/replication/CDCPoller.js.map +1 -1
  13. package/dist/replication/CDCReplicationJob.js +1 -1
  14. package/dist/replication/CDCReplicationJob.js.map +1 -1
  15. package/dist/replication/CDCReplicator.js +1 -1
  16. package/dist/replication/CDCReplicator.js.map +1 -1
  17. package/dist/replication/CDCStream.d.ts +11 -14
  18. package/dist/replication/CDCStream.js +124 -115
  19. package/dist/replication/CDCStream.js.map +1 -1
  20. package/dist/replication/CaptureReconciler.d.ts +47 -0
  21. package/dist/replication/CaptureReconciler.js +155 -0
  22. package/dist/replication/CaptureReconciler.js.map +1 -0
  23. package/dist/replication/MSSQLConnectionManager.d.ts +1 -0
  24. package/dist/replication/MSSQLConnectionManager.js +19 -14
  25. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  26. package/dist/replication/MSSQLTableReconciliationContext.d.ts +21 -0
  27. package/dist/replication/MSSQLTableReconciliationContext.js +7 -0
  28. package/dist/replication/MSSQLTableReconciliationContext.js.map +1 -0
  29. package/dist/replication/SchemaChange.d.ts +67 -0
  30. package/dist/replication/SchemaChange.js +31 -0
  31. package/dist/replication/SchemaChange.js.map +1 -0
  32. package/dist/types/types.d.ts +13 -0
  33. package/dist/types/types.js +28 -2
  34. package/dist/types/types.js.map +1 -1
  35. package/dist/utils/mssql.js +1 -1
  36. package/dist/utils/schema.d.ts +14 -1
  37. package/dist/utils/schema.js +43 -45
  38. package/dist/utils/schema.js.map +1 -1
  39. package/package.json +11 -21
  40. package/src/api/MSSQLRouteAPIAdapter.ts +38 -49
  41. package/src/common/MSSQLSourceTable.ts +32 -4
  42. package/src/module/MSSQLModule.ts +2 -1
  43. package/src/replication/CDCPoller.ts +206 -162
  44. package/src/replication/CDCReplicationJob.ts +1 -1
  45. package/src/replication/CDCReplicator.ts +1 -1
  46. package/src/replication/CDCStream.ts +157 -163
  47. package/src/replication/CaptureReconciler.ts +186 -0
  48. package/src/replication/MSSQLConnectionManager.ts +23 -14
  49. package/src/replication/MSSQLTableReconciliationContext.ts +29 -0
  50. package/src/replication/SchemaChange.ts +79 -0
  51. package/src/types/types.ts +47 -2
  52. package/src/utils/mssql.ts +1 -1
  53. package/src/utils/schema.ts +60 -54
  54. package/test/src/CDCPoller.test.ts +382 -0
  55. package/test/src/CDCStream.test.ts +6 -2
  56. package/test/src/CDCStreamTestContext.ts +30 -23
  57. package/test/src/CaptureReconciler.test.ts +171 -0
  58. package/test/src/MSSQLSourceTable.test.ts +30 -0
  59. package/test/src/config.test.ts +71 -0
  60. package/test/src/schema-changes.test.ts +513 -249
  61. package/test/src/schema.test.ts +17 -0
  62. package/test/src/util.ts +73 -9
  63. package/tsconfig.json +0 -3
  64. package/tsconfig.tsbuildinfo +1 -1
@@ -26,6 +26,7 @@ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
26
26
  import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
27
27
  import { AdditionalConfig } from '../types/types.js';
28
28
  import {
29
+ CaptureInstanceDetails,
29
30
  checkRetentionThresholds,
30
31
  checkSourceConfiguration,
31
32
  createCheckpoint,
@@ -34,15 +35,21 @@ import {
34
35
  getLatestReplicatedLSN,
35
36
  isIColumnMetadata
36
37
  } from '../utils/mssql.js';
38
+ import { getReplicationIdentityColumns, getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
37
39
  import {
38
- getReplicationIdentityColumns,
39
- getTablesFromPattern,
40
- ResolvedTable,
41
- SourceTableChangeRef
42
- } from '../utils/schema.js';
43
- import { CDCEventHandler, CDCPoller, SchemaChange, SchemaChangeType } from './CDCPoller.js';
40
+ CaptureInstanceMissingError,
41
+ createCaptureReconciler,
42
+ SourceTableUnavailableError
43
+ } from './CaptureReconciler.js';
44
+ import { CDCEventHandler, CDCPoller } from './CDCPoller.js';
44
45
  import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
45
46
  import { BatchedSnapshotQuery, MSSQLSnapshotQuery, SimpleSnapshotQuery } from './MSSQLSnapshotQuery.js';
47
+ import type {
48
+ MSSQLReadyTableReconciliationContext,
49
+ MSSQLUnavailableTableReconciliationContext
50
+ } from './MSSQLTableReconciliationContext.js';
51
+ import { MSSQLTableReconciliationState } from './MSSQLTableReconciliationContext.js';
52
+ import { SchemaChange, SchemaChangeType } from './SchemaChange.js';
46
53
 
47
54
  export interface CDCStreamOptions {
48
55
  connections: MSSQLConnectionManager;
@@ -114,6 +121,10 @@ export class CDCStream {
114
121
 
115
122
  private replicationLag = new ReplicationLagTracker();
116
123
 
124
+ // Latest capture instance warning emitted for each source table.
125
+ private warnedNewerCaptureObjectId = new Map<number, number>();
126
+ private warnedSchemaChangeCount = new Map<number, number>();
127
+
117
128
  constructor(private options: CDCStreamOptions) {
118
129
  this.logger = options.logger ?? defaultLogger;
119
130
  this.storage = options.storage;
@@ -139,7 +150,7 @@ export class CDCStream {
139
150
  }
140
151
 
141
152
  get groupId() {
142
- return this.options.storage.group_id;
153
+ return this.options.storage.replicationStreamId;
143
154
  }
144
155
 
145
156
  get connectionId() {
@@ -178,16 +189,19 @@ export class CDCStream {
178
189
 
179
190
  async populateTableCache() {
180
191
  const sourceTables = this.syncRules.getSourceTables();
192
+ // Loaded once for all patterns - this reads the CDC metadata for the entire database.
193
+ const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
181
194
  await this.storage.startBatch(
182
195
  {
183
196
  logger: this.logger,
184
197
  zeroLSN: LSN.ZERO,
185
198
  defaultSchema: this.defaultSchema,
186
- storeCurrentData: false
199
+ storeCurrentData: false,
200
+ signal: this.abortSignal
187
201
  },
188
202
  async (batch) => {
189
203
  for (let tablePattern of sourceTables) {
190
- await this.getQualifiedTableNames(batch, tablePattern);
204
+ await this.getQualifiedTableNames(batch, tablePattern, captureInstances);
191
205
  }
192
206
  }
193
207
  );
@@ -195,28 +209,31 @@ export class CDCStream {
195
209
 
196
210
  async getQualifiedTableNames(
197
211
  batch: storage.BucketStorageBatch,
198
- tablePattern: TablePattern
212
+ tablePattern: TablePattern,
213
+ captureInstances: Map<number, CaptureInstanceDetails>
199
214
  ): Promise<MSSQLSourceTable[]> {
200
215
  if (tablePattern.connectionTag != this.connections.connectionTag) {
201
216
  return [];
202
217
  }
203
218
 
204
- const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
205
219
  const matchedTables: ResolvedTable[] = await getTablesFromPattern(this.connections, tablePattern);
206
220
 
221
+ if (matchedTables.length == 0) {
222
+ await this.reportUnavailableTable(batch, {
223
+ state: MSSQLTableReconciliationState.TABLE_MISSING,
224
+ source: {
225
+ connectionTag: this.connectionTag,
226
+ name: tablePattern.name,
227
+ schema: tablePattern.schema,
228
+ objectId: undefined,
229
+ // Replica identity metadata is unavailable when the physical table does not exist.
230
+ replicaIdColumns: []
231
+ }
232
+ });
233
+ }
234
+
207
235
  const tables: MSSQLSourceTable[] = [];
208
236
  for (const matchedTable of matchedTables) {
209
- const captureInstanceDetails = captureInstances.get(matchedTable.objectId as number);
210
- if (!captureInstanceDetails) {
211
- // Match Postgres publication handling: If the source table cannot stream changes yet,
212
- // don't resolve it into storage. Once CDC is enabled, the schema poller detects the
213
- // new capture instance and resolves/snapshots it then.
214
- this.logger.info(
215
- `Skipping ${tablePattern.schema}.${matchedTable.name} - not enabled for CDC. This table will not be replicated until CDC is enabled for it.`
216
- );
217
- continue;
218
- }
219
-
220
237
  // TODO: Check RLS settings for table
221
238
 
222
239
  const replicaIdColumns = await getReplicationIdentityColumns({
@@ -225,45 +242,54 @@ export class CDCStream {
225
242
  schema: matchedTable.schema
226
243
  });
227
244
 
228
- const table = await this.processTable(
229
- batch,
230
- {
231
- connectionTag: this.connectionTag,
232
- name: matchedTable.name,
233
- schema: matchedTable.schema,
234
- objectId: matchedTable.objectId,
235
- replicaIdColumns: replicaIdColumns.columns
236
- },
237
- captureInstanceDetails.instances[0],
238
- false
239
- );
245
+ const source: SourceEntityDescriptor = {
246
+ connectionTag: this.connectionTag,
247
+ name: matchedTable.name,
248
+ schema: matchedTable.schema,
249
+ objectId: matchedTable.objectId,
250
+ replicaIdColumns: replicaIdColumns.columns
251
+ };
252
+ const captureInstanceDetails = captureInstances.get(matchedTable.objectId);
253
+ if (!captureInstanceDetails?.instances.length) {
254
+ await this.reportUnavailableTable(batch, {
255
+ state: MSSQLTableReconciliationState.CDC_DISABLED,
256
+ source
257
+ });
258
+ } else {
259
+ const table = await this.processTable(batch, {
260
+ state: MSSQLTableReconciliationState.READY,
261
+ source,
262
+ captureInstances: captureInstanceDetails.instances
263
+ });
240
264
 
241
- tables.push(table);
265
+ tables.push(table);
266
+ }
242
267
  }
243
268
  return tables;
244
269
  }
245
270
 
246
271
  async processTable(
247
272
  batch: storage.BucketStorageBatch,
248
- table: SourceEntityDescriptor,
249
- captureInstance: CaptureInstance | null,
250
- snapshot: boolean
273
+ context: MSSQLReadyTableReconciliationContext
251
274
  ): Promise<MSSQLSourceTable> {
252
- if (!table.objectId && typeof table.objectId != 'number') {
253
- throw new ReplicationAssertionError(`objectId expected, got ${typeof table.objectId}`);
275
+ const { source } = context;
276
+ if (!source.objectId && typeof source.objectId != 'number') {
277
+ throw new ReplicationAssertionError(`objectId expected, got ${typeof source.objectId}`);
254
278
  }
279
+ // Load all source metadata before resolving so the storage transaction does no source I/O.
255
280
  const resolved = await batch.resolveTables({
256
281
  connection_id: this.connectionId,
257
- source: table
282
+ source,
283
+ reconcileSourceTables: createCaptureReconciler(context)
258
284
  });
259
- const resolvedTable = new MSSQLSourceTable(table, resolved.tables);
260
285
 
261
- if (!captureInstance) {
262
- this.logger.warn(
263
- `Missing capture instance for table ${resolvedTable.toQualifiedName()}. This table will not be replicated until CDC is enabled for it.`
286
+ const resolvedTable = new MSSQLSourceTable(source, resolved.tables);
287
+
288
+ resolvedTable.setCaptureInstance(context.captureInstances);
289
+ if (!resolvedTable.enabledForCDC()) {
290
+ throw new ReplicationAssertionError(
291
+ `No capture instance matching the persisted binding is available for table ${resolvedTable.toQualifiedName()}`
264
292
  );
265
- } else {
266
- resolvedTable.setCaptureInstance(captureInstance);
267
293
  }
268
294
 
269
295
  this.tableCache.set(resolvedTable);
@@ -271,27 +297,26 @@ export class CDCStream {
271
297
  // Drop conflicting tables. This includes for example renamed tables.
272
298
  await batch.drop(resolved.dropTables);
273
299
 
274
- // Snapshot if:
275
- // 1. The table is in the sync config and snapshot is requested, or not already done.
276
- // 2. AND the table is enabled for CDC with a valid capture instance.
277
- const snapshotCandidates = resolved.tables.filter(
278
- (candidate) => snapshot && !candidate.snapshotComplete && candidate.syncAny
279
- );
280
- const shouldSnapshot = snapshotCandidates.length > 0 && resolvedTable.enabledForCDC();
281
-
282
- if (shouldSnapshot) {
283
- // Truncate tables in case a previous snapshot was interrupted.
284
- await batch.truncate(snapshotCandidates);
285
-
286
- // Start the snapshot(s) inside a transaction.
287
- for (const candidate of snapshotCandidates) {
288
- await this.snapshotTableInTx(batch, resolvedTable, candidate);
289
- }
290
- }
291
-
300
+ // Snapshotting is driven by startInitialReplication, which picks up any source table that is
301
+ // not snapshot-complete after the table cache has been populated.
292
302
  return resolvedTable;
293
303
  }
294
304
 
305
+ private async reportUnavailableTable(
306
+ batch: storage.BucketStorageBatch,
307
+ context: MSSQLUnavailableTableReconciliationContext
308
+ ): Promise<never> {
309
+ await batch.resolveTables({
310
+ connection_id: this.connectionId,
311
+ source: context.source,
312
+ reconcileSourceTables: createCaptureReconciler(context)
313
+ });
314
+ // Unavailable-state reconciliation must reject; this enforces the Promise<never> contract if it returns.
315
+ throw new ReplicationAssertionError(
316
+ `Unavailable source-table reconciliation unexpectedly completed for ${context.source.schema}.${context.source.name}`
317
+ );
318
+ }
319
+
295
320
  private async snapshotTableInTx(
296
321
  batch: storage.BucketStorageBatch,
297
322
  physicalTable: MSSQLSourceTable,
@@ -479,7 +504,8 @@ export class CDCStream {
479
504
  zeroLSN: LSN.ZERO,
480
505
  defaultSchema: this.defaultSchema,
481
506
  storeCurrentData: false,
482
- skipExistingRows: true
507
+ skipExistingRows: true,
508
+ signal: this.abortSignal
483
509
  },
484
510
  async (batch) => {
485
511
  switch (status) {
@@ -495,9 +521,18 @@ export class CDCStream {
495
521
  break;
496
522
  case SnapshotStatus.LIMITED_RESNAPSHOT:
497
523
  for (const table of specificTablesToResnapshot!) {
524
+ if (table.captureInstance == null) {
525
+ throw new ReplicationAssertionError(
526
+ `No capture instance bound for table ${table.toQualifiedName()} while re-snapshotting`
527
+ );
528
+ }
498
529
  await batch.drop(table.getReplicatedSourceTables());
499
- // Update table in the table cache
500
- await this.processTable(batch, table.ref, table.captureInstance, false);
530
+ // Re-resolve against the bound instance only, so the new record keeps the same pin.
531
+ await this.processTable(batch, {
532
+ state: MSSQLTableReconciliationState.READY,
533
+ source: table.ref,
534
+ captureInstances: [table.captureInstance]
535
+ });
501
536
  }
502
537
  break;
503
538
  default:
@@ -571,7 +606,7 @@ export class CDCStream {
571
606
  private async checkSnapshotStatus(): Promise<SnapshotStatusResult> {
572
607
  const status = await this.storage.getStatus();
573
608
 
574
- if (status.snapshot_done && status.checkpoint_lsn) {
609
+ if (status.snapshotDone) {
575
610
  const additionalTablesToSnapshot: Set<MSSQLSourceTable> = new Set();
576
611
  const newTables = this.tableCache
577
612
  .getAll()
@@ -583,26 +618,26 @@ export class CDCStream {
583
618
  newTables.forEach((table) => additionalTablesToSnapshot.add(table));
584
619
  }
585
620
 
586
- // Snapshot is done, but we still need to check that the last known checkpoint LSN is still
621
+ // Snapshot is done, but we still need to check that the last known resume LSN is still
587
622
  // within the retention threshold of the CDC tables
588
623
 
589
- const lastCheckpointLSN = LSN.fromString(status.checkpoint_lsn);
624
+ const lastResumeLSN = LSN.fromString(status.resumeLsn!);
590
625
  // Check that the CDC tables still have valid data
591
626
  const tablesOutsideRetentionThreshold = await checkRetentionThresholds({
592
- checkpointLSN: lastCheckpointLSN,
627
+ checkpointLSN: lastResumeLSN,
593
628
  tables: this.tableCache.getAll(),
594
629
  connectionManager: this.connections
595
630
  });
596
631
  if (tablesOutsideRetentionThreshold.length > 0) {
597
632
  this.logger.warn(
598
- `Updates from the last checkpoint are no longer available in the CDC instances of the following table(s): ${tablesOutsideRetentionThreshold.map((table) => table.toQualifiedName()).join(', ')}.`
633
+ `Updates from the last resume LSN are no longer available in the CDC instances of the following table(s): ${tablesOutsideRetentionThreshold.map((table) => table.toQualifiedName()).join(', ')}.`
599
634
  );
600
635
  tablesOutsideRetentionThreshold.forEach((table) => additionalTablesToSnapshot.add(table));
601
636
  }
602
637
  if (additionalTablesToSnapshot.size > 0) {
603
638
  return {
604
639
  status: SnapshotStatus.LIMITED_RESNAPSHOT,
605
- snapshotLSN: status.checkpoint_lsn,
640
+ snapshotLSN: status.resumeLsn,
606
641
  specificTablesToResnapshot: Array.from(additionalTablesToSnapshot)
607
642
  };
608
643
  } else {
@@ -613,8 +648,8 @@ export class CDCStream {
613
648
  }
614
649
  } else {
615
650
  return {
616
- status: status.snapshot_lsn != null ? SnapshotStatus.RESUME : SnapshotStatus.INITIAL,
617
- snapshotLSN: status.snapshot_lsn
651
+ status: status.resumeLsn != null ? SnapshotStatus.RESUME : SnapshotStatus.INITIAL,
652
+ snapshotLSN: status.resumeLsn
618
653
  };
619
654
  }
620
655
  }
@@ -626,7 +661,8 @@ export class CDCStream {
626
661
  zeroLSN: LSN.ZERO,
627
662
  defaultSchema: this.defaultSchema,
628
663
  storeCurrentData: false,
629
- skipExistingRows: false
664
+ skipExistingRows: false,
665
+ signal: this.abortSignal
630
666
  },
631
667
  async (batch) => {
632
668
  if (batch.resumeFromLsn == null) {
@@ -639,7 +675,6 @@ export class CDCStream {
639
675
  connectionManager: this.connections,
640
676
  eventHandler,
641
677
  getReplicatedTables: () => this.tableCache.getAll(),
642
- sourceTables: this.syncRules.getSourceTables(),
643
678
  startLSN,
644
679
  logger: this.logger,
645
680
  additionalConfig: this.options.additionalConfig,
@@ -723,110 +758,69 @@ export class CDCStream {
723
758
  }
724
759
 
725
760
  async handleSchemaChange(batch: storage.BucketStorageBatch, change: SchemaChange): Promise<void> {
726
- let actionedSchemaChange = true;
727
-
728
761
  switch (change.type) {
729
762
  case SchemaChangeType.TABLE_RENAME:
730
- const fromTable = change.table!;
731
- this.logger.info(
732
- `Table ${fromTable.toQualifiedName()} has been renamed ${change.newTable ? `to [${change.newTable.name}].` : '.'}`
763
+ // Continuing could commit past unread changes because the rename has no known LSN.
764
+ throw new SourceTableUnavailableError(
765
+ `Table ${change.table.toQualifiedName()} has been renamed to [${change.newTable.name}]. ` +
766
+ `Deploy a new sync config that uses the new name to replicate it again. ` +
767
+ `Its already-replicated data is retained until then.`
733
768
  );
734
-
735
- // Old table needs to be cleaned up
736
- const fromTables = fromTable.getReplicatedSourceTables();
737
- await batch.drop(fromTables);
738
- this.tableCache.delete(fromTable.objectId);
739
-
740
- if (change.newTable) {
741
- await this.handleCreateOrUpdateTable(batch, change.newTable, change.newCaptureInstance!);
742
- }
743
- break;
744
- case SchemaChangeType.TABLE_CREATE:
745
- await this.handleCreateOrUpdateTable(batch, change.newTable!, change.newCaptureInstance!);
746
- break;
747
- case SchemaChangeType.TABLE_COLUMN_CHANGES:
748
- await this.handleColumnChanges(change.table!, change.newCaptureInstance!);
749
- actionedSchemaChange = false;
750
- break;
751
- case SchemaChangeType.NEW_CAPTURE_INSTANCE:
752
- this.logger.info(
753
- `New CDC capture instance detected for table ${change.table!.toQualifiedName()}. Re-snapshotting table...`
769
+ case SchemaChangeType.TABLE_DROP:
770
+ throw new SourceTableUnavailableError(
771
+ `Table ${change.table.toQualifiedName()} has been dropped from the source. Deploy a new sync config ` +
772
+ `that no longer includes it. Its already-replicated data is retained until the new sync config ` +
773
+ `becomes active.`
754
774
  );
755
- await batch.drop(change.table!.getReplicatedSourceTables());
756
- this.tableCache.delete(change.table!.objectId);
757
-
758
- await this.handleCreateOrUpdateTable(batch, change.table!.ref, change.newCaptureInstance!);
775
+ case SchemaChangeType.TABLE_COLUMN_CHANGES:
776
+ this.warnColumnChanges(change.table, change.captureInstance);
759
777
  break;
760
- case SchemaChangeType.TABLE_DROP:
761
- await batch.drop(change.table!.getReplicatedSourceTables());
762
- this.tableCache.delete(change.table!.objectId);
778
+ case SchemaChangeType.NEW_CAPTURE_INSTANCE: {
779
+ // Warn once per newer instance and keep polling the pinned one.
780
+ const { table, newCaptureInstance } = change;
781
+ if (this.warnedNewerCaptureObjectId.get(table.objectId) !== newCaptureInstance.objectId) {
782
+ this.logger.warn(
783
+ `A newer CDC capture instance (object id ${newCaptureInstance.objectId}) is available for table ${table.toQualifiedName()}, ` +
784
+ `but the current replication process remains pinned to capture instance object id ${table.pinnedCaptureObjectId}. ` +
785
+ `Deploy a new sync config to adopt the new capture instance, and keep the current instance ` +
786
+ `available until that sync config becomes active.`
787
+ );
788
+ this.warnedNewerCaptureObjectId.set(table.objectId, newCaptureInstance.objectId);
789
+ }
763
790
  break;
791
+ }
764
792
  case SchemaChangeType.MISSING_CAPTURE_INSTANCE:
765
- // Stop replication for this table until CDC is re-enabled.
766
- this.logger.warn(
767
- `Table ${change.table!.toQualifiedName()} has been disabled for CDC. Re-enable CDC to continue replication.`
793
+ // A replacement may have a different schema, so never adopt it in place.
794
+ throw new CaptureInstanceMissingError(
795
+ `The CDC capture instance for table ${change.table.toQualifiedName()} (pinned to object id ` +
796
+ `${change.table.pinnedCaptureObjectId}) is no longer available. ` +
797
+ `PowerSync cannot continue replicating the table - a dropped capture instance cannot be ` +
798
+ `restored, because re-enabling CDC creates a new one with a different object id. ` +
799
+ (change.replacementInstance
800
+ ? `Deploy a new sync config to adopt the replacement.`
801
+ : `Re-enable CDC for this table, then deploy a new sync config.`)
768
802
  );
769
- change.table!.clearCaptureInstance();
770
- actionedSchemaChange = false;
771
- break;
772
803
  default:
773
- throw new ReplicationAssertionError(`Unknown schema change type: ${change.type}`);
774
- }
775
-
776
- // Create a new checkpoint after the schema change
777
- if (actionedSchemaChange) {
778
- await createCheckpoint(this.connections);
804
+ throw new ReplicationAssertionError(`Unknown schema change type: ${(change as SchemaChange).type}`);
779
805
  }
780
- }
781
-
782
- private async handleCreateOrUpdateTable(
783
- batch: storage.BucketStorageBatch,
784
- table: SourceTableChangeRef,
785
- captureInstance: CaptureInstance
786
- ): Promise<void> {
787
- const replicaIdColumns = await getReplicationIdentityColumns({
788
- connectionManager: this.connections,
789
- tableName: table.name,
790
- schema: table.schema
791
- });
792
-
793
- await this.processTable(
794
- batch,
795
- {
796
- connectionTag: this.connectionTag,
797
- name: table.name,
798
- schema: table.schema,
799
- objectId: table.objectId,
800
- replicaIdColumns: replicaIdColumns.columns
801
- },
802
- captureInstance,
803
- true
804
- );
806
+ // Warning-only changes do not need a checkpoint.
805
807
  }
806
808
 
807
809
  /**
808
- * There is very little that can be automatically done to handle the column changes other than to warn the user about the schema drift.
809
- *
810
- * Due to the way CDC works, users are prevented from making column schema changes that affect the replication identities of a table.
811
- * If changes like that are required, CDC has to be disabled and re-enabled for the table. This would then be handled by the detection of the new
812
- * capture instance.
813
- * @param table
814
- * @param captureInstance
810
+ * Warn about column drift while continuing with the pinned capture schema.
815
811
  */
816
- private async handleColumnChanges(table: MSSQLSourceTable, captureInstance: CaptureInstance): Promise<void> {
817
- // Check there are any new pending schema changes
818
- if (
819
- table.captureInstance?.objectId === captureInstance.objectId &&
820
- table.captureInstance?.pendingSchemaChanges.length === captureInstance.pendingSchemaChanges.length
821
- ) {
812
+ private warnColumnChanges(table: MSSQLSourceTable, captureInstance: CaptureInstance): void {
813
+ const changeCount = captureInstance.pendingSchemaChanges.length;
814
+ if (this.warnedSchemaChangeCount.get(table.objectId) === changeCount) {
822
815
  return;
823
816
  }
824
817
 
825
- // New pending schema changes were detected - warn about those as well.
826
818
  this.logger.warn(
827
- `Schema drift detected for table ${table.toQualifiedName()}. To ensure consistency, disable and re-enable CDC for this table.\n Pending schema changes:\n ${captureInstance.pendingSchemaChanges.join(', \n')}`
819
+ `Schema drift detected for table ${table.toQualifiedName()}. Until a new sync config becomes active, ` +
820
+ `replication continues against the captured schema, so these changes are not replicated. Deploy a new ` +
821
+ `sync config to pick them up.\n Pending schema changes:\n ${captureInstance.pendingSchemaChanges.join(', \n')}`
828
822
  );
829
- table.captureInstance = captureInstance;
823
+ this.warnedSchemaChangeCount.set(table.objectId, changeCount);
830
824
  }
831
825
 
832
826
  /**