@powersync/service-module-mssql 0.0.0-dev-20260225160713 → 0.0.0-dev-20260511080634

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 (76) hide show
  1. package/CHANGELOG.md +113 -6
  2. package/dist/api/MSSQLRouteAPIAdapter.d.ts +2 -2
  3. package/dist/api/MSSQLRouteAPIAdapter.js +3 -3
  4. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/CaptureInstance.d.ts +14 -0
  6. package/dist/common/CaptureInstance.js +2 -0
  7. package/dist/common/CaptureInstance.js.map +1 -0
  8. package/dist/common/MSSQLSourceTable.d.ts +16 -14
  9. package/dist/common/MSSQLSourceTable.js +35 -16
  10. package/dist/common/MSSQLSourceTable.js.map +1 -1
  11. package/dist/common/MSSQLSourceTableCache.js.map +1 -1
  12. package/dist/common/mssqls-to-sqlite.d.ts +1 -1
  13. package/dist/common/mssqls-to-sqlite.js +1 -1
  14. package/dist/common/mssqls-to-sqlite.js.map +1 -1
  15. package/dist/module/MSSQLModule.js +4 -4
  16. package/dist/module/MSSQLModule.js.map +1 -1
  17. package/dist/replication/CDCPoller.d.ts +45 -23
  18. package/dist/replication/CDCPoller.js +201 -61
  19. package/dist/replication/CDCPoller.js.map +1 -1
  20. package/dist/replication/CDCReplicationJob.d.ts +2 -2
  21. package/dist/replication/CDCReplicationJob.js +12 -4
  22. package/dist/replication/CDCReplicationJob.js.map +1 -1
  23. package/dist/replication/CDCReplicator.d.ts +2 -3
  24. package/dist/replication/CDCReplicator.js +1 -24
  25. package/dist/replication/CDCReplicator.js.map +1 -1
  26. package/dist/replication/CDCStream.d.ts +39 -16
  27. package/dist/replication/CDCStream.js +200 -103
  28. package/dist/replication/CDCStream.js.map +1 -1
  29. package/dist/replication/MSSQLConnectionManager.d.ts +1 -1
  30. package/dist/replication/MSSQLConnectionManager.js +17 -6
  31. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  32. package/dist/replication/MSSQLConnectionManagerFactory.d.ts +1 -1
  33. package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -1
  34. package/dist/replication/MSSQLSnapshotQuery.d.ts +1 -1
  35. package/dist/replication/MSSQLSnapshotQuery.js +2 -2
  36. package/dist/replication/MSSQLSnapshotQuery.js.map +1 -1
  37. package/dist/types/types.d.ts +4 -56
  38. package/dist/types/types.js +5 -24
  39. package/dist/types/types.js.map +1 -1
  40. package/dist/utils/deadlock.d.ts +9 -0
  41. package/dist/utils/deadlock.js +40 -0
  42. package/dist/utils/deadlock.js.map +1 -0
  43. package/dist/utils/mssql.d.ts +36 -18
  44. package/dist/utils/mssql.js +102 -100
  45. package/dist/utils/mssql.js.map +1 -1
  46. package/dist/utils/schema.d.ts +9 -0
  47. package/dist/utils/schema.js +34 -0
  48. package/dist/utils/schema.js.map +1 -1
  49. package/package.json +14 -14
  50. package/src/api/MSSQLRouteAPIAdapter.ts +4 -4
  51. package/src/common/CaptureInstance.ts +15 -0
  52. package/src/common/MSSQLSourceTable.ts +33 -24
  53. package/src/common/MSSQLSourceTableCache.ts +1 -1
  54. package/src/common/mssqls-to-sqlite.ts +1 -1
  55. package/src/module/MSSQLModule.ts +4 -4
  56. package/src/replication/CDCPoller.ts +275 -75
  57. package/src/replication/CDCReplicationJob.ts +13 -6
  58. package/src/replication/CDCReplicator.ts +2 -28
  59. package/src/replication/CDCStream.ts +271 -128
  60. package/src/replication/MSSQLConnectionManager.ts +17 -7
  61. package/src/replication/MSSQLConnectionManagerFactory.ts +1 -1
  62. package/src/replication/MSSQLSnapshotQuery.ts +3 -3
  63. package/src/types/types.ts +5 -28
  64. package/src/utils/deadlock.ts +44 -0
  65. package/src/utils/mssql.ts +163 -128
  66. package/src/utils/schema.ts +44 -1
  67. package/test/src/CDCStream.test.ts +10 -8
  68. package/test/src/CDCStreamTestContext.ts +32 -12
  69. package/test/src/CDCStream_resumable_snapshot.test.ts +14 -12
  70. package/test/src/LSN.test.ts +1 -1
  71. package/test/src/env.ts +1 -1
  72. package/test/src/mssql-to-sqlite.test.ts +25 -17
  73. package/test/src/schema-changes.test.ts +474 -0
  74. package/test/src/util.ts +81 -18
  75. package/test/tsconfig.json +0 -1
  76. package/tsconfig.tsbuildinfo +1 -1
@@ -1,45 +1,43 @@
1
1
  import {
2
2
  container,
3
3
  DatabaseConnectionError,
4
+ logger as defaultLogger,
4
5
  ErrorCode,
5
6
  Logger,
6
- logger as defaultLogger,
7
7
  ReplicationAbortedError,
8
- ReplicationAssertionError,
9
- ServiceAssertionError
8
+ ReplicationAssertionError
10
9
  } from '@powersync/lib-services-framework';
11
- import { getUuidReplicaIdentityBson, MetricsEngine, SourceEntityDescriptor, storage } from '@powersync/service-core';
12
-
13
10
  import {
14
- SqliteInputRow,
15
- SqliteRow,
16
- SqlSyncRules,
17
- HydratedSyncRules,
18
- TablePattern
19
- } from '@powersync/service-sync-rules';
11
+ getUuidReplicaIdentityBson,
12
+ MetricsEngine,
13
+ ReplicationLagTracker,
14
+ SourceEntityDescriptor,
15
+ storage
16
+ } from '@powersync/service-core';
17
+
18
+ import { HydratedSyncRules, SqliteInputRow, SqliteRow, TablePattern } from '@powersync/service-sync-rules';
20
19
 
21
20
  import { ReplicationMetric } from '@powersync/service-types';
22
- import { BatchedSnapshotQuery, MSSQLSnapshotQuery, SimpleSnapshotQuery } from './MSSQLSnapshotQuery.js';
23
- import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
24
- import { getReplicationIdentityColumns, getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
21
+ import sql from 'mssql';
22
+ import { CaptureInstance } from '../common/CaptureInstance.js';
23
+ import { LSN } from '../common/LSN.js';
24
+ import { CDCToSqliteRow, toSqliteInputRow } from '../common/mssqls-to-sqlite.js';
25
+ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
26
+ import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
27
+ import { AdditionalConfig } from '../types/types.js';
25
28
  import {
29
+ checkRetentionThresholds,
26
30
  checkSourceConfiguration,
27
31
  createCheckpoint,
28
- getCaptureInstance,
32
+ getCaptureInstances,
29
33
  getLatestLSN,
30
34
  getLatestReplicatedLSN,
31
- isIColumnMetadata,
32
- isTableEnabledForCDC,
33
- isWithinRetentionThreshold,
34
- toQualifiedTableName
35
+ isIColumnMetadata
35
36
  } from '../utils/mssql.js';
36
- import sql from 'mssql';
37
- import { CDCToSqliteRow, toSqliteInputRow } from '../common/mssqls-to-sqlite.js';
38
- import { LSN } from '../common/LSN.js';
39
- import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
40
- import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
41
- import { CDCEventHandler, CDCPoller } from './CDCPoller.js';
42
- import { AdditionalConfig } from '../types/types.js';
37
+ import { getReplicationIdentityColumns, getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
38
+ import { CDCEventHandler, CDCPoller, SchemaChange, SchemaChangeType } from './CDCPoller.js';
39
+ import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
40
+ import { BatchedSnapshotQuery, MSSQLSnapshotQuery, SimpleSnapshotQuery } from './MSSQLSnapshotQuery.js';
43
41
 
44
42
  export interface CDCStreamOptions {
45
43
  connections: MSSQLConnectionManager;
@@ -55,17 +53,30 @@ export interface CDCStreamOptions {
55
53
  snapshotBatchSize?: number;
56
54
 
57
55
  additionalConfig: AdditionalConfig;
56
+
57
+ /**
58
+ * Override schema check interval, defaults is 60 seconds.
59
+ */
60
+ schemaCheckIntervalMs?: number;
58
61
  }
59
62
 
60
63
  export enum SnapshotStatus {
61
- IN_PROGRESS = 'in-progress',
64
+ INITIAL = 'initial',
65
+ RESUME = 'resume',
62
66
  DONE = 'done',
63
- RESTART_REQUIRED = 'restart-required'
67
+ LIMITED_RESNAPSHOT = 'limited-resnapshot'
64
68
  }
65
69
 
66
70
  export interface SnapshotStatusResult {
67
71
  status: SnapshotStatus;
68
72
  snapshotLSN: string | null;
73
+ /**
74
+ * Under certain circumstances it may be necessary to re-snapshot specific tables:
75
+ * * A new capture instance has been created for a table.
76
+ * * A table has been renamed.
77
+ * * The retention threshold has been exceeded for a table.
78
+ */
79
+ specificTablesToResnapshot?: MSSQLSourceTable[];
69
80
  }
70
81
 
71
82
  export class CDCConfigurationError extends Error {
@@ -94,18 +105,9 @@ export class CDCStream {
94
105
  private readonly abortSignal: AbortSignal;
95
106
  private readonly logger: Logger;
96
107
 
97
- private tableCache = new MSSQLSourceTableCache();
108
+ public tableCache = new MSSQLSourceTableCache();
98
109
 
99
- /**
100
- * Time of the oldest uncommitted change, according to the source db.
101
- * This is used to determine the replication lag.
102
- */
103
- private oldestUncommittedChange: Date | null = null;
104
- /**
105
- * Keep track of whether we have done a commit or keepalive yet.
106
- * We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
107
- */
108
- public isStartingReplication = true;
110
+ private replicationLag = new ReplicationLagTracker();
109
111
 
110
112
  constructor(private options: CDCStreamOptions) {
111
113
  this.logger = options.logger ?? defaultLogger;
@@ -123,6 +125,10 @@ export class CDCStream {
123
125
  return this.abortSignal.aborted;
124
126
  }
125
127
 
128
+ get isStartingReplication() {
129
+ return this.replicationLag.isStartingReplication;
130
+ }
131
+
126
132
  get defaultSchema() {
127
133
  return this.connections.schema;
128
134
  }
@@ -172,14 +178,11 @@ export class CDCStream {
172
178
  logger: this.logger,
173
179
  zeroLSN: LSN.ZERO,
174
180
  defaultSchema: this.defaultSchema,
175
- storeCurrentData: true
181
+ storeCurrentData: false
176
182
  },
177
183
  async (batch) => {
178
184
  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
- }
185
+ await this.getQualifiedTableNames(batch, tablePattern);
183
186
  }
184
187
  }
185
188
  );
@@ -193,21 +196,12 @@ export class CDCStream {
193
196
  return [];
194
197
  }
195
198
 
199
+ const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
196
200
  const matchedTables: ResolvedTable[] = await getTablesFromPattern(this.connections, tablePattern);
197
201
 
198
202
  const tables: MSSQLSourceTable[] = [];
199
203
  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
-
204
+ const captureInstanceDetails = captureInstances.get(matchedTable.objectId as number);
211
205
  // TODO: Check RLS settings for table
212
206
 
213
207
  const replicaIdColumns = await getReplicationIdentityColumns({
@@ -224,6 +218,7 @@ export class CDCStream {
224
218
  objectId: matchedTable.objectId,
225
219
  replicaIdColumns: replicaIdColumns.columns
226
220
  },
221
+ captureInstanceDetails?.instances[0] ?? null,
227
222
  false
228
223
  );
229
224
 
@@ -235,6 +230,7 @@ export class CDCStream {
235
230
  async processTable(
236
231
  batch: storage.BucketStorageBatch,
237
232
  table: SourceEntityDescriptor,
233
+ captureInstance: CaptureInstance | null,
238
234
  snapshot: boolean
239
235
  ): Promise<MSSQLSourceTable> {
240
236
  if (!table.objectId && typeof table.objectId != 'number') {
@@ -247,40 +243,33 @@ export class CDCStream {
247
243
  entity_descriptor: table,
248
244
  sync_rules: this.syncRules
249
245
  });
250
- const captureInstance = await getCaptureInstance({
251
- connectionManager: this.connections,
252
- tableName: resolved.table.name,
253
- schema: resolved.table.schema
254
- });
246
+ const resolvedTable = new MSSQLSourceTable(resolved.table);
247
+
255
248
  if (!captureInstance) {
256
- throw new ServiceAssertionError(
257
- `Missing capture instance for table ${toQualifiedTableName(resolved.table.schema, resolved.table.name)}`
249
+ this.logger.warn(
250
+ `Missing capture instance for table ${resolvedTable.toQualifiedName()}. This table will not be replicated until CDC is enabled for it.`
258
251
  );
252
+ } else {
253
+ resolvedTable.setCaptureInstance(captureInstance);
259
254
  }
260
- const resolvedTable = new MSSQLSourceTable({
261
- sourceTable: resolved.table,
262
- captureInstance: captureInstance
263
- });
255
+
256
+ this.tableCache.set(resolvedTable);
264
257
 
265
258
  // Drop conflicting tables. This includes for example renamed tables.
266
259
  await batch.drop(resolved.dropTables);
267
260
 
268
261
  // 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;
262
+ // 1. The table is in the sync config and snapshot is requested, or not already done.
263
+ // 2. AND the table is enabled for CDC with a valid capture instance.
264
+ const shouldSnapshot =
265
+ snapshot && !resolved.table.snapshotComplete && resolved.table.syncAny && resolvedTable.enabledForCDC();
273
266
 
274
267
  if (shouldSnapshot) {
275
268
  // Truncate this table in case a previous snapshot was interrupted.
276
269
  await batch.truncate([resolved.table]);
277
270
 
278
271
  // Start the snapshot inside a transaction.
279
- try {
280
- await this.snapshotTableInTx(batch, resolvedTable);
281
- } finally {
282
- // TODO Cleanup?
283
- }
272
+ await this.snapshotTableInTx(batch, resolvedTable);
284
273
  }
285
274
 
286
275
  return resolvedTable;
@@ -310,7 +299,10 @@ export class CDCStream {
310
299
  const postSnapshotLSN = await getLatestLSN(this.connections);
311
300
  // Side note: A ROLLBACK would probably also be fine here, since we only read in this transaction.
312
301
  await transaction.commit();
313
- const [updatedSourceTable] = await batch.markSnapshotDone([table.sourceTable], postSnapshotLSN.toString());
302
+ const [updatedSourceTable] = await batch.markTableSnapshotDone([table.sourceTable], postSnapshotLSN.toString());
303
+ this.logger.info(
304
+ `Snapshot of ${table.toQualifiedName()} completed. Post-snapshot LSN: ${postSnapshotLSN.toString()}`
305
+ );
314
306
  this.tableCache.updateSourceTable(updatedSourceTable);
315
307
  } catch (e) {
316
308
  await transaction.rollback();
@@ -341,17 +333,17 @@ export class CDCStream {
341
333
  );
342
334
  if (table.sourceTable.snapshotStatus?.lastKey != null) {
343
335
  this.logger.info(
344
- `Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resuming from ${orderByKey.name} > ${(query as BatchedSnapshotQuery).lastKey}`
336
+ `Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resuming from ${orderByKey.name} > ${(query as BatchedSnapshotQuery).lastKey}`
345
337
  );
346
338
  } else {
347
339
  this.logger.info(
348
- `Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resumable`
340
+ `Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resumable`
349
341
  );
350
342
  }
351
343
  } else {
352
344
  // Fallback case - query the entire table
353
345
  this.logger.info(
354
- `Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - not resumable`
346
+ `Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - not resumable`
355
347
  );
356
348
  query = new SimpleSnapshotQuery(transaction, table);
357
349
  replicatedCount = 0;
@@ -417,8 +409,6 @@ export class CDCStream {
417
409
  });
418
410
  this.tableCache.updateSourceTable(updatedSourceTable);
419
411
 
420
- this.logger.info(`Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
421
-
422
412
  if (this.abortSignal.aborted) {
423
413
  // We only abort after flushing
424
414
  throw new ReplicationAbortedError(`Initial replication interrupted`);
@@ -427,6 +417,8 @@ export class CDCStream {
427
417
  // When the batch of rows is smaller than the requested batch size we know it is the final batch
428
418
  if (batchReplicatedCount < this.snapshotBatchSize) {
429
419
  hasRemainingData = false;
420
+ } else {
421
+ this.logger.info(`Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
430
422
  }
431
423
  }
432
424
  }
@@ -455,13 +447,7 @@ export class CDCStream {
455
447
  * and starts again from scratch.
456
448
  */
457
449
  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
- }
450
+ let { status, snapshotLSN, specificTablesToResnapshot } = snapshotStatus;
465
451
 
466
452
  await this.storage.startBatch(
467
453
  {
@@ -472,14 +458,26 @@ export class CDCStream {
472
458
  skipExistingRows: true
473
459
  },
474
460
  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}.`);
461
+ switch (status) {
462
+ case SnapshotStatus.INITIAL:
463
+ // First replication attempt - set the snapshot LSN to the current LSN before starting
464
+ snapshotLSN = (await getLatestReplicatedLSN(this.connections)).toString();
465
+ await batch.setResumeLsn(snapshotLSN);
466
+ const latestLSN = (await getLatestLSN(this.connections)).toString();
467
+ this.logger.info(`Marking snapshot at ${snapshotLSN}, Latest DB LSN ${latestLSN}.`);
468
+ break;
469
+ case SnapshotStatus.RESUME:
470
+ this.logger.info(`Resuming snapshot at ${snapshotLSN}.`);
471
+ break;
472
+ case SnapshotStatus.LIMITED_RESNAPSHOT:
473
+ for (const table of specificTablesToResnapshot!) {
474
+ await batch.drop([table.sourceTable]);
475
+ // Update table in the table cache
476
+ await this.processTable(batch, table.sourceTable, table.captureInstance, false);
477
+ }
478
+ break;
479
+ default:
480
+ throw new ReplicationAssertionError(`Unsupported snapshot status: ${status}`);
483
481
  }
484
482
 
485
483
  const tablesToSnapshot: MSSQLSourceTable[] = [];
@@ -489,14 +487,17 @@ export class CDCStream {
489
487
  continue;
490
488
  }
491
489
 
490
+ if (!table.enabledForCDC()) {
491
+ this.logger.info(`Skipping table [${table.toQualifiedName()}] - not enabled for CDC.`);
492
+ continue;
493
+ }
494
+
492
495
  const count = await this.estimatedCountNumber(table);
493
496
  const updatedSourceTable = await batch.updateTableProgress(table.sourceTable, {
494
497
  totalEstimatedCount: count
495
498
  });
496
499
  this.tableCache.updateSourceTable(updatedSourceTable);
497
500
  tablesToSnapshot.push(table);
498
-
499
- this.logger.info(`To replicate: ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
500
501
  }
501
502
 
502
503
  for (const table of tablesToSnapshot) {
@@ -506,11 +507,17 @@ export class CDCStream {
506
507
 
507
508
  // This will not create a consistent checkpoint yet, but will persist the op.
508
509
  // Actual checkpoint will be created when streaming replication caught up.
509
- await batch.commit(snapshotLSN);
510
-
511
- this.logger.info(
512
- `Snapshot done. Need to replicate from ${snapshotLSN} to ${batch.noCheckpointBeforeLsn} to be consistent`
513
- );
510
+ const postSnapshotLSN = await getLatestLSN(this.connections);
511
+ await batch.markAllSnapshotDone(postSnapshotLSN.toString());
512
+ await batch.commit(snapshotLSN!);
513
+
514
+ if (tablesToSnapshot.length > 0) {
515
+ this.logger.info(
516
+ `All snapshots done. Need to replicate from ${snapshotLSN} to ${postSnapshotLSN} to be consistent.`
517
+ );
518
+ } else {
519
+ this.logger.info(`No tables to snapshot. Need to replicate from ${snapshotLSN}.`);
520
+ }
514
521
  }
515
522
  );
516
523
  }
@@ -525,6 +532,8 @@ export class CDCStream {
525
532
  const snapshotStatus = await this.checkSnapshotStatus();
526
533
  if (snapshotStatus.status !== SnapshotStatus.DONE) {
527
534
  await this.startInitialReplication(snapshotStatus);
535
+ } else {
536
+ this.logger.info(`Initial replication already done`);
528
537
  }
529
538
  }
530
539
 
@@ -534,26 +543,50 @@ export class CDCStream {
534
543
  */
535
544
  private async checkSnapshotStatus(): Promise<SnapshotStatusResult> {
536
545
  const status = await this.storage.getStatus();
546
+
537
547
  if (status.snapshot_done && status.checkpoint_lsn) {
548
+ const additionalTablesToSnapshot: Set<MSSQLSourceTable> = new Set();
549
+ const newTables = this.tableCache.getAll().filter((table) => !table.sourceTable.snapshotComplete);
550
+ if (newTables.length > 0) {
551
+ this.logger.info(
552
+ `Detected new table(s) [${newTables.map((table) => table.toQualifiedName()).join(', ')}] that have not been snapshotted yet.`
553
+ );
554
+ newTables.forEach((table) => additionalTablesToSnapshot.add(table));
555
+ }
556
+
538
557
  // 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`);
558
+ // within the retention threshold of the CDC tables
541
559
 
542
560
  const lastCheckpointLSN = LSN.fromString(status.checkpoint_lsn);
543
561
  // Check that the CDC tables still have valid data
544
- const isAvailable = await isWithinRetentionThreshold({
562
+ const tablesOutsideRetentionThreshold = await checkRetentionThresholds({
545
563
  checkpointLSN: lastCheckpointLSN,
546
564
  tables: this.tableCache.getAll(),
547
565
  connectionManager: this.connections
548
566
  });
549
- if (!isAvailable) {
567
+ if (tablesOutsideRetentionThreshold.length > 0) {
550
568
  this.logger.warn(
551
- `Updates from the last checkpoint are no longer available in the CDC instance, starting initial replication again.`
569
+ `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
570
  );
571
+ tablesOutsideRetentionThreshold.forEach((table) => additionalTablesToSnapshot.add(table));
572
+ }
573
+ if (additionalTablesToSnapshot.size > 0) {
574
+ return {
575
+ status: SnapshotStatus.LIMITED_RESNAPSHOT,
576
+ snapshotLSN: status.checkpoint_lsn,
577
+ specificTablesToResnapshot: Array.from(additionalTablesToSnapshot)
578
+ };
579
+ } else {
580
+ return {
581
+ status: SnapshotStatus.DONE,
582
+ snapshotLSN: null
583
+ };
553
584
  }
554
- return { status: isAvailable ? SnapshotStatus.DONE : SnapshotStatus.RESTART_REQUIRED, snapshotLSN: null };
555
585
  } else {
556
- return { status: SnapshotStatus.IN_PROGRESS, snapshotLSN: status.snapshot_lsn };
586
+ return {
587
+ status: status.snapshot_lsn != null ? SnapshotStatus.RESUME : SnapshotStatus.INITIAL,
588
+ snapshotLSN: status.snapshot_lsn
589
+ };
557
590
  }
558
591
  }
559
592
 
@@ -571,16 +604,17 @@ export class CDCStream {
571
604
  throw new ReplicationAssertionError(`No LSN found to resume replication from.`);
572
605
  }
573
606
  const startLSN = LSN.fromString(batch.resumeFromLsn);
574
- const sourceTables: MSSQLSourceTable[] = this.tableCache.getAll();
575
607
  const eventHandler = this.createEventHandler(batch);
576
608
 
577
609
  const poller = new CDCPoller({
578
610
  connectionManager: this.connections,
579
611
  eventHandler,
580
- sourceTables,
612
+ getReplicatedTables: () => this.tableCache.getAll(),
613
+ sourceTables: this.syncRules.getSourceTables(),
581
614
  startLSN,
582
615
  logger: this.logger,
583
- additionalConfig: this.options.additionalConfig
616
+ additionalConfig: this.options.additionalConfig,
617
+ schemaCheckIntervalMs: this.options.schemaCheckIntervalMs
584
618
  });
585
619
 
586
620
  this.abortSignal.addEventListener(
@@ -639,16 +673,125 @@ export class CDCStream {
639
673
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
640
674
  },
641
675
  onCommit: async (lsn: string, transactionCount: number) => {
642
- await batch.commit(lsn);
676
+ const { checkpointBlocked } = await batch.commit(lsn, {
677
+ oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
678
+ });
643
679
  this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED).add(transactionCount);
644
- this.isStartingReplication = false;
680
+ if (!checkpointBlocked) {
681
+ this.replicationLag.markCommitted();
682
+ }
645
683
  },
646
- onSchemaChange: async () => {
647
- // TODO: Handle schema changes
684
+ onSchemaChange: async (schemaChange: SchemaChange) => {
685
+ await this.handleSchemaChange(batch, schemaChange);
648
686
  }
649
687
  };
650
688
  }
651
689
 
690
+ async handleSchemaChange(batch: storage.BucketStorageBatch, change: SchemaChange): Promise<void> {
691
+ let actionedSchemaChange = true;
692
+
693
+ switch (change.type) {
694
+ case SchemaChangeType.TABLE_RENAME:
695
+ const fromTable = change.table!;
696
+ this.logger.info(
697
+ `Table ${fromTable.toQualifiedName()} has been renamed ${change.newTable ? `to [${change.newTable.name}].` : '.'}`
698
+ );
699
+
700
+ // Old table needs to be cleaned up
701
+ await batch.drop([fromTable.sourceTable]);
702
+ this.tableCache.delete(fromTable.objectId);
703
+
704
+ if (change.newTable) {
705
+ await this.handleCreateOrUpdateTable(batch, change.newTable, change.newCaptureInstance!);
706
+ }
707
+ break;
708
+ case SchemaChangeType.TABLE_CREATE:
709
+ await this.handleCreateOrUpdateTable(batch, change.newTable!, change.newCaptureInstance!);
710
+ break;
711
+ case SchemaChangeType.TABLE_COLUMN_CHANGES:
712
+ await this.handleColumnChanges(change.table!, change.newCaptureInstance!);
713
+ actionedSchemaChange = false;
714
+ break;
715
+ case SchemaChangeType.NEW_CAPTURE_INSTANCE:
716
+ this.logger.info(
717
+ `New CDC capture instance detected for table ${change.table!.toQualifiedName()}. Re-snapshotting table...`
718
+ );
719
+ await batch.drop([change.table!.sourceTable]);
720
+ this.tableCache.delete(change.table!.objectId);
721
+
722
+ await this.handleCreateOrUpdateTable(batch, change.table!.sourceTable, change.newCaptureInstance!);
723
+ break;
724
+ case SchemaChangeType.TABLE_DROP:
725
+ await batch.drop([change.table!.sourceTable]);
726
+ this.tableCache.delete(change.table!.objectId);
727
+ break;
728
+ case SchemaChangeType.MISSING_CAPTURE_INSTANCE:
729
+ // Stop replication for this table until CDC is re-enabled.
730
+ this.logger.warn(
731
+ `Table ${change.table!.toQualifiedName()} has been disabled for CDC. Re-enable CDC to continue replication.`
732
+ );
733
+ change.table!.clearCaptureInstance();
734
+ actionedSchemaChange = false;
735
+ break;
736
+ default:
737
+ throw new ReplicationAssertionError(`Unknown schema change type: ${change.type}`);
738
+ }
739
+
740
+ // Create a new checkpoint after the schema change
741
+ if (actionedSchemaChange) {
742
+ await createCheckpoint(this.connections);
743
+ }
744
+ }
745
+
746
+ private async handleCreateOrUpdateTable(
747
+ batch: storage.BucketStorageBatch,
748
+ table: Omit<SourceEntityDescriptor, 'replicaIdColumns'>,
749
+ captureInstance: CaptureInstance
750
+ ): Promise<void> {
751
+ const replicaIdColumns = await getReplicationIdentityColumns({
752
+ connectionManager: this.connections,
753
+ tableName: table.name,
754
+ schema: table.schema
755
+ });
756
+
757
+ await this.processTable(
758
+ batch,
759
+ {
760
+ name: table.name,
761
+ schema: table.schema,
762
+ objectId: table.objectId,
763
+ replicaIdColumns: replicaIdColumns.columns
764
+ },
765
+ captureInstance,
766
+ true
767
+ );
768
+ }
769
+
770
+ /**
771
+ * There is very little that can be automatically done to handle the column changes other than to warn the user about the schema drift.
772
+ *
773
+ * Due to the way CDC works, users are prevented from making column schema changes that affect the replication identities of a table.
774
+ * 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
775
+ * capture instance.
776
+ * @param table
777
+ * @param captureInstance
778
+ */
779
+ private async handleColumnChanges(table: MSSQLSourceTable, captureInstance: CaptureInstance): Promise<void> {
780
+ // Check there are any new pending schema changes
781
+ if (
782
+ table.captureInstance?.objectId === captureInstance.objectId &&
783
+ table.captureInstance?.pendingSchemaChanges.length === captureInstance.pendingSchemaChanges.length
784
+ ) {
785
+ return;
786
+ }
787
+
788
+ // New pending schema changes were detected - warn about those as well.
789
+ this.logger.warn(
790
+ `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')}`
791
+ );
792
+ table.captureInstance = captureInstance;
793
+ }
794
+
652
795
  /**
653
796
  * Convert CDC row data to SqliteRow format.
654
797
  * CDC rows include table columns plus CDC metadata columns (__$operation, __$start_lsn, etc.).
@@ -660,17 +803,8 @@ export class CDCStream {
660
803
  return this.syncRules.applyRowContext<never>(inputRow);
661
804
  }
662
805
 
663
- async getReplicationLagMillis(): Promise<number | undefined> {
664
- if (this.oldestUncommittedChange == null) {
665
- if (this.isStartingReplication) {
666
- // We don't have anything to compute replication lag with yet.
667
- return undefined;
668
- } else {
669
- // We don't have any uncommitted changes, so replication is up-to-date.
670
- return 0;
671
- }
672
- }
673
- return Date.now() - this.oldestUncommittedChange.getTime();
806
+ getReplicationLagMillis(): number | undefined {
807
+ return this.replicationLag.getLagMillis();
674
808
  }
675
809
 
676
810
  private touch() {
@@ -678,4 +812,13 @@ export class CDCStream {
678
812
  this.logger.error(`Error touching probe`, e);
679
813
  });
680
814
  }
815
+
816
+ /**
817
+ * Creates an update in the source database to ensure regular checkpoints via the CDC
818
+ */
819
+ public async keepAlive() {
820
+ if (!this.replicationLag.isStartingReplication && !this.stopped) {
821
+ await createCheckpoint(this.connections);
822
+ }
823
+ }
681
824
  }
@@ -1,8 +1,8 @@
1
1
  import { BaseObserver, logger } from '@powersync/lib-services-framework';
2
- import sql from 'mssql';
3
- import { NormalizedMSSQLConnectionConfig } from '../types/types.js';
4
2
  import { POWERSYNC_VERSION } from '@powersync/service-core';
3
+ import sql from 'mssql';
5
4
  import { MSSQLParameter } from '../types/mssql-data-types.js';
5
+ import { NormalizedMSSQLConnectionConfig } from '../types/types.js';
6
6
  import { addParameters } from '../utils/mssql.js';
7
7
 
8
8
  export const DEFAULT_SCHEMA = 'dbo';
@@ -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> {
@@ -1,7 +1,7 @@
1
1
  import { logger } from '@powersync/lib-services-framework';
2
+ import sql from 'mssql';
2
3
  import { ResolvedMSSQLConnectionConfig } from '../types/types.js';
3
4
  import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
4
- import sql from 'mssql';
5
5
 
6
6
  export class MSSQLConnectionManagerFactory {
7
7
  private readonly connectionManagers: Set<MSSQLConnectionManager>;