@powersync/service-module-mssql 0.5.0 → 0.6.0

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 +26 -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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @powersync/service-module-mssql
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8d5d7ee: Added schema change detection and handling for the SQL Server adapter
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [224c35e]
12
+ - Updated dependencies [acf1486]
13
+ - Updated dependencies [391c5ef]
14
+ - Updated dependencies [7ee87d4]
15
+ - Updated dependencies [99de8be]
16
+ - Updated dependencies [8d5d7ee]
17
+ - Updated dependencies [9daf965]
18
+ - Updated dependencies [4c92c24]
19
+ - Updated dependencies [3d230c2]
20
+ - Updated dependencies [206633f]
21
+ - Updated dependencies [3a0627e]
22
+ - Updated dependencies [275fd5f]
23
+ - Updated dependencies [7ce1b8e]
24
+ - @powersync/service-sync-rules@0.34.0
25
+ - @powersync/service-core@1.20.2
26
+ - @powersync/service-errors@0.4.1
27
+ - @powersync/lib-services-framework@0.9.1
28
+
3
29
  ## 0.5.0
4
30
 
5
31
  ### Minor Changes
@@ -0,0 +1,14 @@
1
+ import { LSN } from './LSN.js';
2
+ export interface CaptureInstance {
3
+ name: string;
4
+ /**
5
+ * Object ID for the capture instance table
6
+ */
7
+ objectId: number;
8
+ minLSN: LSN;
9
+ createDate: Date;
10
+ /**
11
+ * DDL commands that have been applied to the source table but are not reflected in the capture instance.
12
+ */
13
+ pendingSchemaChanges: string[];
14
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=CaptureInstance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CaptureInstance.js","sourceRoot":"","sources":["../../src/common/CaptureInstance.ts"],"names":[],"mappings":""}
@@ -1,25 +1,27 @@
1
1
  import { SourceTable } from '@powersync/service-core';
2
- export interface CaptureInstance {
3
- name: string;
4
- schema: string;
5
- }
6
- export interface MSSQLSourceTableOptions {
2
+ import { CaptureInstance } from './CaptureInstance.js';
3
+ /**
4
+ * The cdc schema in SQL Server is reserved and created when enabling CDC on a database.
5
+ */
6
+ export declare const CDC_SCHEMA = "cdc";
7
+ export declare class MSSQLSourceTable {
7
8
  sourceTable: SourceTable;
8
9
  /**
9
10
  * The unique name of the CDC capture instance for this table
10
11
  */
11
- captureInstance: CaptureInstance;
12
- }
13
- export declare class MSSQLSourceTable {
14
- private options;
15
- constructor(options: MSSQLSourceTableOptions);
16
- get sourceTable(): SourceTable;
12
+ captureInstance: CaptureInstance | null;
13
+ constructor(sourceTable: SourceTable);
17
14
  updateSourceTable(updated: SourceTable): void;
18
- get captureInstance(): string;
19
- get cdcSchema(): string;
20
- get CTTable(): string;
15
+ enabledForCDC(): boolean;
16
+ setCaptureInstance(captureInstance: CaptureInstance): void;
17
+ clearCaptureInstance(): void;
21
18
  get allChangesFunction(): string;
22
19
  get netChangesFunction(): string;
20
+ /**
21
+ * Return the object ID of the source table.
22
+ * Object IDs in SQL Server are always numbers.
23
+ */
24
+ get objectId(): number;
23
25
  /**
24
26
  * Escapes this source table's name and schema for use in MSSQL queries.
25
27
  */
@@ -1,29 +1,48 @@
1
- import { escapeIdentifier, toQualifiedTableName } from '../utils/mssql.js';
1
+ import { toQualifiedTableName } from '../utils/mssql.js';
2
+ import { ServiceAssertionError } from '@powersync/service-errors';
3
+ /**
4
+ * The cdc schema in SQL Server is reserved and created when enabling CDC on a database.
5
+ */
6
+ export const CDC_SCHEMA = 'cdc';
2
7
  export class MSSQLSourceTable {
3
- options;
4
- constructor(options) {
5
- this.options = options;
6
- }
7
- get sourceTable() {
8
- return this.options.sourceTable;
8
+ sourceTable;
9
+ /**
10
+ * The unique name of the CDC capture instance for this table
11
+ */
12
+ captureInstance = null;
13
+ constructor(sourceTable) {
14
+ this.sourceTable = sourceTable;
9
15
  }
10
16
  updateSourceTable(updated) {
11
- this.options.sourceTable = updated;
17
+ this.sourceTable = updated;
12
18
  }
13
- get captureInstance() {
14
- return this.options.captureInstance.name;
19
+ enabledForCDC() {
20
+ return this.captureInstance !== null;
15
21
  }
16
- get cdcSchema() {
17
- return this.options.captureInstance.schema;
22
+ setCaptureInstance(captureInstance) {
23
+ this.captureInstance = captureInstance;
18
24
  }
19
- get CTTable() {
20
- return `${escapeIdentifier(this.cdcSchema)}.${this.captureInstance}_CT`;
25
+ clearCaptureInstance() {
26
+ this.captureInstance = null;
21
27
  }
22
28
  get allChangesFunction() {
23
- return `${escapeIdentifier(this.cdcSchema)}.fn_cdc_get_all_changes_${this.captureInstance}`;
29
+ if (!this.captureInstance) {
30
+ throw new ServiceAssertionError(`No capture instance set for table: ${this.sourceTable.name}`);
31
+ }
32
+ return `${CDC_SCHEMA}.fn_cdc_get_all_changes_${this.captureInstance.name}`;
24
33
  }
25
34
  get netChangesFunction() {
26
- return `${escapeIdentifier(this.cdcSchema)}.fn_cdc_get_net_changes_${this.captureInstance}`;
35
+ if (!this.captureInstance) {
36
+ throw new ServiceAssertionError(`No capture instance set for table: ${this.sourceTable.name}`);
37
+ }
38
+ return `${CDC_SCHEMA}.fn_cdc_get_net_changes_${this.captureInstance.name}`;
39
+ }
40
+ /**
41
+ * Return the object ID of the source table.
42
+ * Object IDs in SQL Server are always numbers.
43
+ */
44
+ get objectId() {
45
+ return this.sourceTable.objectId;
27
46
  }
28
47
  /**
29
48
  * Escapes this source table's name and schema for use in MSSQL queries.
@@ -1 +1 @@
1
- {"version":3,"file":"MSSQLSourceTable.js","sourceRoot":"","sources":["../../src/common/MSSQLSourceTable.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAe3E,MAAM,OAAO,gBAAgB;IACP;IAApB,YAAoB,OAAgC;QAAhC,YAAO,GAAP,OAAO,CAAyB;IAAG,CAAC;IAExD,IAAI,WAAW;QACb,OAAO,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC;IAClC,CAAC;IAED,iBAAiB,CAAC,OAAoB;QACpC,IAAI,CAAC,OAAO,CAAC,WAAW,GAAG,OAAO,CAAC;IACrC,CAAC;IAED,IAAI,eAAe;QACjB,OAAO,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC;IAC3C,CAAC;IAED,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC;IAC7C,CAAC;IAED,IAAI,OAAO;QACT,OAAO,GAAG,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,eAAe,KAAK,CAAC;IAC1E,CAAC;IAED,IAAI,kBAAkB;QACpB,OAAO,GAAG,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,2BAA2B,IAAI,CAAC,eAAe,EAAE,CAAC;IAC9F,CAAC;IAED,IAAI,kBAAkB;QACpB,OAAO,GAAG,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,2BAA2B,IAAI,CAAC,eAAe,EAAE,CAAC;IAC9F,CAAC;IAED;;OAEG;IACH,eAAe;QACb,OAAO,oBAAoB,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IAC9E,CAAC;CACF"}
1
+ {"version":3,"file":"MSSQLSourceTable.js","sourceRoot":"","sources":["../../src/common/MSSQLSourceTable.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAGlE;;GAEG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,CAAC;AAEhC,MAAM,OAAO,gBAAgB;IAMR;IALnB;;OAEG;IACI,eAAe,GAA2B,IAAI,CAAC;IAEtD,YAAmB,WAAwB;QAAxB,gBAAW,GAAX,WAAW,CAAa;IAAG,CAAC;IAE/C,iBAAiB,CAAC,OAAoB;QACpC,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC;IAC7B,CAAC;IAED,aAAa;QACX,OAAO,IAAI,CAAC,eAAe,KAAK,IAAI,CAAC;IACvC,CAAC;IAED,kBAAkB,CAAC,eAAgC;QACjD,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;IACzC,CAAC;IAED,oBAAoB;QAClB,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC;IAC9B,CAAC;IAED,IAAI,kBAAkB;QACpB,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;YAC1B,MAAM,IAAI,qBAAqB,CAAC,sCAAsC,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC;QACjG,CAAC;QACD,OAAO,GAAG,UAAU,2BAA2B,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;IAC7E,CAAC;IAED,IAAI,kBAAkB;QACpB,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;YAC1B,MAAM,IAAI,qBAAqB,CAAC,sCAAsC,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC;QACjG,CAAC;QACD,OAAO,GAAG,UAAU,2BAA2B,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;IAC7E,CAAC;IAED;;;OAGG;IACH,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,WAAW,CAAC,QAAkB,CAAC;IAC7C,CAAC;IAED;;OAEG;IACH,eAAe;QACb,OAAO,oBAAoB,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IAC9E,CAAC;CACF"}
@@ -4,44 +4,55 @@ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
4
4
  import { LSN } from '../common/LSN.js';
5
5
  import sql from 'mssql';
6
6
  import { AdditionalConfig } from '../types/types.js';
7
- /**
8
- * Schema changes that are detectable by inspecting query events.
9
- * Create table statements are not included here, since new tables are automatically detected when row events
10
- * are received for them.
11
- */
7
+ import { TablePattern } from '@powersync/service-sync-rules';
8
+ import { CaptureInstance } from '../common/CaptureInstance.js';
9
+ import { SourceEntityDescriptor } from '@powersync/service-core';
12
10
  export declare enum SchemaChangeType {
13
- RENAME_TABLE = "Rename Table",
14
- DROP_TABLE = "Drop Table",
15
- TRUNCATE_TABLE = "Truncate Table",
16
- ALTER_TABLE_COLUMN = "Alter Table Column",
17
- REPLICATION_IDENTITY = "Alter Replication Identity"
11
+ TABLE_RENAME = "table_rename",
12
+ TABLE_DROP = "table_drop",
13
+ TABLE_CREATE = "table_create",
14
+ TABLE_COLUMN_CHANGES = "table_column_changes",
15
+ NEW_CAPTURE_INSTANCE = "new_capture_instance",
16
+ MISSING_CAPTURE_INSTANCE = "missing_capture_instance"
18
17
  }
19
18
  export interface SchemaChange {
20
19
  type: SchemaChangeType;
21
20
  /**
22
- * The table that the schema change applies to.
21
+ * The table that the schema change applies to. Populated for table drops, renames, new capture instances, and DDL changes.
23
22
  */
24
- table: string;
25
- schema: string;
23
+ table?: MSSQLSourceTable;
26
24
  /**
27
- * Populated for table renames if the newTable was matched by the DatabaseFilter
25
+ * Populated for new tables or renames, but only if the new table matches a sync rule source table.
28
26
  */
29
- newTable?: string;
27
+ newTable?: Omit<SourceEntityDescriptor, 'replicaIdColumns'>;
28
+ newCaptureInstance?: CaptureInstance;
30
29
  }
31
30
  export interface CDCEventHandler {
32
- onInsert: (row: any, table: MSSQLSourceTable, collumns: sql.IColumnMetadata) => Promise<void>;
33
- onUpdate: (rowAfter: any, rowBefore: any, table: MSSQLSourceTable, collumns: sql.IColumnMetadata) => Promise<void>;
34
- onDelete: (row: any, table: MSSQLSourceTable, collumns: sql.IColumnMetadata) => Promise<void>;
31
+ onInsert: (row: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
32
+ onUpdate: (rowAfter: any, rowBefore: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
33
+ onDelete: (row: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
35
34
  onCommit: (lsn: string, transactionCount: number) => Promise<void>;
36
35
  onSchemaChange: (change: SchemaChange) => Promise<void>;
37
36
  }
37
+ export declare const DEFAULT_SCHEMA_CHECK_INTERVAL_MS = 60000;
38
38
  export interface CDCPollerOptions {
39
39
  connectionManager: MSSQLConnectionManager;
40
40
  eventHandler: CDCEventHandler;
41
- sourceTables: MSSQLSourceTable[];
41
+ /** CDC enabled source tables from the sync rules to replicate */
42
+ getReplicatedTables: () => MSSQLSourceTable[];
43
+ /** All table patterns from the sync rules. Can contain tables that need to be replicated
44
+ * but do not yet have CDC enabled
45
+ */
46
+ sourceTables: TablePattern[];
42
47
  startLSN: LSN;
43
48
  logger?: Logger;
44
49
  additionalConfig: AdditionalConfig;
50
+ /**
51
+ * Interval in milliseconds between schema change checks.
52
+ * Schema checks also run immediately after a recoverable error during polling
53
+ * (e.g. a dropped capture instance).
54
+ */
55
+ schemaCheckIntervalMs?: number;
45
56
  }
46
57
  /**
47
58
  *
@@ -53,15 +64,26 @@ export declare class CDCPoller {
53
64
  private currentLSN;
54
65
  private logger;
55
66
  private listenerError;
67
+ private captureInstances;
56
68
  private isStopped;
57
69
  private isStopping;
58
70
  private isPolling;
71
+ private lastSchemaCheckTime;
59
72
  constructor(options: CDCPollerOptions);
60
73
  private get pollingBatchSize();
61
74
  private get pollingIntervalMs();
62
- private get sourceTables();
75
+ private get schemaCheckIntervalMs();
76
+ private get replicatedTables();
63
77
  stop(): Promise<void>;
64
78
  replicateUntilStopped(): Promise<void>;
65
79
  private poll;
66
80
  private pollTable;
81
+ private shouldCheckSchema;
82
+ /**
83
+ * Checks the given table for pending schema changes that can lead to inconsistencies in the replicated data if not handled.
84
+ * Returns the SchemaChange if any are found, null otherwise.
85
+ */
86
+ private checkForSchemaChanges;
87
+ private checkForNewTables;
88
+ private tableMatchesSyncRules;
67
89
  }
@@ -1,8 +1,10 @@
1
- import { logger as defaultLogger, ReplicationAssertionError } from '@powersync/lib-services-framework';
1
+ import { DatabaseQueryError, ErrorCode, logger as defaultLogger, ReplicationAssertionError } from '@powersync/lib-services-framework';
2
2
  import timers from 'timers/promises';
3
3
  import { LSN } from '../common/LSN.js';
4
4
  import sql from 'mssql';
5
- import { getMinLSN, incrementLSN } from '../utils/mssql.js';
5
+ import { getCaptureInstances, incrementLSN, toQualifiedTableName } from '../utils/mssql.js';
6
+ import { isDeadlockError } from '../utils/deadlock.js';
7
+ import { tableExists } from '../utils/schema.js';
6
8
  var Operation;
7
9
  (function (Operation) {
8
10
  Operation[Operation["DELETE"] = 1] = "DELETE";
@@ -10,19 +12,16 @@ var Operation;
10
12
  Operation[Operation["UPDATE_BEFORE"] = 3] = "UPDATE_BEFORE";
11
13
  Operation[Operation["UPDATE_AFTER"] = 4] = "UPDATE_AFTER";
12
14
  })(Operation || (Operation = {}));
13
- /**
14
- * Schema changes that are detectable by inspecting query events.
15
- * Create table statements are not included here, since new tables are automatically detected when row events
16
- * are received for them.
17
- */
18
15
  export var SchemaChangeType;
19
16
  (function (SchemaChangeType) {
20
- SchemaChangeType["RENAME_TABLE"] = "Rename Table";
21
- SchemaChangeType["DROP_TABLE"] = "Drop Table";
22
- SchemaChangeType["TRUNCATE_TABLE"] = "Truncate Table";
23
- SchemaChangeType["ALTER_TABLE_COLUMN"] = "Alter Table Column";
24
- SchemaChangeType["REPLICATION_IDENTITY"] = "Alter Replication Identity";
17
+ SchemaChangeType["TABLE_RENAME"] = "table_rename";
18
+ SchemaChangeType["TABLE_DROP"] = "table_drop";
19
+ SchemaChangeType["TABLE_CREATE"] = "table_create";
20
+ SchemaChangeType["TABLE_COLUMN_CHANGES"] = "table_column_changes";
21
+ SchemaChangeType["NEW_CAPTURE_INSTANCE"] = "new_capture_instance";
22
+ SchemaChangeType["MISSING_CAPTURE_INSTANCE"] = "missing_capture_instance";
25
23
  })(SchemaChangeType || (SchemaChangeType = {}));
24
+ export const DEFAULT_SCHEMA_CHECK_INTERVAL_MS = 60_000;
26
25
  /**
27
26
  *
28
27
  */
@@ -33,9 +32,11 @@ export class CDCPoller {
33
32
  currentLSN;
34
33
  logger;
35
34
  listenerError;
35
+ captureInstances;
36
36
  isStopped = false;
37
37
  isStopping = false;
38
38
  isPolling = false;
39
+ lastSchemaCheckTime = 0;
39
40
  constructor(options) {
40
41
  this.options = options;
41
42
  this.logger = options.logger ?? defaultLogger;
@@ -43,6 +44,7 @@ export class CDCPoller {
43
44
  this.eventHandler = options.eventHandler;
44
45
  this.currentLSN = options.startLSN;
45
46
  this.listenerError = null;
47
+ this.captureInstances = new Map();
46
48
  }
47
49
  get pollingBatchSize() {
48
50
  return this.options.additionalConfig.pollingBatchSize;
@@ -50,8 +52,11 @@ export class CDCPoller {
50
52
  get pollingIntervalMs() {
51
53
  return this.options.additionalConfig.pollingIntervalMs;
52
54
  }
53
- get sourceTables() {
54
- return this.options.sourceTables;
55
+ get schemaCheckIntervalMs() {
56
+ return this.options.schemaCheckIntervalMs ?? DEFAULT_SCHEMA_CHECK_INTERVAL_MS;
57
+ }
58
+ get replicatedTables() {
59
+ return this.options.getReplicatedTables();
55
60
  }
56
61
  async stop() {
57
62
  if (!(this.isStopped || this.isStopping)) {
@@ -68,15 +73,38 @@ export class CDCPoller {
68
73
  throw new ReplicationAssertionError('A polling cycle is already in progress.');
69
74
  }
70
75
  try {
76
+ if (this.shouldCheckSchema()) {
77
+ this.captureInstances = await getCaptureInstances({ connectionManager: this.connectionManager });
78
+ const schemaChanges = await this.checkForSchemaChanges();
79
+ for (const schemaChange of schemaChanges) {
80
+ await this.eventHandler.onSchemaChange(schemaChange);
81
+ }
82
+ this.lastSchemaCheckTime = Date.now();
83
+ this.logger.debug(`Schema change check complete. Schema changes found: ${schemaChanges.map((c) => c.type).join(', ')}`);
84
+ }
71
85
  const hasChanges = await this.poll();
72
86
  if (!hasChanges) {
73
- // No changes found, wait before next poll
87
+ // No changes found, wait before polling again
74
88
  await timers.setTimeout(this.pollingIntervalMs);
75
89
  }
76
90
  // If changes were found, poll immediately again (no wait)
77
91
  }
78
92
  catch (error) {
79
93
  if (!(this.isStopped || this.isStopping)) {
94
+ // Recoverable errors
95
+ if (error instanceof DatabaseQueryError) {
96
+ this.logger.warn(error.message);
97
+ // Force schema check on next iteration to detect breaking changes
98
+ this.lastSchemaCheckTime = 0;
99
+ continue;
100
+ }
101
+ // Deadlock errors are transient — even if all retries within retryOnDeadlock were
102
+ // exhausted, we should not crash the poller. Instead, log and retry the entire cycle.
103
+ if (isDeadlockError(error)) {
104
+ this.logger.warn(`Deadlock persisted after all retry attempts during CDC polling cycle. Will retry on next cycle: ${error.message}`);
105
+ continue;
106
+ }
107
+ // Non-recoverable errors
80
108
  this.listenerError = error;
81
109
  this.logger.error('Error during CDC polling:', error);
82
110
  this.stop();
@@ -110,15 +138,18 @@ export class CDCPoller {
110
138
  const endLSN = LSN.fromBinary(results[results.length - 1].start_lsn);
111
139
  this.logger.info(`Polling bounds are ${startLSN} -> ${endLSN} spanning ${results.length} transaction(s).`);
112
140
  let transactionCount = 0;
113
- for (const table of this.sourceTables) {
114
- const tableTransactionCount = await this.pollTable(table, { startLSN, endLSN });
115
- // We poll for batch size transactions, but these include transactions not applicable to our Source Tables.
116
- // Each Source Table may or may not have transactions that are applicable to it, so just keep track of the highest number of transactions processed for any Source Table.
117
- if (tableTransactionCount > transactionCount) {
118
- transactionCount = tableTransactionCount;
141
+ this.logger.debug(`Currently replicating tables: ${this.replicatedTables.map((table) => table.toQualifiedName()).join(', ')}`);
142
+ for (const table of this.replicatedTables) {
143
+ if (table.enabledForCDC()) {
144
+ const tableTransactionCount = await this.pollTable(table, { startLSN, endLSN });
145
+ // We poll for batch size transactions, but these include transactions not applicable to our Source Tables.
146
+ // Each Source Table may or may not have transactions that are applicable to it, so just keep track of the highest number of transactions processed for any Source Table.
147
+ if (tableTransactionCount > transactionCount) {
148
+ transactionCount = tableTransactionCount;
149
+ }
119
150
  }
120
151
  }
121
- this.logger.info(`Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s).`);
152
+ this.logger.info(`Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s). Commited LSN: ${endLSN.toString()}`);
122
153
  // Call eventHandler.onCommit() with toLSN after processing all tables
123
154
  await this.eventHandler.onCommit(endLSN.toString(), transactionCount);
124
155
  this.currentLSN = endLSN;
@@ -131,57 +162,166 @@ export class CDCPoller {
131
162
  }
132
163
  async pollTable(table, bounds) {
133
164
  // Ensure that the startLSN is not before the minimum LSN for the table
134
- const minLSN = await getMinLSN(this.connectionManager, table.captureInstance);
165
+ const minLSN = this.captureInstances.get(table.objectId).instances[0].minLSN;
135
166
  if (minLSN > bounds.endLSN) {
136
167
  return 0;
137
168
  }
138
169
  else if (minLSN >= bounds.startLSN) {
139
170
  bounds.startLSN = minLSN;
140
171
  }
141
- const { recordset: results } = await this.connectionManager.query(`
172
+ try {
173
+ const { recordset: results } = await this.connectionManager.query(`
142
174
  SELECT * FROM ${table.allChangesFunction}(@from_lsn, @to_lsn, 'all update old') ORDER BY __$start_lsn, __$seqval
143
175
  `, [
144
- { name: 'from_lsn', type: sql.VarBinary, value: bounds.startLSN.toBinary() },
145
- { name: 'to_lsn', type: sql.VarBinary, value: bounds.endLSN.toBinary() }
146
- ]);
147
- let transactionCount = 0;
148
- let updateBefore = null;
149
- let lastTransactionLSN = null;
150
- for (const row of results) {
151
- const transactionLSN = LSN.fromBinary(row.__$start_lsn);
152
- switch (row.__$operation) {
153
- case Operation.DELETE:
154
- await this.eventHandler.onDelete(row, table, results.columns);
155
- this.logger.info(`Processed DELETE row LSN: ${transactionLSN}`);
156
- break;
157
- case Operation.INSERT:
158
- await this.eventHandler.onInsert(row, table, results.columns);
159
- this.logger.info(`Processed INSERT row LSN: ${transactionLSN}`);
160
- break;
161
- case Operation.UPDATE_BEFORE:
162
- updateBefore = row;
163
- this.logger.debug(`Processed UPDATE, before row LSN: ${transactionLSN}`);
164
- break;
165
- case Operation.UPDATE_AFTER:
166
- if (updateBefore === null) {
167
- throw new ReplicationAssertionError('Missing before image for update event.');
176
+ { name: 'from_lsn', type: sql.VarBinary, value: bounds.startLSN.toBinary() },
177
+ { name: 'to_lsn', type: sql.VarBinary, value: bounds.endLSN.toBinary() }
178
+ ]);
179
+ let transactionCount = 0;
180
+ let updateBefore = null;
181
+ let lastTransactionLSN = null;
182
+ for (const row of results) {
183
+ const transactionLSN = LSN.fromBinary(row.__$start_lsn);
184
+ switch (row.__$operation) {
185
+ case Operation.DELETE:
186
+ await this.eventHandler.onDelete(row, table, results.columns);
187
+ this.logger.info(`Processed DELETE row LSN: ${transactionLSN}`);
188
+ break;
189
+ case Operation.INSERT:
190
+ await this.eventHandler.onInsert(row, table, results.columns);
191
+ this.logger.info(`Processed INSERT row LSN: ${transactionLSN}`);
192
+ break;
193
+ case Operation.UPDATE_BEFORE:
194
+ updateBefore = row;
195
+ this.logger.debug(`Processed UPDATE, before row LSN: ${transactionLSN}`);
196
+ break;
197
+ case Operation.UPDATE_AFTER:
198
+ if (updateBefore === null) {
199
+ throw new ReplicationAssertionError('Missing before image for update event.');
200
+ }
201
+ await this.eventHandler.onUpdate(row, updateBefore, table, results.columns);
202
+ updateBefore = null;
203
+ this.logger.info(`Processed UPDATE row LSN: ${transactionLSN}`);
204
+ break;
205
+ default:
206
+ this.logger.warn(`Unknown operation type [${row.__$operation}] encountered in CDC changes.`);
207
+ }
208
+ // Increment transaction count when we encounter a new transaction LSN (except for UPDATE_BEFORE rows)
209
+ if (transactionLSN != lastTransactionLSN) {
210
+ lastTransactionLSN = transactionLSN;
211
+ if (row.__$operation !== Operation.UPDATE_BEFORE) {
212
+ transactionCount++;
213
+ }
214
+ }
215
+ }
216
+ return transactionCount;
217
+ }
218
+ catch (error) {
219
+ // This Covers both deleted tables and capture instances
220
+ if (error.message.includes(`Invalid object name`)) {
221
+ throw new DatabaseQueryError(ErrorCode.PSYNC_S1601, `Capture instance for table ${table.toQualifiedName()} is no longer available.`, error);
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+ shouldCheckSchema() {
227
+ return Date.now() - this.lastSchemaCheckTime >= this.schemaCheckIntervalMs;
228
+ }
229
+ /**
230
+ * Checks the given table for pending schema changes that can lead to inconsistencies in the replicated data if not handled.
231
+ * Returns the SchemaChange if any are found, null otherwise.
232
+ */
233
+ async checkForSchemaChanges() {
234
+ const schemaChanges = [];
235
+ const newTables = this.checkForNewTables();
236
+ for (const table of newTables) {
237
+ this.logger.info(`New table ${toQualifiedTableName(table.sourceTable.schema, table.sourceTable.name)} matching the sync rules has been created. Handling schema change...`);
238
+ schemaChanges.push({
239
+ type: SchemaChangeType.TABLE_CREATE,
240
+ newTable: {
241
+ name: table.sourceTable.name,
242
+ schema: table.sourceTable.schema,
243
+ objectId: table.sourceTable.objectId
244
+ },
245
+ newCaptureInstance: table.instances[0]
246
+ });
247
+ }
248
+ for (const table of this.replicatedTables) {
249
+ const exists = await tableExists(table.objectId, this.connectionManager);
250
+ if (!exists) {
251
+ this.logger.info(`Table ${table.toQualifiedName()} has been dropped. Handling schema change...`);
252
+ schemaChanges.push({
253
+ type: SchemaChangeType.TABLE_DROP,
254
+ table
255
+ });
256
+ continue;
257
+ }
258
+ const captureInstanceDetails = this.captureInstances.get(table.objectId);
259
+ if (!captureInstanceDetails) {
260
+ if (table.enabledForCDC()) {
261
+ // Table had a capture instance but no longer does.
262
+ schemaChanges.push({
263
+ type: SchemaChangeType.MISSING_CAPTURE_INSTANCE,
264
+ table
265
+ });
266
+ }
267
+ continue;
268
+ }
269
+ const latestCaptureInstance = captureInstanceDetails.instances[0];
270
+ // If the table is not enabled for CDC or the capture instance is different, we need to re-snapshot the source table
271
+ if (!table.enabledForCDC() || table.captureInstance.objectId !== latestCaptureInstance.objectId) {
272
+ schemaChanges.push({
273
+ type: SchemaChangeType.NEW_CAPTURE_INSTANCE,
274
+ table,
275
+ newCaptureInstance: latestCaptureInstance
276
+ });
277
+ continue;
278
+ }
279
+ // One of the replicated tables has been renamed
280
+ if (table.sourceTable.name !== captureInstanceDetails.sourceTable.name) {
281
+ const newTable = this.tableMatchesSyncRules(captureInstanceDetails.sourceTable.schema, captureInstanceDetails.sourceTable.name)
282
+ ? {
283
+ name: captureInstanceDetails.sourceTable.name,
284
+ schema: captureInstanceDetails.sourceTable.schema,
285
+ objectId: captureInstanceDetails.sourceTable.objectId
168
286
  }
169
- await this.eventHandler.onUpdate(row, updateBefore, table, results.columns);
170
- updateBefore = null;
171
- this.logger.info(`Processed UPDATE row LSN: ${transactionLSN}`);
172
- break;
173
- default:
174
- this.logger.warn(`Unknown operation type [${row.__$operation}] encountered in CDC changes.`);
287
+ : undefined;
288
+ schemaChanges.push({
289
+ type: SchemaChangeType.TABLE_RENAME,
290
+ table,
291
+ newTable,
292
+ newCaptureInstance: latestCaptureInstance
293
+ });
294
+ continue;
295
+ }
296
+ if (latestCaptureInstance.pendingSchemaChanges.length > 0) {
297
+ schemaChanges.push({
298
+ type: SchemaChangeType.TABLE_COLUMN_CHANGES,
299
+ table,
300
+ newCaptureInstance: latestCaptureInstance
301
+ });
175
302
  }
176
- // Increment transaction count when we encounter a new transaction LSN (except for UPDATE_BEFORE rows)
177
- if (transactionLSN != lastTransactionLSN) {
178
- lastTransactionLSN = transactionLSN;
179
- if (row.__$operation !== Operation.UPDATE_BEFORE) {
180
- transactionCount++;
303
+ }
304
+ return schemaChanges;
305
+ }
306
+ checkForNewTables() {
307
+ const newTables = [];
308
+ for (const [objectId, captureInstanceDetails] of this.captureInstances.entries()) {
309
+ // If a source table is not in the replicated tables array, but a capture instance exists for it, it is potentially a new table to replicate.
310
+ if (!this.replicatedTables.some((table) => table.objectId === objectId)) {
311
+ // Check if the new table matches any of the sync rules source tables.
312
+ if (this.tableMatchesSyncRules(captureInstanceDetails.sourceTable.schema, captureInstanceDetails.sourceTable.name)) {
313
+ newTables.push(captureInstanceDetails);
181
314
  }
182
315
  }
183
316
  }
184
- return transactionCount;
317
+ return newTables;
318
+ }
319
+ tableMatchesSyncRules(schema, tableName) {
320
+ return this.options.sourceTables.some((tablePattern) => tablePattern.matches({
321
+ connectionTag: this.connectionManager.connectionTag,
322
+ schema: schema,
323
+ name: tableName
324
+ }));
185
325
  }
186
326
  }
187
327
  //# sourceMappingURL=CDCPoller.js.map