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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +130 -6
  2. package/dist/api/MSSQLRouteAPIAdapter.js +26 -34
  3. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
  4. package/dist/common/MSSQLSourceTable.d.ts +12 -2
  5. package/dist/common/MSSQLSourceTable.js +30 -4
  6. package/dist/common/MSSQLSourceTable.js.map +1 -1
  7. package/dist/module/MSSQLModule.js +2 -1
  8. package/dist/module/MSSQLModule.js.map +1 -1
  9. package/dist/replication/CDCPoller.d.ts +13 -36
  10. package/dist/replication/CDCPoller.js +173 -129
  11. package/dist/replication/CDCPoller.js.map +1 -1
  12. package/dist/replication/CDCStream.d.ts +11 -14
  13. package/dist/replication/CDCStream.js +115 -106
  14. package/dist/replication/CDCStream.js.map +1 -1
  15. package/dist/replication/CaptureReconciler.d.ts +47 -0
  16. package/dist/replication/CaptureReconciler.js +155 -0
  17. package/dist/replication/CaptureReconciler.js.map +1 -0
  18. package/dist/replication/MSSQLConnectionManager.d.ts +1 -0
  19. package/dist/replication/MSSQLConnectionManager.js +19 -14
  20. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  21. package/dist/replication/MSSQLTableReconciliationContext.d.ts +21 -0
  22. package/dist/replication/MSSQLTableReconciliationContext.js +7 -0
  23. package/dist/replication/MSSQLTableReconciliationContext.js.map +1 -0
  24. package/dist/replication/SchemaChange.d.ts +67 -0
  25. package/dist/replication/SchemaChange.js +31 -0
  26. package/dist/replication/SchemaChange.js.map +1 -0
  27. package/dist/types/types.d.ts +13 -0
  28. package/dist/types/types.js +28 -2
  29. package/dist/types/types.js.map +1 -1
  30. package/dist/utils/mssql.js +1 -1
  31. package/dist/utils/schema.d.ts +14 -1
  32. package/dist/utils/schema.js +40 -45
  33. package/dist/utils/schema.js.map +1 -1
  34. package/package.json +9 -9
  35. package/src/api/MSSQLRouteAPIAdapter.ts +27 -33
  36. package/src/common/MSSQLSourceTable.ts +32 -4
  37. package/src/module/MSSQLModule.ts +2 -1
  38. package/src/replication/CDCPoller.ts +206 -162
  39. package/src/replication/CDCStream.ts +148 -154
  40. package/src/replication/CaptureReconciler.ts +186 -0
  41. package/src/replication/MSSQLConnectionManager.ts +23 -14
  42. package/src/replication/MSSQLTableReconciliationContext.ts +29 -0
  43. package/src/replication/SchemaChange.ts +79 -0
  44. package/src/types/types.ts +47 -2
  45. package/src/utils/mssql.ts +1 -1
  46. package/src/utils/schema.ts +57 -58
  47. package/test/src/CDCPoller.test.ts +382 -0
  48. package/test/src/CDCStream.test.ts +6 -2
  49. package/test/src/CDCStreamTestContext.ts +7 -0
  50. package/test/src/CaptureReconciler.test.ts +171 -0
  51. package/test/src/MSSQLSourceTable.test.ts +30 -0
  52. package/test/src/config.test.ts +71 -0
  53. package/test/src/schema-changes.test.ts +513 -249
  54. package/test/src/schema.test.ts +17 -0
  55. package/test/src/util.ts +70 -6
  56. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,29 @@
1
+ import type { SourceEntityDescriptor } from '@powersync/service-core';
2
+ import type { CaptureInstance } from '../common/CaptureInstance.js';
3
+
4
+ export enum MSSQLTableReconciliationState {
5
+ TABLE_MISSING = 'table_missing',
6
+ CDC_DISABLED = 'cdc_disabled',
7
+ READY = 'ready'
8
+ }
9
+
10
+ interface BaseMSSQLTableReconciliationContext {
11
+ source: SourceEntityDescriptor;
12
+ }
13
+
14
+ export type MSSQLUnavailableTableReconciliationContext =
15
+ | (BaseMSSQLTableReconciliationContext & {
16
+ state: MSSQLTableReconciliationState.TABLE_MISSING;
17
+ })
18
+ | (BaseMSSQLTableReconciliationContext & {
19
+ state: MSSQLTableReconciliationState.CDC_DISABLED;
20
+ });
21
+
22
+ export type MSSQLReadyTableReconciliationContext = BaseMSSQLTableReconciliationContext & {
23
+ state: MSSQLTableReconciliationState.READY;
24
+ captureInstances: readonly CaptureInstance[];
25
+ };
26
+
27
+ export type MSSQLTableReconciliationContext =
28
+ | MSSQLUnavailableTableReconciliationContext
29
+ | MSSQLReadyTableReconciliationContext;
@@ -0,0 +1,79 @@
1
+ import { CaptureInstance } from '../common/CaptureInstance.js';
2
+ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
3
+ import { SourceTableChangeRef } from '../utils/schema.js';
4
+
5
+ /**
6
+ * Schema changes are detected to warn or error on, not applied automatically. Polling cannot be
7
+ * made atomic with a commit, so the replicated schema stays fixed until the next deploy.
8
+ *
9
+ * Drops and renames fail the job because they have no known LSN. Continuing could skip unread
10
+ * changes. Existing replicated data is kept for the next deploy to clean up.
11
+ */
12
+ export enum SchemaChangeType {
13
+ /**
14
+ * A replicated table was renamed.
15
+ */
16
+ TABLE_RENAME = 'table_rename',
17
+ /**
18
+ * A replicated table was dropped.
19
+ */
20
+ TABLE_DROP = 'table_drop',
21
+ /**
22
+ * The source columns changed. The pinned capture schema remains active.
23
+ */
24
+ TABLE_COLUMN_CHANGES = 'table_column_changes',
25
+ /**
26
+ * A newer capture instance exists, but the pinned one is still available.
27
+ */
28
+ NEW_CAPTURE_INSTANCE = 'new_capture_instance',
29
+ /**
30
+ * The pinned capture instance is no longer available.
31
+ */
32
+ MISSING_CAPTURE_INSTANCE = 'missing_capture_instance'
33
+ }
34
+
35
+ interface SchemaChangeBase<Type extends SchemaChangeType> {
36
+ type: Type;
37
+ /**
38
+ * The replicated table the change applies to.
39
+ */
40
+ table: MSSQLSourceTable;
41
+ }
42
+
43
+ export interface TableRenameChange extends SchemaChangeBase<SchemaChangeType.TABLE_RENAME> {
44
+ /**
45
+ * The name the table was renamed to. Only used in the error reported for the rename.
46
+ */
47
+ newTable: SourceTableChangeRef;
48
+ }
49
+
50
+ export type TableDropChange = SchemaChangeBase<SchemaChangeType.TABLE_DROP>;
51
+
52
+ export interface TableColumnChanges extends SchemaChangeBase<SchemaChangeType.TABLE_COLUMN_CHANGES> {
53
+ /**
54
+ * The pinned capture instance the drift was observed against.
55
+ */
56
+ captureInstance: CaptureInstance;
57
+ }
58
+
59
+ export interface NewCaptureInstanceChange extends SchemaChangeBase<SchemaChangeType.NEW_CAPTURE_INSTANCE> {
60
+ /**
61
+ * The newer instance that is available but will not be adopted by this stream.
62
+ */
63
+ newCaptureInstance: CaptureInstance;
64
+ }
65
+
66
+ export interface MissingCaptureInstanceChange extends SchemaChangeBase<SchemaChangeType.MISSING_CAPTURE_INSTANCE> {
67
+ /**
68
+ * A newer instance that replaced the pinned one, if CDC is still enabled for the table. Only used
69
+ * to report the right recovery step.
70
+ */
71
+ replacementInstance?: CaptureInstance;
72
+ }
73
+
74
+ export type SchemaChange =
75
+ | TableRenameChange
76
+ | TableDropChange
77
+ | TableColumnChanges
78
+ | NewCaptureInstanceChange
79
+ | MissingCaptureInstanceChange;
@@ -8,6 +8,10 @@ export const DEFAULT_POLLING_BATCH_SIZE = 10;
8
8
  export const DEFAULT_POLLING_INTERVAL_MS = 1000;
9
9
  export const MSSQL_CONNECTION_TYPE = 'mssql' as const;
10
10
 
11
+ const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 60;
12
+ const MIN_HEARTBEAT_INTERVAL_SECONDS = 5;
13
+ const MAX_HEARTBEAT_INTERVAL_SECONDS = 60;
14
+
11
15
  export const AzureActiveDirectoryServicePrincipalSecret = t.object({
12
16
  type: t.literal('azure-active-directory-service-principal-secret'),
13
17
  options: t.object({
@@ -89,11 +93,19 @@ export interface NormalizedMSSQLConnectionConfig {
89
93
  database: string;
90
94
  schema?: string;
91
95
 
96
+ /** PEM-encoded certificate authority used to validate the SQL Server certificate. */
97
+ cacert?: string;
98
+
99
+ /** Server name to use when validating the SQL Server certificate. */
100
+ tls_servername?: string;
101
+
92
102
  authentication?: Authentication;
93
103
 
94
104
  lookup?: LookupFunction;
95
105
 
96
106
  additionalConfig: AdditionalConfig;
107
+
108
+ heartbeat_interval_seconds: number;
97
109
  }
98
110
 
99
111
  export const MSSQLConnectionConfig = service_types.configFile.DataSourceConfig.and(
@@ -107,10 +119,20 @@ export const MSSQLConnectionConfig = service_types.configFile.DataSourceConfig.a
107
119
  hostname: t.string.optional(),
108
120
  port: service_types.configFile.portCodec.optional(),
109
121
 
122
+ /** PEM-encoded certificate authority used to validate the SQL Server certificate. */
123
+ cacert: t.string.optional(),
124
+
125
+ /** Server name to use when validating the SQL Server certificate. */
126
+ tls_servername: t.string.optional(),
127
+
110
128
  authentication: Authentication.optional(),
111
129
 
112
130
  reject_ip_ranges: t.array(t.string).optional(),
113
- additionalConfig: AdditionalConfig.optional()
131
+ additionalConfig: AdditionalConfig.optional(),
132
+ /**
133
+ * Interval in seconds between source connection heartbeats. Null or omitted uses the default.
134
+ */
135
+ heartbeat_interval_seconds: service_types.orNull(t.number).optional()
114
136
  })
115
137
  );
116
138
 
@@ -169,6 +191,13 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
169
191
  throw new ServiceError(ErrorCode.PSYNC_S1105, `MSSQL connection: database required`);
170
192
  }
171
193
 
194
+ if (options.cacert && options.additionalConfig?.trustServerCertificate) {
195
+ throw new ServiceError(
196
+ ErrorCode.PSYNC_S1604,
197
+ 'MSSQL connection: cacert cannot be used with trustServerCertificate because certificate validation would be disabled'
198
+ );
199
+ }
200
+
172
201
  const lookup = makeHostnameLookupFunction(hostname, { reject_ip_ranges: options.reject_ip_ranges ?? [] });
173
202
 
174
203
  return {
@@ -180,6 +209,8 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
180
209
  hostname,
181
210
  port,
182
211
  database,
212
+ cacert: options.cacert,
213
+ tls_servername: options.tls_servername,
183
214
 
184
215
  lookup,
185
216
  authentication: options.authentication,
@@ -188,10 +219,24 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
188
219
  pollingIntervalMs: options.additionalConfig?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS,
189
220
  pollingBatchSize: options.additionalConfig?.pollingBatchSize ?? DEFAULT_POLLING_BATCH_SIZE,
190
221
  trustServerCertificate: options.additionalConfig?.trustServerCertificate ?? false
191
- }
222
+ },
223
+ heartbeat_interval_seconds: normalizeHeartbeatInterval(options.heartbeat_interval_seconds)
192
224
  } satisfies NormalizedMSSQLConnectionConfig;
193
225
  }
194
226
 
227
+ function normalizeHeartbeatInterval(value: number | null | undefined): number {
228
+ if (value == null) {
229
+ return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
230
+ }
231
+ if (!Number.isFinite(value) || value < MIN_HEARTBEAT_INTERVAL_SECONDS || value > MAX_HEARTBEAT_INTERVAL_SECONDS) {
232
+ throw new ServiceError(
233
+ ErrorCode.PSYNC_S1109,
234
+ `MSSQL connection: heartbeat_interval_seconds must be between ${MIN_HEARTBEAT_INTERVAL_SECONDS} and ${MAX_HEARTBEAT_INTERVAL_SECONDS} seconds`
235
+ );
236
+ }
237
+ return value;
238
+ }
239
+
195
240
  export function baseUri(config: ResolvedMSSQLConnectionConfig) {
196
241
  return `mssql://${config.hostname}:${config.port}/${config.database}`;
197
242
  }
@@ -351,7 +351,7 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
351
351
  if (captureInstanceDetails && captureInstanceDetails.instances[0].pendingSchemaChanges.length > 0) {
352
352
  schemaDriftError = {
353
353
  level: 'warning',
354
- message: `Source table ${toQualifiedTableName(schema, table.name)} has schema changes not reflected in the CDC capture instance. Please disable and re-enable CDC on the source table to update the capture instance schema.
354
+ message: `Source table ${toQualifiedTableName(schema, table.name)} has schema changes not reflected in the CDC capture instance. Create a replacement CDC capture instance with the intended columns, update the sync config if needed, and deploy a new sync config. If the change requires disabling CDC, re-enable CDC before deploying.
355
355
  Pending schema changes: ${captureInstanceDetails.instances[0].pendingSchemaChanges.join(', \n')}`
356
356
  };
357
357
  }
@@ -139,77 +139,76 @@ export async function getReplicationIdentityColumns(
139
139
  }
140
140
 
141
141
  export interface SourceTableChangeRef {
142
- objectId: number | string | undefined;
142
+ /**
143
+ * `sys.tables.object_id`, which SQL Server always reports as an int. Narrower than
144
+ * `SourceEntityDescriptor.objectId`, which has to accommodate other source databases.
145
+ */
146
+ objectId: number;
143
147
  schema: string;
144
148
  name: string;
145
149
  }
146
150
 
147
151
  export type ResolvedTable = SourceTableChangeRef;
148
152
 
153
+ export const SCHEMA_WILDCARD_MESSAGE =
154
+ 'Schema wildcards ("%") in table patterns are not supported for SQL Server connections.';
155
+
156
+ /**
157
+ * Wildcards would allow the replicated table set to change between polls, so they are rejected.
158
+ */
159
+ export function tableWildcardMessage(tablePattern: TablePattern): string {
160
+ return (
161
+ `Table wildcards ("%") are not supported for SQL Server connections: "${tablePattern.tablePattern}". ` +
162
+ `List each table explicitly.`
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Returns the reason this pattern cannot be replicated, or null if it is supported.
168
+ */
169
+ export function unsupportedTablePatternMessage(tablePattern: TablePattern): string | null {
170
+ if (tablePattern.isSchemaWildcard) {
171
+ return SCHEMA_WILDCARD_MESSAGE;
172
+ }
173
+ if (tablePattern.isWildcard) {
174
+ return tableWildcardMessage(tablePattern);
175
+ }
176
+ return null;
177
+ }
178
+
149
179
  export async function getTablesFromPattern(
150
180
  connectionManager: MSSQLConnectionManager,
151
181
  tablePattern: TablePattern
152
182
  ): Promise<ResolvedTable[]> {
153
- if (tablePattern.isSchemaWildcard) {
154
- throw new ServiceError(
155
- ErrorCode.PSYNC_R2201,
156
- 'Schema wildcards ("%") in table patterns are not supported for SQL Server connections.'
157
- );
183
+ const unsupported = unsupportedTablePatternMessage(tablePattern);
184
+ if (unsupported != null) {
185
+ throw new ServiceError(ErrorCode.PSYNC_R2201, unsupported);
158
186
  }
159
187
 
160
- if (tablePattern.isWildcard) {
161
- const { recordset: tableResults } = await connectionManager.query(
162
- `
163
- SELECT
164
- tbl.name AS [table],
165
- sch.name AS [schema],
166
- tbl.object_id AS object_id
167
- FROM sys.tables tbl
168
- JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
188
+ const { recordset: tableResults } = await connectionManager.query(
189
+ `
190
+ SELECT
191
+ tbl.name AS [table],
192
+ sch.name AS [schema],
193
+ tbl.object_id AS object_id
194
+ FROM sys.tables tbl
195
+ JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
169
196
  WHERE sch.name = @schema
170
- AND tbl.name LIKE @tablePattern
171
- `,
172
- [
173
- { name: 'schema', type: sql.VarChar(sql.MAX), value: tablePattern.schema },
174
- { name: 'tablePattern', type: sql.VarChar(sql.MAX), value: tablePattern.tablePattern }
175
- ]
176
- );
177
-
178
- return tableResults
179
- .map((row) => {
180
- return {
181
- objectId: row.object_id,
182
- schema: row.schema,
183
- name: row.table
184
- };
185
- })
186
- .filter((table: ResolvedTable) => table.name.startsWith(tablePattern.tablePrefix));
187
- } else {
188
- const { recordset: tableResults } = await connectionManager.query(
189
- `
190
- SELECT
191
- tbl.name AS [table],
192
- sch.name AS [schema],
193
- tbl.object_id AS object_id
194
- FROM sys.tables tbl
195
- JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
196
- WHERE sch.name = @schema
197
- AND tbl.name = @tablePattern
198
- `,
199
- [
200
- { name: 'schema', type: sql.VarChar(sql.MAX), value: tablePattern.schema },
201
- { name: 'tablePattern', type: sql.VarChar(sql.MAX), value: tablePattern.tablePattern }
202
- ]
203
- );
204
-
205
- return tableResults.map((row) => {
206
- return {
207
- objectId: row.object_id,
208
- schema: row.schema,
209
- name: row.table
210
- };
211
- });
212
- }
197
+ AND tbl.name = @tablePattern
198
+ `,
199
+ [
200
+ { name: 'schema', type: sql.VarChar(sql.MAX), value: tablePattern.schema },
201
+ { name: 'tablePattern', type: sql.VarChar(sql.MAX), value: tablePattern.tablePattern }
202
+ ]
203
+ );
204
+
205
+ return tableResults.map((row) => {
206
+ return {
207
+ objectId: row.object_id,
208
+ schema: row.schema,
209
+ name: row.table
210
+ };
211
+ });
213
212
  }
214
213
 
215
214
  export interface GetPendingSchemaChangesOptions {