@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,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
+ }