@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.
- package/LICENSE +67 -0
- package/README.md +3 -0
- package/ci/init-mssql.sql +50 -0
- package/dist/api/MSSQLRouteAPIAdapter.d.ts +21 -0
- package/dist/api/MSSQLRouteAPIAdapter.js +248 -0
- package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -0
- package/dist/common/LSN.d.ts +37 -0
- package/dist/common/LSN.js +64 -0
- package/dist/common/LSN.js.map +1 -0
- package/dist/common/MSSQLSourceTable.d.ts +27 -0
- package/dist/common/MSSQLSourceTable.js +35 -0
- package/dist/common/MSSQLSourceTable.js.map +1 -0
- package/dist/common/MSSQLSourceTableCache.d.ts +14 -0
- package/dist/common/MSSQLSourceTableCache.js +28 -0
- package/dist/common/MSSQLSourceTableCache.js.map +1 -0
- package/dist/common/mssqls-to-sqlite.d.ts +18 -0
- package/dist/common/mssqls-to-sqlite.js +143 -0
- package/dist/common/mssqls-to-sqlite.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/module/MSSQLModule.d.ts +15 -0
- package/dist/module/MSSQLModule.js +68 -0
- package/dist/module/MSSQLModule.js.map +1 -0
- package/dist/replication/CDCPoller.d.ts +67 -0
- package/dist/replication/CDCPoller.js +183 -0
- package/dist/replication/CDCPoller.js.map +1 -0
- package/dist/replication/CDCReplicationJob.d.ts +17 -0
- package/dist/replication/CDCReplicationJob.js +76 -0
- package/dist/replication/CDCReplicationJob.js.map +1 -0
- package/dist/replication/CDCReplicator.d.ts +18 -0
- package/dist/replication/CDCReplicator.js +55 -0
- package/dist/replication/CDCReplicator.js.map +1 -0
- package/dist/replication/CDCStream.d.ts +106 -0
- package/dist/replication/CDCStream.js +536 -0
- package/dist/replication/CDCStream.js.map +1 -0
- package/dist/replication/MSSQLConnectionManager.d.ts +23 -0
- package/dist/replication/MSSQLConnectionManager.js +97 -0
- package/dist/replication/MSSQLConnectionManager.js.map +1 -0
- package/dist/replication/MSSQLConnectionManagerFactory.d.ts +10 -0
- package/dist/replication/MSSQLConnectionManagerFactory.js +28 -0
- package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -0
- package/dist/replication/MSSQLErrorRateLimiter.d.ts +10 -0
- package/dist/replication/MSSQLErrorRateLimiter.js +34 -0
- package/dist/replication/MSSQLErrorRateLimiter.js.map +1 -0
- package/dist/replication/MSSQLSnapshotQuery.d.ts +71 -0
- package/dist/replication/MSSQLSnapshotQuery.js +190 -0
- package/dist/replication/MSSQLSnapshotQuery.js.map +1 -0
- package/dist/types/mssql-data-types.d.ts +66 -0
- package/dist/types/mssql-data-types.js +62 -0
- package/dist/types/mssql-data-types.js.map +1 -0
- package/dist/types/types.d.ts +177 -0
- package/dist/types/types.js +141 -0
- package/dist/types/types.js.map +1 -0
- package/dist/utils/mssql.d.ts +80 -0
- package/dist/utils/mssql.js +329 -0
- package/dist/utils/mssql.js.map +1 -0
- package/dist/utils/schema.d.ts +21 -0
- package/dist/utils/schema.js +131 -0
- package/dist/utils/schema.js.map +1 -0
- package/package.json +51 -0
- package/src/api/MSSQLRouteAPIAdapter.ts +283 -0
- package/src/common/LSN.ts +77 -0
- package/src/common/MSSQLSourceTable.ts +54 -0
- package/src/common/MSSQLSourceTableCache.ts +36 -0
- package/src/common/mssqls-to-sqlite.ts +151 -0
- package/src/index.ts +1 -0
- package/src/module/MSSQLModule.ts +82 -0
- package/src/replication/CDCPoller.ts +241 -0
- package/src/replication/CDCReplicationJob.ts +87 -0
- package/src/replication/CDCReplicator.ts +70 -0
- package/src/replication/CDCStream.ts +688 -0
- package/src/replication/MSSQLConnectionManager.ts +113 -0
- package/src/replication/MSSQLConnectionManagerFactory.ts +33 -0
- package/src/replication/MSSQLErrorRateLimiter.ts +36 -0
- package/src/replication/MSSQLSnapshotQuery.ts +230 -0
- package/src/types/mssql-data-types.ts +79 -0
- package/src/types/types.ts +224 -0
- package/src/utils/mssql.ts +420 -0
- package/src/utils/schema.ts +172 -0
- package/test/src/CDCStream.test.ts +206 -0
- package/test/src/CDCStreamTestContext.ts +212 -0
- package/test/src/CDCStream_resumable_snapshot.test.ts +152 -0
- package/test/src/env.ts +11 -0
- package/test/src/mssql-to-sqlite.test.ts +474 -0
- package/test/src/setup.ts +12 -0
- package/test/src/util.ts +189 -0
- package/test/tsconfig.json +28 -0
- package/test/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.json +26 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|