@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,9 +1,9 @@
1
- import { bson, ColumnDescriptor, SourceTable } from '@powersync/service-core';
2
1
  import { ServiceAssertionError } from '@powersync/lib-services-framework';
3
- import { MSSQLBaseType } from '../types/mssql-data-types.js';
2
+ import { bson, ColumnDescriptor, SourceTable } from '@powersync/service-core';
4
3
  import sql from 'mssql';
5
- import { escapeIdentifier } from '../utils/mssql.js';
6
4
  import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
5
+ import { MSSQLBaseType } from '../types/mssql-data-types.js';
6
+ import { escapeIdentifier } from '../utils/mssql.js';
7
7
 
8
8
  export interface MSSQLSnapshotQuery {
9
9
  initialize(): 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
+ }
@@ -1,14 +1,21 @@
1
+ import { logger } from '@powersync/lib-services-framework';
2
+ import * as sync_rules from '@powersync/service-sync-rules';
3
+ import { SqlSyncRules, TablePattern } from '@powersync/service-sync-rules';
4
+ import * as service_types from '@powersync/service-types';
1
5
  import sql from 'mssql';
2
6
  import { coerce, gte } from 'semver';
3
- import { logger } from '@powersync/lib-services-framework';
4
- import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
7
+ import { CaptureInstance } from '../common/CaptureInstance.js';
5
8
  import { LSN } from '../common/LSN.js';
6
- import { CaptureInstance, MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
9
+ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
10
+ import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
7
11
  import { MSSQLParameter } from '../types/mssql-data-types.js';
8
- import { SqlSyncRules, TablePattern } from '@powersync/service-sync-rules';
9
- import { getReplicationIdentityColumns, ReplicationIdentityColumnsResult, ResolvedTable } from './schema.js';
10
- import * as service_types from '@powersync/service-types';
11
- import * as sync_rules from '@powersync/service-sync-rules';
12
+ import { retryOnDeadlock } from './deadlock.js';
13
+ import {
14
+ getPendingSchemaChanges,
15
+ getReplicationIdentityColumns,
16
+ ReplicationIdentityColumnsResult,
17
+ ResolvedTable
18
+ } from './schema.js';
12
19
 
13
20
  export const POWERSYNC_CHECKPOINTS_TABLE = '_powersync_checkpoints';
14
21
 
@@ -78,16 +85,16 @@ export async function checkSourceConfiguration(connectionManager: MSSQLConnectio
78
85
  }
79
86
 
80
87
  // 4) Check if the _powersync_checkpoints table is correctly configured
81
- const checkpointTableErrors = await ensurePowerSyncCheckpointsTable(connectionManager);
88
+ const checkpointTableErrors = await checkPowerSyncCheckpointsTable(connectionManager);
82
89
  errors.push(...checkpointTableErrors);
83
90
 
84
91
  return errors;
85
92
  }
86
93
 
87
- export async function ensurePowerSyncCheckpointsTable(connectionManager: MSSQLConnectionManager): Promise<string[]> {
94
+ export async function checkPowerSyncCheckpointsTable(connectionManager: MSSQLConnectionManager): Promise<string[]> {
88
95
  const errors: string[] = [];
89
96
  try {
90
- // check if the dbo_powersync_checkpoints table exists
97
+ // Check if the table exists
91
98
  const { recordset: checkpointsResult } = await connectionManager.query(
92
99
  `
93
100
  SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @tableName;
@@ -97,45 +104,22 @@ export async function ensurePowerSyncCheckpointsTable(connectionManager: MSSQLCo
97
104
  { name: 'tableName', type: sql.VarChar(sql.MAX), value: POWERSYNC_CHECKPOINTS_TABLE }
98
105
  ]
99
106
  );
100
- if (checkpointsResult.length > 0) {
101
- // Table already exists, check if CDC is enabled
102
- const isEnabled = await isTableEnabledForCDC({
103
- connectionManager,
104
- table: POWERSYNC_CHECKPOINTS_TABLE,
105
- schema: connectionManager.schema
106
- });
107
- if (!isEnabled) {
108
- // Enable CDC on the table
109
- await enableCDCForTable({
110
- connectionManager,
111
- table: POWERSYNC_CHECKPOINTS_TABLE
112
- });
113
- }
114
- return errors;
107
+ if (checkpointsResult.length === 0) {
108
+ throw new Error(`The ${POWERSYNC_CHECKPOINTS_TABLE} table does not exist. Please create it.`);
115
109
  }
116
- } catch (error) {
117
- errors.push(`Failed ensure ${POWERSYNC_CHECKPOINTS_TABLE} table is correctly configured: ${error}`);
118
- }
119
-
120
- // Try to create the table
121
- try {
122
- await connectionManager.query(`
123
- CREATE TABLE ${toQualifiedTableName(connectionManager.schema, POWERSYNC_CHECKPOINTS_TABLE)} (
124
- id INT IDENTITY PRIMARY KEY,
125
- last_updated DATETIME NOT NULL DEFAULT (GETDATE())
126
- )`);
127
- } catch (error) {
128
- errors.push(`Failed to create ${POWERSYNC_CHECKPOINTS_TABLE} table: ${error}`);
129
- }
130
-
131
- try {
132
- // Enable CDC on the table if not already enabled
133
- await enableCDCForTable({
110
+ // Check if CDC is enabled
111
+ const isEnabled = await isTableEnabledForCDC({
134
112
  connectionManager,
135
- table: POWERSYNC_CHECKPOINTS_TABLE
113
+ table: POWERSYNC_CHECKPOINTS_TABLE,
114
+ schema: connectionManager.schema
136
115
  });
116
+ if (!isEnabled) {
117
+ throw new Error(
118
+ `The ${POWERSYNC_CHECKPOINTS_TABLE} table exists but is not enabled for CDC. Please enable CDC on this table.`
119
+ );
120
+ }
137
121
  } catch (error) {
138
- errors.push(`Failed to enable CDC on ${POWERSYNC_CHECKPOINTS_TABLE} table: ${error}`);
122
+ errors.push(`Failed ensure ${POWERSYNC_CHECKPOINTS_TABLE} table is correctly configured: ${error}`);
139
123
  }
140
124
 
141
125
  return errors;
@@ -165,36 +149,9 @@ export interface IsTableEnabledForCDCOptions {
165
149
  export async function isTableEnabledForCDC(options: IsTableEnabledForCDCOptions): Promise<boolean> {
166
150
  const { connectionManager, table, schema } = options;
167
151
 
168
- const { recordset: checkResult } = await connectionManager.query(
169
- `
170
- SELECT 1 FROM cdc.change_tables ct
171
- JOIN sys.tables AS tbl ON tbl.object_id = ct.source_object_id
172
- JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
173
- WHERE sch.name = @schema
174
- AND tbl.name = @tableName
175
- `,
176
- [
177
- { name: 'schema', type: sql.VarChar(sql.MAX), value: schema },
178
- { name: 'tableName', type: sql.VarChar(sql.MAX), value: table }
179
- ]
180
- );
181
- return checkResult.length > 0;
182
- }
183
-
184
- export interface EnableCDCForTableOptions {
185
- connectionManager: MSSQLConnectionManager;
186
- table: string;
187
- }
188
-
189
- export async function enableCDCForTable(options: EnableCDCForTableOptions): Promise<void> {
190
- const { connectionManager, table } = options;
152
+ const captureInstance = await getCaptureInstance({ connectionManager, table: { schema, name: table } });
191
153
 
192
- await connectionManager.execute('sys.sp_cdc_enable_table', [
193
- { name: 'source_schema', value: connectionManager.schema },
194
- { name: 'source_name', value: table },
195
- { name: 'role_name', value: 'NULL' },
196
- { name: 'supports_net_changes', value: 1 }
197
- ]);
154
+ return captureInstance != null;
198
155
  }
199
156
 
200
157
  /**
@@ -216,23 +173,28 @@ export interface IsWithinRetentionThresholdOptions {
216
173
  }
217
174
 
218
175
  /**
219
- * Checks that CDC the specified checkpoint LSN is within the retention threshold for all specified tables.
176
+ * Checks that the given checkpoint LSN is still within the retention threshold of the source table capture instances.
220
177
  * CDC periodically cleans up old data up to the retention threshold. If replication has been stopped for too long it is
221
178
  * possible for the checkpoint LSN to be older than the minimum LSN in the CDC tables. In such a case we need to perform a new snapshot.
222
179
  * @param options
223
180
  */
224
- export async function isWithinRetentionThreshold(options: IsWithinRetentionThresholdOptions): Promise<boolean> {
181
+ export async function checkRetentionThresholds(
182
+ options: IsWithinRetentionThresholdOptions
183
+ ): Promise<MSSQLSourceTable[]> {
225
184
  const { checkpointLSN, tables, connectionManager } = options;
185
+ const tablesOutsideRetentionThreshold: MSSQLSourceTable[] = [];
226
186
  for (const table of tables) {
227
- const minLSN = await getMinLSN(connectionManager, table.captureInstance);
228
- if (minLSN > checkpointLSN) {
229
- logger.warn(
230
- `The checkpoint LSN:[${checkpointLSN}] is older than the minimum LSN:[${minLSN}] for table ${table.sourceTable.qualifiedName}. This indicates that the checkpoint LSN is outside of the retention window.`
231
- );
232
- return false;
187
+ if (table.enabledForCDC()) {
188
+ const minLSN = await getMinLSN(connectionManager, table.captureInstance!.name);
189
+ if (minLSN > checkpointLSN) {
190
+ logger.warn(
191
+ `The checkpoint LSN:[${checkpointLSN}] is older than the minimum LSN:[${minLSN}] for table ${table.toQualifiedName()}. This indicates that the checkpoint LSN is outside of the retention window.`
192
+ );
193
+ tablesOutsideRetentionThreshold.push(table);
194
+ }
233
195
  }
234
196
  }
235
- return true;
197
+ return tablesOutsideRetentionThreshold;
236
198
  }
237
199
 
238
200
  export async function getMinLSN(connectionManager: MSSQLConnectionManager, captureInstance: string): Promise<LSN> {
@@ -252,43 +214,6 @@ export async function incrementLSN(lsn: LSN, connectionManager: MSSQLConnectionM
252
214
  return LSN.fromBinary(result[0].incremented_lsn);
253
215
  }
254
216
 
255
- export interface GetCaptureInstanceOptions {
256
- connectionManager: MSSQLConnectionManager;
257
- tableName: string;
258
- schema: string;
259
- }
260
-
261
- export async function getCaptureInstance(options: GetCaptureInstanceOptions): Promise<CaptureInstance | null> {
262
- const { connectionManager, tableName, schema } = options;
263
- const { recordset: result } = await connectionManager.query(
264
- `
265
- SELECT
266
- ct.capture_instance,
267
- OBJECT_SCHEMA_NAME(ct.[object_id]) AS cdc_schema
268
- FROM
269
- sys.tables tbl
270
- INNER JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
271
- INNER JOIN cdc.change_tables ct ON ct.source_object_id = tbl.object_id
272
- WHERE sch.name = @schema
273
- AND tbl.name = @tableName
274
- AND ct.end_lsn IS NULL;
275
- `,
276
- [
277
- { name: 'schema', type: sql.VarChar(sql.MAX), value: schema },
278
- { name: 'tableName', type: sql.VarChar(sql.MAX), value: tableName }
279
- ]
280
- );
281
-
282
- if (result.length === 0) {
283
- return null;
284
- }
285
-
286
- return {
287
- name: result[0].capture_instance,
288
- schema: result[0].cdc_schema
289
- };
290
- }
291
-
292
217
  /**
293
218
  * Return the LSN of the latest transaction recorded in the transaction log
294
219
  * @param connectionManager
@@ -406,18 +331,28 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
406
331
  selectError = { level: 'fatal', message: e.message };
407
332
  }
408
333
 
409
- // Check if CDC is enabled for the table
410
334
  let cdcError: service_types.ReplicationError | null = null;
335
+ let schemaDriftError: service_types.ReplicationError | null = null;
411
336
  try {
412
- const isEnabled = await isTableEnabledForCDC({
337
+ const captureInstanceDetails = await getCaptureInstance({
413
338
  connectionManager: connectionManager,
414
- table: table.name,
415
- schema: schema
339
+ table: {
340
+ schema: schema,
341
+ name: table.name
342
+ }
416
343
  });
417
- if (!isEnabled) {
344
+ if (captureInstanceDetails == null) {
418
345
  cdcError = {
419
- level: 'fatal',
420
- message: `CDC is not enabled for table ${toQualifiedTableName(schema, table.name)}. Enable CDC with: sys.sp_cdc_enable_table @source_schema = '${schema}', @source_name = '${table.name}', @role_name = NULL, @supports_net_changes = 1`
346
+ level: 'warning',
347
+ message: `CDC is not enabled for table ${toQualifiedTableName(schema, table.name)}. Please enable CDC on the table to capture changes.`
348
+ };
349
+ }
350
+
351
+ if (captureInstanceDetails && captureInstanceDetails.instances[0].pendingSchemaChanges.length > 0) {
352
+ schemaDriftError = {
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.
355
+ Pending schema changes: ${captureInstanceDetails.instances[0].pendingSchemaChanges.join(', \n')}`
421
356
  };
422
357
  }
423
358
  } catch (e) {
@@ -433,6 +368,106 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
433
368
  replication_id: idColumns.map((c) => c.name),
434
369
  data_queries: syncData,
435
370
  parameter_queries: syncParameters,
436
- errors: [idColumnsError, selectError, cdcError].filter((error) => error != null) as service_types.ReplicationError[]
371
+ errors: [idColumnsError, selectError, cdcError, schemaDriftError].filter(
372
+ (error) => error != null
373
+ ) as service_types.ReplicationError[]
374
+ };
375
+ }
376
+
377
+ // Describes the capture instances linked to a source table.
378
+ export interface CaptureInstanceDetails {
379
+ sourceTable: {
380
+ schema: string;
381
+ name: string;
382
+ objectId: number;
383
+ };
384
+
385
+ /**
386
+ * The capture instances for the source table.
387
+ * The instances are sorted by create date in descending order.
388
+ */
389
+ instances: CaptureInstance[];
390
+ }
391
+
392
+ export interface GetCaptureInstancesOptions {
393
+ connectionManager: MSSQLConnectionManager;
394
+ table?: {
395
+ schema: string;
396
+ name: string;
397
+ };
398
+ }
399
+
400
+ export async function getCaptureInstances(
401
+ options: GetCaptureInstancesOptions
402
+ ): Promise<Map<number, CaptureInstanceDetails>> {
403
+ return retryOnDeadlock(async () => {
404
+ const { connectionManager, table } = options;
405
+ const instances = new Map<number, CaptureInstanceDetails>();
406
+
407
+ const { recordset: results } = table
408
+ ? await connectionManager.execute('sys.sp_cdc_help_change_data_capture', [
409
+ { name: 'source_schema', value: table.schema },
410
+ { name: 'source_name', value: table.name }
411
+ ])
412
+ : await connectionManager.execute('sys.sp_cdc_help_change_data_capture', []);
413
+
414
+ if (results.length === 0) {
415
+ return new Map<number, CaptureInstanceDetails>();
416
+ }
417
+
418
+ for (const row of results) {
419
+ const instance: CaptureInstance = {
420
+ name: row.capture_instance,
421
+ objectId: row.object_id,
422
+ minLSN: LSN.fromBinary(row.start_lsn),
423
+ createDate: new Date(row.create_date),
424
+ pendingSchemaChanges: []
425
+ };
426
+
427
+ instance.pendingSchemaChanges = await getPendingSchemaChanges({
428
+ connectionManager: connectionManager,
429
+ captureInstanceName: instance.name
430
+ });
431
+
432
+ const sourceTable = {
433
+ schema: row.source_schema,
434
+ name: row.source_table,
435
+ objectId: row.source_object_id
436
+ };
437
+
438
+ // There can only ever be 2 capture instances active at any given time for a source table.
439
+ if (instances.has(row.source_object_id)) {
440
+ if (instance.createDate > instances.get(row.source_object_id)!.instances[0].createDate) {
441
+ instances.get(row.source_object_id)!.instances.unshift(instance);
442
+ } else {
443
+ instances.get(row.source_object_id)!.instances.push(instance);
444
+ }
445
+ } else {
446
+ instances.set(row.source_object_id, {
447
+ instances: [instance],
448
+ sourceTable
449
+ });
450
+ }
451
+ }
452
+
453
+ return instances;
454
+ }, 'getCaptureInstances');
455
+ }
456
+
457
+ export interface GetCaptureInstanceOptions {
458
+ connectionManager: MSSQLConnectionManager;
459
+ table: {
460
+ schema: string;
461
+ name: string;
437
462
  };
438
463
  }
464
+ export async function getCaptureInstance(options: GetCaptureInstanceOptions): Promise<CaptureInstanceDetails | null> {
465
+ const { connectionManager, table } = options;
466
+ const instances = await getCaptureInstances({ connectionManager, table });
467
+
468
+ if (instances.size === 0) {
469
+ return null;
470
+ }
471
+
472
+ return instances.values().next().value!;
473
+ }
@@ -1,8 +1,9 @@
1
+ import { logger } from '@powersync/lib-services-framework';
1
2
  import { SourceEntityDescriptor } from '@powersync/service-core';
2
3
  import { TablePattern } from '@powersync/service-sync-rules';
4
+ import sql from 'mssql';
3
5
  import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
4
6
  import { MSSQLColumnDescriptor } from '../types/mssql-data-types.js';
5
- import sql from 'mssql';
6
7
 
7
8
  export interface GetColumnsOptions {
8
9
  connectionManager: MSSQLConnectionManager;
@@ -198,3 +199,45 @@ export async function getTablesFromPattern(
198
199
  });
199
200
  }
200
201
  }
202
+
203
+ export interface GetPendingSchemaChangesOptions {
204
+ connectionManager: MSSQLConnectionManager;
205
+ captureInstanceName: string;
206
+ }
207
+
208
+ /**
209
+ * Returns the DDL commands that have been applied to the source table since the capture instance was created.
210
+ */
211
+ export async function getPendingSchemaChanges(options: GetPendingSchemaChangesOptions): Promise<string[]> {
212
+ const { connectionManager, captureInstanceName } = options;
213
+
214
+ try {
215
+ const { recordset: results } = await connectionManager.execute('sys.sp_cdc_get_ddl_history', [
216
+ { name: 'capture_instance', type: sql.VarChar(sql.MAX), value: captureInstanceName }
217
+ ]);
218
+ return results.map((row) => row.ddl_command);
219
+ } catch (e) {
220
+ if (isObjectNotExistError(e)) {
221
+ // Defensive check to cover the case where the capture instance metadata is temporarily unavailable.
222
+ logger.warn(`Unable to retrieve schema changes for capture instance: [${captureInstanceName}].`);
223
+ return [];
224
+ }
225
+ throw e;
226
+ }
227
+ }
228
+
229
+ function isObjectNotExistError(error: unknown): boolean {
230
+ if (error != null && typeof error === 'object' && 'number' in error) {
231
+ // SQL Server Object does not exist or access is denied error number.
232
+ return (error as { number: unknown }).number === 22981;
233
+ }
234
+ return false;
235
+ }
236
+
237
+ export async function tableExists(tableId: number, connectionManager: MSSQLConnectionManager): Promise<boolean> {
238
+ const { recordset: results } = await connectionManager.query(`SELECT 1 FROM sys.tables WHERE object_id = @tableId`, [
239
+ { name: 'tableId', type: sql.Int, value: tableId }
240
+ ]);
241
+
242
+ return results.length > 0;
243
+ }
@@ -1,11 +1,11 @@
1
- import { describe, expect, test } from 'vitest';
1
+ import { getLatestLSN } from '@module/utils/mssql.js';
2
+ import { storage } from '@powersync/service-core';
2
3
  import { METRICS_HELPER, putOp, removeOp } from '@powersync/service-core-tests';
3
4
  import { ReplicationMetric } from '@powersync/service-types';
4
- import { createTestTable, describeWithStorage, insertTestData, waitForPendingCDCChanges } from './util.js';
5
- import { storage } from '@powersync/service-core';
6
- import { CDCStreamTestContext } from './CDCStreamTestContext.js';
7
- import { getLatestLSN } from '@module/utils/mssql.js';
8
5
  import sql from 'mssql';
6
+ import { describe, expect, test } from 'vitest';
7
+ import { CDCStreamTestContext } from './CDCStreamTestContext.js';
8
+ import { createTestTable, describeWithStorage, insertTestData, waitForPendingCDCChanges } from './util.js';
9
9
 
10
10
  const BASIC_SYNC_RULES = `
11
11
  bucket_definitions:
@@ -18,7 +18,9 @@ describe('CDCStream tests', () => {
18
18
  describeWithStorage({ timeout: 20_000 }, defineCDCStreamTests);
19
19
  });
20
20
 
21
- function defineCDCStreamTests(factory: storage.TestStorageFactory) {
21
+ function defineCDCStreamTests(config: storage.TestStorageConfig) {
22
+ const { factory } = config;
23
+
22
24
  test('Initial snapshot sync', async () => {
23
25
  await using context = await CDCStreamTestContext.open(factory);
24
26
  const { connectionManager } = context;
@@ -141,7 +143,7 @@ function defineCDCStreamTests(factory: storage.TestStorageFactory) {
141
143
  ]);
142
144
  });
143
145
 
144
- test('Replication for tables not in the sync rules are ignored', async () => {
146
+ test('Replication for tables not in the sync config are ignored', async () => {
145
147
  await using context = await CDCStreamTestContext.open(factory);
146
148
  const { connectionManager } = context;
147
149
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -162,7 +164,7 @@ function defineCDCStreamTests(factory: storage.TestStorageFactory) {
162
164
  const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
163
165
  const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
164
166
 
165
- // There was a transaction, but it is not counted since it is not for a table in the sync rules
167
+ // There was a transaction, but it is not counted since it is not for a table in the sync config
166
168
  expect(endRowCount - startRowCount).toEqual(0);
167
169
  expect(endTxCount - startTxCount).toEqual(0);
168
170
  });