@powersync/service-module-mssql 0.8.3 → 0.10.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 (57) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/api/MSSQLRouteAPIAdapter.d.ts +2 -2
  3. package/dist/api/MSSQLRouteAPIAdapter.js +33 -39
  4. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/MSSQLSourceTable.d.ts +12 -2
  6. package/dist/common/MSSQLSourceTable.js +30 -4
  7. package/dist/common/MSSQLSourceTable.js.map +1 -1
  8. package/dist/module/MSSQLModule.js +2 -1
  9. package/dist/module/MSSQLModule.js.map +1 -1
  10. package/dist/replication/CDCPoller.d.ts +13 -36
  11. package/dist/replication/CDCPoller.js +173 -129
  12. package/dist/replication/CDCPoller.js.map +1 -1
  13. package/dist/replication/CDCStream.d.ts +11 -14
  14. package/dist/replication/CDCStream.js +109 -103
  15. package/dist/replication/CDCStream.js.map +1 -1
  16. package/dist/replication/CaptureReconciler.d.ts +47 -0
  17. package/dist/replication/CaptureReconciler.js +155 -0
  18. package/dist/replication/CaptureReconciler.js.map +1 -0
  19. package/dist/replication/MSSQLConnectionManager.d.ts +1 -0
  20. package/dist/replication/MSSQLConnectionManager.js +19 -14
  21. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  22. package/dist/replication/MSSQLTableReconciliationContext.d.ts +21 -0
  23. package/dist/replication/MSSQLTableReconciliationContext.js +7 -0
  24. package/dist/replication/MSSQLTableReconciliationContext.js.map +1 -0
  25. package/dist/replication/SchemaChange.d.ts +67 -0
  26. package/dist/replication/SchemaChange.js +31 -0
  27. package/dist/replication/SchemaChange.js.map +1 -0
  28. package/dist/types/types.d.ts +13 -0
  29. package/dist/types/types.js +28 -2
  30. package/dist/types/types.js.map +1 -1
  31. package/dist/utils/mssql.js +1 -1
  32. package/dist/utils/schema.d.ts +14 -1
  33. package/dist/utils/schema.js +43 -45
  34. package/dist/utils/schema.js.map +1 -1
  35. package/package.json +9 -9
  36. package/src/api/MSSQLRouteAPIAdapter.ts +36 -46
  37. package/src/common/MSSQLSourceTable.ts +32 -4
  38. package/src/module/MSSQLModule.ts +2 -1
  39. package/src/replication/CDCPoller.ts +206 -162
  40. package/src/replication/CDCStream.ts +142 -151
  41. package/src/replication/CaptureReconciler.ts +186 -0
  42. package/src/replication/MSSQLConnectionManager.ts +23 -14
  43. package/src/replication/MSSQLTableReconciliationContext.ts +29 -0
  44. package/src/replication/SchemaChange.ts +79 -0
  45. package/src/types/types.ts +47 -2
  46. package/src/utils/mssql.ts +1 -1
  47. package/src/utils/schema.ts +60 -54
  48. package/test/src/CDCPoller.test.ts +382 -0
  49. package/test/src/CDCStream.test.ts +6 -2
  50. package/test/src/CDCStreamTestContext.ts +9 -2
  51. package/test/src/CaptureReconciler.test.ts +171 -0
  52. package/test/src/MSSQLSourceTable.test.ts +30 -0
  53. package/test/src/config.test.ts +71 -0
  54. package/test/src/schema-changes.test.ts +513 -249
  55. package/test/src/schema.test.ts +17 -0
  56. package/test/src/util.ts +70 -6
  57. package/tsconfig.tsbuildinfo +1 -1
@@ -1,9 +1,10 @@
1
- import { storage } from '@powersync/service-core';
2
- import { putOp, removeOp } from '@powersync/service-core-tests';
1
+ import { SourceEntityDescriptor, storage } from '@powersync/service-core';
2
+ import { putOp, test_utils } from '@powersync/service-core-tests';
3
3
  import sql from 'mssql';
4
4
  import { describe, expect, test, vi } from 'vitest';
5
5
 
6
- import { SchemaChangeType } from '@module/replication/CDCPoller.js';
6
+ import { LSN } from '@module/common/LSN.js';
7
+ import { SchemaChangeType } from '@module/replication/SchemaChange.js';
7
8
  import { getLatestLSN, toQualifiedTableName } from '@module/utils/mssql.js';
8
9
  import { logger } from '@powersync/lib-services-framework';
9
10
  import { CDCStreamTestContext } from './CDCStreamTestContext.js';
@@ -29,60 +30,42 @@ bucket_definitions:
29
30
  - SELECT id, description FROM "test_data"
30
31
  `;
31
32
 
33
+ const WILDCARD_SYNC_RULES = `
34
+ bucket_definitions:
35
+ global:
36
+ data:
37
+ - SELECT * FROM "test_data"
38
+ `;
39
+
32
40
  function defineSchemaChangesTests(config: storage.TestStorageConfig) {
33
41
  const { factory } = config;
34
42
 
35
- test('Create table: New table in the sync rules', async () => {
43
+ test('Create table: a sync rules table that does not exist stops replication', async () => {
36
44
  await using context = await CDCStreamTestContext.open(factory);
37
- const { connectionManager } = context;
38
45
  await context.updateSyncRules(BASIC_SYNC_RULES);
39
46
 
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)]);
47
+ await expect(context.replicateSnapshot()).rejects.toThrow(/does not exist/);
49
48
  });
50
49
 
51
- test('Create table: New table created while PowerSync is stopped', async () => {
50
+ test('Create table: a table created after deploy is not replicated', async () => {
52
51
  await using context = await CDCStreamTestContext.open(factory);
53
52
  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');
53
+ await context.updateSyncRules(BASIC_SYNC_RULES);
63
54
 
55
+ await createTestTableWithBasicId(connectionManager, 'test_data');
64
56
  await context.replicateSnapshot();
65
57
  await context.startStreaming();
66
58
 
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();
59
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
60
+ expect(await context.getBucketData('global[]')).toMatchObject([putOp('test_data', testData1)]);
76
61
 
77
- const testData1 = await insertBasicIdTestData(connectionManager, 'test_data2');
78
- const testData2 = await insertBasicIdTestData(connectionManager, 'test_data2');
62
+ // This table is outside the fixed set for the running stream.
63
+ await createTestTableWithBasicId(connectionManager, 'test_data_new');
64
+ await insertBasicIdTestData(connectionManager, 'test_data_new');
79
65
 
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
- ]);
66
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
67
+ const data = await context.getFinalBucketState('global[]');
68
+ expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
86
69
  });
87
70
 
88
71
  test('Create table: New table not in the sync rules', async () => {
@@ -90,6 +73,8 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
90
73
  const { connectionManager } = context;
91
74
  await context.updateSyncRules(BASIC_SYNC_RULES);
92
75
 
76
+ await createTestTableWithBasicId(connectionManager, 'test_data');
77
+
93
78
  await context.replicateSnapshot();
94
79
  await context.startStreaming();
95
80
 
@@ -100,10 +85,8 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
100
85
  expect(data).toMatchObject([]);
101
86
  });
102
87
 
103
- test('Drop table: Table in the sync rules', async () => {
104
- await using context = await CDCStreamTestContext.open(factory, {
105
- cdcStreamOptions: { schemaCheckIntervalMs: 5000 }
106
- });
88
+ test('Drop table: replication stops and the replicated data is retained', async () => {
89
+ await using context = await CDCStreamTestContext.open(factory);
107
90
  await context.updateSyncRules(BASIC_SYNC_RULES);
108
91
 
109
92
  const { connectionManager } = context;
@@ -116,133 +99,90 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
116
99
  await context.replicateSnapshot();
117
100
  await context.startStreaming();
118
101
 
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)]);
102
+ expect(await context.getBucketData('global[]')).toMatchObject([
103
+ putOp('test_data', testData1),
104
+ putOp('test_data', testData2)
105
+ ]);
140
106
 
141
- let schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
107
+ // Attach before the DDL to avoid an unhandled rejection race.
108
+ const streamFailure = expect(
109
+ context.streamingPromise,
110
+ 'Dropping a replicated table should stop the job'
111
+ ).rejects.toThrow(/has been dropped from the source/);
142
112
  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);
113
+ await streamFailure;
190
114
 
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)]);
115
+ // The usual helper races the rejected stream promise.
116
+ expect(
117
+ await context.getCurrentBucketData('global[]'),
118
+ 'Dropping the source table should not delete data already replicated to clients'
119
+ ).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
208
120
  });
209
121
 
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)]);
122
+ test('Drop and recreate while stopped requires a new sync config', async () => {
123
+ let originalRows: Awaited<ReturnType<typeof insertBasicIdTestData>>[];
124
+ {
125
+ await using context = await CDCStreamTestContext.open(factory);
126
+ await context.updateSyncRules(BASIC_SYNC_RULES);
127
+
128
+ const { connectionManager } = context;
129
+ await createTestTableWithBasicId(connectionManager, 'test_data');
130
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
131
+ const beforeLSN = await getLatestLSN(connectionManager);
132
+ const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
133
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
134
+ originalRows = [testData1, testData2];
135
+
136
+ await context.replicateSnapshot();
137
+ await context.startStreaming();
138
+ expect(await context.getBucketData('global[]')).toMatchObject(originalRows.map((row) => putOp('test_data', row)));
139
+ }
140
+
141
+ {
142
+ await using changeContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
143
+ const { connectionManager } = changeContext;
144
+ await dropTestTable(connectionManager, 'test_data');
145
+ }
146
+
147
+ {
148
+ await using missingRestartContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
149
+ await missingRestartContext.loadActiveSyncRules();
150
+
151
+ await expect(
152
+ missingRestartContext.replicateSnapshot(),
153
+ 'A normal job restart must preserve the unavailable-table error while the table is absent'
154
+ ).rejects.toThrow(/no longer matches the source table binding/);
155
+
156
+ expect(
157
+ await missingRestartContext.getCurrentBucketData('global[]'),
158
+ 'The failed restart must retain data from the missing source table'
159
+ ).toMatchObject(originalRows.map((row) => putOp('test_data', row)));
160
+ }
161
+
162
+ {
163
+ await using changeContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
164
+ const { connectionManager } = changeContext;
165
+ await createTestTableWithBasicId(connectionManager, 'test_data');
166
+ await insertBasicIdTestData(connectionManager, 'test_data');
167
+ }
168
+
169
+ {
170
+ await using restartContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
171
+ await restartContext.loadActiveSyncRules();
172
+
173
+ await expect(
174
+ restartContext.replicateSnapshot(),
175
+ 'A normal job restart must not adopt the replacement table'
176
+ ).rejects.toThrow(/no longer matches the source table binding/);
177
+
178
+ expect(
179
+ await restartContext.getCurrentBucketData('global[]'),
180
+ 'The failed restart must retain data from the persisted binding'
181
+ ).toMatchObject(originalRows.map((row) => putOp('test_data', row)));
182
+ }
243
183
  });
244
184
 
245
- test('Rename table: Table in the sync rules to not in the sync rules', async () => {
185
+ test('Rename table: replication stops and the replicated data is retained', async () => {
246
186
  await using context = await CDCStreamTestContext.open(factory);
247
187
  await context.updateSyncRules(BASIC_SYNC_RULES);
248
188
 
@@ -255,80 +195,376 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
255
195
  await context.replicateSnapshot();
256
196
  await context.startStreaming();
257
197
 
258
- let data = await context.getBucketData('global[]');
259
- expect(data).toMatchObject([putOp('test_data', testData)]);
198
+ expect(await context.getBucketData('global[]')).toMatchObject([putOp('test_data', testData)]);
260
199
 
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
- ]);
200
+ const streamFailure = expect(
201
+ context.streamingPromise,
202
+ 'Renaming a replicated table should stop the job'
203
+ ).rejects.toThrow(/has been renamed/);
204
+ await renameTable(connectionManager, 'test_data', 'test_data_renamed');
205
+ await streamFailure;
206
+
207
+ // The usual helper races the rejected stream promise.
208
+ expect(
209
+ await context.getCurrentBucketData('global[]'),
210
+ 'Renaming the source table should not delete data already replicated to clients'
211
+ ).toMatchObject([putOp('test_data', testData)]);
272
212
  });
273
213
 
274
- test('New capture instance created for replicating table triggers re-snapshot', async () => {
214
+ test('New capture instance with changed schema keeps the existing pinned capture instance', async () => {
275
215
  await using context = await CDCStreamTestContext.open(factory);
276
- await context.updateSyncRules(BASIC_SYNC_RULES);
216
+ await context.updateSyncRules(WILDCARD_SYNC_RULES);
277
217
 
278
218
  const { connectionManager } = context;
279
219
  await createTestTableWithBasicId(connectionManager, 'test_data');
280
220
  const beforeLSN = await getLatestLSN(connectionManager);
281
- const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
221
+ await insertBasicIdTestData(connectionManager, 'test_data');
282
222
  await waitForPendingCDCChanges(beforeLSN, connectionManager);
283
223
 
284
224
  await context.replicateSnapshot();
285
225
  await context.startStreaming();
286
226
 
227
+ const tableBefore = context.cdcStream.tableCache.getAll()[0];
228
+ expect(tableBefore.pinnedCaptureObjectId, 'The initial source-table binding should be pinned').not.toBeNull();
229
+ const pinnedCaptureObjectId = tableBefore.pinnedCaptureObjectId;
230
+ expect(
231
+ tableBefore.captureInstance?.objectId,
232
+ 'The active capture instance should match the persisted capture-instance pin'
233
+ ).toBe(pinnedCaptureObjectId);
234
+ const sourceTableIds = tableBefore.sourceTables.map((table) => table.id.toString());
235
+
236
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
237
+ const warnSpy = vi.spyOn(logger, 'warn');
238
+ await connectionManager.query(
239
+ `ALTER TABLE ${toQualifiedTableName(connectionManager.schema, 'test_data')} ADD new_column INT`
240
+ );
287
241
  await enableCDCForTable({ connectionManager, table: 'test_data', captureInstance: 'capture_instance_new' });
242
+ await expectedSchemaChange(schemaSpy, SchemaChangeType.NEW_CAPTURE_INSTANCE);
243
+ await expectedWarning(warnSpy, /newer CDC capture instance/);
288
244
 
289
- const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
245
+ const tableAfter = context.cdcStream.tableCache.get(tableBefore.objectId)!;
246
+ expect(
247
+ tableAfter.pinnedCaptureObjectId,
248
+ 'Detecting a newer capture instance should not change the persisted pin'
249
+ ).toBe(pinnedCaptureObjectId);
250
+ expect(
251
+ tableAfter.captureInstance?.objectId,
252
+ 'The running stream should continue polling its pinned capture instance'
253
+ ).toBe(pinnedCaptureObjectId);
254
+ expect(
255
+ tableAfter.sourceTables.map((table) => table.id.toString()),
256
+ 'Detecting a newer capture instance should not replace the PowerSync SourceTable records'
257
+ ).toEqual(sourceTableIds);
258
+
259
+ const { recordset: result } = await connectionManager.query(
260
+ `
261
+ INSERT INTO ${toQualifiedTableName(connectionManager.schema, 'test_data')} (description, new_column)
262
+ OUTPUT INSERTED.id, INSERTED.description, INSERTED.new_column
263
+ VALUES (@description, @new_column)
264
+ `,
265
+ [
266
+ { name: 'description', type: sql.NVarChar(sql.MAX), value: 'new_capture_column_description' },
267
+ { name: 'new_column', type: sql.Int, value: 1 }
268
+ ]
269
+ );
270
+ const testData2 = result[0];
290
271
 
291
272
  const data = await context.getFinalBucketState('global[]');
292
- expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
273
+ const replicatedTestData2 = data.find((operation) => operation.object_id === String(testData2.id));
274
+ expect(replicatedTestData2, 'The row should be available at the next checkpoint').toBeDefined();
275
+ expect(
276
+ JSON.parse(replicatedTestData2!.data!),
277
+ 'The pinned stream should replicate the row using the original capture schema'
278
+ ).toEqual({ id: testData2.id, description: testData2.description });
293
279
  });
294
280
 
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();
281
+ test('Restart restores the existing pin after advancing past an undetected capture instance', async () => {
282
+ let initialBinding: {
283
+ sourceTableObjectId: number;
284
+ pinnedCaptureObjectId: number | null;
285
+ sourceTableIds: string[];
286
+ };
287
+ {
288
+ await using context = await CDCStreamTestContext.open(factory, {
289
+ cdcStreamOptions: { schemaCheckIntervalMs: 60_000 }
290
+ });
291
+ await context.updateSyncRules(WILDCARD_SYNC_RULES);
292
+ const { connectionManager } = context;
293
+
294
+ await createTestTableWithBasicId(connectionManager, 'test_data');
295
+ await context.replicateSnapshot();
296
+ await context.startStreaming();
297
+ await context.getCheckpoint();
298
+
299
+ const tableBefore = context.cdcStream.tableCache.getAll()[0];
300
+ expect(tableBefore.pinnedCaptureObjectId, 'The initial source-table binding should be pinned').not.toBeNull();
301
+ initialBinding = {
302
+ sourceTableObjectId: tableBefore.objectId,
303
+ pinnedCaptureObjectId: tableBefore.pinnedCaptureObjectId,
304
+ sourceTableIds: tableBefore.sourceTables.map((table) => table.id.toString())
305
+ };
306
+
307
+ const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
308
+ await connectionManager.query(
309
+ `ALTER TABLE ${toQualifiedTableName(connectionManager.schema, 'test_data')} ADD new_column INT`
310
+ );
311
+ await enableCDCForTable({
312
+ connectionManager,
313
+ table: 'test_data',
314
+ captureInstance: 'capture_instance_new',
315
+ // Make the replacement schema distinguishable from the pinned one.
316
+ capturedColumns: ['id', 'new_column']
317
+ });
318
+
319
+ const { recordset: result } = await connectionManager.query(
320
+ `
321
+ INSERT INTO ${toQualifiedTableName(connectionManager.schema, 'test_data')} (description, new_column)
322
+ OUTPUT INSERTED.id, INSERTED.description, INSERTED.new_column
323
+ VALUES (@description, @new_column)
324
+ `,
325
+ [
326
+ { name: 'description', type: sql.NVarChar(sql.MAX), value: 'before_restart' },
327
+ { name: 'new_column', type: sql.Int, value: 1 }
328
+ ]
329
+ );
330
+ const rowBeforeRestart = result[0];
331
+
332
+ const data = await context.getFinalBucketState('global[]');
333
+ const replicatedRow = data.find((operation) => operation.object_id === String(rowBeforeRestart.id));
334
+ expect(
335
+ replicatedRow,
336
+ 'A transaction after the new capture instance was created should reach a PowerSync checkpoint'
337
+ ).toBeDefined();
338
+ expect(
339
+ JSON.parse(replicatedRow!.data!),
340
+ 'Data captured only by the original instance should be visible before PowerSync stops'
341
+ ).toEqual({ id: rowBeforeRestart.id, description: rowBeforeRestart.description });
342
+ expect(
343
+ schemaSpy.mock.calls.some(([, change]) => change.type === SchemaChangeType.NEW_CAPTURE_INSTANCE),
344
+ 'The replacement capture instance should still be undetected when PowerSync stops'
345
+ ).toBe(false);
346
+ }
347
+
348
+ {
349
+ await using replicationContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
350
+ await replicationContext.loadActiveSyncRules();
351
+ await replicationContext.replicateSnapshot();
352
+
353
+ const tableAfter = replicationContext.cdcStream.tableCache.get(initialBinding.sourceTableObjectId)!;
354
+ expect(
355
+ tableAfter.pinnedCaptureObjectId,
356
+ 'Restarting should restore the persisted pin even though the resume LSN passed the newer capture instance'
357
+ ).toBe(initialBinding.pinnedCaptureObjectId);
358
+ expect(
359
+ tableAfter.captureInstance?.objectId,
360
+ 'The restarted stream should poll the original capture instance rather than infer the newest one from LSNs'
361
+ ).toBe(initialBinding.pinnedCaptureObjectId);
362
+ expect(
363
+ tableAfter.sourceTables.map((table) => table.id.toString()),
364
+ 'Restarting should reuse the existing PowerSync SourceTable records without re-snapshotting'
365
+ ).toEqual(initialBinding.sourceTableIds);
366
+
367
+ await replicationContext.startStreaming();
368
+ const { connectionManager } = replicationContext;
369
+ const { recordset: result } = await connectionManager.query(
370
+ `
371
+ INSERT INTO ${toQualifiedTableName(connectionManager.schema, 'test_data')} (description, new_column)
372
+ OUTPUT INSERTED.id, INSERTED.description, INSERTED.new_column
373
+ VALUES (@description, @new_column)
374
+ `,
375
+ [
376
+ { name: 'description', type: sql.NVarChar(sql.MAX), value: 'after_restart' },
377
+ { name: 'new_column', type: sql.Int, value: 2 }
378
+ ]
379
+ );
380
+ const rowAfterRestart = result[0];
381
+
382
+ const finalState = await replicationContext.getFinalBucketState('global[]');
383
+ const replicatedRow = finalState.find((operation) => operation.object_id === String(rowAfterRestart.id));
384
+ expect(replicatedRow, 'The original capture instance should continue replicating after restart').toBeDefined();
385
+ expect(
386
+ JSON.parse(replicatedRow!.data!),
387
+ 'Data captured only by the original instance should remain visible after restart'
388
+ ).toEqual({ id: rowAfterRestart.id, description: rowAfterRestart.description });
389
+ }
390
+ });
320
391
 
321
- const testData3 = await insertBasicIdTestData(connectionManager, 'test_data');
392
+ test('New capture instance with changed schema created while PowerSync is stopped restores the existing pin', async () => {
393
+ let initialBinding: {
394
+ sourceTableObjectId: number;
395
+ pinnedCaptureObjectId: number | null;
396
+ sourceTableIds: string[];
397
+ };
398
+ {
399
+ await using context = await CDCStreamTestContext.open(factory);
400
+ await context.updateSyncRules(WILDCARD_SYNC_RULES);
401
+ const { connectionManager } = context;
402
+
403
+ await createTestTableWithBasicId(connectionManager, 'test_data');
404
+ const beforeLSN = await getLatestLSN(connectionManager);
405
+ const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
406
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
407
+
408
+ await context.replicateSnapshot();
409
+ await context.startStreaming();
410
+
411
+ const data = await context.getBucketData('global[]');
412
+ expect(data, 'The initial row should be available before PowerSync stops').toMatchObject([
413
+ putOp('test_data', testData1)
414
+ ]);
415
+
416
+ const tableBefore = context.cdcStream.tableCache.getAll()[0];
417
+ expect(tableBefore.pinnedCaptureObjectId, 'The initial source-table binding should be pinned').not.toBeNull();
418
+ initialBinding = {
419
+ sourceTableObjectId: tableBefore.objectId,
420
+ pinnedCaptureObjectId: tableBefore.pinnedCaptureObjectId,
421
+ sourceTableIds: tableBefore.sourceTables.map((table) => table.id.toString())
422
+ };
423
+ }
424
+
425
+ let testData2: any;
426
+ {
427
+ await using changeContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
428
+ const changeConnectionManager = changeContext.connectionManager;
429
+ await changeConnectionManager.query(
430
+ `ALTER TABLE ${toQualifiedTableName(changeConnectionManager.schema, 'test_data')} ADD new_column INT`
431
+ );
432
+ const beforeChangedRowLSN = await getLatestLSN(changeConnectionManager);
433
+ const { recordset: result } = await changeConnectionManager.query(
434
+ `
435
+ INSERT INTO ${toQualifiedTableName(changeConnectionManager.schema, 'test_data')} (description, new_column)
436
+ OUTPUT INSERTED.id, INSERTED.description, INSERTED.new_column
437
+ VALUES (@description, @new_column)
438
+ `,
439
+ [
440
+ { name: 'description', type: sql.NVarChar(sql.MAX), value: 'new_capture_column_while_stopped' },
441
+ { name: 'new_column', type: sql.Int, value: 1 }
442
+ ]
443
+ );
444
+ testData2 = result[0];
445
+ await waitForPendingCDCChanges(beforeChangedRowLSN, changeConnectionManager);
446
+ await enableCDCForTable({
447
+ connectionManager: changeConnectionManager,
448
+ table: 'test_data',
449
+ captureInstance: 'capture_instance_new'
450
+ });
451
+ }
452
+
453
+ {
454
+ await using replicationContext = await CDCStreamTestContext.open(factory, { doNotClear: true });
455
+ await replicationContext.loadActiveSyncRules();
456
+ await replicationContext.replicateSnapshot();
457
+
458
+ const tableAfter = replicationContext.cdcStream.tableCache.get(initialBinding.sourceTableObjectId)!;
459
+ expect(
460
+ tableAfter.pinnedCaptureObjectId,
461
+ 'Restarting the replication stream should restore the persisted capture-instance pin'
462
+ ).toBe(initialBinding.pinnedCaptureObjectId);
463
+ expect(
464
+ tableAfter.captureInstance?.objectId,
465
+ 'The restarted stream should poll the persisted capture instance rather than the newest instance'
466
+ ).toBe(initialBinding.pinnedCaptureObjectId);
467
+ expect(
468
+ tableAfter.sourceTables.map((table) => table.id.toString()),
469
+ 'Restarting with a newer capture instance available should reuse the PowerSync SourceTable records'
470
+ ).toEqual(initialBinding.sourceTableIds);
471
+
472
+ await replicationContext.startStreaming();
473
+
474
+ const finalState = await replicationContext.getFinalBucketState('global[]');
475
+ const replicatedTestData2 = finalState.find((operation) => operation.object_id === String(testData2.id));
476
+ expect(replicatedTestData2, 'The row should be available at the next checkpoint after restart').toBeDefined();
477
+ expect(
478
+ JSON.parse(replicatedTestData2!.data!),
479
+ 'The restored pin should replicate the row using the original capture schema'
480
+ ).toEqual({ id: testData2.id, description: testData2.description });
481
+ }
482
+ });
322
483
 
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
- ]);
484
+ test('A legacy binding without source metadata is pinned on the next job', async () => {
485
+ let sourceDescriptor: SourceEntityDescriptor;
486
+ let initialBinding: {
487
+ sourceTableObjectId: number;
488
+ pinnedCaptureObjectId: number | null;
489
+ sourceTableIds: string[];
490
+ };
491
+
492
+ {
493
+ await using context = await CDCStreamTestContext.open(factory);
494
+ await context.updateSyncRules(BASIC_SYNC_RULES);
495
+ const { connectionManager } = context;
496
+
497
+ await createTestTableWithBasicId(connectionManager, 'test_data');
498
+ const beforeLSN = await getLatestLSN(connectionManager);
499
+ const testData = await insertBasicIdTestData(connectionManager, 'test_data');
500
+ await waitForPendingCDCChanges(beforeLSN, connectionManager);
501
+
502
+ await context.replicateSnapshot();
503
+ await context.startStreaming();
504
+
505
+ expect(await context.getBucketData('global[]')).toMatchObject([putOp('test_data', testData)]);
506
+
507
+ const table = context.cdcStream.tableCache.getAll()[0];
508
+ expect(table.pinnedCaptureObjectId, 'The binding should start out pinned').not.toBeNull();
509
+ sourceDescriptor = table.ref;
510
+ initialBinding = {
511
+ sourceTableObjectId: table.objectId,
512
+ pinnedCaptureObjectId: table.pinnedCaptureObjectId,
513
+ sourceTableIds: table.sourceTables.map((sourceTable) => sourceTable.id.toString())
514
+ };
515
+ }
516
+
517
+ {
518
+ // Reproduce a binding written before capture pinning.
519
+ await using context = await CDCStreamTestContext.open(factory, { doNotClear: true });
520
+ await context.loadActiveSyncRules();
521
+
522
+ await using writer = await context.storage!.createWriter({
523
+ ...test_utils.BATCH_OPTIONS,
524
+ zeroLSN: LSN.ZERO,
525
+ defaultSchema: context.connectionManager.schema,
526
+ storeCurrentData: false
527
+ });
528
+ const resolved = await writer.resolveTables({
529
+ connection_id: 1,
530
+ source: sourceDescriptor,
531
+ reconcileSourceTables: ({ candidates }) => ({
532
+ compatibleTables: candidates.map((candidate) => candidate.withSourceMetadata(null)),
533
+ incompatibleTables: [],
534
+ newTableValues: { sourceMetadata: null }
535
+ })
536
+ });
537
+
538
+ expect(
539
+ resolved.tables.map((table) => table.id.toString()),
540
+ 'Stripping the metadata should reuse the existing records'
541
+ ).toEqual(initialBinding.sourceTableIds);
542
+ expect(
543
+ resolved.tables.map((table) => table.sourceMetadata),
544
+ 'The persisted pin should have been cleared'
545
+ ).toEqual(resolved.tables.map(() => null));
546
+ }
547
+
548
+ {
549
+ await using context = await CDCStreamTestContext.open(factory, { doNotClear: true });
550
+ await context.loadActiveSyncRules();
551
+ await context.replicateSnapshot();
552
+
553
+ const table = context.cdcStream.tableCache.get(initialBinding.sourceTableObjectId)!;
554
+ expect(table.pinnedCaptureObjectId, 'The legacy binding should be backfilled with a pin').toBe(
555
+ initialBinding.pinnedCaptureObjectId
556
+ );
557
+ expect(table.captureInstance?.objectId, 'The stream should poll the capture instance it was just pinned to').toBe(
558
+ initialBinding.pinnedCaptureObjectId
559
+ );
560
+ expect(
561
+ table.sourceTables.map((sourceTable) => sourceTable.id.toString()),
562
+ 'Backfilling should update the existing records in place, not re-snapshot'
563
+ ).toEqual(initialBinding.sourceTableIds);
564
+ }
329
565
  });
330
566
 
331
- test('Capture instance created for a sync rule table without a capture instance', async () => {
567
+ test('A sync rule table without a capture instance blocks the job until CDC is enabled', async () => {
332
568
  await using context = await CDCStreamTestContext.open(factory);
333
569
  await context.updateSyncRules(BASIC_SYNC_RULES);
334
570
  const { connectionManager } = context;
@@ -336,21 +572,23 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
336
572
  await createTestTableWithBasicId(connectionManager, 'test_data', false);
337
573
  const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
338
574
 
339
- await context.replicateSnapshot();
340
- await context.startStreaming();
575
+ await expect(
576
+ context.replicateSnapshot(),
577
+ 'Replication should not start while a sync config table has no capture instance'
578
+ ).rejects.toThrow(/CDC is not enabled/);
341
579
 
342
580
  await enableCDCForTable({ connectionManager, table: 'test_data' });
343
581
 
344
- let data = await context.getBucketData('global[]');
345
- expect(data).toMatchObject([putOp('test_data', testData1)]);
582
+ await context.replicateSnapshot();
583
+ await context.startStreaming();
346
584
 
347
585
  const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
348
586
 
349
- data = await context.getFinalBucketState('global[]');
587
+ const data = await context.getFinalBucketState('global[]');
350
588
  expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
351
589
  });
352
590
 
353
- test('Capture instance removed for an actively replicating table', async () => {
591
+ test('Removing the last capture instance for a replicating table fails the job', async () => {
354
592
  await using context = await CDCStreamTestContext.open(factory, {
355
593
  cdcStreamOptions: { schemaCheckIntervalMs: 5000 }
356
594
  });
@@ -366,18 +604,35 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
366
604
  await context.startStreaming();
367
605
 
368
606
  const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
369
- let data = await context.getBucketData('global[]');
607
+ const data = await context.getBucketData('global[]');
370
608
  expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
371
609
 
372
- const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
610
+ const streamFailure = expect(
611
+ context.streamingPromise,
612
+ 'Losing the last capture instance should stop the whole replication job'
613
+ ).rejects.toThrow(/Re-enable CDC for this table/);
373
614
  await disableCDCForTable(connectionManager, 'test_data');
374
- await expectedSchemaChange(schemaSpy, SchemaChangeType.MISSING_CAPTURE_INSTANCE);
615
+ await streamFailure;
616
+ });
375
617
 
376
- data = await context.getBucketData('global[]');
377
- expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
618
+ test('Table wildcards in the sync configuration are rejected', async () => {
619
+ await using context = await CDCStreamTestContext.open(factory);
620
+ await context.updateSyncRules(`
621
+ bucket_definitions:
622
+ global:
623
+ data:
624
+ - SELECT id, description FROM "test_data%"
625
+ `);
626
+ const { connectionManager } = context;
627
+ await createTestTableWithBasicId(connectionManager, 'test_data');
628
+
629
+ await expect(
630
+ context.replicateSnapshot(),
631
+ 'A table wildcard should stop replication, even where it matches an existing CDC-enabled table'
632
+ ).rejects.toThrow(/wildcards/);
378
633
  });
379
634
 
380
- test('Capture instance removed, and then re-added', async () => {
635
+ test('A replacement for a dropped pinned capture instance is not adopted, and stops the job', async () => {
381
636
  await using context = await CDCStreamTestContext.open(factory);
382
637
  await context.updateSyncRules(BASIC_SYNC_RULES);
383
638
  const { connectionManager } = context;
@@ -389,27 +644,20 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
389
644
 
390
645
  const testData1 = await insertBasicIdTestData(connectionManager, 'test_data');
391
646
  const testData2 = await insertBasicIdTestData(connectionManager, 'test_data');
392
- let data = await context.getBucketData('global[]');
647
+ const data = await context.getBucketData('global[]');
393
648
  expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
394
649
 
395
- let schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
396
- await disableCDCForTable(connectionManager, 'test_data');
397
- await expectedSchemaChange(schemaSpy, SchemaChangeType.MISSING_CAPTURE_INSTANCE);
650
+ const table = context.cdcStream.tableCache.getAll()[0];
651
+ const originalCaptureInstanceName = table.captureInstance!.name;
398
652
 
399
- schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
400
- await enableCDCForTable({ connectionManager, table: 'test_data' });
401
- await expectedSchemaChange(schemaSpy, SchemaChangeType.NEW_CAPTURE_INSTANCE);
402
-
403
- const testData3 = await insertBasicIdTestData(connectionManager, 'test_data');
404
- const testData4 = await insertBasicIdTestData(connectionManager, 'test_data');
405
-
406
- const finalState = await context.getFinalBucketState('global[]');
407
- expect(finalState).toMatchObject([
408
- putOp('test_data', testData1),
409
- putOp('test_data', testData2),
410
- putOp('test_data', testData3),
411
- putOp('test_data', testData4)
412
- ]);
653
+ const streamFailure = expect(
654
+ context.streamingPromise,
655
+ 'An available replacement should not keep the job alive - adopting it needs a new deploy'
656
+ ).rejects.toThrow(/adopt the replacement/);
657
+ // Keep a replacement available to exercise the pinning failure.
658
+ await enableCDCForTable({ connectionManager, table: 'test_data', captureInstance: 'capture_instance_new' });
659
+ await disableCDCForTable(connectionManager, 'test_data', originalCaptureInstanceName);
660
+ await streamFailure;
413
661
  });
414
662
 
415
663
  test('Column schema changes continue replication, but with warning.', async () => {
@@ -425,8 +673,10 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
425
673
  await context.replicateSnapshot();
426
674
  await context.startStreaming();
427
675
  const schemaSpy = vi.spyOn(context.cdcStream, 'handleSchemaChange');
676
+ const warnSpy = vi.spyOn(logger, 'warn');
428
677
  await connectionManager.query(`ALTER TABLE test_data ADD new_column INT`);
429
678
  await expectedSchemaChange(schemaSpy, SchemaChangeType.TABLE_COLUMN_CHANGES);
679
+ await expectedWarning(warnSpy, /Schema drift detected/);
430
680
 
431
681
  const { recordset: result } = await connectionManager.query(
432
682
  `
@@ -443,18 +693,32 @@ function defineSchemaChangesTests(config: storage.TestStorageConfig) {
443
693
  const testData2 = { id: result[0].id, description: result[0].description };
444
694
 
445
695
  const data = await context.getBucketData('global[]');
446
- // Capture instances do not reflect most schema changes until the capture instance is re-created
447
- // So testData2 will be replicated but without the new column
696
+ // The pinned capture schema does not include the new column.
448
697
  expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
449
698
 
699
+ // Schema polling must not replace the capture instance selected when the stream started.
450
700
  expect(
451
701
  context.cdcStream.tableCache
452
702
  .getAll()
453
- .every((t) => t.captureInstance && t.captureInstance.pendingSchemaChanges.length > 0)
703
+ .every((table) => table.captureInstance?.objectId === table.pinnedCaptureObjectId)
454
704
  ).toBe(true);
455
705
  });
456
706
  }
457
707
 
708
+ async function expectedWarning(warnSpy: any, pattern: RegExp) {
709
+ try {
710
+ await vi.waitFor(
711
+ () =>
712
+ expect(warnSpy.mock.calls.some(([message]: any[]) => typeof message == 'string' && pattern.test(message))).toBe(
713
+ true
714
+ ),
715
+ { timeout: 55000 }
716
+ );
717
+ } catch (error) {
718
+ throw new Error(`Test Assertion: Timeout waiting for a warning matching ${pattern}`);
719
+ }
720
+ }
721
+
458
722
  async function expectedSchemaChange(spy: any, type: SchemaChangeType) {
459
723
  logger.info(`Test Assertion: Waiting for schema change: ${type}`);
460
724
  try {