@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,241 @@
1
+ import { Logger, logger as defaultLogger, ReplicationAssertionError } from '@powersync/lib-services-framework';
2
+ import timers from 'timers/promises';
3
+ import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
4
+ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
5
+ import { LSN } from '../common/LSN.js';
6
+ import sql from 'mssql';
7
+ import { getMinLSN, incrementLSN } from '../utils/mssql.js';
8
+ import { CDCPollingOptions } from '../types/types.js';
9
+
10
+ enum Operation {
11
+ DELETE = 1,
12
+ INSERT = 2,
13
+ UPDATE_BEFORE = 3,
14
+ UPDATE_AFTER = 4
15
+ }
16
+ /**
17
+ * Schema changes that are detectable by inspecting query events.
18
+ * Create table statements are not included here, since new tables are automatically detected when row events
19
+ * are received for them.
20
+ */
21
+ export enum SchemaChangeType {
22
+ RENAME_TABLE = 'Rename Table',
23
+ DROP_TABLE = 'Drop Table',
24
+ TRUNCATE_TABLE = 'Truncate Table',
25
+ ALTER_TABLE_COLUMN = 'Alter Table Column',
26
+ REPLICATION_IDENTITY = 'Alter Replication Identity'
27
+ }
28
+
29
+ export interface SchemaChange {
30
+ type: SchemaChangeType;
31
+ /**
32
+ * The table that the schema change applies to.
33
+ */
34
+ table: string;
35
+ schema: string;
36
+ /**
37
+ * Populated for table renames if the newTable was matched by the DatabaseFilter
38
+ */
39
+ newTable?: string;
40
+ }
41
+
42
+ export interface CDCEventHandler {
43
+ onInsert: (row: any, table: MSSQLSourceTable, collumns: sql.IColumnMetadata) => Promise<void>;
44
+ onUpdate: (rowAfter: any, rowBefore: any, table: MSSQLSourceTable, collumns: sql.IColumnMetadata) => Promise<void>;
45
+ onDelete: (row: any, table: MSSQLSourceTable, collumns: sql.IColumnMetadata) => Promise<void>;
46
+ onCommit: (lsn: string, transactionCount: number) => Promise<void>;
47
+ onSchemaChange: (change: SchemaChange) => Promise<void>;
48
+ }
49
+
50
+ export interface CDCPollerOptions {
51
+ connectionManager: MSSQLConnectionManager;
52
+ eventHandler: CDCEventHandler;
53
+ sourceTables: MSSQLSourceTable[];
54
+ startLSN: LSN;
55
+ pollingOptions: CDCPollingOptions;
56
+ logger?: Logger;
57
+ }
58
+
59
+ /**
60
+ *
61
+ */
62
+ export class CDCPoller {
63
+ private connectionManager: MSSQLConnectionManager;
64
+ private eventHandler: CDCEventHandler;
65
+ private currentLSN: LSN;
66
+ private logger: Logger;
67
+ private listenerError: Error | null;
68
+
69
+ private isStopped: boolean = false;
70
+ private isStopping: boolean = false;
71
+ private isPolling: boolean = false;
72
+
73
+ constructor(public options: CDCPollerOptions) {
74
+ this.logger = options.logger ?? defaultLogger;
75
+ this.connectionManager = options.connectionManager;
76
+ this.eventHandler = options.eventHandler;
77
+ this.currentLSN = options.startLSN;
78
+ this.listenerError = null;
79
+ }
80
+
81
+ private get pollingBatchSize(): number {
82
+ return this.options.pollingOptions.batchSize;
83
+ }
84
+
85
+ private get pollingIntervalMs(): number {
86
+ return this.options.pollingOptions.intervalMs;
87
+ }
88
+
89
+ private get sourceTables(): MSSQLSourceTable[] {
90
+ return this.options.sourceTables;
91
+ }
92
+
93
+ public async stop(): Promise<void> {
94
+ if (!(this.isStopped || this.isStopping)) {
95
+ this.isStopping = true;
96
+ this.isStopped = true;
97
+ }
98
+ }
99
+
100
+ public async replicateUntilStopped(): Promise<void> {
101
+ this.logger.info(`CDC polling started with interval of ${this.pollingIntervalMs}ms...`);
102
+ this.logger.info(`Polling a maximum of [${this.pollingBatchSize}] transactions per polling cycle.`);
103
+ while (!this.isStopped) {
104
+ // Don't poll if already polling (concurrency guard)
105
+ if (this.isPolling) {
106
+ await timers.setTimeout(this.pollingIntervalMs);
107
+ continue;
108
+ }
109
+
110
+ try {
111
+ const hasChanges = await this.poll();
112
+ if (!hasChanges) {
113
+ // No changes found, wait before next poll
114
+ await timers.setTimeout(this.pollingIntervalMs);
115
+ }
116
+ // If changes were found, poll immediately again (no wait)
117
+ } catch (error) {
118
+ if (!(this.isStopped || this.isStopping)) {
119
+ this.listenerError = error as Error;
120
+ this.logger.error('Error during CDC polling:', error);
121
+ this.stop();
122
+ }
123
+ break;
124
+ }
125
+ }
126
+
127
+ if (this.listenerError) {
128
+ this.logger.error('CDC polling was stopped due to an error:', this.listenerError);
129
+ throw this.listenerError;
130
+ }
131
+
132
+ this.logger.info(`CDC polling stopped...`);
133
+ }
134
+
135
+ private async poll(): Promise<boolean> {
136
+ // Set polling flag to prevent concurrent polling cycles
137
+ this.isPolling = true;
138
+
139
+ try {
140
+ // Calculate the LSN bounds for this batch
141
+ // CDC bounds are inclusive, so the new startLSN is the currentLSN incremented by 1
142
+ const startLSN = await incrementLSN(this.currentLSN, this.connectionManager);
143
+
144
+ const { recordset: results } = await this.connectionManager.query(
145
+ `SELECT TOP (${this.pollingBatchSize}) start_lsn
146
+ FROM cdc.lsn_time_mapping
147
+ WHERE start_lsn >= @startLSN
148
+ ORDER BY start_lsn ASC
149
+ `,
150
+ [{ name: 'startLSN', type: sql.VarBinary, value: startLSN.toBinary() }]
151
+ );
152
+
153
+ // No new LSNs found, no changes to process
154
+ if (results.length === 0) {
155
+ return false;
156
+ }
157
+
158
+ // The new endLSN is the largest LSN in the result
159
+ const endLSN = LSN.fromBinary(results[results.length - 1].start_lsn);
160
+
161
+ this.logger.info(`Polling bounds are ${startLSN} -> ${endLSN} spanning ${results.length} transaction(s).`);
162
+
163
+ let transactionCount = 0;
164
+ for (const table of this.sourceTables) {
165
+ const tableTransactionCount = await this.pollTable(table, { startLSN, endLSN });
166
+ // We poll for batch size transactions, but these include transactions not applicable to our Source Tables.
167
+ // Each Source Table may or may not have transactions that are applicable to it, so just keep track of the highest number of transactions processed for any Source Table.
168
+ if (tableTransactionCount > transactionCount) {
169
+ transactionCount = tableTransactionCount;
170
+ }
171
+ }
172
+
173
+ this.logger.info(
174
+ `Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s).`
175
+ );
176
+ // Call eventHandler.onCommit() with toLSN after processing all tables
177
+ await this.eventHandler.onCommit(endLSN.toString(), transactionCount);
178
+
179
+ this.currentLSN = endLSN;
180
+
181
+ return true;
182
+ } finally {
183
+ // Always clear polling flag, even on error
184
+ this.isPolling = false;
185
+ }
186
+ }
187
+
188
+ private async pollTable(table: MSSQLSourceTable, bounds: { startLSN: LSN; endLSN: LSN }): Promise<number> {
189
+ // Ensure that the startLSN is not before the minimum LSN for the table
190
+ const minLSN = await getMinLSN(this.connectionManager, table.captureInstance);
191
+ if (minLSN > bounds.endLSN) {
192
+ return 0;
193
+ } else if (minLSN >= bounds.startLSN) {
194
+ bounds.startLSN = minLSN;
195
+ }
196
+ const { recordset: results } = await this.connectionManager.query(
197
+ `
198
+ SELECT * FROM ${table.allChangesFunction}(@from_lsn, @to_lsn, 'all update old') ORDER BY __$start_lsn, __$seqval
199
+ `,
200
+ [
201
+ { name: 'from_lsn', type: sql.VarBinary, value: bounds.startLSN.toBinary() },
202
+ { name: 'to_lsn', type: sql.VarBinary, value: bounds.endLSN.toBinary() }
203
+ ]
204
+ );
205
+
206
+ let transactionCount = 0;
207
+ let updateBefore: any = null;
208
+ for (const row of results) {
209
+ const transactionLSN = LSN.fromBinary(row.__$start_lsn);
210
+ switch (row.__$operation) {
211
+ case Operation.DELETE:
212
+ await this.eventHandler.onDelete(row, table, results.columns);
213
+ transactionCount++;
214
+ this.logger.info(`Processed DELETE row LSN: ${transactionLSN}`);
215
+ break;
216
+ case Operation.INSERT:
217
+ await this.eventHandler.onInsert(row, table, results.columns);
218
+ transactionCount++;
219
+ this.logger.info(`Processed INSERT row LSN: ${transactionLSN}`);
220
+ break;
221
+ case Operation.UPDATE_BEFORE:
222
+ updateBefore = row;
223
+ this.logger.debug(`Processed UPDATE, before row LSN: ${transactionLSN}`);
224
+ break;
225
+ case Operation.UPDATE_AFTER:
226
+ if (updateBefore === null) {
227
+ throw new ReplicationAssertionError('Missing before image for update event.');
228
+ }
229
+ await this.eventHandler.onUpdate(row, updateBefore, table, results.columns);
230
+ updateBefore = null;
231
+ transactionCount++;
232
+ this.logger.info(`Processed UPDATE row LSN: ${transactionLSN}`);
233
+ break;
234
+ default:
235
+ this.logger.warn(`Unknown operation type [${row.__$operation}] encountered in CDC changes.`);
236
+ }
237
+ }
238
+
239
+ return transactionCount;
240
+ }
241
+ }
@@ -0,0 +1,87 @@
1
+ import { replication } from '@powersync/service-core';
2
+ import { MSSQLConnectionManagerFactory } from './MSSQLConnectionManagerFactory.js';
3
+ import { container, logger as defaultLogger } from '@powersync/lib-services-framework';
4
+ import { CDCDataExpiredError, CDCStream } from './CDCStream.js';
5
+ import { CDCPollingOptions } from '../types/types.js';
6
+
7
+ export interface CDCReplicationJobOptions extends replication.AbstractReplicationJobOptions {
8
+ connectionFactory: MSSQLConnectionManagerFactory;
9
+ pollingOptions: CDCPollingOptions;
10
+ }
11
+
12
+ export class CDCReplicationJob extends replication.AbstractReplicationJob {
13
+ private connectionFactory: MSSQLConnectionManagerFactory;
14
+ private lastStream: CDCStream | null = null;
15
+ private cdcReplicationJobOptions: CDCReplicationJobOptions;
16
+
17
+ constructor(options: CDCReplicationJobOptions) {
18
+ super(options);
19
+ this.logger = defaultLogger.child({ prefix: `[powersync_${this.options.storage.group_id}] ` });
20
+ this.connectionFactory = options.connectionFactory;
21
+ this.cdcReplicationJobOptions = options;
22
+ }
23
+
24
+ async keepAlive() {
25
+ // TODO Might need to leverage checkpoints table as a keepAlive
26
+ }
27
+
28
+ async replicate() {
29
+ try {
30
+ await this.replicateOnce();
31
+ } catch (e) {
32
+ // Fatal exception
33
+ if (!this.isStopped) {
34
+ // Ignore aborted errors
35
+ this.logger.error(`Replication error`, e);
36
+ if (e.cause != null) {
37
+ this.logger.error(`cause`, e.cause);
38
+ }
39
+
40
+ container.reporter.captureException(e, {
41
+ metadata: {}
42
+ });
43
+
44
+ // This sets the retry delay
45
+ this.rateLimiter.reportError(e);
46
+ }
47
+ if (e instanceof CDCDataExpiredError) {
48
+ // This stops replication and restarts with a new instance
49
+ await this.options.storage.factory.restartReplication(this.storage.group_id);
50
+ }
51
+ } finally {
52
+ this.abortController.abort();
53
+ }
54
+ }
55
+
56
+ async replicateOnce() {
57
+ // New connections on every iteration (every error with retry),
58
+ // otherwise we risk repeating errors related to the connection,
59
+ // such as caused by cached PG schemas.
60
+ const connectionManager = this.connectionFactory.create({
61
+ idleTimeoutMillis: 30_000,
62
+ max: 2
63
+ });
64
+ try {
65
+ await this.rateLimiter?.waitUntilAllowed({ signal: this.abortController.signal });
66
+ if (this.isStopped) {
67
+ return;
68
+ }
69
+ const stream = new CDCStream({
70
+ logger: this.logger,
71
+ abortSignal: this.abortController.signal,
72
+ storage: this.options.storage,
73
+ metrics: this.options.metrics,
74
+ connections: connectionManager,
75
+ pollingOptions: this.cdcReplicationJobOptions.pollingOptions
76
+ });
77
+ this.lastStream = stream;
78
+ await stream.replicate();
79
+ } finally {
80
+ await connectionManager.end();
81
+ }
82
+ }
83
+
84
+ async getReplicationLagMillis(): Promise<number | undefined> {
85
+ return this.lastStream?.getReplicationLagMillis();
86
+ }
87
+ }
@@ -0,0 +1,70 @@
1
+ import { replication, storage } from '@powersync/service-core';
2
+ import { MSSQLConnectionManagerFactory } from './MSSQLConnectionManagerFactory.js';
3
+ import { CDCReplicationJob } from './CDCReplicationJob.js';
4
+ import { MSSQLModule } from '../module/MSSQLModule.js';
5
+ import { CDCPollingOptions } from '../types/types.js';
6
+
7
+ export interface CDCReplicatorOptions extends replication.AbstractReplicatorOptions {
8
+ connectionFactory: MSSQLConnectionManagerFactory;
9
+ pollingOptions: CDCPollingOptions;
10
+ }
11
+
12
+ export class CDCReplicator extends replication.AbstractReplicator<CDCReplicationJob> {
13
+ private readonly connectionFactory: MSSQLConnectionManagerFactory;
14
+ private readonly cdcReplicatorOptions: CDCReplicatorOptions;
15
+
16
+ constructor(options: CDCReplicatorOptions) {
17
+ super(options);
18
+ this.connectionFactory = options.connectionFactory;
19
+ this.cdcReplicatorOptions = options;
20
+ }
21
+
22
+ createJob(options: replication.CreateJobOptions): CDCReplicationJob {
23
+ return new CDCReplicationJob({
24
+ id: this.createJobId(options.storage.group_id),
25
+ storage: options.storage,
26
+ metrics: this.metrics,
27
+ lock: options.lock,
28
+ connectionFactory: this.connectionFactory,
29
+ rateLimiter: this.rateLimiter,
30
+ pollingOptions: this.cdcReplicatorOptions.pollingOptions
31
+ });
32
+ }
33
+
34
+ async cleanUp(syncRulesStorage: storage.SyncRulesBucketStorage): Promise<void> {}
35
+
36
+ async stop(): Promise<void> {
37
+ await super.stop();
38
+ await this.connectionFactory.shutdown();
39
+ }
40
+
41
+ async testConnection() {
42
+ return await MSSQLModule.testConnection(this.connectionFactory.connectionConfig);
43
+ }
44
+
45
+ async getReplicationLagMillis(): Promise<number | undefined> {
46
+ // TODO:Get replication lag
47
+ const lag = await super.getReplicationLagMillis();
48
+ if (lag != null) {
49
+ return lag;
50
+ }
51
+
52
+ // Booting or in an error loop. Check last active replication status.
53
+ // This includes sync rules in an ERROR state.
54
+ const content = await this.storage.getActiveSyncRulesContent();
55
+ if (content == null) {
56
+ return undefined;
57
+ }
58
+ // Measure the lag from the last commit or keepalive timestamp.
59
+ // This is not 100% accurate since it is the commit time in the storage db rather than
60
+ // the source db, but it's the best we currently have for mssql.
61
+ const checkpointTs = content.last_checkpoint_ts?.getTime() ?? 0;
62
+ const keepaliveTs = content.last_keepalive_ts?.getTime() ?? 0;
63
+ const latestTs = Math.max(checkpointTs, keepaliveTs);
64
+ if (latestTs != 0) {
65
+ return Date.now() - latestTs;
66
+ }
67
+
68
+ return undefined;
69
+ }
70
+ }