@powersync/service-module-mssql 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (64) hide show
  1. package/CHANGELOG.md +209 -4
  2. package/dist/api/MSSQLRouteAPIAdapter.d.ts +4 -5
  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/CDCReplicationJob.js +1 -1
  14. package/dist/replication/CDCReplicationJob.js.map +1 -1
  15. package/dist/replication/CDCReplicator.js +1 -1
  16. package/dist/replication/CDCReplicator.js.map +1 -1
  17. package/dist/replication/CDCStream.d.ts +11 -14
  18. package/dist/replication/CDCStream.js +124 -115
  19. package/dist/replication/CDCStream.js.map +1 -1
  20. package/dist/replication/CaptureReconciler.d.ts +47 -0
  21. package/dist/replication/CaptureReconciler.js +155 -0
  22. package/dist/replication/CaptureReconciler.js.map +1 -0
  23. package/dist/replication/MSSQLConnectionManager.d.ts +1 -0
  24. package/dist/replication/MSSQLConnectionManager.js +19 -14
  25. package/dist/replication/MSSQLConnectionManager.js.map +1 -1
  26. package/dist/replication/MSSQLTableReconciliationContext.d.ts +21 -0
  27. package/dist/replication/MSSQLTableReconciliationContext.js +7 -0
  28. package/dist/replication/MSSQLTableReconciliationContext.js.map +1 -0
  29. package/dist/replication/SchemaChange.d.ts +67 -0
  30. package/dist/replication/SchemaChange.js +31 -0
  31. package/dist/replication/SchemaChange.js.map +1 -0
  32. package/dist/types/types.d.ts +13 -0
  33. package/dist/types/types.js +28 -2
  34. package/dist/types/types.js.map +1 -1
  35. package/dist/utils/mssql.js +1 -1
  36. package/dist/utils/schema.d.ts +14 -1
  37. package/dist/utils/schema.js +43 -45
  38. package/dist/utils/schema.js.map +1 -1
  39. package/package.json +11 -21
  40. package/src/api/MSSQLRouteAPIAdapter.ts +38 -49
  41. package/src/common/MSSQLSourceTable.ts +32 -4
  42. package/src/module/MSSQLModule.ts +2 -1
  43. package/src/replication/CDCPoller.ts +206 -162
  44. package/src/replication/CDCReplicationJob.ts +1 -1
  45. package/src/replication/CDCReplicator.ts +1 -1
  46. package/src/replication/CDCStream.ts +157 -163
  47. package/src/replication/CaptureReconciler.ts +186 -0
  48. package/src/replication/MSSQLConnectionManager.ts +23 -14
  49. package/src/replication/MSSQLTableReconciliationContext.ts +29 -0
  50. package/src/replication/SchemaChange.ts +79 -0
  51. package/src/types/types.ts +47 -2
  52. package/src/utils/mssql.ts +1 -1
  53. package/src/utils/schema.ts +60 -54
  54. package/test/src/CDCPoller.test.ts +382 -0
  55. package/test/src/CDCStream.test.ts +6 -2
  56. package/test/src/CDCStreamTestContext.ts +30 -23
  57. package/test/src/CaptureReconciler.test.ts +171 -0
  58. package/test/src/MSSQLSourceTable.test.ts +30 -0
  59. package/test/src/config.test.ts +71 -0
  60. package/test/src/schema-changes.test.ts +513 -249
  61. package/test/src/schema.test.ts +17 -0
  62. package/test/src/util.ts +73 -9
  63. package/tsconfig.json +0 -3
  64. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ });
@@ -0,0 +1,71 @@
1
+ import { createConnectionPoolConfig } from '@module/replication/MSSQLConnectionManager.js';
2
+ import { normalizeConnectionConfig } from '@module/types/types.js';
3
+ import { describe, expect, test } from 'vitest';
4
+
5
+ const BASE_CONFIG = {
6
+ type: 'mssql' as const,
7
+ uri: 'mssql://user:password@localhost:1433/powersync_test'
8
+ };
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
+
47
+ test('defaults heartbeat_interval_seconds to 60 seconds', () => {
48
+ expect(normalizeConnectionConfig(BASE_CONFIG).heartbeat_interval_seconds).toBe(60);
49
+ });
50
+
51
+ test('uses the default for a null heartbeat_interval_seconds', () => {
52
+ expect(
53
+ normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds: null }).heartbeat_interval_seconds
54
+ ).toBe(60);
55
+ });
56
+
57
+ test('allows the SQL Server maximum heartbeat interval', () => {
58
+ expect(
59
+ normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds: 60 }).heartbeat_interval_seconds
60
+ ).toBe(60);
61
+ });
62
+
63
+ test.each([0, 4, 61, Number.NaN, Number.POSITIVE_INFINITY])(
64
+ 'rejects invalid heartbeat_interval_seconds: %s',
65
+ (heartbeat_interval_seconds) => {
66
+ expect(() => normalizeConnectionConfig({ ...BASE_CONFIG, heartbeat_interval_seconds })).toThrow(
67
+ 'heartbeat_interval_seconds'
68
+ );
69
+ }
70
+ );
71
+ });