@powersync/service-module-mssql 0.4.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 (48) hide show
  1. package/CHANGELOG.md +45 -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 +188 -77
  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 +245 -96
  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/CDCStream.test.ts +3 -1
  42. package/test/src/CDCStreamTestContext.ts +28 -7
  43. package/test/src/CDCStream_resumable_snapshot.test.ts +9 -7
  44. package/test/src/env.ts +1 -1
  45. package/test/src/mssql-to-sqlite.test.ts +18 -10
  46. package/test/src/schema-changes.test.ts +470 -0
  47. package/test/src/util.ts +84 -15
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -6,19 +6,24 @@ import {
6
6
  TimeValuePrecision
7
7
  } from '@powersync/service-sync-rules';
8
8
  import { afterAll, beforeEach, describe, expect, test } from 'vitest';
9
- import { clearTestDb, createUpperCaseUUID, TEST_CONNECTION_OPTIONS, waitForPendingCDCChanges } from './util.js';
9
+ import {
10
+ clearTestDb,
11
+ createUpperCaseUUID,
12
+ enableCDCForTable,
13
+ TEST_CONNECTION_OPTIONS,
14
+ waitForPendingCDCChanges
15
+ } from './util.js';
10
16
  import { CDCToSqliteRow, toSqliteInputRow } from '@module/common/mssqls-to-sqlite.js';
11
17
  import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
12
18
  import {
13
- enableCDCForTable,
14
19
  escapeIdentifier,
15
- getCaptureInstance,
20
+ getCaptureInstances,
16
21
  getLatestLSN,
17
22
  getLatestReplicatedLSN,
18
- getMinLSN,
19
23
  toQualifiedTableName
20
24
  } from '@module/utils/mssql.js';
21
25
  import sql from 'mssql';
26
+ import { CDC_SCHEMA } from '@module/common/MSSQLSourceTable.js';
22
27
 
23
28
  describe('MSSQL Data Types Tests', () => {
24
29
  const connectionManager = new MSSQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
@@ -482,20 +487,23 @@ async function getReplicatedRows(
482
487
  ): Promise<SqliteInputRow[]> {
483
488
  const endLSN = await getLatestReplicatedLSN(connectionManager);
484
489
 
485
- const captureInstance = await getCaptureInstance({
490
+ const captureInstances = await getCaptureInstances({
486
491
  connectionManager,
487
- schema: connectionManager.schema,
488
- tableName
492
+ table: {
493
+ schema: connectionManager.schema,
494
+ name: tableName
495
+ }
489
496
  });
490
- if (!captureInstance) {
497
+ if (captureInstances.size === 0) {
491
498
  throw new Error(`No CDC capture instance found for table ${tableName}`);
492
499
  }
493
500
 
494
- const startLSN = await getMinLSN(connectionManager, captureInstance.name);
501
+ const captureInstance = Array.from(captureInstances.values())[0].instances[0];
502
+ const startLSN = captureInstance.minLSN;
495
503
  // Query CDC changes
496
504
  const { recordset: results } = await connectionManager.query(
497
505
  `
498
- SELECT * FROM ${captureInstance.schema}.fn_cdc_get_all_changes_${captureInstance.name}(@from_lsn, @to_lsn, 'all update old') ORDER BY __$start_lsn, __$seqval
506
+ SELECT * FROM ${CDC_SCHEMA}.fn_cdc_get_all_changes_${captureInstance.name}(@from_lsn, @to_lsn, 'all update old') ORDER BY __$start_lsn, __$seqval
499
507
  `,
500
508
  [
501
509
  { name: 'from_lsn', type: sql.VarBinary, value: startLSN.toBinary() },
@@ -0,0 +1,470 @@
1
+ import { putOp, removeOp } from '@powersync/service-core-tests';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { storage } from '@powersync/service-core';
4
+ import sql from 'mssql';
5
+
6
+ import { CDCStreamTestContext } from './CDCStreamTestContext.js';
7
+ import {
8
+ createTestTableWithBasicId,
9
+ describeWithStorage,
10
+ disableCDCForTable,
11
+ dropTestTable,
12
+ enableCDCForTable,
13
+ insertBasicIdTestData,
14
+ renameTable,
15
+ waitForPendingCDCChanges
16
+ } from './util.js';
17
+ import { getLatestLSN, toQualifiedTableName } from '@module/utils/mssql.js';
18
+ import { SchemaChangeType } from '@module/replication/CDCPoller.js';
19
+ import { logger } from '@powersync/lib-services-framework';
20
+
21
+ describe('MSSQL Schema Changes Tests', () => {
22
+ describeWithStorage({ timeout: 60_000 }, defineSchemaChangesTests);
23
+ });
24
+
25
+ const BASIC_SYNC_RULES = `
26
+ bucket_definitions:
27
+ global:
28
+ data:
29
+ - SELECT id, description FROM "test_data"
30
+ `;
31
+
32
+ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
33
+ const { factory } = config;
34
+
35
+ test('Create table: New table in the sync rules', async () => {
36
+ await using context = await CDCStreamTestContext.open(factory);
37
+ const { connectionManager } = context;
38
+ await context.updateSyncRules(BASIC_SYNC_RULES);
39
+
40
+ await context.replicateSnapshot();
41
+ await context.startStreaming();
42
+
43
+ await createTestTableWithBasicId(connectionManager, 'test_data');
44
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
45
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
46
+
47
+ const data = await context.getFinalBucketState('global[]');
48
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
49
+ });
50
+
51
+ test('Create table: New table created while PowerSync is stopped', async () => {
52
+ await using context = await CDCStreamTestContext.open(factory);
53
+ const { connectionManager } = context;
54
+ await context.updateSyncRules(`
55
+ bucket_definitions:
56
+ global:
57
+ data:
58
+ - SELECT id, description FROM "test_data%"
59
+ `);
60
+
61
+ await createTestTableWithBasicId(connectionManager, 'test_data1');
62
+ const testData = await insertBasicIdTestData(connectionManager, 'test_data1');
63
+
64
+ await context.replicateSnapshot();
65
+ await context.startStreaming();
66
+
67
+ await context.dispose();
68
+
69
+ await createTestTableWithBasicId(connectionManager, 'test_data2');
70
+
71
+ await using newContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
72
+ await newContext.loadActiveSyncRules();
73
+
74
+ await newContext.replicateSnapshot();
75
+ await newContext.startStreaming();
76
+
77
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data2');
78
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data2');
79
+
80
+ const finalState = await newContext.getFinalBucketState('global[]');
81
+ expect(finalState).toMatchObject([
82
+ putOp('test_data1', testData),
83
+ putOp('test_data2', testData1),
84
+ putOp('test_data2', testData2)
85
+ ]);
86
+ });
87
+
88
+ test('Create table: New table not in the sync rules', async () => {
89
+ await using context = await CDCStreamTestContext.open(factory);
90
+ const { connectionManager } = context;
91
+ await context.updateSyncRules(BASIC_SYNC_RULES);
92
+
93
+ await context.replicateSnapshot();
94
+ await context.startStreaming();
95
+
96
+ await createTestTableWithBasicId(connectionManager, 'test_data_ignored');
97
+ await insertBasicIdTestData(connectionManager, 'test_data_ignored');
98
+
99
+ const data = await context.getBucketData('global[]');
100
+ expect(data).toMatchObject([]);
101
+ });
102
+
103
+ test('Drop table: Table in the sync rules', async () => {
104
+ await using context = await CDCStreamTestContext.open(factory, {
105
+ cdcStreamOptions: { schemaCheckIntervalMs: 5000 }
106
+ });
107
+ await context.updateSyncRules(BASIC_SYNC_RULES);
108
+
109
+ const { connectionManager } = context;
110
+ await createTestTableWithBasicId(connectionManager, 'test_data');
111
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
112
+ const beforeLSN = await getLatestLSN(connectionManager);
113
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
114
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
115
+
116
+ await context.replicateSnapshot();
117
+ await context.startStreaming();
118
+
119
+ let data = await context.getBucketData('global[]');
120
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
121
+ await dropTestTable(connectionManager, 'test_data');
122
+
123
+ data = await context.getFinalBucketState('global[]');
124
+ expect(data).toMatchObject([]);
125
+ });
126
+
127
+ test('Re-create table', async () => {
128
+ await using context = await CDCStreamTestContext.open(factory);
129
+ const { connectionManager } = context;
130
+ await context.updateSyncRules(BASIC_SYNC_RULES);
131
+
132
+ await createTestTableWithBasicId(connectionManager, 'test_data');
133
+
134
+ await context.replicateSnapshot();
135
+ await context.startStreaming();
136
+
137
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
138
+ let data = await context.getBucketData('global[]');
139
+ expect(data).toMatchObject([putOp('test_data', testData1)]);
140
+
141
+ let schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
142
+ await dropTestTable(connectionManager, 'test_data');
143
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.TABLE_DROP);
144
+
145
+ await createTestTableWithBasicId(connectionManager, 'test_data');
146
+
147
+ const testData = await insertBasicIdTestData(connectionManager, 'test_data');
148
+
149
+ data = await context.getFinalBucketState('global[]');
150
+ expect(data).toMatchObject([putOp('test_data', testData)]);
151
+ });
152
+
153
+ test('Rename table: Table not in the sync rules to one in the sync rules', async () => {
154
+ await using context = await CDCStreamTestContext.open(factory);
155
+ const { connectionManager } = context;
156
+ await context.updateSyncRules(BASIC_SYNC_RULES);
157
+
158
+ await createTestTableWithBasicId(connectionManager, 'test_data_old');
159
+ const beforeLSN = await getLatestLSN(connectionManager);
160
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data_old');
161
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
162
+
163
+ await context.replicateSnapshot();
164
+ await context.startStreaming();
165
+
166
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
167
+ await renameTable(connectionManager, 'test_data_old', 'test_data');
168
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.TABLE_CREATE);
169
+
170
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
171
+ const data = await context.getFinalBucketState('global[]');
172
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
173
+ });
174
+
175
+ test('Rename table: Table in the sync rules to another table in the sync rules', async () => {
176
+ await using context = await CDCStreamTestContext.open(factory);
177
+ const { connectionManager } = context;
178
+
179
+ await context.updateSyncRules(`
180
+ bucket_definitions:
181
+ global:
182
+ data:
183
+ - SELECT id, description FROM "test_data%"
184
+ `);
185
+
186
+ await createTestTableWithBasicId(connectionManager, 'test_data1');
187
+ const beforeLSN = await getLatestLSN(connectionManager);
188
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data1');
189
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
190
+
191
+ await context.replicateSnapshot();
192
+ await context.startStreaming();
193
+
194
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
195
+ await renameTable(connectionManager, 'test_data1', 'test_data2');
196
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.TABLE_RENAME);
197
+
198
+ const data = await context.getBucketData('global[]');
199
+ expect(data.slice(0, 2)).toMatchObject([
200
+ // Initial replication
201
+ putOp('test_data1', testData1),
202
+ // Initial truncate
203
+ removeOp('test_data1', testData1.id)
204
+ ]);
205
+
206
+ const finalState = await context.getFinalBucketState('global[]');
207
+ expect(finalState).toMatchObject([putOp('test_data2', testData1)]);
208
+ });
209
+
210
+ test('Rename table: Table renamed while PowerSync is stopped', async () => {
211
+ let context = await CDCStreamTestContext.open(factory);
212
+ let { connectionManager } = context;
213
+
214
+ await context.updateSyncRules(`
215
+ bucket_definitions:
216
+ global:
217
+ data:
218
+ - SELECT id, description FROM "test_data%"
219
+ `);
220
+
221
+ await createTestTableWithBasicId(connectionManager, 'test_data1');
222
+ const beforeLSN = await getLatestLSN(connectionManager);
223
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data1');
224
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
225
+
226
+ await context.replicateSnapshot();
227
+ await context.startStreaming();
228
+
229
+ let data = await context.getBucketData('global[]');
230
+ expect(data).toMatchObject([putOp('test_data1', testData1)]);
231
+
232
+ await context.dispose();
233
+ await renameTable(connectionManager, 'test_data1', 'test_data2');
234
+
235
+ await using newContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
236
+ await newContext.loadActiveSyncRules();
237
+
238
+ await newContext.replicateSnapshot();
239
+ await newContext.startStreaming();
240
+
241
+ const finalState = await newContext.getFinalBucketState('global[]');
242
+ expect(finalState).toMatchObject([putOp('test_data2', testData1)]);
243
+ });
244
+
245
+ test('Rename table: Table in the sync rules to not in the sync rules', async () => {
246
+ await using context = await CDCStreamTestContext.open(factory);
247
+ await context.updateSyncRules(BASIC_SYNC_RULES);
248
+
249
+ const { connectionManager } = context;
250
+ await createTestTableWithBasicId(connectionManager, 'test_data');
251
+ const beforeLSN = await getLatestLSN(connectionManager);
252
+ const testData = await insertBasicIdTestData(connectionManager, 'test_data');
253
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
254
+
255
+ await context.replicateSnapshot();
256
+ await context.startStreaming();
257
+
258
+ let data = await context.getBucketData('global[]');
259
+ expect(data).toMatchObject([putOp('test_data', testData)]);
260
+
261
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
262
+ await renameTable(connectionManager, 'test_data', 'test_data_ignored');
263
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.TABLE_RENAME);
264
+
265
+ data = await context.getBucketData('global[]');
266
+ expect(data).toMatchObject([
267
+ // Initial replication
268
+ putOp('test_data', testData),
269
+ // Truncate
270
+ removeOp('test_data', testData.id)
271
+ ]);
272
+ });
273
+
274
+ test('New capture instance created for replicating table triggers re-snapshot', async () => {
275
+ await using context = await CDCStreamTestContext.open(factory);
276
+ await context.updateSyncRules(BASIC_SYNC_RULES);
277
+
278
+ const { connectionManager } = context;
279
+ await createTestTableWithBasicId(connectionManager, 'test_data');
280
+ const beforeLSN = await getLatestLSN(connectionManager);
281
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
282
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
283
+
284
+ await context.replicateSnapshot();
285
+ await context.startStreaming();
286
+
287
+ await enableCDCForTable({ connectionManager, table: 'test_data', captureInstance: 'capture_instance_new' });
288
+
289
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
290
+
291
+ const data = await context.getFinalBucketState('global[]');
292
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
293
+ });
294
+
295
+ test('New capture instance created for replicating table while PowerSync is stopped', async () => {
296
+ await using context = await CDCStreamTestContext.open(factory);
297
+ await context.updateSyncRules(BASIC_SYNC_RULES);
298
+ const { connectionManager } = context;
299
+
300
+ await createTestTableWithBasicId(connectionManager, 'test_data');
301
+ let beforeLSN = await getLatestLSN(connectionManager);
302
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
303
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
304
+
305
+ await context.replicateSnapshot();
306
+ await context.startStreaming();
307
+
308
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
309
+ let data = await context.getBucketData('global[]');
310
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
311
+
312
+ await context.dispose();
313
+ await enableCDCForTable({ connectionManager, table: 'test_data', captureInstance: 'capture_instance_new' });
314
+
315
+ await using newContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
316
+ await newContext.loadActiveSyncRules();
317
+
318
+ await newContext.replicateSnapshot();
319
+ await newContext.startStreaming();
320
+
321
+ const testData3 = await insertBasicIdTestData(connectionManager, 'test_data');
322
+
323
+ const finalState = await newContext.getFinalBucketState('global[]');
324
+ expect(finalState).toMatchObject([
325
+ putOp('test_data', testData1),
326
+ putOp('test_data', testData2),
327
+ putOp('test_data', testData3)
328
+ ]);
329
+ });
330
+
331
+ test('Capture instance created for a sync rule table without a capture instance', async () => {
332
+ await using context = await CDCStreamTestContext.open(factory);
333
+ await context.updateSyncRules(BASIC_SYNC_RULES);
334
+ const { connectionManager } = context;
335
+
336
+ await createTestTableWithBasicId(connectionManager, 'test_data', false);
337
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
338
+
339
+ await context.replicateSnapshot();
340
+ await context.startStreaming();
341
+
342
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
343
+ await enableCDCForTable({ connectionManager, table: 'test_data' });
344
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.NEW_CAPTURE_INSTANCE);
345
+
346
+ let data = await context.getBucketData('global[]');
347
+ expect(data).toMatchObject([putOp('test_data', testData1)]);
348
+
349
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
350
+
351
+ data = await context.getFinalBucketState('global[]');
352
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
353
+ });
354
+
355
+ test('Capture instance removed for an actively replicating table', async () => {
356
+ await using context = await CDCStreamTestContext.open(factory, {
357
+ cdcStreamOptions: { schemaCheckIntervalMs: 5000 }
358
+ });
359
+ await context.updateSyncRules(BASIC_SYNC_RULES);
360
+ const { connectionManager } = context;
361
+
362
+ await createTestTableWithBasicId(connectionManager, 'test_data');
363
+ let beforeLSN = await getLatestLSN(connectionManager);
364
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
365
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
366
+
367
+ await context.replicateSnapshot();
368
+ await context.startStreaming();
369
+
370
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
371
+ let data = await context.getBucketData('global[]');
372
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
373
+
374
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
375
+ await disableCDCForTable(connectionManager, 'test_data');
376
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.MISSING_CAPTURE_INSTANCE);
377
+
378
+ data = await context.getBucketData('global[]');
379
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
380
+ });
381
+
382
+ test('Capture instance removed, and then re-added', async () => {
383
+ await using context = await CDCStreamTestContext.open(factory);
384
+ await context.updateSyncRules(BASIC_SYNC_RULES);
385
+ const { connectionManager } = context;
386
+
387
+ await createTestTableWithBasicId(connectionManager, 'test_data');
388
+
389
+ await context.replicateSnapshot();
390
+ await context.startStreaming();
391
+
392
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
393
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
394
+ let data = await context.getBucketData('global[]');
395
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
396
+
397
+ let schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
398
+ await disableCDCForTable(connectionManager, 'test_data');
399
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.MISSING_CAPTURE_INSTANCE);
400
+
401
+ schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
402
+ await enableCDCForTable({ connectionManager, table: 'test_data' });
403
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.NEW_CAPTURE_INSTANCE);
404
+
405
+ const testData3 = await insertBasicIdTestData(connectionManager, 'test_data');
406
+ const testData4 = await insertBasicIdTestData(connectionManager, 'test_data');
407
+
408
+ const finalState = await context.getFinalBucketState('global[]');
409
+ expect(finalState).toMatchObject([
410
+ putOp('test_data', testData1),
411
+ putOp('test_data', testData2),
412
+ putOp('test_data', testData3),
413
+ putOp('test_data', testData4)
414
+ ]);
415
+ });
416
+
417
+ test('Column schema changes continue replication, but with warning.', async () => {
418
+ await using context = await CDCStreamTestContext.open(factory);
419
+ await context.updateSyncRules(BASIC_SYNC_RULES);
420
+ const { connectionManager } = context;
421
+
422
+ await createTestTableWithBasicId(connectionManager, 'test_data');
423
+ const beforeLSN = await getLatestLSN(connectionManager);
424
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
425
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
426
+
427
+ await context.replicateSnapshot();
428
+ await context.startStreaming();
429
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
430
+ await connectionManager.query(`ALTER TABLE test_data ADD new_column INT`);
431
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.TABLE_COLUMN_CHANGES);
432
+
433
+ const { recordset: result } = await connectionManager.query(
434
+ `
435
+ INSERT INTO ${toQualifiedTableName(connectionManager.schema, 'test_data')} (description, new_column)
436
+ OUTPUT INSERTED.id, INSERTED.description
437
+ VALUES (@description, @new_column)
438
+ `,
439
+ [
440
+ { name: 'description', type: sql.NVarChar(sql.MAX), value: 'new_column_description' },
441
+ { name: 'new_column', type: sql.Int, value: 1 }
442
+ ]
443
+ );
444
+
445
+ const testData2 = { id: result[0].id, description: result[0].description };
446
+
447
+ const data = await context.getBucketData('global[]');
448
+ // Capture instances do not reflect most schema changes until the capture instance is re-created
449
+ // So testData2 will be replicated but without the new column
450
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
451
+
452
+ expect(
453
+ context.cdcStream.tableCache
454
+ .getAll()
455
+ .every((t) => t.captureInstance && t.captureInstance.pendingSchemaChanges.length > 0)
456
+ ).toBe(true);
457
+ });
458
+ }
459
+
460
+ async function expectedSchemaChange(spy: any, type: SchemaChangeType) {
461
+ logger.info(`Test Assertion: Waiting for schema change: ${type}`);
462
+ await vi.waitFor(() => expect(spy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type })), {
463
+ timeout: 20000
464
+ });
465
+
466
+ const promises = spy.mock.results.filter((r: any) => r.type === 'return').map((r: any) => r.value);
467
+
468
+ await Promise.all(promises.map((p: Promise<unknown>) => expect(p).resolves.toBeUndefined()));
469
+ logger.info(`Test Assertion: Received expected schema change: ${type}`);
470
+ }