@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/api/MSSQLRouteAPIAdapter.js +26 -34
  3. package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
  4. package/dist/common/MSSQLSourceTable.d.ts +12 -2
  5. package/dist/common/MSSQLSourceTable.js +30 -4
  6. package/dist/common/MSSQLSourceTable.js.map +1 -1
  7. package/dist/replication/CDCPoller.d.ts +13 -36
  8. package/dist/replication/CDCPoller.js +173 -129
  9. package/dist/replication/CDCPoller.js.map +1 -1
  10. package/dist/replication/CDCStream.d.ts +11 -14
  11. package/dist/replication/CDCStream.js +115 -106
  12. package/dist/replication/CDCStream.js.map +1 -1
  13. package/dist/replication/CaptureReconciler.d.ts +47 -0
  14. package/dist/replication/CaptureReconciler.js +155 -0
  15. package/dist/replication/CaptureReconciler.js.map +1 -0
  16. package/dist/replication/MSSQLConnectionManager.d.ts +1 -0
  17. package/dist/replication/MSSQLConnectionManager.js +19 -14
  18. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  19. package/dist/replication/MSSQLTableReconciliationContext.d.ts +21 -0
  20. package/dist/replication/MSSQLTableReconciliationContext.js +7 -0
  21. package/dist/replication/MSSQLTableReconciliationContext.js.map +1 -0
  22. package/dist/replication/SchemaChange.d.ts +67 -0
  23. package/dist/replication/SchemaChange.js +31 -0
  24. package/dist/replication/SchemaChange.js.map +1 -0
  25. package/dist/types/types.d.ts +8 -0
  26. package/dist/types/types.js +10 -1
  27. package/dist/types/types.js.map +1 -1
  28. package/dist/utils/mssql.js +1 -1
  29. package/dist/utils/schema.d.ts +14 -1
  30. package/dist/utils/schema.js +40 -45
  31. package/dist/utils/schema.js.map +1 -1
  32. package/package.json +8 -8
  33. package/src/api/MSSQLRouteAPIAdapter.ts +27 -33
  34. package/src/common/MSSQLSourceTable.ts +32 -4
  35. package/src/replication/CDCPoller.ts +206 -162
  36. package/src/replication/CDCStream.ts +148 -154
  37. package/src/replication/CaptureReconciler.ts +186 -0
  38. package/src/replication/MSSQLConnectionManager.ts +23 -14
  39. package/src/replication/MSSQLTableReconciliationContext.ts +29 -0
  40. package/src/replication/SchemaChange.ts +79 -0
  41. package/src/types/types.ts +22 -1
  42. package/src/utils/mssql.ts +1 -1
  43. package/src/utils/schema.ts +57 -58
  44. package/test/src/CDCPoller.test.ts +382 -0
  45. package/test/src/CDCStream.test.ts +6 -2
  46. package/test/src/CDCStreamTestContext.ts +7 -0
  47. package/test/src/CaptureReconciler.test.ts +171 -0
  48. package/test/src/MSSQLSourceTable.test.ts +30 -0
  49. package/test/src/config.test.ts +37 -0
  50. package/test/src/schema-changes.test.ts +513 -249
  51. package/test/src/schema.test.ts +17 -0
  52. package/test/src/util.ts +70 -6
  53. 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 matched wild card tables in sync rules', async () => {
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 "test_data_%"`);
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
+ });
@@ -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
  });