@powersync/service-module-mssql 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +3 -0
  3. package/ci/init-mssql.sql +50 -0
  4. package/dist/api/MSSQLRouteAPIAdapter.d.ts +21 -0
  5. package/dist/api/MSSQLRouteAPIAdapter.js +248 -0
  6. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -0
  7. package/dist/common/LSN.d.ts +37 -0
  8. package/dist/common/LSN.js +64 -0
  9. package/dist/common/LSN.js.map +1 -0
  10. package/dist/common/MSSQLSourceTable.d.ts +27 -0
  11. package/dist/common/MSSQLSourceTable.js +35 -0
  12. package/dist/common/MSSQLSourceTable.js.map +1 -0
  13. package/dist/common/MSSQLSourceTableCache.d.ts +14 -0
  14. package/dist/common/MSSQLSourceTableCache.js +28 -0
  15. package/dist/common/MSSQLSourceTableCache.js.map +1 -0
  16. package/dist/common/mssqls-to-sqlite.d.ts +18 -0
  17. package/dist/common/mssqls-to-sqlite.js +143 -0
  18. package/dist/common/mssqls-to-sqlite.js.map +1 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/module/MSSQLModule.d.ts +15 -0
  23. package/dist/module/MSSQLModule.js +68 -0
  24. package/dist/module/MSSQLModule.js.map +1 -0
  25. package/dist/replication/CDCPoller.d.ts +67 -0
  26. package/dist/replication/CDCPoller.js +183 -0
  27. package/dist/replication/CDCPoller.js.map +1 -0
  28. package/dist/replication/CDCReplicationJob.d.ts +17 -0
  29. package/dist/replication/CDCReplicationJob.js +76 -0
  30. package/dist/replication/CDCReplicationJob.js.map +1 -0
  31. package/dist/replication/CDCReplicator.d.ts +18 -0
  32. package/dist/replication/CDCReplicator.js +55 -0
  33. package/dist/replication/CDCReplicator.js.map +1 -0
  34. package/dist/replication/CDCStream.d.ts +106 -0
  35. package/dist/replication/CDCStream.js +536 -0
  36. package/dist/replication/CDCStream.js.map +1 -0
  37. package/dist/replication/MSSQLConnectionManager.d.ts +23 -0
  38. package/dist/replication/MSSQLConnectionManager.js +97 -0
  39. package/dist/replication/MSSQLConnectionManager.js.map +1 -0
  40. package/dist/replication/MSSQLConnectionManagerFactory.d.ts +10 -0
  41. package/dist/replication/MSSQLConnectionManagerFactory.js +28 -0
  42. package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -0
  43. package/dist/replication/MSSQLErrorRateLimiter.d.ts +10 -0
  44. package/dist/replication/MSSQLErrorRateLimiter.js +34 -0
  45. package/dist/replication/MSSQLErrorRateLimiter.js.map +1 -0
  46. package/dist/replication/MSSQLSnapshotQuery.d.ts +71 -0
  47. package/dist/replication/MSSQLSnapshotQuery.js +190 -0
  48. package/dist/replication/MSSQLSnapshotQuery.js.map +1 -0
  49. package/dist/types/mssql-data-types.d.ts +66 -0
  50. package/dist/types/mssql-data-types.js +62 -0
  51. package/dist/types/mssql-data-types.js.map +1 -0
  52. package/dist/types/types.d.ts +177 -0
  53. package/dist/types/types.js +141 -0
  54. package/dist/types/types.js.map +1 -0
  55. package/dist/utils/mssql.d.ts +80 -0
  56. package/dist/utils/mssql.js +329 -0
  57. package/dist/utils/mssql.js.map +1 -0
  58. package/dist/utils/schema.d.ts +21 -0
  59. package/dist/utils/schema.js +131 -0
  60. package/dist/utils/schema.js.map +1 -0
  61. package/package.json +51 -0
  62. package/src/api/MSSQLRouteAPIAdapter.ts +283 -0
  63. package/src/common/LSN.ts +77 -0
  64. package/src/common/MSSQLSourceTable.ts +54 -0
  65. package/src/common/MSSQLSourceTableCache.ts +36 -0
  66. package/src/common/mssqls-to-sqlite.ts +151 -0
  67. package/src/index.ts +1 -0
  68. package/src/module/MSSQLModule.ts +82 -0
  69. package/src/replication/CDCPoller.ts +241 -0
  70. package/src/replication/CDCReplicationJob.ts +87 -0
  71. package/src/replication/CDCReplicator.ts +70 -0
  72. package/src/replication/CDCStream.ts +688 -0
  73. package/src/replication/MSSQLConnectionManager.ts +113 -0
  74. package/src/replication/MSSQLConnectionManagerFactory.ts +33 -0
  75. package/src/replication/MSSQLErrorRateLimiter.ts +36 -0
  76. package/src/replication/MSSQLSnapshotQuery.ts +230 -0
  77. package/src/types/mssql-data-types.ts +79 -0
  78. package/src/types/types.ts +224 -0
  79. package/src/utils/mssql.ts +420 -0
  80. package/src/utils/schema.ts +172 -0
  81. package/test/src/CDCStream.test.ts +206 -0
  82. package/test/src/CDCStreamTestContext.ts +212 -0
  83. package/test/src/CDCStream_resumable_snapshot.test.ts +152 -0
  84. package/test/src/env.ts +11 -0
  85. package/test/src/mssql-to-sqlite.test.ts +474 -0
  86. package/test/src/setup.ts +12 -0
  87. package/test/src/util.ts +189 -0
  88. package/test/tsconfig.json +28 -0
  89. package/test/tsconfig.tsbuildinfo +1 -0
  90. package/tsconfig.json +26 -0
  91. package/tsconfig.tsbuildinfo +1 -0
  92. package/vitest.config.ts +15 -0
@@ -0,0 +1,224 @@
1
+ import { ErrorCode, makeHostnameLookupFunction, ServiceError } from '@powersync/lib-services-framework';
2
+ import * as service_types from '@powersync/service-types';
3
+ import { LookupFunction } from 'node:net';
4
+ import * as t from 'ts-codec';
5
+ import * as urijs from 'uri-js';
6
+
7
+ export const MSSQL_CONNECTION_TYPE = 'mssql' as const;
8
+
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
+ export const AzureActiveDirectoryServicePrincipalSecret = t.object({
33
+ type: t.literal('azure-active-directory-service-principal-secret'),
34
+ options: t.object({
35
+ /**
36
+ * Application (`client`) ID from your registered Azure application
37
+ */
38
+ clientId: t.string,
39
+ /**
40
+ * The created `client secret` for this registered Azure application
41
+ */
42
+ clientSecret: t.string,
43
+ /**
44
+ * Directory (`tenant`) ID from your registered Azure application
45
+ */
46
+ tenantId: t.string
47
+ })
48
+ });
49
+ export type AzureActiveDirectoryServicePrincipalSecret = t.Decoded<typeof AzureActiveDirectoryServicePrincipalSecret>;
50
+
51
+ export const DefaultAuthentication = t.object({
52
+ type: t.literal('default'),
53
+ options: t.object({
54
+ /**
55
+ * User name to use for sql server login.
56
+ */
57
+ userName: t.string,
58
+ /**
59
+ * Password to use for sql server login.
60
+ */
61
+ password: t.string
62
+ })
63
+ });
64
+ export type DefaultAuthentication = t.Decoded<typeof DefaultAuthentication>;
65
+
66
+ export type AuthenticationType =
67
+ | DefaultAuthentication
68
+ | AzureActiveDirectoryPasswordAuthentication
69
+ | AzureActiveDirectoryServicePrincipalSecret;
70
+
71
+
72
+ export interface CDCPollingOptions {
73
+ /**
74
+ * Maximum number of transactions to poll per polling cycle. Defaults to 10.
75
+ */
76
+ batchSize: number;
77
+ /**
78
+ * Interval in milliseconds to wait between polling cycles. Defaults to 1 second.
79
+ */
80
+ intervalMs: number;
81
+ }
82
+
83
+ export interface NormalizedMSSQLConnectionConfig {
84
+ id: string;
85
+ tag: string;
86
+
87
+ username?: string;
88
+ password?: string;
89
+ hostname: string;
90
+ port: number;
91
+ database: string;
92
+ schema?: string;
93
+
94
+ authentication?: AuthenticationType;
95
+
96
+ cdcPollingOptions: CDCPollingOptions;
97
+
98
+ /**
99
+ * Whether to trust the server certificate. Set to true for local development and self-signed certificates.
100
+ * Default is false.
101
+ */
102
+ trustServerCertificate: boolean;
103
+ lookup?: LookupFunction;
104
+ }
105
+
106
+ export const MSSQLConnectionConfig = service_types.configFile.DataSourceConfig.and(
107
+ t.object({
108
+ type: t.literal(MSSQL_CONNECTION_TYPE),
109
+ uri: t.string.optional(),
110
+ username: t.string.optional(),
111
+ password: t.string.optional(),
112
+ database: t.string.optional(),
113
+ schema: t.string.optional(),
114
+ hostname: t.string.optional(),
115
+ port: service_types.configFile.portCodec.optional(),
116
+
117
+ authentication: DefaultAuthentication.or(AzureActiveDirectoryPasswordAuthentication)
118
+ .or(AzureActiveDirectoryServicePrincipalSecret)
119
+ .optional(),
120
+
121
+ cdcPollingOptions: t.object({
122
+ batchSize: t.number.optional(),
123
+ intervalMs: t.number.optional()
124
+ }).optional(),
125
+
126
+ /**
127
+ * Whether to trust the server certificate. Set to true for local development and self-signed certificates.
128
+ * Default is false.
129
+ */
130
+ trustServerCertificate: t.boolean.optional(),
131
+
132
+ reject_ip_ranges: t.array(t.string).optional()
133
+ })
134
+ );
135
+
136
+ /**
137
+ * Config input specified when starting services
138
+ */
139
+ export type MSSQLConnectionConfig = t.Decoded<typeof MSSQLConnectionConfig>;
140
+
141
+ /**
142
+ * Resolved version of {@link MSSQLConnectionConfig}
143
+ */
144
+ export type ResolvedMSSQLConnectionConfig = MSSQLConnectionConfig & NormalizedMSSQLConnectionConfig;
145
+
146
+ /**
147
+ * Validate and normalize connection options.
148
+ *
149
+ * Returns destructured options.
150
+ */
151
+ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): NormalizedMSSQLConnectionConfig {
152
+ let uri: urijs.URIComponents;
153
+ if (options.uri) {
154
+ uri = urijs.parse(options.uri);
155
+ if (uri.scheme != 'mssql') {
156
+ throw new ServiceError(
157
+ ErrorCode.PSYNC_S1109,
158
+ `Invalid URI - protocol must be mssql, got ${JSON.stringify(uri.scheme)}`
159
+ );
160
+ }
161
+ } else {
162
+ uri = urijs.parse('mssql:///');
163
+ }
164
+
165
+ const hostname = options.hostname ?? uri.host ?? '';
166
+ const port = Number(options.port ?? uri.port ?? 1433);
167
+
168
+ const database = options.database ?? uri.path?.substring(1) ?? '';
169
+
170
+ const [uri_username, uri_password] = (uri.userinfo ?? '').split(':');
171
+
172
+ const username = options.username ?? uri_username ?? '';
173
+ const password = options.password ?? uri_password ?? '';
174
+
175
+ if (hostname == '') {
176
+ throw new ServiceError(ErrorCode.PSYNC_S1106, `MSSQL connection: hostname required`);
177
+ }
178
+
179
+ if (username == '' && !options.authentication) {
180
+ throw new ServiceError(ErrorCode.PSYNC_S1107, `MSSQL connection: username or authentication config is required`);
181
+ }
182
+
183
+ if (password == '' && !options.authentication) {
184
+ throw new ServiceError(ErrorCode.PSYNC_S1108, `MSSQL connection: password or authentication config is required`);
185
+ }
186
+
187
+ if (database == '') {
188
+ throw new ServiceError(ErrorCode.PSYNC_S1105, `MSSQL connection: database required`);
189
+ }
190
+
191
+ const lookup = makeHostnameLookupFunction(hostname, { reject_ip_ranges: options.reject_ip_ranges ?? [] });
192
+
193
+ return {
194
+ id: options.id ?? 'default',
195
+ tag: options.tag ?? 'default',
196
+
197
+ username,
198
+ password,
199
+ hostname,
200
+ port,
201
+ database,
202
+
203
+ lookup,
204
+ authentication: options.authentication,
205
+
206
+ cdcPollingOptions: {
207
+ /**
208
+ * Maximum number of transactions to poll per polling cycle. Defaults to 10.
209
+ */
210
+ batchSize: options.cdcPollingOptions?.batchSize ?? 10,
211
+
212
+ /**
213
+ * Interval in milliseconds to wait between polling cycles. Defaults to 1 second.
214
+ */
215
+ intervalMs: options.cdcPollingOptions?.intervalMs ?? 1000,
216
+ },
217
+
218
+ trustServerCertificate: options.trustServerCertificate ?? false,
219
+ } satisfies NormalizedMSSQLConnectionConfig;
220
+ }
221
+
222
+ export function baseUri(config: ResolvedMSSQLConnectionConfig) {
223
+ return `mssql://${config.hostname}:${config.port}/${config.database}`;
224
+ }
@@ -0,0 +1,420 @@
1
+ import sql from 'mssql';
2
+ import { coerce, gte } from 'semver';
3
+ import { logger } from '@powersync/lib-services-framework';
4
+ import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
5
+ import { LSN } from '../common/LSN.js';
6
+ import { CaptureInstance, MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
7
+ 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
+
13
+ export const POWERSYNC_CHECKPOINTS_TABLE = '_powersync_checkpoints';
14
+
15
+ export const SUPPORTED_ENGINE_EDITIONS = new Map([
16
+ [2, 'Standard'],
17
+ [3, 'Enterprise - Enterprise, Developer, Evaluation'],
18
+ [5, 'SqlDatabase - Azure SQL Database'],
19
+ [8, 'SqlManagedInstance - Azure SQL Managed Instance']
20
+ ]);
21
+
22
+ // SQL Server 2022 and newer
23
+ export const MINIMUM_SUPPORTED_VERSION = '16.0';
24
+
25
+ export async function checkSourceConfiguration(connectionManager: MSSQLConnectionManager): Promise<string[]> {
26
+ const errors: string[] = [];
27
+ // 1) Check MSSQL version and Editions
28
+ const { recordset: versionResult } = await connectionManager.query(`
29
+ SELECT
30
+ CAST(SERVERPROPERTY('EngineEdition') AS int) AS engine,
31
+ CAST(SERVERPROPERTY('Edition') AS nvarchar(128)) AS edition,
32
+ CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS version
33
+ `);
34
+
35
+ // If the edition is unsupported, return immediately
36
+ if (!SUPPORTED_ENGINE_EDITIONS.has(versionResult[0]?.engine)) {
37
+ errors.push(
38
+ `The SQL Server edition '${versionResult[0]?.edition}' is not supported. PowerSync requires a MSSQL edition that supports CDC: ${Array.from(
39
+ SUPPORTED_ENGINE_EDITIONS.values()
40
+ ).join(', ')}.`
41
+ );
42
+ return errors;
43
+ }
44
+
45
+ // Only applicable to SQL Server stand-alone editions
46
+ if (versionResult[0]?.engine == 2 || versionResult[0]?.engine == 3) {
47
+ if (!isVersionAtLeast(versionResult[0]?.version, MINIMUM_SUPPORTED_VERSION)) {
48
+ errors.push(
49
+ `The SQL Server version '${versionResult[0]?.version}' is not supported. PowerSync requires MSSQL 2022 (v16) or newer.`
50
+ );
51
+ }
52
+ }
53
+
54
+ // 2) Check DB-level CDC
55
+ const { recordset: cdcEnabledResult } = await connectionManager.query(`
56
+ SELECT name AS db_name, is_cdc_enabled FROM sys.databases WHERE name = DB_NAME();
57
+ `);
58
+ const cdcEnabled = cdcEnabledResult[0]?.is_cdc_enabled;
59
+
60
+ if (!cdcEnabled) {
61
+ errors.push(`CDC is not enabled for database. Please enable it.`);
62
+ }
63
+
64
+ // 3) Check CDC user permissions
65
+ const { recordset: cdcUserResult } = await connectionManager.query(`
66
+ SELECT
67
+ CASE
68
+ WHEN IS_SRVROLEMEMBER('sysadmin') = 1
69
+ OR IS_MEMBER('db_owner') = 1
70
+ OR IS_MEMBER('cdc_admin') = 1
71
+ OR IS_MEMBER('cdc_reader') = 1
72
+ THEN 1 ELSE 0
73
+ END AS has_cdc_access;
74
+ `);
75
+
76
+ if (!cdcUserResult[0]?.has_cdc_access) {
77
+ errors.push(`The current user does not have the 'cdc_reader' role. Please assign this role to the user.`);
78
+ }
79
+
80
+ // 4) Check if the _powersync_checkpoints table is correctly configured
81
+ const checkpointTableErrors = await ensurePowerSyncCheckpointsTable(connectionManager);
82
+ errors.push(...checkpointTableErrors);
83
+
84
+ return errors;
85
+ }
86
+
87
+ export async function ensurePowerSyncCheckpointsTable(connectionManager: MSSQLConnectionManager): Promise<string[]> {
88
+ const errors: string[] = [];
89
+ try {
90
+ // check if the dbo_powersync_checkpoints table exists
91
+ const { recordset: checkpointsResult } = await connectionManager.query(`
92
+ SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '${connectionManager.schema}' AND TABLE_NAME = '${POWERSYNC_CHECKPOINTS_TABLE}';
93
+ `);
94
+ if (checkpointsResult.length > 0) {
95
+ // Table already exists, check if CDC is enabled
96
+ const isEnabled = await isTableEnabledForCDC({
97
+ connectionManager,
98
+ table: POWERSYNC_CHECKPOINTS_TABLE,
99
+ schema: connectionManager.schema
100
+ });
101
+ if (!isEnabled) {
102
+ // Enable CDC on the table
103
+ await enableCDCForTable({
104
+ connectionManager,
105
+ table: POWERSYNC_CHECKPOINTS_TABLE
106
+ });
107
+ }
108
+ return errors;
109
+ }
110
+ } catch (error) {
111
+ errors.push(`Failed ensure ${POWERSYNC_CHECKPOINTS_TABLE} table is correctly configured: ${error}`);
112
+ }
113
+
114
+ // Try to create the table
115
+ try {
116
+ await connectionManager.query(`
117
+ CREATE TABLE ${connectionManager.schema}.${POWERSYNC_CHECKPOINTS_TABLE} (
118
+ id INT IDENTITY PRIMARY KEY,
119
+ last_updated DATETIME NOT NULL DEFAULT (GETDATE())
120
+ )`);
121
+ } catch (error) {
122
+ errors.push(`Failed to create ${POWERSYNC_CHECKPOINTS_TABLE} table: ${error}`);
123
+ }
124
+
125
+ try {
126
+ // Enable CDC on the table if not already enabled
127
+ await enableCDCForTable({
128
+ connectionManager,
129
+ table: POWERSYNC_CHECKPOINTS_TABLE
130
+ });
131
+ } catch (error) {
132
+ errors.push(`Failed to enable CDC on ${POWERSYNC_CHECKPOINTS_TABLE} table: ${error}`);
133
+ }
134
+
135
+ return errors;
136
+ }
137
+
138
+ export async function createCheckpoint(connectionManager: MSSQLConnectionManager): Promise<void> {
139
+ await connectionManager.query(`
140
+ MERGE ${connectionManager.schema}.${POWERSYNC_CHECKPOINTS_TABLE} AS target
141
+ USING (SELECT 1 AS id) AS source
142
+ ON target.id = source.id
143
+ WHEN MATCHED THEN
144
+ UPDATE SET last_updated = GETDATE()
145
+ WHEN NOT MATCHED THEN
146
+ INSERT (last_updated) VALUES (GETDATE());
147
+ `);
148
+ }
149
+
150
+ export interface IsTableEnabledForCDCOptions {
151
+ connectionManager: MSSQLConnectionManager;
152
+ table: string;
153
+ schema: string;
154
+ }
155
+ /**
156
+ * Check if the specified table is enabled for CDC.
157
+ * @param options
158
+ */
159
+ export async function isTableEnabledForCDC(options: IsTableEnabledForCDCOptions): Promise<boolean> {
160
+ const { connectionManager, table, schema } = options;
161
+
162
+ const { recordset: checkResult } = await connectionManager.query(
163
+ `
164
+ SELECT 1 FROM cdc.change_tables ct
165
+ JOIN sys.tables AS tbl ON tbl.object_id = ct.source_object_id
166
+ JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
167
+ WHERE sch.name = '${schema}'
168
+ AND tbl.name = '${table}'
169
+ `
170
+ );
171
+ return checkResult.length > 0;
172
+ }
173
+
174
+ export interface EnableCDCForTableOptions {
175
+ connectionManager: MSSQLConnectionManager;
176
+ table: string;
177
+ }
178
+
179
+ export async function enableCDCForTable(options: EnableCDCForTableOptions): Promise<void> {
180
+ const { connectionManager, table } = options;
181
+
182
+ await connectionManager.execute('sys.sp_cdc_enable_table', [
183
+ { name: 'source_schema', value: connectionManager.schema },
184
+ { name: 'source_name', value: table },
185
+ { name: 'role_name', value: 'NULL' },
186
+ { name: 'supports_net_changes', value: 1 }
187
+ ]);
188
+ }
189
+
190
+ /**
191
+ * Check if the supplied version is newer or equal to the target version.
192
+ * @param version
193
+ * @param minimumVersion
194
+ */
195
+ export function isVersionAtLeast(version: string, minimumVersion: string): boolean {
196
+ const coercedVersion = coerce(version);
197
+ const coercedMinimumVersion = coerce(minimumVersion);
198
+
199
+ return gte(coercedVersion!, coercedMinimumVersion!, { loose: true });
200
+ }
201
+
202
+ export interface IsWithinRetentionThresholdOptions {
203
+ checkpointLSN: LSN;
204
+ tables: MSSQLSourceTable[];
205
+ connectionManager: MSSQLConnectionManager;
206
+ }
207
+
208
+ /**
209
+ * Checks that CDC the specified checkpoint LSN is within the retention threshold for all specified tables.
210
+ * CDC periodically cleans up old data up to the retention threshold. If replication has been stopped for too long it is
211
+ * 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.
212
+ * @param options
213
+ */
214
+ export async function isWithinRetentionThreshold(options: IsWithinRetentionThresholdOptions): Promise<boolean> {
215
+ const { checkpointLSN, tables, connectionManager } = options;
216
+ for (const table of tables) {
217
+ const minLSN = await getMinLSN(connectionManager, table.captureInstance);
218
+ if (minLSN > checkpointLSN) {
219
+ logger.warn(
220
+ `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.`
221
+ );
222
+ return false;
223
+ }
224
+ }
225
+ return true;
226
+ }
227
+
228
+ export async function getMinLSN(connectionManager: MSSQLConnectionManager, captureInstance: string): Promise<LSN> {
229
+ const { recordset: result } = await connectionManager.query(
230
+ `SELECT sys.fn_cdc_get_min_lsn('${captureInstance}') AS min_lsn`
231
+ );
232
+ const rawMinLSN: Buffer = result[0].min_lsn;
233
+ return LSN.fromBinary(rawMinLSN);
234
+ }
235
+
236
+ export async function incrementLSN(lsn: LSN, connectionManager: MSSQLConnectionManager): Promise<LSN> {
237
+ const { recordset: result } = await connectionManager.query(
238
+ `SELECT sys.fn_cdc_increment_lsn(@lsn) AS incremented_lsn`,
239
+ [{ name: 'lsn', type: sql.VarBinary, value: lsn.toBinary() }]
240
+ );
241
+ return LSN.fromBinary(result[0].incremented_lsn);
242
+ }
243
+
244
+ export interface GetCaptureInstanceOptions {
245
+ connectionManager: MSSQLConnectionManager;
246
+ tableName: string;
247
+ schema: string;
248
+ }
249
+
250
+ export async function getCaptureInstance(options: GetCaptureInstanceOptions): Promise<CaptureInstance | null> {
251
+ const { connectionManager, tableName, schema } = options;
252
+ const { recordset: result } = await connectionManager.query(
253
+ `
254
+ SELECT
255
+ ct.capture_instance,
256
+ OBJECT_SCHEMA_NAME(ct.[object_id]) AS cdc_schema
257
+ FROM
258
+ sys.tables tbl
259
+ INNER JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
260
+ INNER JOIN cdc.change_tables ct ON ct.source_object_id = tbl.object_id
261
+ WHERE sch.name = '${schema}'
262
+ AND tbl.name = '${tableName}'
263
+ AND ct.end_lsn IS NULL;
264
+ `
265
+ );
266
+
267
+ if (result.length === 0) {
268
+ return null;
269
+ }
270
+
271
+ return {
272
+ name: result[0].capture_instance,
273
+ schema: result[0].cdc_schema
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Return the LSN of the latest transaction recorded in the transaction log
279
+ * @param connectionManager
280
+ */
281
+ export async function getLatestLSN(connectionManager: MSSQLConnectionManager): Promise<LSN> {
282
+ const { recordset: result } = await connectionManager.query(
283
+ 'SELECT log_end_lsn FROM sys.dm_db_log_stats(DB_ID()) AS log_end_lsn'
284
+ );
285
+ return LSN.fromString(result[0].log_end_lsn);
286
+ }
287
+
288
+ /**
289
+ * Return the LSN of the lastest transaction replicated to the CDC tables.
290
+ * @param connectionManager
291
+ */
292
+ export async function getLatestReplicatedLSN(connectionManager: MSSQLConnectionManager): Promise<LSN> {
293
+ const { recordset: result } = await connectionManager.query('SELECT sys.fn_cdc_get_max_lsn() AS max_lsn;');
294
+ // LSN is a binary(10) returned as a Buffer
295
+ const rawLSN: Buffer = result[0].max_lsn;
296
+ return LSN.fromBinary(rawLSN);
297
+ }
298
+
299
+ /**
300
+ * Escapes an identifier for use in MSSQL queries.
301
+ * @param identifier
302
+ */
303
+ export function escapeIdentifier(identifier: string): string {
304
+ return `[${identifier}]`;
305
+ }
306
+
307
+ export function toQualifiedTableName(schema: string, tableName: string): string {
308
+ return `${escapeIdentifier(schema)}.${escapeIdentifier(tableName)}`;
309
+ }
310
+
311
+ export function isIColumnMetadata(obj: any): obj is sql.IColumnMetadata {
312
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
313
+ return false;
314
+ }
315
+
316
+ let propertiesMatched = true;
317
+ for (const value of Object.values(obj)) {
318
+ const property = value as any;
319
+ propertiesMatched =
320
+ typeof property.index === 'number' &&
321
+ typeof property.name === 'string' &&
322
+ typeof property.length === 'number' &&
323
+ (typeof property.type === 'function' || typeof property.type === 'object') &&
324
+ typeof property.nullable === 'boolean' &&
325
+ typeof property.caseSensitive === 'boolean' &&
326
+ typeof property.identity === 'boolean' &&
327
+ typeof property.readOnly === 'boolean';
328
+ }
329
+
330
+ return propertiesMatched;
331
+ }
332
+
333
+ export function addParameters(request: sql.Request, parameters: MSSQLParameter[]): sql.Request {
334
+ for (const param of parameters) {
335
+ if (param.type) {
336
+ request.input(param.name, param.type, param.value);
337
+ } else {
338
+ request.input(param.name, param.value);
339
+ }
340
+ }
341
+ return request;
342
+ }
343
+
344
+ export interface GetDebugTableInfoOptions {
345
+ connectionManager: MSSQLConnectionManager;
346
+ tablePattern: TablePattern;
347
+ table: ResolvedTable;
348
+ syncRules: SqlSyncRules;
349
+ }
350
+
351
+ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Promise<service_types.TableInfo> {
352
+ const { connectionManager, tablePattern, table, syncRules } = options;
353
+ const { schema } = tablePattern;
354
+
355
+ let idColumnsResult: ReplicationIdentityColumnsResult | null = null;
356
+ let idColumnsError: service_types.ReplicationError | null = null;
357
+ try {
358
+ idColumnsResult = await getReplicationIdentityColumns({
359
+ connectionManager: connectionManager,
360
+ schema,
361
+ tableName: table.name
362
+ });
363
+ } catch (ex) {
364
+ idColumnsError = { level: 'fatal', message: ex.message };
365
+ }
366
+
367
+ const idColumns = idColumnsResult?.columns ?? [];
368
+ const sourceTable: sync_rules.SourceTableInterface = {
369
+ connectionTag: connectionManager.connectionTag,
370
+ schema: schema,
371
+ name: table.name
372
+ };
373
+ const syncData = syncRules.tableSyncsData(sourceTable);
374
+ const syncParameters = syncRules.tableSyncsParameters(sourceTable);
375
+
376
+ if (idColumns.length === 0 && idColumnsError == null) {
377
+ let message = `No replication id found for ${toQualifiedTableName(schema, table.name)}. Replica identity: ${idColumnsResult?.identity}.`;
378
+ if (idColumnsResult?.identity === 'default') {
379
+ message += ' Configure a primary key on the table.';
380
+ }
381
+ idColumnsError = { level: 'fatal', message };
382
+ }
383
+
384
+ let selectError: service_types.ReplicationError | null = null;
385
+ try {
386
+ await connectionManager.query(`SELECT TOP 1 * FROM [${toQualifiedTableName(schema, table.name)}]`);
387
+ } catch (e) {
388
+ selectError = { level: 'fatal', message: e.message };
389
+ }
390
+
391
+ // Check if CDC is enabled for the table
392
+ let cdcError: service_types.ReplicationError | null = null;
393
+ try {
394
+ const isEnabled = await isTableEnabledForCDC({
395
+ connectionManager: connectionManager,
396
+ table: table.name,
397
+ schema: schema
398
+ });
399
+ if (!isEnabled) {
400
+ cdcError = {
401
+ level: 'fatal',
402
+ 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`
403
+ };
404
+ }
405
+ } catch (e) {
406
+ cdcError = { level: 'warning', message: `Could not check CDC status: ${e.message}` };
407
+ }
408
+
409
+ // TODO check RLS settings for table
410
+
411
+ return {
412
+ schema: schema,
413
+ name: table.name,
414
+ pattern: tablePattern.isWildcard ? tablePattern.tablePattern : undefined,
415
+ replication_id: idColumns.map((c) => c.name),
416
+ data_queries: syncData,
417
+ parameter_queries: syncParameters,
418
+ errors: [idColumnsError, selectError, cdcError].filter((error) => error != null) as service_types.ReplicationError[]
419
+ };
420
+ }