@powersync/service-module-mssql 0.5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/common/CaptureInstance.d.ts +14 -0
  3. package/dist/common/CaptureInstance.js +2 -0
  4. package/dist/common/CaptureInstance.js.map +1 -0
  5. package/dist/common/MSSQLSourceTable.d.ts +16 -14
  6. package/dist/common/MSSQLSourceTable.js +35 -16
  7. package/dist/common/MSSQLSourceTable.js.map +1 -1
  8. package/dist/replication/CDCPoller.d.ts +42 -20
  9. package/dist/replication/CDCPoller.js +200 -60
  10. package/dist/replication/CDCPoller.js.map +1 -1
  11. package/dist/replication/CDCReplicationJob.js +9 -1
  12. package/dist/replication/CDCReplicationJob.js.map +1 -1
  13. package/dist/replication/CDCStream.d.ts +35 -4
  14. package/dist/replication/CDCStream.js +181 -74
  15. package/dist/replication/CDCStream.js.map +1 -1
  16. package/dist/replication/MSSQLConnectionManager.js +16 -5
  17. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  18. package/dist/types/types.d.ts +4 -56
  19. package/dist/types/types.js +5 -24
  20. package/dist/types/types.js.map +1 -1
  21. package/dist/utils/deadlock.d.ts +9 -0
  22. package/dist/utils/deadlock.js +40 -0
  23. package/dist/utils/deadlock.js.map +1 -0
  24. package/dist/utils/mssql.d.ts +33 -15
  25. package/dist/utils/mssql.js +101 -99
  26. package/dist/utils/mssql.js.map +1 -1
  27. package/dist/utils/schema.d.ts +9 -0
  28. package/dist/utils/schema.js +34 -0
  29. package/dist/utils/schema.js.map +1 -1
  30. package/package.json +8 -8
  31. package/src/common/CaptureInstance.ts +15 -0
  32. package/src/common/MSSQLSourceTable.ts +33 -24
  33. package/src/replication/CDCPoller.ts +272 -72
  34. package/src/replication/CDCReplicationJob.ts +8 -1
  35. package/src/replication/CDCStream.ts +237 -90
  36. package/src/replication/MSSQLConnectionManager.ts +15 -5
  37. package/src/types/types.ts +5 -28
  38. package/src/utils/deadlock.ts +44 -0
  39. package/src/utils/mssql.ts +159 -124
  40. package/src/utils/schema.ts +43 -0
  41. package/test/src/CDCStreamTestContext.ts +9 -2
  42. package/test/src/env.ts +1 -1
  43. package/test/src/mssql-to-sqlite.test.ts +18 -10
  44. package/test/src/schema-changes.test.ts +470 -0
  45. package/test/src/util.ts +75 -12
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -5,41 +5,33 @@ import {
5
5
  Logger,
6
6
  logger as defaultLogger,
7
7
  ReplicationAbortedError,
8
- ReplicationAssertionError,
9
- ServiceAssertionError
8
+ ReplicationAssertionError
10
9
  } from '@powersync/lib-services-framework';
11
10
  import { getUuidReplicaIdentityBson, MetricsEngine, SourceEntityDescriptor, storage } from '@powersync/service-core';
12
11
 
13
- import {
14
- SqliteInputRow,
15
- SqliteRow,
16
- SqlSyncRules,
17
- HydratedSyncRules,
18
- TablePattern
19
- } from '@powersync/service-sync-rules';
12
+ import { HydratedSyncRules, SqliteInputRow, SqliteRow, TablePattern } from '@powersync/service-sync-rules';
20
13
 
21
14
  import { ReplicationMetric } from '@powersync/service-types';
22
15
  import { BatchedSnapshotQuery, MSSQLSnapshotQuery, SimpleSnapshotQuery } from './MSSQLSnapshotQuery.js';
23
16
  import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
24
17
  import { getReplicationIdentityColumns, getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
25
18
  import {
19
+ checkRetentionThresholds,
26
20
  checkSourceConfiguration,
27
21
  createCheckpoint,
28
- getCaptureInstance,
22
+ getCaptureInstances,
29
23
  getLatestLSN,
30
24
  getLatestReplicatedLSN,
31
- isIColumnMetadata,
32
- isTableEnabledForCDC,
33
- isWithinRetentionThreshold,
34
- toQualifiedTableName
25
+ isIColumnMetadata
35
26
  } from '../utils/mssql.js';
36
27
  import sql from 'mssql';
37
28
  import { CDCToSqliteRow, toSqliteInputRow } from '../common/mssqls-to-sqlite.js';
38
29
  import { LSN } from '../common/LSN.js';
39
30
  import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
40
31
  import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
41
- import { CDCEventHandler, CDCPoller } from './CDCPoller.js';
32
+ import { CDCEventHandler, CDCPoller, SchemaChange, SchemaChangeType } from './CDCPoller.js';
42
33
  import { AdditionalConfig } from '../types/types.js';
34
+ import { CaptureInstance } from '../common/CaptureInstance.js';
43
35
 
44
36
  export interface CDCStreamOptions {
45
37
  connections: MSSQLConnectionManager;
@@ -55,17 +47,30 @@ export interface CDCStreamOptions {
55
47
  snapshotBatchSize?: number;
56
48
 
57
49
  additionalConfig: AdditionalConfig;
50
+
51
+ /**
52
+ * Override schema check interval, defaults is 60 seconds.
53
+ */
54
+ schemaCheckIntervalMs?: number;
58
55
  }
59
56
 
60
57
  export enum SnapshotStatus {
61
- IN_PROGRESS = 'in-progress',
58
+ INITIAL = 'initial',
59
+ RESUME = 'resume',
62
60
  DONE = 'done',
63
- RESTART_REQUIRED = 'restart-required'
61
+ LIMITED_RESNAPSHOT = 'limited-resnapshot'
64
62
  }
65
63
 
66
64
  export interface SnapshotStatusResult {
67
65
  status: SnapshotStatus;
68
66
  snapshotLSN: string | null;
67
+ /**
68
+ * Under certain circumstances it may be necessary to re-snapshot specific tables:
69
+ * * A new capture instance has been created for a table.
70
+ * * A table has been renamed.
71
+ * * The retention threshold has been exceeded for a table.
72
+ */
73
+ specificTablesToResnapshot?: MSSQLSourceTable[];
69
74
  }
70
75
 
71
76
  export class CDCConfigurationError extends Error {
@@ -94,7 +99,7 @@ export class CDCStream {
94
99
  private readonly abortSignal: AbortSignal;
95
100
  private readonly logger: Logger;
96
101
 
97
- private tableCache = new MSSQLSourceTableCache();
102
+ public tableCache = new MSSQLSourceTableCache();
98
103
 
99
104
  /**
100
105
  * Time of the oldest uncommitted change, according to the source db.
@@ -172,14 +177,11 @@ export class CDCStream {
172
177
  logger: this.logger,
173
178
  zeroLSN: LSN.ZERO,
174
179
  defaultSchema: this.defaultSchema,
175
- storeCurrentData: true
180
+ storeCurrentData: false
176
181
  },
177
182
  async (batch) => {
178
183
  for (let tablePattern of sourceTables) {
179
- const tables = await this.getQualifiedTableNames(batch, tablePattern);
180
- for (const table of tables) {
181
- this.tableCache.set(table);
182
- }
184
+ await this.getQualifiedTableNames(batch, tablePattern);
183
185
  }
184
186
  }
185
187
  );
@@ -193,21 +195,12 @@ export class CDCStream {
193
195
  return [];
194
196
  }
195
197
 
198
+ const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
196
199
  const matchedTables: ResolvedTable[] = await getTablesFromPattern(this.connections, tablePattern);
197
200
 
198
201
  const tables: MSSQLSourceTable[] = [];
199
202
  for (const matchedTable of matchedTables) {
200
- const isEnabled = await isTableEnabledForCDC({
201
- connectionManager: this.connections,
202
- table: matchedTable.name,
203
- schema: matchedTable.schema
204
- });
205
-
206
- if (!isEnabled) {
207
- this.logger.info(`Skipping ${matchedTable.schema}.${matchedTable.name} - table is not enabled for CDC.`);
208
- continue;
209
- }
210
-
203
+ const captureInstanceDetails = captureInstances.get(matchedTable.objectId as number);
211
204
  // TODO: Check RLS settings for table
212
205
 
213
206
  const replicaIdColumns = await getReplicationIdentityColumns({
@@ -224,6 +217,7 @@ export class CDCStream {
224
217
  objectId: matchedTable.objectId,
225
218
  replicaIdColumns: replicaIdColumns.columns
226
219
  },
220
+ captureInstanceDetails?.instances[0] ?? null,
227
221
  false
228
222
  );
229
223
 
@@ -235,6 +229,7 @@ export class CDCStream {
235
229
  async processTable(
236
230
  batch: storage.BucketStorageBatch,
237
231
  table: SourceEntityDescriptor,
232
+ captureInstance: CaptureInstance | null,
238
233
  snapshot: boolean
239
234
  ): Promise<MSSQLSourceTable> {
240
235
  if (!table.objectId && typeof table.objectId != 'number') {
@@ -247,40 +242,33 @@ export class CDCStream {
247
242
  entity_descriptor: table,
248
243
  sync_rules: this.syncRules
249
244
  });
250
- const captureInstance = await getCaptureInstance({
251
- connectionManager: this.connections,
252
- tableName: resolved.table.name,
253
- schema: resolved.table.schema
254
- });
245
+ const resolvedTable = new MSSQLSourceTable(resolved.table);
246
+
255
247
  if (!captureInstance) {
256
- throw new ServiceAssertionError(
257
- `Missing capture instance for table ${toQualifiedTableName(resolved.table.schema, resolved.table.name)}`
248
+ this.logger.warn(
249
+ `Missing capture instance for table ${resolvedTable.toQualifiedName()}. This table will not be replicated until CDC is enabled for it.`
258
250
  );
251
+ } else {
252
+ resolvedTable.setCaptureInstance(captureInstance);
259
253
  }
260
- const resolvedTable = new MSSQLSourceTable({
261
- sourceTable: resolved.table,
262
- captureInstance: captureInstance
263
- });
254
+
255
+ this.tableCache.set(resolvedTable);
264
256
 
265
257
  // Drop conflicting tables. This includes for example renamed tables.
266
258
  await batch.drop(resolved.dropTables);
267
259
 
268
260
  // Snapshot if:
269
- // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
270
- // 2. Snapshot is not already done, AND:
271
- // 3. The table is used in sync rules.
272
- const shouldSnapshot = snapshot && !resolved.table.snapshotComplete && resolved.table.syncAny;
261
+ // 1. The table is in the sync rules and snapshot is requested, or not already done.
262
+ // 2. AND the table is enabled for CDC with a valid capture instance.
263
+ const shouldSnapshot =
264
+ snapshot && !resolved.table.snapshotComplete && resolved.table.syncAny && resolvedTable.enabledForCDC();
273
265
 
274
266
  if (shouldSnapshot) {
275
267
  // Truncate this table in case a previous snapshot was interrupted.
276
268
  await batch.truncate([resolved.table]);
277
269
 
278
270
  // Start the snapshot inside a transaction.
279
- try {
280
- await this.snapshotTableInTx(batch, resolvedTable);
281
- } finally {
282
- // TODO Cleanup?
283
- }
271
+ await this.snapshotTableInTx(batch, resolvedTable);
284
272
  }
285
273
 
286
274
  return resolvedTable;
@@ -311,6 +299,9 @@ export class CDCStream {
311
299
  // Side note: A ROLLBACK would probably also be fine here, since we only read in this transaction.
312
300
  await transaction.commit();
313
301
  const [updatedSourceTable] = await batch.markTableSnapshotDone([table.sourceTable], postSnapshotLSN.toString());
302
+ this.logger.info(
303
+ `Snapshot of ${table.toQualifiedName()} completed. Post-snapshot LSN: ${postSnapshotLSN.toString()}`
304
+ );
314
305
  this.tableCache.updateSourceTable(updatedSourceTable);
315
306
  } catch (e) {
316
307
  await transaction.rollback();
@@ -341,17 +332,17 @@ export class CDCStream {
341
332
  );
342
333
  if (table.sourceTable.snapshotStatus?.lastKey != null) {
343
334
  this.logger.info(
344
- `Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resuming from ${orderByKey.name} > ${(query as BatchedSnapshotQuery).lastKey}`
335
+ `Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resuming from ${orderByKey.name} > ${(query as BatchedSnapshotQuery).lastKey}`
345
336
  );
346
337
  } else {
347
338
  this.logger.info(
348
- `Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resumable`
339
+ `Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resumable`
349
340
  );
350
341
  }
351
342
  } else {
352
343
  // Fallback case - query the entire table
353
344
  this.logger.info(
354
- `Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - not resumable`
345
+ `Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - not resumable`
355
346
  );
356
347
  query = new SimpleSnapshotQuery(transaction, table);
357
348
  replicatedCount = 0;
@@ -417,8 +408,6 @@ export class CDCStream {
417
408
  });
418
409
  this.tableCache.updateSourceTable(updatedSourceTable);
419
410
 
420
- this.logger.info(`Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
421
-
422
411
  if (this.abortSignal.aborted) {
423
412
  // We only abort after flushing
424
413
  throw new ReplicationAbortedError(`Initial replication interrupted`);
@@ -427,6 +416,8 @@ export class CDCStream {
427
416
  // When the batch of rows is smaller than the requested batch size we know it is the final batch
428
417
  if (batchReplicatedCount < this.snapshotBatchSize) {
429
418
  hasRemainingData = false;
419
+ } else {
420
+ this.logger.info(`Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
430
421
  }
431
422
  }
432
423
  }
@@ -455,13 +446,7 @@ export class CDCStream {
455
446
  * and starts again from scratch.
456
447
  */
457
448
  async startInitialReplication(snapshotStatus: SnapshotStatusResult) {
458
- let { status, snapshotLSN } = snapshotStatus;
459
-
460
- if (status === SnapshotStatus.RESTART_REQUIRED) {
461
- this.logger.info(`Snapshot restart required, clearing state.`);
462
- // This happens if the last replicated checkpoint LSN is no longer available in the CDC tables.
463
- await this.storage.clear({ signal: this.abortSignal });
464
- }
449
+ let { status, snapshotLSN, specificTablesToResnapshot } = snapshotStatus;
465
450
 
466
451
  await this.storage.startBatch(
467
452
  {
@@ -472,14 +457,26 @@ export class CDCStream {
472
457
  skipExistingRows: true
473
458
  },
474
459
  async (batch) => {
475
- if (snapshotLSN == null) {
476
- // First replication attempt - set the snapshot LSN to the current LSN before starting
477
- snapshotLSN = (await getLatestReplicatedLSN(this.connections)).toString();
478
- await batch.setResumeLsn(snapshotLSN);
479
- const latestLSN = (await getLatestLSN(this.connections)).toString();
480
- this.logger.info(`Marking snapshot at ${snapshotLSN}, Latest DB LSN ${latestLSN}.`);
481
- } else {
482
- this.logger.info(`Resuming snapshot at ${snapshotLSN}.`);
460
+ switch (status) {
461
+ case SnapshotStatus.INITIAL:
462
+ // First replication attempt - set the snapshot LSN to the current LSN before starting
463
+ snapshotLSN = (await getLatestReplicatedLSN(this.connections)).toString();
464
+ await batch.setResumeLsn(snapshotLSN);
465
+ const latestLSN = (await getLatestLSN(this.connections)).toString();
466
+ this.logger.info(`Marking snapshot at ${snapshotLSN}, Latest DB LSN ${latestLSN}.`);
467
+ break;
468
+ case SnapshotStatus.RESUME:
469
+ this.logger.info(`Resuming snapshot at ${snapshotLSN}.`);
470
+ break;
471
+ case SnapshotStatus.LIMITED_RESNAPSHOT:
472
+ for (const table of specificTablesToResnapshot!) {
473
+ await batch.drop([table.sourceTable]);
474
+ // Update table in the table cache
475
+ await this.processTable(batch, table.sourceTable, table.captureInstance, false);
476
+ }
477
+ break;
478
+ default:
479
+ throw new ReplicationAssertionError(`Unsupported snapshot status: ${status}`);
483
480
  }
484
481
 
485
482
  const tablesToSnapshot: MSSQLSourceTable[] = [];
@@ -489,14 +486,17 @@ export class CDCStream {
489
486
  continue;
490
487
  }
491
488
 
489
+ if (!table.enabledForCDC()) {
490
+ this.logger.info(`Skipping table [${table.toQualifiedName()}] - not enabled for CDC.`);
491
+ continue;
492
+ }
493
+
492
494
  const count = await this.estimatedCountNumber(table);
493
495
  const updatedSourceTable = await batch.updateTableProgress(table.sourceTable, {
494
496
  totalEstimatedCount: count
495
497
  });
496
498
  this.tableCache.updateSourceTable(updatedSourceTable);
497
499
  tablesToSnapshot.push(table);
498
-
499
- this.logger.info(`To replicate: ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
500
500
  }
501
501
 
502
502
  for (const table of tablesToSnapshot) {
@@ -508,9 +508,15 @@ export class CDCStream {
508
508
  // Actual checkpoint will be created when streaming replication caught up.
509
509
  const postSnapshotLSN = await getLatestLSN(this.connections);
510
510
  await batch.markAllSnapshotDone(postSnapshotLSN.toString());
511
- await batch.commit(snapshotLSN);
511
+ await batch.commit(snapshotLSN!);
512
512
 
513
- this.logger.info(`Snapshot done. Need to replicate from ${snapshotLSN} to ${postSnapshotLSN} to be consistent`);
513
+ if (tablesToSnapshot.length > 0) {
514
+ this.logger.info(
515
+ `All snapshots done. Need to replicate from ${snapshotLSN} to ${postSnapshotLSN} to be consistent.`
516
+ );
517
+ } else {
518
+ this.logger.info(`No tables to snapshot. Need to replicate from ${snapshotLSN}.`);
519
+ }
514
520
  }
515
521
  );
516
522
  }
@@ -525,6 +531,8 @@ export class CDCStream {
525
531
  const snapshotStatus = await this.checkSnapshotStatus();
526
532
  if (snapshotStatus.status !== SnapshotStatus.DONE) {
527
533
  await this.startInitialReplication(snapshotStatus);
534
+ } else {
535
+ this.logger.info(`Initial replication already done`);
528
536
  }
529
537
  }
530
538
 
@@ -534,26 +542,50 @@ export class CDCStream {
534
542
  */
535
543
  private async checkSnapshotStatus(): Promise<SnapshotStatusResult> {
536
544
  const status = await this.storage.getStatus();
545
+
537
546
  if (status.snapshot_done && status.checkpoint_lsn) {
547
+ const additionalTablesToSnapshot: Set<MSSQLSourceTable> = new Set();
548
+ const newTables = this.tableCache.getAll().filter((table) => !table.sourceTable.snapshotComplete);
549
+ if (newTables.length > 0) {
550
+ this.logger.info(
551
+ `Detected new table(s) [${newTables.map((table) => table.toQualifiedName()).join(', ')}] that have not been snapshotted yet.`
552
+ );
553
+ newTables.forEach((table) => additionalTablesToSnapshot.add(table));
554
+ }
555
+
538
556
  // Snapshot is done, but we still need to check that the last known checkpoint LSN is still
539
- // within the threshold of the CDC tables
540
- this.logger.info(`Initial replication already done`);
557
+ // within the retention threshold of the CDC tables
541
558
 
542
559
  const lastCheckpointLSN = LSN.fromString(status.checkpoint_lsn);
543
560
  // Check that the CDC tables still have valid data
544
- const isAvailable = await isWithinRetentionThreshold({
561
+ const tablesOutsideRetentionThreshold = await checkRetentionThresholds({
545
562
  checkpointLSN: lastCheckpointLSN,
546
563
  tables: this.tableCache.getAll(),
547
564
  connectionManager: this.connections
548
565
  });
549
- if (!isAvailable) {
566
+ if (tablesOutsideRetentionThreshold.length > 0) {
550
567
  this.logger.warn(
551
- `Updates from the last checkpoint are no longer available in the CDC instance, starting initial replication again.`
568
+ `Updates from the last checkpoint are no longer available in the CDC instances of the following table(s): ${tablesOutsideRetentionThreshold.map((table) => table.toQualifiedName()).join(', ')}.`
552
569
  );
570
+ tablesOutsideRetentionThreshold.forEach((table) => additionalTablesToSnapshot.add(table));
571
+ }
572
+ if (additionalTablesToSnapshot.size > 0) {
573
+ return {
574
+ status: SnapshotStatus.LIMITED_RESNAPSHOT,
575
+ snapshotLSN: status.checkpoint_lsn,
576
+ specificTablesToResnapshot: Array.from(additionalTablesToSnapshot)
577
+ };
578
+ } else {
579
+ return {
580
+ status: SnapshotStatus.DONE,
581
+ snapshotLSN: null
582
+ };
553
583
  }
554
- return { status: isAvailable ? SnapshotStatus.DONE : SnapshotStatus.RESTART_REQUIRED, snapshotLSN: null };
555
584
  } else {
556
- return { status: SnapshotStatus.IN_PROGRESS, snapshotLSN: status.snapshot_lsn };
585
+ return {
586
+ status: status.snapshot_lsn != null ? SnapshotStatus.RESUME : SnapshotStatus.INITIAL,
587
+ snapshotLSN: status.snapshot_lsn
588
+ };
557
589
  }
558
590
  }
559
591
 
@@ -571,16 +603,17 @@ export class CDCStream {
571
603
  throw new ReplicationAssertionError(`No LSN found to resume replication from.`);
572
604
  }
573
605
  const startLSN = LSN.fromString(batch.resumeFromLsn);
574
- const sourceTables: MSSQLSourceTable[] = this.tableCache.getAll();
575
606
  const eventHandler = this.createEventHandler(batch);
576
607
 
577
608
  const poller = new CDCPoller({
578
609
  connectionManager: this.connections,
579
610
  eventHandler,
580
- sourceTables,
611
+ getReplicatedTables: () => this.tableCache.getAll(),
612
+ sourceTables: this.syncRules.getSourceTables(),
581
613
  startLSN,
582
614
  logger: this.logger,
583
- additionalConfig: this.options.additionalConfig
615
+ additionalConfig: this.options.additionalConfig,
616
+ schemaCheckIntervalMs: this.options.schemaCheckIntervalMs
584
617
  });
585
618
 
586
619
  this.abortSignal.addEventListener(
@@ -645,12 +678,117 @@ export class CDCStream {
645
678
  this.isStartingReplication = false;
646
679
  }
647
680
  },
648
- onSchemaChange: async () => {
649
- // TODO: Handle schema changes
681
+ onSchemaChange: async (schemaChange: SchemaChange) => {
682
+ await this.handleSchemaChange(batch, schemaChange);
650
683
  }
651
684
  };
652
685
  }
653
686
 
687
+ async handleSchemaChange(batch: storage.BucketStorageBatch, change: SchemaChange): Promise<void> {
688
+ let actionedSchemaChange = true;
689
+
690
+ switch (change.type) {
691
+ case SchemaChangeType.TABLE_RENAME:
692
+ const fromTable = change.table!;
693
+ this.logger.info(
694
+ `Table ${fromTable.toQualifiedName()} has been renamed ${change.newTable ? `to [${change.newTable.name}].` : '.'}`
695
+ );
696
+
697
+ // Old table needs to be cleaned up
698
+ await batch.drop([fromTable.sourceTable]);
699
+ this.tableCache.delete(fromTable.objectId);
700
+
701
+ if (change.newTable) {
702
+ await this.handleCreateOrUpdateTable(batch, change.newTable, change.newCaptureInstance!);
703
+ }
704
+ break;
705
+ case SchemaChangeType.TABLE_CREATE:
706
+ await this.handleCreateOrUpdateTable(batch, change.newTable!, change.newCaptureInstance!);
707
+ break;
708
+ case SchemaChangeType.TABLE_COLUMN_CHANGES:
709
+ await this.handleColumnChanges(change.table!, change.newCaptureInstance!);
710
+ actionedSchemaChange = false;
711
+ break;
712
+ case SchemaChangeType.NEW_CAPTURE_INSTANCE:
713
+ this.logger.info(
714
+ `New CDC capture instance detected for table ${change.table!.toQualifiedName()}. Re-snapshotting table...`
715
+ );
716
+ await batch.drop([change.table!.sourceTable]);
717
+ this.tableCache.delete(change.table!.objectId);
718
+
719
+ await this.handleCreateOrUpdateTable(batch, change.table!.sourceTable, change.newCaptureInstance!);
720
+ break;
721
+ case SchemaChangeType.TABLE_DROP:
722
+ await batch.drop([change.table!.sourceTable]);
723
+ this.tableCache.delete(change.table!.objectId);
724
+ break;
725
+ case SchemaChangeType.MISSING_CAPTURE_INSTANCE:
726
+ // Stop replication for this table until CDC is re-enabled.
727
+ this.logger.warn(
728
+ `Table ${change.table!.toQualifiedName()} has been disabled for CDC. Re-enable CDC to continue replication.`
729
+ );
730
+ change.table!.clearCaptureInstance();
731
+ actionedSchemaChange = false;
732
+ break;
733
+ default:
734
+ throw new ReplicationAssertionError(`Unknown schema change type: ${change.type}`);
735
+ }
736
+
737
+ // Create a new checkpoint after the schema change
738
+ if (actionedSchemaChange) {
739
+ await createCheckpoint(this.connections);
740
+ }
741
+ }
742
+
743
+ private async handleCreateOrUpdateTable(
744
+ batch: storage.BucketStorageBatch,
745
+ table: Omit<SourceEntityDescriptor, 'replicaIdColumns'>,
746
+ captureInstance: CaptureInstance
747
+ ): Promise<void> {
748
+ const replicaIdColumns = await getReplicationIdentityColumns({
749
+ connectionManager: this.connections,
750
+ tableName: table.name,
751
+ schema: table.schema
752
+ });
753
+
754
+ await this.processTable(
755
+ batch,
756
+ {
757
+ name: table.name,
758
+ schema: table.schema,
759
+ objectId: table.objectId,
760
+ replicaIdColumns: replicaIdColumns.columns
761
+ },
762
+ captureInstance,
763
+ true
764
+ );
765
+ }
766
+
767
+ /**
768
+ * There is very little that can be automatically done to handle the column changes other than to warn the user about the schema drift.
769
+ *
770
+ * Due to the way CDC works, users are prevented from making column schema changes that affect the replication identities of a table.
771
+ * 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
772
+ * capture instance.
773
+ * @param table
774
+ * @param captureInstance
775
+ */
776
+ private async handleColumnChanges(table: MSSQLSourceTable, captureInstance: CaptureInstance): Promise<void> {
777
+ // Check there are any new pending schema changes
778
+ if (
779
+ table.captureInstance?.objectId === captureInstance.objectId &&
780
+ table.captureInstance?.pendingSchemaChanges.length === captureInstance.pendingSchemaChanges.length
781
+ ) {
782
+ return;
783
+ }
784
+
785
+ // New pending schema changes were detected - warn about those as well.
786
+ this.logger.warn(
787
+ `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')}`
788
+ );
789
+ table.captureInstance = captureInstance;
790
+ }
791
+
654
792
  /**
655
793
  * Convert CDC row data to SqliteRow format.
656
794
  * CDC rows include table columns plus CDC metadata columns (__$operation, __$start_lsn, etc.).
@@ -680,4 +818,13 @@ export class CDCStream {
680
818
  this.logger.error(`Error touching probe`, e);
681
819
  });
682
820
  }
821
+
822
+ /**
823
+ * Creates an update in the source database to ensure regular checkpoints via the CDC
824
+ */
825
+ public async keepAlive() {
826
+ if (!this.isStartingReplication && !this.stopped) {
827
+ await createCheckpoint(this.connections);
828
+ }
829
+ }
683
830
  }
@@ -87,13 +87,23 @@ export class MSSQLConnectionManager extends BaseObserver<MSSQLConnectionManagerL
87
87
 
88
88
  async execute(procedure: string, parameters?: MSSQLParameter[]): Promise<sql.IProcedureResult<any>> {
89
89
  await this.ensureConnected();
90
- let request = this.pool.request();
91
- if (parameters) {
92
- if (parameters) {
93
- request = addParameters(request, parameters);
90
+ for (let tries = 2; ; tries--) {
91
+ try {
92
+ logger.debug(`Executing procedure: ${procedure}`);
93
+ let request = this.pool.request();
94
+ if (parameters) {
95
+ if (parameters) {
96
+ request = addParameters(request, parameters);
97
+ }
98
+ }
99
+ return request.execute(procedure);
100
+ } catch (e) {
101
+ if (tries == 1) {
102
+ throw e;
103
+ }
104
+ logger.warn(`Error executing stored procedure: ${procedure}, retrying..`, e);
94
105
  }
95
106
  }
96
- return request.execute(procedure);
97
107
  }
98
108
 
99
109
  async end(): Promise<void> {
@@ -4,31 +4,10 @@ import { LookupFunction } from 'node:net';
4
4
  import * as t from 'ts-codec';
5
5
  import * as urijs from 'uri-js';
6
6
 
7
+ export const DEFAULT_POLLING_BATCH_SIZE = 10;
8
+ export const DEFAULT_POLLING_INTERVAL_MS = 1000;
7
9
  export const MSSQL_CONNECTION_TYPE = 'mssql' as const;
8
10
 
9
- export const AzureActiveDirectoryPasswordAuthentication = t.object({
10
- type: t.literal('azure-active-directory-password'),
11
- options: t.object({
12
- /**
13
- * A user need to provide `userName` associate to their account.
14
- */
15
- userName: t.string,
16
- /**
17
- * A user need to provide `password` associate to their account.
18
- */
19
- password: t.string,
20
- /**
21
- * A client id to use.
22
- */
23
- clientId: t.string,
24
- /**
25
- * Azure tenant ID
26
- */
27
- tenantId: t.string
28
- })
29
- });
30
- export type AzureActiveDirectoryPasswordAuthentication = t.Decoded<typeof AzureActiveDirectoryPasswordAuthentication>;
31
-
32
11
  export const AzureActiveDirectoryServicePrincipalSecret = t.object({
33
12
  type: t.literal('azure-active-directory-service-principal-secret'),
34
13
  options: t.object({
@@ -63,9 +42,7 @@ export const DefaultAuthentication = t.object({
63
42
  });
64
43
  export type DefaultAuthentication = t.Decoded<typeof DefaultAuthentication>;
65
44
 
66
- export const Authentication = DefaultAuthentication.or(AzureActiveDirectoryPasswordAuthentication).or(
67
- AzureActiveDirectoryServicePrincipalSecret
68
- );
45
+ export const Authentication = DefaultAuthentication.or(AzureActiveDirectoryServicePrincipalSecret);
69
46
  export type Authentication = t.Decoded<typeof Authentication>;
70
47
 
71
48
  export const AdditionalConfig = t.object({
@@ -208,8 +185,8 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
208
185
  authentication: options.authentication,
209
186
 
210
187
  additionalConfig: {
211
- pollingIntervalMs: options.additionalConfig?.pollingIntervalMs ?? 1000,
212
- pollingBatchSize: options.additionalConfig?.pollingBatchSize ?? 10,
188
+ pollingIntervalMs: options.additionalConfig?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS,
189
+ pollingBatchSize: options.additionalConfig?.pollingBatchSize ?? DEFAULT_POLLING_BATCH_SIZE,
213
190
  trustServerCertificate: options.additionalConfig?.trustServerCertificate ?? false
214
191
  }
215
192
  } satisfies NormalizedMSSQLConnectionConfig;
@@ -0,0 +1,44 @@
1
+ import { logger } from '@powersync/lib-services-framework';
2
+ import timers from 'timers/promises';
3
+
4
+ const MSSQL_DEADLOCK_RETRIES = 5;
5
+ const MSSQL_DEADLOCK_BACKOFF_FACTOR = 2;
6
+ const MSSQL_DEADLOCK_RETRY_DELAY_MS = 200;
7
+
8
+ /**
9
+ * Retries the given async function if it fails with a SQL Server deadlock error (1205).
10
+ * Deadlocks, while uncommon, can occur when CDC altering functions are being called whilst actively replicating
11
+ * using CDC functions.
12
+ *
13
+ * If the error is not a deadlock or all retries are exhausted, the error is re-thrown.
14
+ */
15
+ export async function retryOnDeadlock<T>(fn: () => Promise<T>, operationName: string): Promise<T> {
16
+ let lastError: Error | null = null;
17
+ for (let attempt = 0; attempt <= MSSQL_DEADLOCK_RETRIES; attempt++) {
18
+ try {
19
+ return await fn();
20
+ } catch (error) {
21
+ lastError = error;
22
+ if (!isDeadlockError(error) || attempt === MSSQL_DEADLOCK_RETRIES) {
23
+ throw error;
24
+ }
25
+ const delay = MSSQL_DEADLOCK_RETRY_DELAY_MS * Math.pow(MSSQL_DEADLOCK_BACKOFF_FACTOR, attempt);
26
+ logger.warn(
27
+ `Deadlock detected during ${operationName} (attempt ${attempt + 1}/${MSSQL_DEADLOCK_RETRIES}). Retrying in ${delay}ms...`
28
+ );
29
+ await timers.setTimeout(delay);
30
+ }
31
+ }
32
+
33
+ throw lastError;
34
+ }
35
+
36
+ export function isDeadlockError(error: unknown): boolean {
37
+ if (error != null && typeof error === 'object' && 'number' in error) {
38
+ // SQL Server deadlock victim error number.
39
+ // When SQL Server detects a deadlock, it chooses one of the participating transactions
40
+ // as the "deadlock victim" and terminates it with error 1205.
41
+ return (error as { number: unknown }).number === 1205;
42
+ }
43
+ return false;
44
+ }