@powersync/service-module-mssql 0.9.0 → 0.10.1
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 +113 -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 +115 -106
- 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 +10 -1
- 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 +8 -8
- 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 +148 -154
- 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 +22 -1
- 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
|
@@ -110,14 +110,16 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
110
110
|
expect(data).toMatchObject([putOp('test_data', testData), removeOp('test_data', testData.id)]);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
test('Replicate
|
|
113
|
+
test('Replicate multiple tables in sync rules', async () => {
|
|
114
114
|
await using context = await CDCStreamTestContext.open(factory);
|
|
115
115
|
const { connectionManager } = context;
|
|
116
|
+
// Table wildcards are not supported - each table is listed explicitly.
|
|
116
117
|
await context.updateSyncRules(`
|
|
117
118
|
bucket_definitions:
|
|
118
119
|
global:
|
|
119
120
|
data:
|
|
120
|
-
- SELECT id, description FROM "
|
|
121
|
+
- SELECT id, description FROM "test_data_1"
|
|
122
|
+
- SELECT id, description FROM "test_data_2"`);
|
|
121
123
|
|
|
122
124
|
await createTestTable(connectionManager, 'test_data_1');
|
|
123
125
|
await createTestTable(connectionManager, 'test_data_2');
|
|
@@ -148,6 +150,8 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
148
150
|
const { connectionManager } = context;
|
|
149
151
|
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
150
152
|
|
|
153
|
+
// test_data is in the sync config and must exist for replication to start; test_donotsync is not.
|
|
154
|
+
await createTestTable(connectionManager, 'test_data');
|
|
151
155
|
await createTestTable(connectionManager, 'test_donotsync');
|
|
152
156
|
|
|
153
157
|
await context.replicateSnapshot();
|
|
@@ -74,6 +74,13 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
74
74
|
return this.connectionManager.connectionTag;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Resolves when streaming stops, or rejects with the failure that stopped it.
|
|
79
|
+
*/
|
|
80
|
+
get streamingPromise() {
|
|
81
|
+
return this.streamPromise;
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
async updateSyncRules(content: string) {
|
|
78
85
|
const replicationStream = await this.factory.updateSyncRules(
|
|
79
86
|
updateSyncRulesFromYaml(content, { validate: true, storageVersion: LEGACY_STORAGE_VERSION })
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { CaptureInstance } from '@module/common/CaptureInstance.js';
|
|
2
|
+
import { createCaptureReconciler, readCaptureMetadata } from '@module/replication/CaptureReconciler.js';
|
|
3
|
+
import type { MSSQLTableReconciliationContext } from '@module/replication/MSSQLTableReconciliationContext.js';
|
|
4
|
+
import { MSSQLTableReconciliationState } from '@module/replication/MSSQLTableReconciliationContext.js';
|
|
5
|
+
import { SourceEntityDescriptor, SourceTable } from '@powersync/service-core';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { createCaptureInstance, createSourceDescriptor, createSourceTableCandidate } from './util.js';
|
|
8
|
+
|
|
9
|
+
function reconcile(context: MSSQLTableReconciliationContext, candidates: SourceTable[]) {
|
|
10
|
+
return createCaptureReconciler(context)({ source: context.source, candidates });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('readCaptureMetadata', () => {
|
|
14
|
+
it('parses a valid metadata object', () => {
|
|
15
|
+
expect(readCaptureMetadata({ captureTableObjectId: 7 })).toEqual({ captureTableObjectId: 7 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns null for legacy metadata', () => {
|
|
19
|
+
expect(readCaptureMetadata(null)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects malformed metadata', () => {
|
|
23
|
+
expect(() => readCaptureMetadata([1, 2] as any)).toThrow();
|
|
24
|
+
expect(() => readCaptureMetadata({ foo: 1 } as any)).toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('createCaptureReconciler', () => {
|
|
29
|
+
it('pins a new binding to the newest available capture instance', () => {
|
|
30
|
+
const resolution = reconcile(readyContext([createCaptureInstance(50), createCaptureInstance(40)]), []);
|
|
31
|
+
expect(resolution.compatibleTables).toEqual([]);
|
|
32
|
+
expect(resolution.incompatibleTables).toEqual([]);
|
|
33
|
+
expect(resolution.newTableValues).toEqual({ sourceMetadata: { captureTableObjectId: 50 } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('reports a missing table with no persisted binding as not ready', () => {
|
|
37
|
+
expect(() =>
|
|
38
|
+
reconcile(
|
|
39
|
+
unavailableContext(
|
|
40
|
+
MSSQLTableReconciliationState.TABLE_MISSING,
|
|
41
|
+
createSourceDescriptor({ objectId: undefined, replicaIdColumns: [] })
|
|
42
|
+
),
|
|
43
|
+
[]
|
|
44
|
+
)
|
|
45
|
+
).toThrow(/does not exist/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reports a missing table with a persisted binding as unavailable', () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
reconcile(
|
|
51
|
+
unavailableContext(
|
|
52
|
+
MSSQLTableReconciliationState.TABLE_MISSING,
|
|
53
|
+
createSourceDescriptor({ objectId: undefined, replicaIdColumns: [] })
|
|
54
|
+
),
|
|
55
|
+
[createSourceTableCandidate('old', { captureTableObjectId: 40 })]
|
|
56
|
+
)
|
|
57
|
+
).toThrow(/no longer matches the source table binding/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('fails a new binding when CDC is disabled', () => {
|
|
61
|
+
expect(() => reconcile(unavailableContext(MSSQLTableReconciliationState.CDC_DISABLED), [])).toThrow(
|
|
62
|
+
/CDC is not enabled/
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('updates legacy metadata-free candidates to the newest capture instance', () => {
|
|
67
|
+
const resolution = reconcile(readyContext([createCaptureInstance(50)]), [
|
|
68
|
+
createSourceTableCandidate('a'),
|
|
69
|
+
createSourceTableCandidate('b')
|
|
70
|
+
]);
|
|
71
|
+
expect(
|
|
72
|
+
resolution.compatibleTables.map((table) => ({ id: table.id, sourceMetadata: table.sourceMetadata }))
|
|
73
|
+
).toEqual([
|
|
74
|
+
{ id: 'a', sourceMetadata: { captureTableObjectId: 50 } },
|
|
75
|
+
{ id: 'b', sourceMetadata: { captureTableObjectId: 50 } }
|
|
76
|
+
]);
|
|
77
|
+
expect(resolution.incompatibleTables).toEqual([]);
|
|
78
|
+
expect(resolution.newTableValues).toEqual({ sourceMetadata: { captureTableObjectId: 50 } });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fails an existing binding when CDC is disabled', () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
reconcile(unavailableContext(MSSQLTableReconciliationState.CDC_DISABLED), [createSourceTableCandidate('a')])
|
|
84
|
+
).toThrow(/CDC is no longer enabled/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('reports a changed source identity as unavailable when CDC is disabled', () => {
|
|
88
|
+
const changedSource = createSourceDescriptor({ objectId: 200 });
|
|
89
|
+
expect(() =>
|
|
90
|
+
reconcile(unavailableContext(MSSQLTableReconciliationState.CDC_DISABLED, changedSource), [
|
|
91
|
+
createSourceTableCandidate('old')
|
|
92
|
+
])
|
|
93
|
+
).toThrow(/no longer matches the source table binding/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('preserves a pinned capture identity that is still available', () => {
|
|
97
|
+
const resolution = reconcile(readyContext([createCaptureInstance(50), createCaptureInstance(40)]), [
|
|
98
|
+
createSourceTableCandidate('a', { captureTableObjectId: 40 })
|
|
99
|
+
]);
|
|
100
|
+
expect(resolution.compatibleTables.map((table) => table.id)).toEqual(['a']);
|
|
101
|
+
expect(resolution.compatibleTables[0].sourceMetadata).toEqual({ captureTableObjectId: 40 });
|
|
102
|
+
expect(resolution.incompatibleTables).toEqual([]);
|
|
103
|
+
expect(resolution.newTableValues).toEqual({ sourceMetadata: { captureTableObjectId: 40 } });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('fails when the pinned capture instance was dropped, even with a replacement available', () => {
|
|
107
|
+
expect(() =>
|
|
108
|
+
reconcile(readyContext([createCaptureInstance(50)]), [
|
|
109
|
+
createSourceTableCandidate('a', { captureTableObjectId: 40 })
|
|
110
|
+
])
|
|
111
|
+
).toThrow(/no longer available/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('fails on a mixture of metadata-free and pinned candidates', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
reconcile(readyContext([createCaptureInstance(40)]), [
|
|
117
|
+
createSourceTableCandidate('a'),
|
|
118
|
+
createSourceTableCandidate('b', { captureTableObjectId: 40 })
|
|
119
|
+
])
|
|
120
|
+
).toThrow(/mixture/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('fails on multiple distinct pinned identities', () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
reconcile(readyContext([createCaptureInstance(40), createCaptureInstance(41)]), [
|
|
126
|
+
createSourceTableCandidate('a', { captureTableObjectId: 40 }),
|
|
127
|
+
createSourceTableCandidate('b', { captureTableObjectId: 41 })
|
|
128
|
+
])
|
|
129
|
+
).toThrow(/multiple persisted capture identities/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not replace an existing binding when the source identity changed', () => {
|
|
133
|
+
const changedSource = createSourceDescriptor({ objectId: 200 });
|
|
134
|
+
expect(() =>
|
|
135
|
+
reconcile(readyContext([createCaptureInstance(50)], changedSource), [
|
|
136
|
+
createSourceTableCandidate('old', { captureTableObjectId: 40 })
|
|
137
|
+
])
|
|
138
|
+
).toThrow(/Table \[dbo]\.\[users] no longer matches the source table binding.*already-replicated data is retained/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('drops stale incompatible candidates when a compatible candidate anchors the binding', () => {
|
|
142
|
+
const resolution = reconcile(readyContext([createCaptureInstance(50)]), [
|
|
143
|
+
createSourceTableCandidate('a'),
|
|
144
|
+
createSourceTableCandidate('mismatch', undefined, {
|
|
145
|
+
replicaIdColumns: [{ name: 'id', type: 'bigint', typeId: 127 }]
|
|
146
|
+
})
|
|
147
|
+
]);
|
|
148
|
+
expect(
|
|
149
|
+
resolution.compatibleTables.map((table) => ({ id: table.id, sourceMetadata: table.sourceMetadata }))
|
|
150
|
+
).toEqual([{ id: 'a', sourceMetadata: { captureTableObjectId: 50 } }]);
|
|
151
|
+
expect(resolution.incompatibleTables.map((table) => table.id)).toEqual(['mismatch']);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function readyContext(
|
|
156
|
+
captureInstances: CaptureInstance[],
|
|
157
|
+
sourceDescriptor: SourceEntityDescriptor = createSourceDescriptor()
|
|
158
|
+
): MSSQLTableReconciliationContext {
|
|
159
|
+
return {
|
|
160
|
+
state: MSSQLTableReconciliationState.READY,
|
|
161
|
+
source: sourceDescriptor,
|
|
162
|
+
captureInstances
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function unavailableContext(
|
|
167
|
+
state: MSSQLTableReconciliationState.TABLE_MISSING | MSSQLTableReconciliationState.CDC_DISABLED,
|
|
168
|
+
sourceDescriptor: SourceEntityDescriptor = createSourceDescriptor()
|
|
169
|
+
): MSSQLTableReconciliationContext {
|
|
170
|
+
return { state, source: sourceDescriptor };
|
|
171
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MSSQLSourceTable } from '@module/common/MSSQLSourceTable.js';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createCaptureInstance, createSourceDescriptor, createSourceTableCandidate } from './util.js';
|
|
4
|
+
|
|
5
|
+
describe('MSSQLSourceTable.setCaptureInstance', () => {
|
|
6
|
+
it('sets the instance matching the persisted capture-table object id', () => {
|
|
7
|
+
const table = new MSSQLSourceTable(createSourceDescriptor(), [
|
|
8
|
+
createSourceTableCandidate('a', { captureTableObjectId: 40 })
|
|
9
|
+
]);
|
|
10
|
+
const expected = createCaptureInstance(40);
|
|
11
|
+
|
|
12
|
+
table.setCaptureInstance([createCaptureInstance(50), expected]);
|
|
13
|
+
|
|
14
|
+
expect(table.captureInstance).toBe(expected);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('sets null when the binding is legacy or the pinned instance is unavailable', () => {
|
|
18
|
+
const legacy = new MSSQLSourceTable(createSourceDescriptor(), [createSourceTableCandidate('legacy')]);
|
|
19
|
+
const pinned = new MSSQLSourceTable(createSourceDescriptor(), [
|
|
20
|
+
createSourceTableCandidate('pinned', { captureTableObjectId: 40 })
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
legacy.setCaptureInstance([createCaptureInstance(40)]);
|
|
24
|
+
pinned.setCaptureInstance([createCaptureInstance(40)]);
|
|
25
|
+
pinned.setCaptureInstance([createCaptureInstance(50)]);
|
|
26
|
+
|
|
27
|
+
expect(legacy.captureInstance).toBeNull();
|
|
28
|
+
expect(pinned.captureInstance).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
package/test/src/config.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createConnectionPoolConfig } from '@module/replication/MSSQLConnectionManager.js';
|
|
1
2
|
import { normalizeConnectionConfig } from '@module/types/types.js';
|
|
2
3
|
import { describe, expect, test } from 'vitest';
|
|
3
4
|
|
|
@@ -7,6 +8,42 @@ const BASE_CONFIG = {
|
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
describe('SQL Server connection config', () => {
|
|
11
|
+
test('passes a custom CA to the SQL Server TLS configuration', () => {
|
|
12
|
+
const cacert = `-----BEGIN CERTIFICATE-----
|
|
13
|
+
test certificate
|
|
14
|
+
-----END CERTIFICATE-----`;
|
|
15
|
+
const normalized = normalizeConnectionConfig({ ...BASE_CONFIG, cacert });
|
|
16
|
+
|
|
17
|
+
expect(normalized.cacert).toBe(cacert);
|
|
18
|
+
expect(createConnectionPoolConfig(normalized, {}).options).toMatchObject({
|
|
19
|
+
encrypt: true,
|
|
20
|
+
trustServerCertificate: false,
|
|
21
|
+
cryptoCredentialsDetails: { ca: cacert }
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('passes a custom TLS server name to the SQL Server TLS configuration', () => {
|
|
26
|
+
const normalized = normalizeConnectionConfig({ ...BASE_CONFIG, tls_servername: 'sql.internal.example' });
|
|
27
|
+
|
|
28
|
+
expect(createConnectionPoolConfig(normalized, {}).options?.serverName).toBe('sql.internal.example');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('rejects a custom CA when server certificate validation is disabled', () => {
|
|
32
|
+
expect(() =>
|
|
33
|
+
normalizeConnectionConfig({
|
|
34
|
+
...BASE_CONFIG,
|
|
35
|
+
cacert: 'certificate',
|
|
36
|
+
additionalConfig: { trustServerCertificate: true }
|
|
37
|
+
})
|
|
38
|
+
).toThrow(/cacert cannot be used with trustServerCertificate/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('does not configure a custom TLS context without a custom CA', () => {
|
|
42
|
+
const normalized = normalizeConnectionConfig(BASE_CONFIG);
|
|
43
|
+
|
|
44
|
+
expect(createConnectionPoolConfig(normalized, {}).options?.cryptoCredentialsDetails).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
10
47
|
test('defaults heartbeat_interval_seconds to 60 seconds', () => {
|
|
11
48
|
expect(normalizeConnectionConfig(BASE_CONFIG).heartbeat_interval_seconds).toBe(60);
|
|
12
49
|
});
|