@powersync/service-module-mssql 0.5.0 → 0.6.0

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 (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/common/CaptureInstance.d.ts +14 -0
  3. package/dist/common/CaptureInstance.js +2 -0
  4. package/dist/common/CaptureInstance.js.map +1 -0
  5. package/dist/common/MSSQLSourceTable.d.ts +16 -14
  6. package/dist/common/MSSQLSourceTable.js +35 -16
  7. package/dist/common/MSSQLSourceTable.js.map +1 -1
  8. package/dist/replication/CDCPoller.d.ts +42 -20
  9. package/dist/replication/CDCPoller.js +200 -60
  10. package/dist/replication/CDCPoller.js.map +1 -1
  11. package/dist/replication/CDCReplicationJob.js +9 -1
  12. package/dist/replication/CDCReplicationJob.js.map +1 -1
  13. package/dist/replication/CDCStream.d.ts +35 -4
  14. package/dist/replication/CDCStream.js +181 -74
  15. package/dist/replication/CDCStream.js.map +1 -1
  16. package/dist/replication/MSSQLConnectionManager.js +16 -5
  17. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  18. package/dist/types/types.d.ts +4 -56
  19. package/dist/types/types.js +5 -24
  20. package/dist/types/types.js.map +1 -1
  21. package/dist/utils/deadlock.d.ts +9 -0
  22. package/dist/utils/deadlock.js +40 -0
  23. package/dist/utils/deadlock.js.map +1 -0
  24. package/dist/utils/mssql.d.ts +33 -15
  25. package/dist/utils/mssql.js +101 -99
  26. package/dist/utils/mssql.js.map +1 -1
  27. package/dist/utils/schema.d.ts +9 -0
  28. package/dist/utils/schema.js +34 -0
  29. package/dist/utils/schema.js.map +1 -1
  30. package/package.json +8 -8
  31. package/src/common/CaptureInstance.ts +15 -0
  32. package/src/common/MSSQLSourceTable.ts +33 -24
  33. package/src/replication/CDCPoller.ts +272 -72
  34. package/src/replication/CDCReplicationJob.ts +8 -1
  35. package/src/replication/CDCStream.ts +237 -90
  36. package/src/replication/MSSQLConnectionManager.ts +15 -5
  37. package/src/types/types.ts +5 -28
  38. package/src/utils/deadlock.ts +44 -0
  39. package/src/utils/mssql.ts +159 -124
  40. package/src/utils/schema.ts +43 -0
  41. package/test/src/CDCStreamTestContext.ts +9 -2
  42. package/test/src/env.ts +1 -1
  43. package/test/src/mssql-to-sqlite.test.ts +18 -10
  44. package/test/src/schema-changes.test.ts +470 -0
  45. package/test/src/util.ts +75 -12
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -1,11 +1,22 @@
1
- import { Logger, logger as defaultLogger, ReplicationAssertionError } from '@powersync/lib-services-framework';
1
+ import {
2
+ DatabaseQueryError,
3
+ ErrorCode,
4
+ Logger,
5
+ logger as defaultLogger,
6
+ ReplicationAssertionError
7
+ } from '@powersync/lib-services-framework';
2
8
  import timers from 'timers/promises';
3
9
  import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
4
10
  import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
5
11
  import { LSN } from '../common/LSN.js';
6
12
  import sql from 'mssql';
7
- import { getMinLSN, incrementLSN } from '../utils/mssql.js';
13
+ import { CaptureInstanceDetails, getCaptureInstances, incrementLSN, toQualifiedTableName } from '../utils/mssql.js';
14
+ import { isDeadlockError } from '../utils/deadlock.js';
8
15
  import { AdditionalConfig } from '../types/types.js';
16
+ import { tableExists } from '../utils/schema.js';
17
+ import { TablePattern } from '@powersync/service-sync-rules';
18
+ import { CaptureInstance } from '../common/CaptureInstance.js';
19
+ import { SourceEntityDescriptor } from '@powersync/service-core';
9
20
 
10
21
  enum Operation {
11
22
  DELETE = 1,
@@ -13,47 +24,58 @@ enum Operation {
13
24
  UPDATE_BEFORE = 3,
14
25
  UPDATE_AFTER = 4
15
26
  }
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
- */
27
+
21
28
  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'
29
+ TABLE_RENAME = 'table_rename',
30
+ TABLE_DROP = 'table_drop',
31
+ TABLE_CREATE = 'table_create',
32
+ TABLE_COLUMN_CHANGES = 'table_column_changes',
33
+ NEW_CAPTURE_INSTANCE = 'new_capture_instance',
34
+ MISSING_CAPTURE_INSTANCE = 'missing_capture_instance'
27
35
  }
28
36
 
29
37
  export interface SchemaChange {
30
38
  type: SchemaChangeType;
31
39
  /**
32
- * The table that the schema change applies to.
40
+ * The table that the schema change applies to. Populated for table drops, renames, new capture instances, and DDL changes.
33
41
  */
34
- table: string;
35
- schema: string;
42
+ table?: MSSQLSourceTable;
36
43
  /**
37
- * Populated for table renames if the newTable was matched by the DatabaseFilter
44
+ * Populated for new tables or renames, but only if the new table matches a sync rule source table.
38
45
  */
39
- newTable?: string;
46
+ newTable?: Omit<SourceEntityDescriptor, 'replicaIdColumns'>;
47
+
48
+ newCaptureInstance?: CaptureInstance;
40
49
  }
41
50
 
42
51
  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>;
52
+ onInsert: (row: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
53
+ onUpdate: (rowAfter: any, rowBefore: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
54
+ onDelete: (row: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
46
55
  onCommit: (lsn: string, transactionCount: number) => Promise<void>;
47
56
  onSchemaChange: (change: SchemaChange) => Promise<void>;
48
57
  }
49
58
 
59
+ export const DEFAULT_SCHEMA_CHECK_INTERVAL_MS = 60_000;
60
+
50
61
  export interface CDCPollerOptions {
51
62
  connectionManager: MSSQLConnectionManager;
52
63
  eventHandler: CDCEventHandler;
53
- sourceTables: MSSQLSourceTable[];
64
+ /** CDC enabled source tables from the sync rules to replicate */
65
+ getReplicatedTables: () => MSSQLSourceTable[];
66
+ /** All table patterns from the sync rules. Can contain tables that need to be replicated
67
+ * but do not yet have CDC enabled
68
+ */
69
+ sourceTables: TablePattern[];
54
70
  startLSN: LSN;
55
71
  logger?: Logger;
56
72
  additionalConfig: AdditionalConfig;
73
+ /**
74
+ * Interval in milliseconds between schema change checks.
75
+ * Schema checks also run immediately after a recoverable error during polling
76
+ * (e.g. a dropped capture instance).
77
+ */
78
+ schemaCheckIntervalMs?: number;
57
79
  }
58
80
 
59
81
  /**
@@ -65,10 +87,12 @@ export class CDCPoller {
65
87
  private currentLSN: LSN;
66
88
  private logger: Logger;
67
89
  private listenerError: Error | null;
90
+ private captureInstances: Map<number, CaptureInstanceDetails>;
68
91
 
69
92
  private isStopped: boolean = false;
70
93
  private isStopping: boolean = false;
71
94
  private isPolling: boolean = false;
95
+ private lastSchemaCheckTime: number = 0;
72
96
 
73
97
  constructor(public options: CDCPollerOptions) {
74
98
  this.logger = options.logger ?? defaultLogger;
@@ -76,6 +100,7 @@ export class CDCPoller {
76
100
  this.eventHandler = options.eventHandler;
77
101
  this.currentLSN = options.startLSN;
78
102
  this.listenerError = null;
103
+ this.captureInstances = new Map<number, CaptureInstanceDetails>();
79
104
  }
80
105
 
81
106
  private get pollingBatchSize(): number {
@@ -86,8 +111,12 @@ export class CDCPoller {
86
111
  return this.options.additionalConfig.pollingIntervalMs;
87
112
  }
88
113
 
89
- private get sourceTables(): MSSQLSourceTable[] {
90
- return this.options.sourceTables;
114
+ private get schemaCheckIntervalMs(): number {
115
+ return this.options.schemaCheckIntervalMs ?? DEFAULT_SCHEMA_CHECK_INTERVAL_MS;
116
+ }
117
+
118
+ private get replicatedTables(): MSSQLSourceTable[] {
119
+ return this.options.getReplicatedTables();
91
120
  }
92
121
 
93
122
  public async stop(): Promise<void> {
@@ -107,14 +136,45 @@ export class CDCPoller {
107
136
  }
108
137
 
109
138
  try {
139
+ if (this.shouldCheckSchema()) {
140
+ this.captureInstances = await getCaptureInstances({ connectionManager: this.connectionManager });
141
+ const schemaChanges = await this.checkForSchemaChanges();
142
+ for (const schemaChange of schemaChanges) {
143
+ await this.eventHandler.onSchemaChange(schemaChange);
144
+ }
145
+ this.lastSchemaCheckTime = Date.now();
146
+
147
+ this.logger.debug(
148
+ `Schema change check complete. Schema changes found: ${schemaChanges.map((c) => c.type).join(', ')}`
149
+ );
150
+ }
151
+
110
152
  const hasChanges = await this.poll();
111
153
  if (!hasChanges) {
112
- // No changes found, wait before next poll
154
+ // No changes found, wait before polling again
113
155
  await timers.setTimeout(this.pollingIntervalMs);
114
156
  }
157
+
115
158
  // If changes were found, poll immediately again (no wait)
116
159
  } catch (error) {
117
160
  if (!(this.isStopped || this.isStopping)) {
161
+ // Recoverable errors
162
+ if (error instanceof DatabaseQueryError) {
163
+ this.logger.warn(error.message);
164
+ // Force schema check on next iteration to detect breaking changes
165
+ this.lastSchemaCheckTime = 0;
166
+ continue;
167
+ }
168
+ // Deadlock errors are transient — even if all retries within retryOnDeadlock were
169
+ // exhausted, we should not crash the poller. Instead, log and retry the entire cycle.
170
+ if (isDeadlockError(error)) {
171
+ this.logger.warn(
172
+ `Deadlock persisted after all retry attempts during CDC polling cycle. Will retry on next cycle: ${(error as Error).message}`
173
+ );
174
+ continue;
175
+ }
176
+
177
+ // Non-recoverable errors
118
178
  this.listenerError = error as Error;
119
179
  this.logger.error('Error during CDC polling:', error);
120
180
  this.stop();
@@ -160,17 +220,22 @@ export class CDCPoller {
160
220
  this.logger.info(`Polling bounds are ${startLSN} -> ${endLSN} spanning ${results.length} transaction(s).`);
161
221
 
162
222
  let transactionCount = 0;
163
- for (const table of this.sourceTables) {
164
- const tableTransactionCount = await this.pollTable(table, { startLSN, endLSN });
165
- // We poll for batch size transactions, but these include transactions not applicable to our Source Tables.
166
- // 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.
167
- if (tableTransactionCount > transactionCount) {
168
- transactionCount = tableTransactionCount;
223
+ this.logger.debug(
224
+ `Currently replicating tables: ${this.replicatedTables.map((table) => table.toQualifiedName()).join(', ')}`
225
+ );
226
+ for (const table of this.replicatedTables) {
227
+ if (table.enabledForCDC()) {
228
+ const tableTransactionCount = await this.pollTable(table, { startLSN, endLSN });
229
+ // We poll for batch size transactions, but these include transactions not applicable to our Source Tables.
230
+ // 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.
231
+ if (tableTransactionCount > transactionCount) {
232
+ transactionCount = tableTransactionCount;
233
+ }
169
234
  }
170
235
  }
171
236
 
172
237
  this.logger.info(
173
- `Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s).`
238
+ `Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s). Commited LSN: ${endLSN.toString()}`
174
239
  );
175
240
  // Call eventHandler.onCommit() with toLSN after processing all tables
176
241
  await this.eventHandler.onCommit(endLSN.toString(), transactionCount);
@@ -186,61 +251,196 @@ export class CDCPoller {
186
251
 
187
252
  private async pollTable(table: MSSQLSourceTable, bounds: { startLSN: LSN; endLSN: LSN }): Promise<number> {
188
253
  // Ensure that the startLSN is not before the minimum LSN for the table
189
- const minLSN = await getMinLSN(this.connectionManager, table.captureInstance);
254
+ const minLSN = this.captureInstances.get(table.objectId)!.instances[0].minLSN;
190
255
  if (minLSN > bounds.endLSN) {
191
256
  return 0;
192
257
  } else if (minLSN >= bounds.startLSN) {
193
258
  bounds.startLSN = minLSN;
194
259
  }
195
- const { recordset: results } = await this.connectionManager.query(
196
- `
260
+
261
+ try {
262
+ const { recordset: results } = await this.connectionManager.query(
263
+ `
197
264
  SELECT * FROM ${table.allChangesFunction}(@from_lsn, @to_lsn, 'all update old') ORDER BY __$start_lsn, __$seqval
198
265
  `,
199
- [
200
- { name: 'from_lsn', type: sql.VarBinary, value: bounds.startLSN.toBinary() },
201
- { name: 'to_lsn', type: sql.VarBinary, value: bounds.endLSN.toBinary() }
202
- ]
203
- );
266
+ [
267
+ { name: 'from_lsn', type: sql.VarBinary, value: bounds.startLSN.toBinary() },
268
+ { name: 'to_lsn', type: sql.VarBinary, value: bounds.endLSN.toBinary() }
269
+ ]
270
+ );
204
271
 
205
- let transactionCount = 0;
206
- let updateBefore: any = null;
207
- let lastTransactionLSN: LSN | null = 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
- this.logger.info(`Processed DELETE row LSN: ${transactionLSN}`);
214
- break;
215
- case Operation.INSERT:
216
- await this.eventHandler.onInsert(row, table, results.columns);
217
- this.logger.info(`Processed INSERT row LSN: ${transactionLSN}`);
218
- break;
219
- case Operation.UPDATE_BEFORE:
220
- updateBefore = row;
221
- this.logger.debug(`Processed UPDATE, before row LSN: ${transactionLSN}`);
222
- break;
223
- case Operation.UPDATE_AFTER:
224
- if (updateBefore === null) {
225
- throw new ReplicationAssertionError('Missing before image for update event.');
272
+ let transactionCount = 0;
273
+ let updateBefore: any = null;
274
+ let lastTransactionLSN: LSN | null = null;
275
+ for (const row of results) {
276
+ const transactionLSN = LSN.fromBinary(row.__$start_lsn);
277
+ switch (row.__$operation) {
278
+ case Operation.DELETE:
279
+ await this.eventHandler.onDelete(row, table, results.columns);
280
+ this.logger.info(`Processed DELETE row LSN: ${transactionLSN}`);
281
+ break;
282
+ case Operation.INSERT:
283
+ await this.eventHandler.onInsert(row, table, results.columns);
284
+ this.logger.info(`Processed INSERT row LSN: ${transactionLSN}`);
285
+ break;
286
+ case Operation.UPDATE_BEFORE:
287
+ updateBefore = row;
288
+ this.logger.debug(`Processed UPDATE, before row LSN: ${transactionLSN}`);
289
+ break;
290
+ case Operation.UPDATE_AFTER:
291
+ if (updateBefore === null) {
292
+ throw new ReplicationAssertionError('Missing before image for update event.');
293
+ }
294
+ await this.eventHandler.onUpdate(row, updateBefore, table, results.columns);
295
+ updateBefore = null;
296
+ this.logger.info(`Processed UPDATE row LSN: ${transactionLSN}`);
297
+ break;
298
+ default:
299
+ this.logger.warn(`Unknown operation type [${row.__$operation}] encountered in CDC changes.`);
300
+ }
301
+
302
+ // Increment transaction count when we encounter a new transaction LSN (except for UPDATE_BEFORE rows)
303
+ if (transactionLSN != lastTransactionLSN) {
304
+ lastTransactionLSN = transactionLSN;
305
+ if (row.__$operation !== Operation.UPDATE_BEFORE) {
306
+ transactionCount++;
226
307
  }
227
- await this.eventHandler.onUpdate(row, updateBefore, table, results.columns);
228
- updateBefore = null;
229
- this.logger.info(`Processed UPDATE row LSN: ${transactionLSN}`);
230
- break;
231
- default:
232
- this.logger.warn(`Unknown operation type [${row.__$operation}] encountered in CDC changes.`);
308
+ }
309
+ }
310
+
311
+ return transactionCount;
312
+ } catch (error) {
313
+ // This Covers both deleted tables and capture instances
314
+ if (error.message.includes(`Invalid object name`)) {
315
+ throw new DatabaseQueryError(
316
+ ErrorCode.PSYNC_S1601,
317
+ `Capture instance for table ${table.toQualifiedName()} is no longer available.`,
318
+ error
319
+ );
320
+ }
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ private shouldCheckSchema(): boolean {
326
+ return Date.now() - this.lastSchemaCheckTime >= this.schemaCheckIntervalMs;
327
+ }
328
+
329
+ /**
330
+ * Checks the given table for pending schema changes that can lead to inconsistencies in the replicated data if not handled.
331
+ * Returns the SchemaChange if any are found, null otherwise.
332
+ */
333
+ private async checkForSchemaChanges(): Promise<SchemaChange[]> {
334
+ const schemaChanges: SchemaChange[] = [];
335
+
336
+ const newTables = this.checkForNewTables();
337
+ for (const table of newTables) {
338
+ this.logger.info(
339
+ `New table ${toQualifiedTableName(table.sourceTable.schema, table.sourceTable.name)} matching the sync rules has been created. Handling schema change...`
340
+ );
341
+ schemaChanges.push({
342
+ type: SchemaChangeType.TABLE_CREATE,
343
+ newTable: {
344
+ name: table.sourceTable.name,
345
+ schema: table.sourceTable.schema,
346
+ objectId: table.sourceTable.objectId
347
+ },
348
+ newCaptureInstance: table.instances[0]
349
+ });
350
+ }
351
+
352
+ for (const table of this.replicatedTables) {
353
+ const exists = await tableExists(table.objectId, this.connectionManager);
354
+ if (!exists) {
355
+ this.logger.info(`Table ${table.toQualifiedName()} has been dropped. Handling schema change...`);
356
+ schemaChanges.push({
357
+ type: SchemaChangeType.TABLE_DROP,
358
+ table
359
+ });
360
+ continue;
361
+ }
362
+
363
+ const captureInstanceDetails = this.captureInstances.get(table.objectId);
364
+ if (!captureInstanceDetails) {
365
+ if (table.enabledForCDC()) {
366
+ // Table had a capture instance but no longer does.
367
+ schemaChanges.push({
368
+ type: SchemaChangeType.MISSING_CAPTURE_INSTANCE,
369
+ table
370
+ });
371
+ }
372
+ continue;
373
+ }
374
+
375
+ const latestCaptureInstance = captureInstanceDetails.instances[0];
376
+ // If the table is not enabled for CDC or the capture instance is different, we need to re-snapshot the source table
377
+ if (!table.enabledForCDC() || table.captureInstance!.objectId !== latestCaptureInstance.objectId) {
378
+ schemaChanges.push({
379
+ type: SchemaChangeType.NEW_CAPTURE_INSTANCE,
380
+ table,
381
+ newCaptureInstance: latestCaptureInstance
382
+ });
383
+ continue;
233
384
  }
234
385
 
235
- // Increment transaction count when we encounter a new transaction LSN (except for UPDATE_BEFORE rows)
236
- if (transactionLSN != lastTransactionLSN) {
237
- lastTransactionLSN = transactionLSN;
238
- if (row.__$operation !== Operation.UPDATE_BEFORE) {
239
- transactionCount++;
386
+ // One of the replicated tables has been renamed
387
+ if (table.sourceTable.name !== captureInstanceDetails.sourceTable.name) {
388
+ const newTable = this.tableMatchesSyncRules(
389
+ captureInstanceDetails.sourceTable.schema,
390
+ captureInstanceDetails.sourceTable.name
391
+ )
392
+ ? {
393
+ name: captureInstanceDetails.sourceTable.name,
394
+ schema: captureInstanceDetails.sourceTable.schema,
395
+ objectId: captureInstanceDetails.sourceTable.objectId
396
+ }
397
+ : undefined;
398
+
399
+ schemaChanges.push({
400
+ type: SchemaChangeType.TABLE_RENAME,
401
+ table,
402
+ newTable,
403
+ newCaptureInstance: latestCaptureInstance
404
+ });
405
+ continue;
406
+ }
407
+
408
+ if (latestCaptureInstance.pendingSchemaChanges.length > 0) {
409
+ schemaChanges.push({
410
+ type: SchemaChangeType.TABLE_COLUMN_CHANGES,
411
+ table,
412
+ newCaptureInstance: latestCaptureInstance
413
+ });
414
+ }
415
+ }
416
+
417
+ return schemaChanges;
418
+ }
419
+
420
+ private checkForNewTables(): CaptureInstanceDetails[] {
421
+ const newTables: CaptureInstanceDetails[] = [];
422
+ for (const [objectId, captureInstanceDetails] of this.captureInstances.entries()) {
423
+ // If a source table is not in the replicated tables array, but a capture instance exists for it, it is potentially a new table to replicate.
424
+ if (!this.replicatedTables.some((table) => table.objectId === objectId)) {
425
+ // Check if the new table matches any of the sync rules source tables.
426
+ if (
427
+ this.tableMatchesSyncRules(captureInstanceDetails.sourceTable.schema, captureInstanceDetails.sourceTable.name)
428
+ ) {
429
+ newTables.push(captureInstanceDetails);
240
430
  }
241
431
  }
242
432
  }
243
433
 
244
- return transactionCount;
434
+ return newTables;
435
+ }
436
+
437
+ private tableMatchesSyncRules(schema: string, tableName: string): boolean {
438
+ return this.options.sourceTables.some((tablePattern) =>
439
+ tablePattern.matches({
440
+ connectionTag: this.connectionManager.connectionTag,
441
+ schema: schema,
442
+ name: tableName
443
+ })
444
+ );
245
445
  }
246
446
  }
@@ -3,6 +3,7 @@ import { MSSQLConnectionManagerFactory } from './MSSQLConnectionManagerFactory.j
3
3
  import { container, logger as defaultLogger } from '@powersync/lib-services-framework';
4
4
  import { CDCDataExpiredError, CDCStream } from './CDCStream.js';
5
5
  import { AdditionalConfig } from '../types/types.js';
6
+ import { POWERSYNC_CHECKPOINTS_TABLE } from '../utils/mssql.js';
6
7
 
7
8
  export interface CDCReplicationJobOptions extends replication.AbstractReplicationJobOptions {
8
9
  connectionFactory: MSSQLConnectionManagerFactory;
@@ -22,7 +23,13 @@ export class CDCReplicationJob extends replication.AbstractReplicationJob {
22
23
  }
23
24
 
24
25
  async keepAlive() {
25
- // TODO Might need to leverage checkpoints table as a keepAlive
26
+ if (this.lastStream) {
27
+ try {
28
+ await this.lastStream.keepAlive();
29
+ } catch (e) {
30
+ this.logger.warn(`KeepAlive failed, unable to write an update to the ${POWERSYNC_CHECKPOINTS_TABLE} table`, e);
31
+ }
32
+ }
26
33
  }
27
34
 
28
35
  async replicate() {