@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,283 @@
|
|
|
1
|
+
import {
|
|
2
|
+
api,
|
|
3
|
+
ParseSyncRulesOptions,
|
|
4
|
+
PatternResult,
|
|
5
|
+
ReplicationHeadCallback,
|
|
6
|
+
ReplicationLagOptions
|
|
7
|
+
} from '@powersync/service-core';
|
|
8
|
+
import * as service_types from '@powersync/service-types';
|
|
9
|
+
import * as sync_rules from '@powersync/service-sync-rules';
|
|
10
|
+
import { SqlSyncRules, TablePattern } from '@powersync/service-sync-rules';
|
|
11
|
+
import * as types from '../types/types.js';
|
|
12
|
+
import { ExecuteSqlResponse } from '@powersync/service-types/dist/routes.js';
|
|
13
|
+
import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
|
|
14
|
+
import {
|
|
15
|
+
checkSourceConfiguration,
|
|
16
|
+
createCheckpoint,
|
|
17
|
+
getDebugTableInfo,
|
|
18
|
+
getLatestLSN,
|
|
19
|
+
POWERSYNC_CHECKPOINTS_TABLE
|
|
20
|
+
} from '../utils/mssql.js';
|
|
21
|
+
import { getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
|
|
22
|
+
import { toExpressionTypeFromMSSQLType } from '../common/mssqls-to-sqlite.js';
|
|
23
|
+
|
|
24
|
+
export class MSSQLRouteAPIAdapter implements api.RouteAPI {
|
|
25
|
+
protected connectionManager: MSSQLConnectionManager;
|
|
26
|
+
|
|
27
|
+
constructor(protected config: types.ResolvedMSSQLConnectionConfig) {
|
|
28
|
+
this.connectionManager = new MSSQLConnectionManager(config, {});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async createReplicationHead<T>(callback: ReplicationHeadCallback<T>): Promise<T> {
|
|
32
|
+
const currentLSN = await getLatestLSN(this.connectionManager);
|
|
33
|
+
const result = await callback(currentLSN.toString());
|
|
34
|
+
|
|
35
|
+
// Updates the powersync checkpoints table on the source database, ensuring that an update with a newer LSN will be captured by the CDC.
|
|
36
|
+
await createCheckpoint(this.connectionManager);
|
|
37
|
+
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async executeQuery(query: string, params: any[]): Promise<ExecuteSqlResponse> {
|
|
42
|
+
if (!this.config.debug_api) {
|
|
43
|
+
return service_types.internal_routes.ExecuteSqlResponse.encode({
|
|
44
|
+
results: {
|
|
45
|
+
columns: [],
|
|
46
|
+
rows: []
|
|
47
|
+
},
|
|
48
|
+
success: false,
|
|
49
|
+
error: 'SQL querying is not enabled'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const { recordset: result } = await this.connectionManager.query(query, params);
|
|
54
|
+
return service_types.internal_routes.ExecuteSqlResponse.encode({
|
|
55
|
+
success: true,
|
|
56
|
+
results: {
|
|
57
|
+
columns: Object.values(result.columns).map((column) => column.name),
|
|
58
|
+
rows: result.map((row) => {
|
|
59
|
+
return Object.values(row).map((value: any) => {
|
|
60
|
+
const sqlValue = sync_rules.applyValueContext(
|
|
61
|
+
sync_rules.toSyncRulesValue(row),
|
|
62
|
+
sync_rules.CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (typeof sqlValue == 'bigint') {
|
|
66
|
+
return Number(row);
|
|
67
|
+
} else if (value instanceof Date) {
|
|
68
|
+
return value.toISOString();
|
|
69
|
+
} else if (sync_rules.isJsonValue(sqlValue)) {
|
|
70
|
+
return sqlValue;
|
|
71
|
+
} else {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return service_types.internal_routes.ExecuteSqlResponse.encode({
|
|
80
|
+
results: {
|
|
81
|
+
columns: [],
|
|
82
|
+
rows: []
|
|
83
|
+
},
|
|
84
|
+
success: false,
|
|
85
|
+
error: e.message
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
|
|
91
|
+
const { recordset: results } = await this.connectionManager.query(`
|
|
92
|
+
SELECT
|
|
93
|
+
sch.name AS schema_name,
|
|
94
|
+
tbl.name AS table_name,
|
|
95
|
+
col.name AS column_name,
|
|
96
|
+
typ.name AS data_type,
|
|
97
|
+
CASE
|
|
98
|
+
WHEN typ.name IN ('nvarchar', 'nchar')
|
|
99
|
+
AND col.max_length > 0
|
|
100
|
+
AND col.max_length != -1
|
|
101
|
+
THEN typ.name + '(' + CAST(col.max_length / 2 AS VARCHAR) + ')'
|
|
102
|
+
WHEN typ.name IN ('varchar', 'char', 'varbinary', 'binary')
|
|
103
|
+
AND col.max_length > 0
|
|
104
|
+
AND col.max_length != -1
|
|
105
|
+
THEN typ.name + '(' + CAST(col.max_length AS VARCHAR) + ')'
|
|
106
|
+
WHEN typ.name IN ('varchar', 'nvarchar', 'char', 'nchar')
|
|
107
|
+
AND col.max_length = -1
|
|
108
|
+
THEN typ.name + '(MAX)'
|
|
109
|
+
WHEN typ.name IN ('decimal', 'numeric')
|
|
110
|
+
AND col.precision > 0
|
|
111
|
+
THEN typ.name + '(' + CAST(col.precision AS VARCHAR) + ',' + CAST(col.scale AS VARCHAR) + ')'
|
|
112
|
+
WHEN typ.name IN ('float', 'real')
|
|
113
|
+
AND col.precision > 0
|
|
114
|
+
THEN typ.name + '(' + CAST(col.precision AS VARCHAR) + ')'
|
|
115
|
+
ELSE typ.name
|
|
116
|
+
END AS formatted_type
|
|
117
|
+
FROM sys.tables AS tbl
|
|
118
|
+
JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
|
|
119
|
+
JOIN sys.columns AS col ON col.object_id = tbl.object_id
|
|
120
|
+
JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
|
|
121
|
+
WHERE sch.name = '${this.connectionManager.schema}'
|
|
122
|
+
AND sch.name NOT IN ('sys', 'INFORMATION_SCHEMA', 'cdc')
|
|
123
|
+
AND tbl.name NOT IN ('systranschemas', '${POWERSYNC_CHECKPOINTS_TABLE}')
|
|
124
|
+
AND tbl.type = 'U'
|
|
125
|
+
AND col.is_computed = 0
|
|
126
|
+
ORDER BY sch.name, tbl.name, col.column_id
|
|
127
|
+
`);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reduces the SQL results into a Record of {@link DatabaseSchema}
|
|
131
|
+
* then returns the values as an array.
|
|
132
|
+
*/
|
|
133
|
+
const schemas: Record<string, service_types.DatabaseSchema> = {};
|
|
134
|
+
|
|
135
|
+
for (const row of results) {
|
|
136
|
+
const schemaName = row.schema_name as string;
|
|
137
|
+
const tableName = row.table_name as string;
|
|
138
|
+
const columnName = row.column_name as string;
|
|
139
|
+
const dataType = row.data_type as string;
|
|
140
|
+
const formattedType = (row.formatted_type as string) || dataType;
|
|
141
|
+
|
|
142
|
+
const schema =
|
|
143
|
+
schemas[schemaName] ||
|
|
144
|
+
(schemas[schemaName] = {
|
|
145
|
+
name: schemaName,
|
|
146
|
+
tables: []
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let table = schema.tables.find((t) => t.name === tableName);
|
|
150
|
+
if (!table) {
|
|
151
|
+
table = {
|
|
152
|
+
name: tableName,
|
|
153
|
+
columns: []
|
|
154
|
+
};
|
|
155
|
+
schema.tables.push(table);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
table.columns.push({
|
|
159
|
+
name: columnName,
|
|
160
|
+
type: formattedType,
|
|
161
|
+
sqlite_type: toExpressionTypeFromMSSQLType(dataType).typeFlags,
|
|
162
|
+
internal_type: formattedType,
|
|
163
|
+
pg_type: formattedType
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Object.values(schemas);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async getConnectionStatus(): Promise<service_types.ConnectionStatusV2> {
|
|
171
|
+
const base = {
|
|
172
|
+
id: this.config?.id ?? '',
|
|
173
|
+
uri: this.config == null ? '' : types.baseUri(this.config)
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
await this.connectionManager.query(`SELECT 'PowerSync connection test'`);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
connected: false,
|
|
182
|
+
errors: [{ level: 'fatal', message: `${e.code} - message: ${e.message}` }]
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const errors = await checkSourceConfiguration(this.connectionManager);
|
|
188
|
+
if (errors.length) {
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
connected: true,
|
|
192
|
+
errors: errors.map((e) => ({ level: 'fatal', message: e }))
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return {
|
|
197
|
+
...base,
|
|
198
|
+
connected: true,
|
|
199
|
+
errors: [{ level: 'fatal', message: e.message }]
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
connected: true,
|
|
206
|
+
errors: []
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async getDebugTablesInfo(tablePatterns: TablePattern[], sqlSyncRules: SqlSyncRules): Promise<PatternResult[]> {
|
|
211
|
+
const result: PatternResult[] = [];
|
|
212
|
+
|
|
213
|
+
for (const tablePattern of tablePatterns) {
|
|
214
|
+
const schema = tablePattern.schema;
|
|
215
|
+
const patternResult: PatternResult = {
|
|
216
|
+
schema: schema,
|
|
217
|
+
pattern: tablePattern.tablePattern,
|
|
218
|
+
wildcard: tablePattern.isWildcard
|
|
219
|
+
};
|
|
220
|
+
result.push(patternResult);
|
|
221
|
+
|
|
222
|
+
const tables = await getTablesFromPattern(this.connectionManager, tablePattern);
|
|
223
|
+
if (tablePattern.isWildcard) {
|
|
224
|
+
patternResult.tables = [];
|
|
225
|
+
for (const table of tables) {
|
|
226
|
+
const details = await getDebugTableInfo({
|
|
227
|
+
connectionManager: this.connectionManager,
|
|
228
|
+
tablePattern,
|
|
229
|
+
table,
|
|
230
|
+
syncRules: sqlSyncRules
|
|
231
|
+
});
|
|
232
|
+
patternResult.tables.push(details);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
if (tables.length == 0) {
|
|
236
|
+
// This should tenchnically never happen, but we'll handle it anyway.
|
|
237
|
+
const resolvedTable: ResolvedTable = {
|
|
238
|
+
objectId: 0,
|
|
239
|
+
schema: schema,
|
|
240
|
+
name: tablePattern.name
|
|
241
|
+
};
|
|
242
|
+
patternResult.table = await getDebugTableInfo({
|
|
243
|
+
connectionManager: this.connectionManager,
|
|
244
|
+
tablePattern,
|
|
245
|
+
table: resolvedTable,
|
|
246
|
+
syncRules: sqlSyncRules
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
patternResult.table = await getDebugTableInfo({
|
|
250
|
+
connectionManager: this.connectionManager,
|
|
251
|
+
tablePattern,
|
|
252
|
+
table: tables[0],
|
|
253
|
+
syncRules: sqlSyncRules
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getParseSyncRulesOptions(): ParseSyncRulesOptions {
|
|
263
|
+
return {
|
|
264
|
+
defaultSchema: this.connectionManager.schema
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async getReplicationLagBytes(options: ReplicationLagOptions): Promise<number | undefined> {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async getSourceConfig(): Promise<service_types.configFile.ResolvedDataSourceConfig> {
|
|
273
|
+
return this.config;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async [Symbol.asyncDispose]() {
|
|
277
|
+
await this.shutdown();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async shutdown(): Promise<void> {
|
|
281
|
+
await this.connectionManager.end();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ReplicationAssertionError } from '@powersync/service-errors';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helper class for interpreting and manipulating SQL Server Log Sequence Numbers (LSNs).
|
|
5
|
+
* In SQL Server, an LSN is stored as a 10-byte binary value.
|
|
6
|
+
* But it is commonly represented in a human-readable format as three hexadecimal parts separated by colons:
|
|
7
|
+
* `00000000:00000000:0000`.
|
|
8
|
+
*
|
|
9
|
+
* The three parts represent different hierarchical levels of the transaction log:
|
|
10
|
+
* 1. The first part identifies the Virtual Log File (VLF).
|
|
11
|
+
* 2. The second part points to the log block within the VLF.
|
|
12
|
+
* 3. The third part specifies the exact log record within the log block.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class LSN {
|
|
16
|
+
/**
|
|
17
|
+
* The zero or null LSN value. All other LSN values are greater than this.
|
|
18
|
+
*/
|
|
19
|
+
static ZERO = '00000000:00000000:0000';
|
|
20
|
+
|
|
21
|
+
protected value: string;
|
|
22
|
+
|
|
23
|
+
private constructor(lsn: string) {
|
|
24
|
+
this.value = lsn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Converts this LSN back into its raw 10-byte binary representation for use in SQL Server functions.
|
|
29
|
+
*/
|
|
30
|
+
toBinary(): Buffer {
|
|
31
|
+
let sanitized: string = this.value.replace(/:/g, '');
|
|
32
|
+
return Buffer.from(sanitized, 'hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Converts a raw 10-byte binary LSN value into its string representation.
|
|
37
|
+
* An error is thrown if the binary value is not exactly 10 bytes.
|
|
38
|
+
* @param rawLSN
|
|
39
|
+
*/
|
|
40
|
+
static fromBinary(rawLSN: Buffer): LSN {
|
|
41
|
+
if (rawLSN.length !== 10) {
|
|
42
|
+
throw new ReplicationAssertionError(`LSN must be 10 bytes, got ${rawLSN.length}`);
|
|
43
|
+
}
|
|
44
|
+
const hex = rawLSN.toString('hex').toUpperCase(); // 20 hex chars
|
|
45
|
+
|
|
46
|
+
return new LSN(`${hex.slice(0, 8)}:${hex.slice(8, 16)}:${hex.slice(16, 20)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates an LSN instance from the provided string representation. An error is thrown if the format is invalid.
|
|
51
|
+
* @param stringLSN
|
|
52
|
+
*/
|
|
53
|
+
static fromString(stringLSN: string): LSN {
|
|
54
|
+
if (!/^[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:[0-9A-Fa-f]{4}$/.test(stringLSN)) {
|
|
55
|
+
throw new ReplicationAssertionError(
|
|
56
|
+
`Invalid LSN string. Expected format is [00000000:00000000:0000]. Got: ${stringLSN}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new LSN(stringLSN);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
compare(other: LSN): -1 | 0 | 1 {
|
|
64
|
+
if (this.value === other.value) {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
return this.value < other.value ? -1 : 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
valueOf(): string {
|
|
71
|
+
return this.value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toString(): string {
|
|
75
|
+
return this.value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SourceTable } from '@powersync/service-core';
|
|
2
|
+
import { toQualifiedTableName } from '../utils/mssql.js';
|
|
3
|
+
|
|
4
|
+
export interface CaptureInstance {
|
|
5
|
+
name: string;
|
|
6
|
+
schema: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface MSSQLSourceTableOptions {
|
|
10
|
+
sourceTable: SourceTable;
|
|
11
|
+
/**
|
|
12
|
+
* The unique name of the CDC capture instance for this table
|
|
13
|
+
*/
|
|
14
|
+
captureInstance: CaptureInstance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MSSQLSourceTable {
|
|
18
|
+
constructor(private options: MSSQLSourceTableOptions) {}
|
|
19
|
+
|
|
20
|
+
get sourceTable() {
|
|
21
|
+
return this.options.sourceTable;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
updateSourceTable(updated: SourceTable): void {
|
|
25
|
+
this.options.sourceTable = updated;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get captureInstance() {
|
|
29
|
+
return this.options.captureInstance.name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get cdcSchema() {
|
|
33
|
+
return this.options.captureInstance.schema;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get CTTable() {
|
|
37
|
+
return `${this.cdcSchema}.${this.captureInstance}_CT`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get allChangesFunction() {
|
|
41
|
+
return `${this.cdcSchema}.fn_cdc_get_all_changes_${this.captureInstance}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get netChangesFunction() {
|
|
45
|
+
return `${this.cdcSchema}.fn_cdc_get_net_changes_${this.captureInstance}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Escapes this source table's name and schema for use in MSSQL queries.
|
|
50
|
+
*/
|
|
51
|
+
toQualifiedName(): string {
|
|
52
|
+
return toQualifiedTableName(this.sourceTable.schema, this.sourceTable.name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SourceTable } from '@powersync/service-core';
|
|
2
|
+
import { MSSQLSourceTable } from './MSSQLSourceTable.js';
|
|
3
|
+
import { ServiceAssertionError } from '@powersync/service-errors';
|
|
4
|
+
|
|
5
|
+
export class MSSQLSourceTableCache {
|
|
6
|
+
private cache = new Map<number | string, MSSQLSourceTable>();
|
|
7
|
+
|
|
8
|
+
set(table: MSSQLSourceTable): void {
|
|
9
|
+
this.cache.set(table.sourceTable.objectId!, table);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Updates the underlying source table of the cached MSSQLSourceTable.
|
|
14
|
+
* @param updatedTable
|
|
15
|
+
*/
|
|
16
|
+
updateSourceTable(updatedTable: SourceTable) {
|
|
17
|
+
const existingTable = this.cache.get(updatedTable.objectId!);
|
|
18
|
+
|
|
19
|
+
if (!existingTable) {
|
|
20
|
+
throw new ServiceAssertionError('Tried to update a non-existing MSSQLSourceTable in the cache');
|
|
21
|
+
}
|
|
22
|
+
existingTable.updateSourceTable(updatedTable);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(tableId: number): MSSQLSourceTable | undefined {
|
|
26
|
+
return this.cache.get(tableId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getAll(): MSSQLSourceTable[] {
|
|
30
|
+
return Array.from(this.cache.values());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
delete(tableId: number): boolean {
|
|
34
|
+
return this.cache.delete(tableId);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import sql from 'mssql';
|
|
2
|
+
import { DatabaseInputRow, ExpressionType, SqliteInputRow, toSyncRulesRow } from '@powersync/service-sync-rules';
|
|
3
|
+
import { MSSQLUserDefinedType } from '../types/mssql-data-types.js';
|
|
4
|
+
|
|
5
|
+
export function toSqliteInputRow(row: any, columns: sql.IColumnMetadata): SqliteInputRow {
|
|
6
|
+
let result: DatabaseInputRow = {};
|
|
7
|
+
for (const key in row) {
|
|
8
|
+
// We are very much expecting the column to be there
|
|
9
|
+
const columnMetadata = columns[key];
|
|
10
|
+
|
|
11
|
+
if (row[key] !== null) {
|
|
12
|
+
switch (columnMetadata.type) {
|
|
13
|
+
case sql.TYPES.BigInt:
|
|
14
|
+
// MSSQL returns BIGINT as a string to avoid precision loss
|
|
15
|
+
if (typeof row[key] === 'string') {
|
|
16
|
+
result[key] = BigInt(row[key]);
|
|
17
|
+
}
|
|
18
|
+
break;
|
|
19
|
+
case sql.TYPES.Bit:
|
|
20
|
+
// MSSQL returns BIT as boolean
|
|
21
|
+
result[key] = row[key] ? 1 : 0;
|
|
22
|
+
break;
|
|
23
|
+
// Convert Dates to string
|
|
24
|
+
case sql.TYPES.Date:
|
|
25
|
+
result[key] = toISODateString(row[key] as Date);
|
|
26
|
+
break;
|
|
27
|
+
case sql.TYPES.Time:
|
|
28
|
+
result[key] = toISOTimeString(row[key] as Date);
|
|
29
|
+
break;
|
|
30
|
+
case sql.TYPES.DateTime:
|
|
31
|
+
case sql.TYPES.DateTime2:
|
|
32
|
+
case sql.TYPES.SmallDateTime:
|
|
33
|
+
case sql.TYPES.DateTimeOffset: // The offset is lost when the driver converts to Date. This needs to be handled in the sql query.
|
|
34
|
+
const date = row[key] as Date;
|
|
35
|
+
result[key] = isNaN(date.getTime()) ? null : date.toISOString();
|
|
36
|
+
break;
|
|
37
|
+
case sql.TYPES.Binary:
|
|
38
|
+
case sql.TYPES.VarBinary:
|
|
39
|
+
case sql.TYPES.Image:
|
|
40
|
+
result[key] = new Uint8Array(Object.values(row[key]));
|
|
41
|
+
break;
|
|
42
|
+
// TODO: Spatial types need to be converted to binary WKB, they are returned as a non standard object currently
|
|
43
|
+
case sql.TYPES.Geometry:
|
|
44
|
+
case sql.TYPES.Geography:
|
|
45
|
+
result[key] = JSON.stringify(row[key]);
|
|
46
|
+
break;
|
|
47
|
+
case sql.TYPES.UDT:
|
|
48
|
+
if (columnMetadata.udt.name === MSSQLUserDefinedType.HIERARCHYID) {
|
|
49
|
+
result[key] = new Uint8Array(Object.values(row[key]));
|
|
50
|
+
break;
|
|
51
|
+
} else {
|
|
52
|
+
result[key] = row[key];
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
default:
|
|
56
|
+
result[key] = row[key];
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
// If the value is null, we just set it to null
|
|
60
|
+
result[key] = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return toSyncRulesRow(result);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toISODateString(date: Date): string | null {
|
|
67
|
+
return isNaN(date.getTime()) ? null : date.toISOString().split('T')[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* MSSQL time format is HH:mm:ss[.nnnnnnn]
|
|
72
|
+
* @param date
|
|
73
|
+
* @returns
|
|
74
|
+
*/
|
|
75
|
+
function toISOTimeString(date: Date): string | null {
|
|
76
|
+
return isNaN(date.getTime()) ? null : date.toISOString().split('T')[1].replace('Z', '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Converts MSSQL type names to SQLite ExpressionType
|
|
81
|
+
* @param mssqlType - The MSSQL type name (e.g., 'int', 'varchar', 'datetime2')
|
|
82
|
+
*/
|
|
83
|
+
export function toExpressionTypeFromMSSQLType(mssqlType: string | undefined): ExpressionType {
|
|
84
|
+
if (!mssqlType) {
|
|
85
|
+
return ExpressionType.TEXT;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const baseType = mssqlType.toUpperCase();
|
|
89
|
+
switch (baseType) {
|
|
90
|
+
case 'BIT':
|
|
91
|
+
case 'TINYINT':
|
|
92
|
+
case 'SMALLINT':
|
|
93
|
+
case 'INT':
|
|
94
|
+
case 'INTEGER':
|
|
95
|
+
case 'BIGINT':
|
|
96
|
+
return ExpressionType.INTEGER;
|
|
97
|
+
case 'BINARY':
|
|
98
|
+
case 'VARBINARY':
|
|
99
|
+
case 'IMAGE':
|
|
100
|
+
case 'TIMESTAMP':
|
|
101
|
+
return ExpressionType.BLOB;
|
|
102
|
+
case 'FLOAT':
|
|
103
|
+
case 'REAL':
|
|
104
|
+
case 'MONEY':
|
|
105
|
+
case 'SMALLMONEY':
|
|
106
|
+
case 'DECIMAL':
|
|
107
|
+
case 'NUMERIC':
|
|
108
|
+
return ExpressionType.REAL;
|
|
109
|
+
case 'JSON':
|
|
110
|
+
return ExpressionType.TEXT;
|
|
111
|
+
// System and extended types
|
|
112
|
+
case 'SYSNAME':
|
|
113
|
+
// SYSNAME is essentially NVARCHAR(128), map to TEXT
|
|
114
|
+
return ExpressionType.TEXT;
|
|
115
|
+
case 'HIERARCHYID':
|
|
116
|
+
// HIERARCHYID is a CLR UDT representing hierarchical data, stored as string representation
|
|
117
|
+
return ExpressionType.TEXT;
|
|
118
|
+
case 'GEOMETRY':
|
|
119
|
+
case 'GEOGRAPHY':
|
|
120
|
+
// Spatial CLR UDT types, typically stored as WKT (Well-Known Text) strings
|
|
121
|
+
return ExpressionType.TEXT;
|
|
122
|
+
case 'VECTOR':
|
|
123
|
+
// Vector type (SQL Server 2022+), stored as binary data
|
|
124
|
+
return ExpressionType.BLOB;
|
|
125
|
+
default:
|
|
126
|
+
// In addition to the normal text types, includes: VARCHAR, NVARCHAR, CHAR, NCHAR, TEXT, NTEXT, DATE, TIME, DATETIME, DATETIME2, SMALLDATETIME, DATETIMEOFFSET, XML, UNIQUEIDENTIFIER, SQL_VARIANT
|
|
127
|
+
return ExpressionType.TEXT;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface CDCRowToSqliteRowOptions {
|
|
132
|
+
row: any;
|
|
133
|
+
columns: sql.IColumnMetadata;
|
|
134
|
+
}
|
|
135
|
+
// CDC metadata columns in CDCS rows that should be excluded
|
|
136
|
+
const CDC_METADATA_COLUMNS = ['__$operation', '__$start_lsn', '__$end_lsn', '__$seqval', '__$update_mask'];
|
|
137
|
+
/**
|
|
138
|
+
* Convert CDC row data to SqliteRow format.
|
|
139
|
+
* CDC rows include table columns plus CDC metadata columns (__$operation, __$start_lsn, etc.)
|
|
140
|
+
* which we filter out.
|
|
141
|
+
*/
|
|
142
|
+
export function CDCToSqliteRow(options: CDCRowToSqliteRowOptions): SqliteInputRow {
|
|
143
|
+
const { row, columns } = options;
|
|
144
|
+
const filteredRow: DatabaseInputRow = {};
|
|
145
|
+
for (const key in row) {
|
|
146
|
+
if (!CDC_METADATA_COLUMNS.includes(key)) {
|
|
147
|
+
filteredRow[key] = row[key];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return toSqliteInputRow(filteredRow, columns);
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './module/MSSQLModule.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
api,
|
|
3
|
+
ConfigurationFileSyncRulesProvider,
|
|
4
|
+
ConnectionTestResult,
|
|
5
|
+
replication,
|
|
6
|
+
system,
|
|
7
|
+
TearDownOptions
|
|
8
|
+
} from '@powersync/service-core';
|
|
9
|
+
import { MSSQLConnectionManagerFactory } from '../replication/MSSQLConnectionManagerFactory.js';
|
|
10
|
+
import * as types from '../types/types.js';
|
|
11
|
+
import { CDCReplicator } from '../replication/CDCReplicator.js';
|
|
12
|
+
import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
|
|
13
|
+
import { checkSourceConfiguration } from '../utils/mssql.js';
|
|
14
|
+
import { MSSQLErrorRateLimiter } from '../replication/MSSQLErrorRateLimiter.js';
|
|
15
|
+
import { MSSQLRouteAPIAdapter } from '../api/MSSQLRouteAPIAdapter.js';
|
|
16
|
+
|
|
17
|
+
export class MSSQLModule extends replication.ReplicationModule<types.MSSQLConnectionConfig> {
|
|
18
|
+
constructor() {
|
|
19
|
+
super({
|
|
20
|
+
name: 'MSSQL',
|
|
21
|
+
type: types.MSSQL_CONNECTION_TYPE,
|
|
22
|
+
configSchema: types.MSSQLConnectionConfig
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async onInitialized(context: system.ServiceContextContainer): Promise<void> {}
|
|
27
|
+
|
|
28
|
+
protected createRouteAPIAdapter(): api.RouteAPI {
|
|
29
|
+
return new MSSQLRouteAPIAdapter(this.resolveConfig(this.decodedConfig!));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected createReplicator(context: system.ServiceContext): replication.AbstractReplicator {
|
|
33
|
+
const normalisedConfig = this.resolveConfig(this.decodedConfig!);
|
|
34
|
+
const syncRuleProvider = new ConfigurationFileSyncRulesProvider(context.configuration.sync_rules);
|
|
35
|
+
const connectionFactory = new MSSQLConnectionManagerFactory(normalisedConfig);
|
|
36
|
+
|
|
37
|
+
return new CDCReplicator({
|
|
38
|
+
id: this.getDefaultId(normalisedConfig.database),
|
|
39
|
+
syncRuleProvider: syncRuleProvider,
|
|
40
|
+
storageEngine: context.storageEngine,
|
|
41
|
+
metricsEngine: context.metricsEngine,
|
|
42
|
+
connectionFactory: connectionFactory,
|
|
43
|
+
rateLimiter: new MSSQLErrorRateLimiter(),
|
|
44
|
+
pollingOptions: normalisedConfig.cdcPollingOptions
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Combines base config with normalized connection settings
|
|
50
|
+
*/
|
|
51
|
+
private resolveConfig(config: types.MSSQLConnectionConfig): types.ResolvedMSSQLConnectionConfig {
|
|
52
|
+
return {
|
|
53
|
+
...config,
|
|
54
|
+
...types.normalizeConnectionConfig(config)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async teardown(options: TearDownOptions): Promise<void> {
|
|
59
|
+
// No specific teardown required for MSSQL
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async testConnection(config: types.MSSQLConnectionConfig) {
|
|
63
|
+
this.decodeConfig(config);
|
|
64
|
+
const normalizedConfig = this.resolveConfig(this.decodedConfig!);
|
|
65
|
+
return await MSSQLModule.testConnection(normalizedConfig);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async testConnection(normalizedConfig: types.ResolvedMSSQLConnectionConfig): Promise<ConnectionTestResult> {
|
|
69
|
+
const connectionManager = new MSSQLConnectionManager(normalizedConfig, { max: 1 });
|
|
70
|
+
try {
|
|
71
|
+
const errors = await checkSourceConfiguration(connectionManager);
|
|
72
|
+
if (errors.length > 0) {
|
|
73
|
+
throw new Error(errors.join('\n'));
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
await connectionManager.end();
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
connectionDescription: normalizedConfig.hostname
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|