@powersync/service-module-mssql 0.9.0 → 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.
- package/CHANGELOG.md +84 -0
- package/dist/api/MSSQLRouteAPIAdapter.js +26 -34
- 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/replication/CDCPoller.d.ts +13 -36
- package/dist/replication/CDCPoller.js +173 -129
- package/dist/replication/CDCPoller.js.map +1 -1
- package/dist/replication/CDCStream.d.ts +11 -14
- package/dist/replication/CDCStream.js +109 -103
- 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 +8 -0
- package/dist/types/types.js +9 -0
- 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 +40 -45
- package/dist/utils/schema.js.map +1 -1
- package/package.json +4 -4
- package/src/api/MSSQLRouteAPIAdapter.ts +27 -33
- package/src/common/MSSQLSourceTable.ts +32 -4
- package/src/replication/CDCPoller.ts +206 -162
- package/src/replication/CDCStream.ts +142 -151
- 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 +21 -0
- package/src/utils/mssql.ts +1 -1
- package/src/utils/schema.ts +57 -58
- package/test/src/CDCPoller.test.ts +382 -0
- package/test/src/CDCStream.test.ts +6 -2
- package/test/src/CDCStreamTestContext.ts +7 -0
- package/test/src/CaptureReconciler.test.ts +171 -0
- package/test/src/MSSQLSourceTable.test.ts +30 -0
- package/test/src/config.test.ts +37 -0
- package/test/src/schema-changes.test.ts +513 -249
- package/test/src/schema.test.ts +17 -0
- package/test/src/util.ts +70 -6
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { storage } from '@powersync/service-core';
|
|
2
|
-
import { putOp,
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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('
|
|
211
|
-
let
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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:
|
|
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
|
-
|
|
259
|
-
expect(data).toMatchObject([putOp('test_data', testData)]);
|
|
198
|
+
expect(await context.getBucketData('global[]')).toMatchObject([putOp('test_data', testData)]);
|
|
260
199
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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('
|
|
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
|
|
340
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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('
|
|
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
|
-
|
|
607
|
+
const data = await context.getBucketData('global[]');
|
|
370
608
|
expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
|
|
371
609
|
|
|
372
|
-
const
|
|
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
|
|
615
|
+
await streamFailure;
|
|
616
|
+
});
|
|
375
617
|
|
|
376
|
-
|
|
377
|
-
|
|
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('
|
|
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
|
-
|
|
647
|
+
const data = await context.getBucketData('global[]');
|
|
393
648
|
expect(data).toMatchObject([putOp('test_data', testData1), putOp('test_data', testData2)]);
|
|
394
649
|
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
//
|
|
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((
|
|
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 {
|