@powersync/service-module-mssql 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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/CHANGELOG.md +209 -4
- package/dist/api/MSSQLRouteAPIAdapter.d.ts +4 -5
- package/dist/api/MSSQLRouteAPIAdapter.js +33 -39
- package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
- package/dist/common/MSSQLSourceTable.d.ts +12 -2
- package/dist/common/MSSQLSourceTable.js +30 -4
- package/dist/common/MSSQLSourceTable.js.map +1 -1
- package/dist/module/MSSQLModule.js +2 -1
- package/dist/module/MSSQLModule.js.map +1 -1
- package/dist/replication/CDCPoller.d.ts +13 -36
- package/dist/replication/CDCPoller.js +173 -129
- package/dist/replication/CDCPoller.js.map +1 -1
- package/dist/replication/CDCReplicationJob.js +1 -1
- package/dist/replication/CDCReplicationJob.js.map +1 -1
- package/dist/replication/CDCReplicator.js +1 -1
- package/dist/replication/CDCReplicator.js.map +1 -1
- package/dist/replication/CDCStream.d.ts +11 -14
- package/dist/replication/CDCStream.js +124 -115
- package/dist/replication/CDCStream.js.map +1 -1
- package/dist/replication/CaptureReconciler.d.ts +47 -0
- package/dist/replication/CaptureReconciler.js +155 -0
- package/dist/replication/CaptureReconciler.js.map +1 -0
- package/dist/replication/MSSQLConnectionManager.d.ts +1 -0
- package/dist/replication/MSSQLConnectionManager.js +19 -14
- package/dist/replication/MSSQLConnectionManager.js.map +1 -1
- package/dist/replication/MSSQLTableReconciliationContext.d.ts +21 -0
- package/dist/replication/MSSQLTableReconciliationContext.js +7 -0
- package/dist/replication/MSSQLTableReconciliationContext.js.map +1 -0
- package/dist/replication/SchemaChange.d.ts +67 -0
- package/dist/replication/SchemaChange.js +31 -0
- package/dist/replication/SchemaChange.js.map +1 -0
- package/dist/types/types.d.ts +13 -0
- package/dist/types/types.js +28 -2
- package/dist/types/types.js.map +1 -1
- package/dist/utils/mssql.js +1 -1
- package/dist/utils/schema.d.ts +14 -1
- package/dist/utils/schema.js +43 -45
- package/dist/utils/schema.js.map +1 -1
- package/package.json +11 -21
- package/src/api/MSSQLRouteAPIAdapter.ts +38 -49
- package/src/common/MSSQLSourceTable.ts +32 -4
- package/src/module/MSSQLModule.ts +2 -1
- package/src/replication/CDCPoller.ts +206 -162
- package/src/replication/CDCReplicationJob.ts +1 -1
- package/src/replication/CDCReplicator.ts +1 -1
- package/src/replication/CDCStream.ts +157 -163
- package/src/replication/CaptureReconciler.ts +186 -0
- package/src/replication/MSSQLConnectionManager.ts +23 -14
- package/src/replication/MSSQLTableReconciliationContext.ts +29 -0
- package/src/replication/SchemaChange.ts +79 -0
- package/src/types/types.ts +47 -2
- package/src/utils/mssql.ts +1 -1
- package/src/utils/schema.ts +60 -54
- package/test/src/CDCPoller.test.ts +382 -0
- package/test/src/CDCStream.test.ts +6 -2
- package/test/src/CDCStreamTestContext.ts +30 -23
- package/test/src/CaptureReconciler.test.ts +171 -0
- package/test/src/MSSQLSourceTable.test.ts +30 -0
- package/test/src/config.test.ts +71 -0
- package/test/src/schema-changes.test.ts +513 -249
- package/test/src/schema.test.ts +17 -0
- package/test/src/util.ts +73 -9
- package/tsconfig.json +0 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -26,6 +26,7 @@ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
|
|
|
26
26
|
import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
|
|
27
27
|
import { AdditionalConfig } from '../types/types.js';
|
|
28
28
|
import {
|
|
29
|
+
CaptureInstanceDetails,
|
|
29
30
|
checkRetentionThresholds,
|
|
30
31
|
checkSourceConfiguration,
|
|
31
32
|
createCheckpoint,
|
|
@@ -34,15 +35,21 @@ import {
|
|
|
34
35
|
getLatestReplicatedLSN,
|
|
35
36
|
isIColumnMetadata
|
|
36
37
|
} from '../utils/mssql.js';
|
|
38
|
+
import { getReplicationIdentityColumns, getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
|
|
37
39
|
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} from '
|
|
43
|
-
import { CDCEventHandler, CDCPoller, SchemaChange, SchemaChangeType } from './CDCPoller.js';
|
|
40
|
+
CaptureInstanceMissingError,
|
|
41
|
+
createCaptureReconciler,
|
|
42
|
+
SourceTableUnavailableError
|
|
43
|
+
} from './CaptureReconciler.js';
|
|
44
|
+
import { CDCEventHandler, CDCPoller } from './CDCPoller.js';
|
|
44
45
|
import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
|
|
45
46
|
import { BatchedSnapshotQuery, MSSQLSnapshotQuery, SimpleSnapshotQuery } from './MSSQLSnapshotQuery.js';
|
|
47
|
+
import type {
|
|
48
|
+
MSSQLReadyTableReconciliationContext,
|
|
49
|
+
MSSQLUnavailableTableReconciliationContext
|
|
50
|
+
} from './MSSQLTableReconciliationContext.js';
|
|
51
|
+
import { MSSQLTableReconciliationState } from './MSSQLTableReconciliationContext.js';
|
|
52
|
+
import { SchemaChange, SchemaChangeType } from './SchemaChange.js';
|
|
46
53
|
|
|
47
54
|
export interface CDCStreamOptions {
|
|
48
55
|
connections: MSSQLConnectionManager;
|
|
@@ -114,6 +121,10 @@ export class CDCStream {
|
|
|
114
121
|
|
|
115
122
|
private replicationLag = new ReplicationLagTracker();
|
|
116
123
|
|
|
124
|
+
// Latest capture instance warning emitted for each source table.
|
|
125
|
+
private warnedNewerCaptureObjectId = new Map<number, number>();
|
|
126
|
+
private warnedSchemaChangeCount = new Map<number, number>();
|
|
127
|
+
|
|
117
128
|
constructor(private options: CDCStreamOptions) {
|
|
118
129
|
this.logger = options.logger ?? defaultLogger;
|
|
119
130
|
this.storage = options.storage;
|
|
@@ -139,7 +150,7 @@ export class CDCStream {
|
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
get groupId() {
|
|
142
|
-
return this.options.storage.
|
|
153
|
+
return this.options.storage.replicationStreamId;
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
get connectionId() {
|
|
@@ -178,16 +189,19 @@ export class CDCStream {
|
|
|
178
189
|
|
|
179
190
|
async populateTableCache() {
|
|
180
191
|
const sourceTables = this.syncRules.getSourceTables();
|
|
192
|
+
// Loaded once for all patterns - this reads the CDC metadata for the entire database.
|
|
193
|
+
const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
|
|
181
194
|
await this.storage.startBatch(
|
|
182
195
|
{
|
|
183
196
|
logger: this.logger,
|
|
184
197
|
zeroLSN: LSN.ZERO,
|
|
185
198
|
defaultSchema: this.defaultSchema,
|
|
186
|
-
storeCurrentData: false
|
|
199
|
+
storeCurrentData: false,
|
|
200
|
+
signal: this.abortSignal
|
|
187
201
|
},
|
|
188
202
|
async (batch) => {
|
|
189
203
|
for (let tablePattern of sourceTables) {
|
|
190
|
-
await this.getQualifiedTableNames(batch, tablePattern);
|
|
204
|
+
await this.getQualifiedTableNames(batch, tablePattern, captureInstances);
|
|
191
205
|
}
|
|
192
206
|
}
|
|
193
207
|
);
|
|
@@ -195,28 +209,31 @@ export class CDCStream {
|
|
|
195
209
|
|
|
196
210
|
async getQualifiedTableNames(
|
|
197
211
|
batch: storage.BucketStorageBatch,
|
|
198
|
-
tablePattern: TablePattern
|
|
212
|
+
tablePattern: TablePattern,
|
|
213
|
+
captureInstances: Map<number, CaptureInstanceDetails>
|
|
199
214
|
): Promise<MSSQLSourceTable[]> {
|
|
200
215
|
if (tablePattern.connectionTag != this.connections.connectionTag) {
|
|
201
216
|
return [];
|
|
202
217
|
}
|
|
203
218
|
|
|
204
|
-
const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
|
|
205
219
|
const matchedTables: ResolvedTable[] = await getTablesFromPattern(this.connections, tablePattern);
|
|
206
220
|
|
|
221
|
+
if (matchedTables.length == 0) {
|
|
222
|
+
await this.reportUnavailableTable(batch, {
|
|
223
|
+
state: MSSQLTableReconciliationState.TABLE_MISSING,
|
|
224
|
+
source: {
|
|
225
|
+
connectionTag: this.connectionTag,
|
|
226
|
+
name: tablePattern.name,
|
|
227
|
+
schema: tablePattern.schema,
|
|
228
|
+
objectId: undefined,
|
|
229
|
+
// Replica identity metadata is unavailable when the physical table does not exist.
|
|
230
|
+
replicaIdColumns: []
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
const tables: MSSQLSourceTable[] = [];
|
|
208
236
|
for (const matchedTable of matchedTables) {
|
|
209
|
-
const captureInstanceDetails = captureInstances.get(matchedTable.objectId as number);
|
|
210
|
-
if (!captureInstanceDetails) {
|
|
211
|
-
// Match Postgres publication handling: If the source table cannot stream changes yet,
|
|
212
|
-
// don't resolve it into storage. Once CDC is enabled, the schema poller detects the
|
|
213
|
-
// new capture instance and resolves/snapshots it then.
|
|
214
|
-
this.logger.info(
|
|
215
|
-
`Skipping ${tablePattern.schema}.${matchedTable.name} - not enabled for CDC. This table will not be replicated until CDC is enabled for it.`
|
|
216
|
-
);
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
237
|
// TODO: Check RLS settings for table
|
|
221
238
|
|
|
222
239
|
const replicaIdColumns = await getReplicationIdentityColumns({
|
|
@@ -225,45 +242,54 @@ export class CDCStream {
|
|
|
225
242
|
schema: matchedTable.schema
|
|
226
243
|
});
|
|
227
244
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
245
|
+
const source: SourceEntityDescriptor = {
|
|
246
|
+
connectionTag: this.connectionTag,
|
|
247
|
+
name: matchedTable.name,
|
|
248
|
+
schema: matchedTable.schema,
|
|
249
|
+
objectId: matchedTable.objectId,
|
|
250
|
+
replicaIdColumns: replicaIdColumns.columns
|
|
251
|
+
};
|
|
252
|
+
const captureInstanceDetails = captureInstances.get(matchedTable.objectId);
|
|
253
|
+
if (!captureInstanceDetails?.instances.length) {
|
|
254
|
+
await this.reportUnavailableTable(batch, {
|
|
255
|
+
state: MSSQLTableReconciliationState.CDC_DISABLED,
|
|
256
|
+
source
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
const table = await this.processTable(batch, {
|
|
260
|
+
state: MSSQLTableReconciliationState.READY,
|
|
261
|
+
source,
|
|
262
|
+
captureInstances: captureInstanceDetails.instances
|
|
263
|
+
});
|
|
240
264
|
|
|
241
|
-
|
|
265
|
+
tables.push(table);
|
|
266
|
+
}
|
|
242
267
|
}
|
|
243
268
|
return tables;
|
|
244
269
|
}
|
|
245
270
|
|
|
246
271
|
async processTable(
|
|
247
272
|
batch: storage.BucketStorageBatch,
|
|
248
|
-
|
|
249
|
-
captureInstance: CaptureInstance | null,
|
|
250
|
-
snapshot: boolean
|
|
273
|
+
context: MSSQLReadyTableReconciliationContext
|
|
251
274
|
): Promise<MSSQLSourceTable> {
|
|
252
|
-
|
|
253
|
-
|
|
275
|
+
const { source } = context;
|
|
276
|
+
if (!source.objectId && typeof source.objectId != 'number') {
|
|
277
|
+
throw new ReplicationAssertionError(`objectId expected, got ${typeof source.objectId}`);
|
|
254
278
|
}
|
|
279
|
+
// Load all source metadata before resolving so the storage transaction does no source I/O.
|
|
255
280
|
const resolved = await batch.resolveTables({
|
|
256
281
|
connection_id: this.connectionId,
|
|
257
|
-
source
|
|
282
|
+
source,
|
|
283
|
+
reconcileSourceTables: createCaptureReconciler(context)
|
|
258
284
|
});
|
|
259
|
-
const resolvedTable = new MSSQLSourceTable(table, resolved.tables);
|
|
260
285
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
286
|
+
const resolvedTable = new MSSQLSourceTable(source, resolved.tables);
|
|
287
|
+
|
|
288
|
+
resolvedTable.setCaptureInstance(context.captureInstances);
|
|
289
|
+
if (!resolvedTable.enabledForCDC()) {
|
|
290
|
+
throw new ReplicationAssertionError(
|
|
291
|
+
`No capture instance matching the persisted binding is available for table ${resolvedTable.toQualifiedName()}`
|
|
264
292
|
);
|
|
265
|
-
} else {
|
|
266
|
-
resolvedTable.setCaptureInstance(captureInstance);
|
|
267
293
|
}
|
|
268
294
|
|
|
269
295
|
this.tableCache.set(resolvedTable);
|
|
@@ -271,27 +297,26 @@ export class CDCStream {
|
|
|
271
297
|
// Drop conflicting tables. This includes for example renamed tables.
|
|
272
298
|
await batch.drop(resolved.dropTables);
|
|
273
299
|
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
// 2. AND the table is enabled for CDC with a valid capture instance.
|
|
277
|
-
const snapshotCandidates = resolved.tables.filter(
|
|
278
|
-
(candidate) => snapshot && !candidate.snapshotComplete && candidate.syncAny
|
|
279
|
-
);
|
|
280
|
-
const shouldSnapshot = snapshotCandidates.length > 0 && resolvedTable.enabledForCDC();
|
|
281
|
-
|
|
282
|
-
if (shouldSnapshot) {
|
|
283
|
-
// Truncate tables in case a previous snapshot was interrupted.
|
|
284
|
-
await batch.truncate(snapshotCandidates);
|
|
285
|
-
|
|
286
|
-
// Start the snapshot(s) inside a transaction.
|
|
287
|
-
for (const candidate of snapshotCandidates) {
|
|
288
|
-
await this.snapshotTableInTx(batch, resolvedTable, candidate);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
300
|
+
// Snapshotting is driven by startInitialReplication, which picks up any source table that is
|
|
301
|
+
// not snapshot-complete after the table cache has been populated.
|
|
292
302
|
return resolvedTable;
|
|
293
303
|
}
|
|
294
304
|
|
|
305
|
+
private async reportUnavailableTable(
|
|
306
|
+
batch: storage.BucketStorageBatch,
|
|
307
|
+
context: MSSQLUnavailableTableReconciliationContext
|
|
308
|
+
): Promise<never> {
|
|
309
|
+
await batch.resolveTables({
|
|
310
|
+
connection_id: this.connectionId,
|
|
311
|
+
source: context.source,
|
|
312
|
+
reconcileSourceTables: createCaptureReconciler(context)
|
|
313
|
+
});
|
|
314
|
+
// Unavailable-state reconciliation must reject; this enforces the Promise<never> contract if it returns.
|
|
315
|
+
throw new ReplicationAssertionError(
|
|
316
|
+
`Unavailable source-table reconciliation unexpectedly completed for ${context.source.schema}.${context.source.name}`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
295
320
|
private async snapshotTableInTx(
|
|
296
321
|
batch: storage.BucketStorageBatch,
|
|
297
322
|
physicalTable: MSSQLSourceTable,
|
|
@@ -479,7 +504,8 @@ export class CDCStream {
|
|
|
479
504
|
zeroLSN: LSN.ZERO,
|
|
480
505
|
defaultSchema: this.defaultSchema,
|
|
481
506
|
storeCurrentData: false,
|
|
482
|
-
skipExistingRows: true
|
|
507
|
+
skipExistingRows: true,
|
|
508
|
+
signal: this.abortSignal
|
|
483
509
|
},
|
|
484
510
|
async (batch) => {
|
|
485
511
|
switch (status) {
|
|
@@ -495,9 +521,18 @@ export class CDCStream {
|
|
|
495
521
|
break;
|
|
496
522
|
case SnapshotStatus.LIMITED_RESNAPSHOT:
|
|
497
523
|
for (const table of specificTablesToResnapshot!) {
|
|
524
|
+
if (table.captureInstance == null) {
|
|
525
|
+
throw new ReplicationAssertionError(
|
|
526
|
+
`No capture instance bound for table ${table.toQualifiedName()} while re-snapshotting`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
498
529
|
await batch.drop(table.getReplicatedSourceTables());
|
|
499
|
-
//
|
|
500
|
-
await this.processTable(batch,
|
|
530
|
+
// Re-resolve against the bound instance only, so the new record keeps the same pin.
|
|
531
|
+
await this.processTable(batch, {
|
|
532
|
+
state: MSSQLTableReconciliationState.READY,
|
|
533
|
+
source: table.ref,
|
|
534
|
+
captureInstances: [table.captureInstance]
|
|
535
|
+
});
|
|
501
536
|
}
|
|
502
537
|
break;
|
|
503
538
|
default:
|
|
@@ -571,7 +606,7 @@ export class CDCStream {
|
|
|
571
606
|
private async checkSnapshotStatus(): Promise<SnapshotStatusResult> {
|
|
572
607
|
const status = await this.storage.getStatus();
|
|
573
608
|
|
|
574
|
-
if (status.
|
|
609
|
+
if (status.snapshotDone) {
|
|
575
610
|
const additionalTablesToSnapshot: Set<MSSQLSourceTable> = new Set();
|
|
576
611
|
const newTables = this.tableCache
|
|
577
612
|
.getAll()
|
|
@@ -583,26 +618,26 @@ export class CDCStream {
|
|
|
583
618
|
newTables.forEach((table) => additionalTablesToSnapshot.add(table));
|
|
584
619
|
}
|
|
585
620
|
|
|
586
|
-
// Snapshot is done, but we still need to check that the last known
|
|
621
|
+
// Snapshot is done, but we still need to check that the last known resume LSN is still
|
|
587
622
|
// within the retention threshold of the CDC tables
|
|
588
623
|
|
|
589
|
-
const
|
|
624
|
+
const lastResumeLSN = LSN.fromString(status.resumeLsn!);
|
|
590
625
|
// Check that the CDC tables still have valid data
|
|
591
626
|
const tablesOutsideRetentionThreshold = await checkRetentionThresholds({
|
|
592
|
-
checkpointLSN:
|
|
627
|
+
checkpointLSN: lastResumeLSN,
|
|
593
628
|
tables: this.tableCache.getAll(),
|
|
594
629
|
connectionManager: this.connections
|
|
595
630
|
});
|
|
596
631
|
if (tablesOutsideRetentionThreshold.length > 0) {
|
|
597
632
|
this.logger.warn(
|
|
598
|
-
`Updates from the last
|
|
633
|
+
`Updates from the last resume LSN are no longer available in the CDC instances of the following table(s): ${tablesOutsideRetentionThreshold.map((table) => table.toQualifiedName()).join(', ')}.`
|
|
599
634
|
);
|
|
600
635
|
tablesOutsideRetentionThreshold.forEach((table) => additionalTablesToSnapshot.add(table));
|
|
601
636
|
}
|
|
602
637
|
if (additionalTablesToSnapshot.size > 0) {
|
|
603
638
|
return {
|
|
604
639
|
status: SnapshotStatus.LIMITED_RESNAPSHOT,
|
|
605
|
-
snapshotLSN: status.
|
|
640
|
+
snapshotLSN: status.resumeLsn,
|
|
606
641
|
specificTablesToResnapshot: Array.from(additionalTablesToSnapshot)
|
|
607
642
|
};
|
|
608
643
|
} else {
|
|
@@ -613,8 +648,8 @@ export class CDCStream {
|
|
|
613
648
|
}
|
|
614
649
|
} else {
|
|
615
650
|
return {
|
|
616
|
-
status: status.
|
|
617
|
-
snapshotLSN: status.
|
|
651
|
+
status: status.resumeLsn != null ? SnapshotStatus.RESUME : SnapshotStatus.INITIAL,
|
|
652
|
+
snapshotLSN: status.resumeLsn
|
|
618
653
|
};
|
|
619
654
|
}
|
|
620
655
|
}
|
|
@@ -626,7 +661,8 @@ export class CDCStream {
|
|
|
626
661
|
zeroLSN: LSN.ZERO,
|
|
627
662
|
defaultSchema: this.defaultSchema,
|
|
628
663
|
storeCurrentData: false,
|
|
629
|
-
skipExistingRows: false
|
|
664
|
+
skipExistingRows: false,
|
|
665
|
+
signal: this.abortSignal
|
|
630
666
|
},
|
|
631
667
|
async (batch) => {
|
|
632
668
|
if (batch.resumeFromLsn == null) {
|
|
@@ -639,7 +675,6 @@ export class CDCStream {
|
|
|
639
675
|
connectionManager: this.connections,
|
|
640
676
|
eventHandler,
|
|
641
677
|
getReplicatedTables: () => this.tableCache.getAll(),
|
|
642
|
-
sourceTables: this.syncRules.getSourceTables(),
|
|
643
678
|
startLSN,
|
|
644
679
|
logger: this.logger,
|
|
645
680
|
additionalConfig: this.options.additionalConfig,
|
|
@@ -723,110 +758,69 @@ export class CDCStream {
|
|
|
723
758
|
}
|
|
724
759
|
|
|
725
760
|
async handleSchemaChange(batch: storage.BucketStorageBatch, change: SchemaChange): Promise<void> {
|
|
726
|
-
let actionedSchemaChange = true;
|
|
727
|
-
|
|
728
761
|
switch (change.type) {
|
|
729
762
|
case SchemaChangeType.TABLE_RENAME:
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
`Table ${
|
|
763
|
+
// Continuing could commit past unread changes because the rename has no known LSN.
|
|
764
|
+
throw new SourceTableUnavailableError(
|
|
765
|
+
`Table ${change.table.toQualifiedName()} has been renamed to [${change.newTable.name}]. ` +
|
|
766
|
+
`Deploy a new sync config that uses the new name to replicate it again. ` +
|
|
767
|
+
`Its already-replicated data is retained until then.`
|
|
733
768
|
);
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
if (change.newTable) {
|
|
741
|
-
await this.handleCreateOrUpdateTable(batch, change.newTable, change.newCaptureInstance!);
|
|
742
|
-
}
|
|
743
|
-
break;
|
|
744
|
-
case SchemaChangeType.TABLE_CREATE:
|
|
745
|
-
await this.handleCreateOrUpdateTable(batch, change.newTable!, change.newCaptureInstance!);
|
|
746
|
-
break;
|
|
747
|
-
case SchemaChangeType.TABLE_COLUMN_CHANGES:
|
|
748
|
-
await this.handleColumnChanges(change.table!, change.newCaptureInstance!);
|
|
749
|
-
actionedSchemaChange = false;
|
|
750
|
-
break;
|
|
751
|
-
case SchemaChangeType.NEW_CAPTURE_INSTANCE:
|
|
752
|
-
this.logger.info(
|
|
753
|
-
`New CDC capture instance detected for table ${change.table!.toQualifiedName()}. Re-snapshotting table...`
|
|
769
|
+
case SchemaChangeType.TABLE_DROP:
|
|
770
|
+
throw new SourceTableUnavailableError(
|
|
771
|
+
`Table ${change.table.toQualifiedName()} has been dropped from the source. Deploy a new sync config ` +
|
|
772
|
+
`that no longer includes it. Its already-replicated data is retained until the new sync config ` +
|
|
773
|
+
`becomes active.`
|
|
754
774
|
);
|
|
755
|
-
|
|
756
|
-
this.
|
|
757
|
-
|
|
758
|
-
await this.handleCreateOrUpdateTable(batch, change.table!.ref, change.newCaptureInstance!);
|
|
775
|
+
case SchemaChangeType.TABLE_COLUMN_CHANGES:
|
|
776
|
+
this.warnColumnChanges(change.table, change.captureInstance);
|
|
759
777
|
break;
|
|
760
|
-
case SchemaChangeType.
|
|
761
|
-
|
|
762
|
-
|
|
778
|
+
case SchemaChangeType.NEW_CAPTURE_INSTANCE: {
|
|
779
|
+
// Warn once per newer instance and keep polling the pinned one.
|
|
780
|
+
const { table, newCaptureInstance } = change;
|
|
781
|
+
if (this.warnedNewerCaptureObjectId.get(table.objectId) !== newCaptureInstance.objectId) {
|
|
782
|
+
this.logger.warn(
|
|
783
|
+
`A newer CDC capture instance (object id ${newCaptureInstance.objectId}) is available for table ${table.toQualifiedName()}, ` +
|
|
784
|
+
`but the current replication process remains pinned to capture instance object id ${table.pinnedCaptureObjectId}. ` +
|
|
785
|
+
`Deploy a new sync config to adopt the new capture instance, and keep the current instance ` +
|
|
786
|
+
`available until that sync config becomes active.`
|
|
787
|
+
);
|
|
788
|
+
this.warnedNewerCaptureObjectId.set(table.objectId, newCaptureInstance.objectId);
|
|
789
|
+
}
|
|
763
790
|
break;
|
|
791
|
+
}
|
|
764
792
|
case SchemaChangeType.MISSING_CAPTURE_INSTANCE:
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
`
|
|
793
|
+
// A replacement may have a different schema, so never adopt it in place.
|
|
794
|
+
throw new CaptureInstanceMissingError(
|
|
795
|
+
`The CDC capture instance for table ${change.table.toQualifiedName()} (pinned to object id ` +
|
|
796
|
+
`${change.table.pinnedCaptureObjectId}) is no longer available. ` +
|
|
797
|
+
`PowerSync cannot continue replicating the table - a dropped capture instance cannot be ` +
|
|
798
|
+
`restored, because re-enabling CDC creates a new one with a different object id. ` +
|
|
799
|
+
(change.replacementInstance
|
|
800
|
+
? `Deploy a new sync config to adopt the replacement.`
|
|
801
|
+
: `Re-enable CDC for this table, then deploy a new sync config.`)
|
|
768
802
|
);
|
|
769
|
-
change.table!.clearCaptureInstance();
|
|
770
|
-
actionedSchemaChange = false;
|
|
771
|
-
break;
|
|
772
803
|
default:
|
|
773
|
-
throw new ReplicationAssertionError(`Unknown schema change type: ${change.type}`);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Create a new checkpoint after the schema change
|
|
777
|
-
if (actionedSchemaChange) {
|
|
778
|
-
await createCheckpoint(this.connections);
|
|
804
|
+
throw new ReplicationAssertionError(`Unknown schema change type: ${(change as SchemaChange).type}`);
|
|
779
805
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
private async handleCreateOrUpdateTable(
|
|
783
|
-
batch: storage.BucketStorageBatch,
|
|
784
|
-
table: SourceTableChangeRef,
|
|
785
|
-
captureInstance: CaptureInstance
|
|
786
|
-
): Promise<void> {
|
|
787
|
-
const replicaIdColumns = await getReplicationIdentityColumns({
|
|
788
|
-
connectionManager: this.connections,
|
|
789
|
-
tableName: table.name,
|
|
790
|
-
schema: table.schema
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
await this.processTable(
|
|
794
|
-
batch,
|
|
795
|
-
{
|
|
796
|
-
connectionTag: this.connectionTag,
|
|
797
|
-
name: table.name,
|
|
798
|
-
schema: table.schema,
|
|
799
|
-
objectId: table.objectId,
|
|
800
|
-
replicaIdColumns: replicaIdColumns.columns
|
|
801
|
-
},
|
|
802
|
-
captureInstance,
|
|
803
|
-
true
|
|
804
|
-
);
|
|
806
|
+
// Warning-only changes do not need a checkpoint.
|
|
805
807
|
}
|
|
806
808
|
|
|
807
809
|
/**
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
* Due to the way CDC works, users are prevented from making column schema changes that affect the replication identities of a table.
|
|
811
|
-
* If changes like that are required, CDC has to be disabled and re-enabled for the table. This would then be handled by the detection of the new
|
|
812
|
-
* capture instance.
|
|
813
|
-
* @param table
|
|
814
|
-
* @param captureInstance
|
|
810
|
+
* Warn about column drift while continuing with the pinned capture schema.
|
|
815
811
|
*/
|
|
816
|
-
private
|
|
817
|
-
|
|
818
|
-
if (
|
|
819
|
-
table.captureInstance?.objectId === captureInstance.objectId &&
|
|
820
|
-
table.captureInstance?.pendingSchemaChanges.length === captureInstance.pendingSchemaChanges.length
|
|
821
|
-
) {
|
|
812
|
+
private warnColumnChanges(table: MSSQLSourceTable, captureInstance: CaptureInstance): void {
|
|
813
|
+
const changeCount = captureInstance.pendingSchemaChanges.length;
|
|
814
|
+
if (this.warnedSchemaChangeCount.get(table.objectId) === changeCount) {
|
|
822
815
|
return;
|
|
823
816
|
}
|
|
824
817
|
|
|
825
|
-
// New pending schema changes were detected - warn about those as well.
|
|
826
818
|
this.logger.warn(
|
|
827
|
-
`Schema drift detected for table ${table.toQualifiedName()}.
|
|
819
|
+
`Schema drift detected for table ${table.toQualifiedName()}. Until a new sync config becomes active, ` +
|
|
820
|
+
`replication continues against the captured schema, so these changes are not replicated. Deploy a new ` +
|
|
821
|
+
`sync config to pick them up.\n Pending schema changes:\n ${captureInstance.pendingSchemaChanges.join(', \n')}`
|
|
828
822
|
);
|
|
829
|
-
table.
|
|
823
|
+
this.warnedSchemaChangeCount.set(table.objectId, changeCount);
|
|
830
824
|
}
|
|
831
825
|
|
|
832
826
|
/**
|