@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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SourceEntityDescriptor } from '@powersync/service-core';
|
|
2
|
+
import type { CaptureInstance } from '../common/CaptureInstance.js';
|
|
3
|
+
|
|
4
|
+
export enum MSSQLTableReconciliationState {
|
|
5
|
+
TABLE_MISSING = 'table_missing',
|
|
6
|
+
CDC_DISABLED = 'cdc_disabled',
|
|
7
|
+
READY = 'ready'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface BaseMSSQLTableReconciliationContext {
|
|
11
|
+
source: SourceEntityDescriptor;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type MSSQLUnavailableTableReconciliationContext =
|
|
15
|
+
| (BaseMSSQLTableReconciliationContext & {
|
|
16
|
+
state: MSSQLTableReconciliationState.TABLE_MISSING;
|
|
17
|
+
})
|
|
18
|
+
| (BaseMSSQLTableReconciliationContext & {
|
|
19
|
+
state: MSSQLTableReconciliationState.CDC_DISABLED;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type MSSQLReadyTableReconciliationContext = BaseMSSQLTableReconciliationContext & {
|
|
23
|
+
state: MSSQLTableReconciliationState.READY;
|
|
24
|
+
captureInstances: readonly CaptureInstance[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type MSSQLTableReconciliationContext =
|
|
28
|
+
| MSSQLUnavailableTableReconciliationContext
|
|
29
|
+
| MSSQLReadyTableReconciliationContext;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { CaptureInstance } from '../common/CaptureInstance.js';
|
|
2
|
+
import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
|
|
3
|
+
import { SourceTableChangeRef } from '../utils/schema.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Schema changes are detected to warn or error on, not applied automatically. Polling cannot be
|
|
7
|
+
* made atomic with a commit, so the replicated schema stays fixed until the next deploy.
|
|
8
|
+
*
|
|
9
|
+
* Drops and renames fail the job because they have no known LSN. Continuing could skip unread
|
|
10
|
+
* changes. Existing replicated data is kept for the next deploy to clean up.
|
|
11
|
+
*/
|
|
12
|
+
export enum SchemaChangeType {
|
|
13
|
+
/**
|
|
14
|
+
* A replicated table was renamed.
|
|
15
|
+
*/
|
|
16
|
+
TABLE_RENAME = 'table_rename',
|
|
17
|
+
/**
|
|
18
|
+
* A replicated table was dropped.
|
|
19
|
+
*/
|
|
20
|
+
TABLE_DROP = 'table_drop',
|
|
21
|
+
/**
|
|
22
|
+
* The source columns changed. The pinned capture schema remains active.
|
|
23
|
+
*/
|
|
24
|
+
TABLE_COLUMN_CHANGES = 'table_column_changes',
|
|
25
|
+
/**
|
|
26
|
+
* A newer capture instance exists, but the pinned one is still available.
|
|
27
|
+
*/
|
|
28
|
+
NEW_CAPTURE_INSTANCE = 'new_capture_instance',
|
|
29
|
+
/**
|
|
30
|
+
* The pinned capture instance is no longer available.
|
|
31
|
+
*/
|
|
32
|
+
MISSING_CAPTURE_INSTANCE = 'missing_capture_instance'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SchemaChangeBase<Type extends SchemaChangeType> {
|
|
36
|
+
type: Type;
|
|
37
|
+
/**
|
|
38
|
+
* The replicated table the change applies to.
|
|
39
|
+
*/
|
|
40
|
+
table: MSSQLSourceTable;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TableRenameChange extends SchemaChangeBase<SchemaChangeType.TABLE_RENAME> {
|
|
44
|
+
/**
|
|
45
|
+
* The name the table was renamed to. Only used in the error reported for the rename.
|
|
46
|
+
*/
|
|
47
|
+
newTable: SourceTableChangeRef;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TableDropChange = SchemaChangeBase<SchemaChangeType.TABLE_DROP>;
|
|
51
|
+
|
|
52
|
+
export interface TableColumnChanges extends SchemaChangeBase<SchemaChangeType.TABLE_COLUMN_CHANGES> {
|
|
53
|
+
/**
|
|
54
|
+
* The pinned capture instance the drift was observed against.
|
|
55
|
+
*/
|
|
56
|
+
captureInstance: CaptureInstance;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface NewCaptureInstanceChange extends SchemaChangeBase<SchemaChangeType.NEW_CAPTURE_INSTANCE> {
|
|
60
|
+
/**
|
|
61
|
+
* The newer instance that is available but will not be adopted by this stream.
|
|
62
|
+
*/
|
|
63
|
+
newCaptureInstance: CaptureInstance;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MissingCaptureInstanceChange extends SchemaChangeBase<SchemaChangeType.MISSING_CAPTURE_INSTANCE> {
|
|
67
|
+
/**
|
|
68
|
+
* A newer instance that replaced the pinned one, if CDC is still enabled for the table. Only used
|
|
69
|
+
* to report the right recovery step.
|
|
70
|
+
*/
|
|
71
|
+
replacementInstance?: CaptureInstance;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type SchemaChange =
|
|
75
|
+
| TableRenameChange
|
|
76
|
+
| TableDropChange
|
|
77
|
+
| TableColumnChanges
|
|
78
|
+
| NewCaptureInstanceChange
|
|
79
|
+
| MissingCaptureInstanceChange;
|
package/src/types/types.ts
CHANGED
|
@@ -93,6 +93,12 @@ export interface NormalizedMSSQLConnectionConfig {
|
|
|
93
93
|
database: string;
|
|
94
94
|
schema?: string;
|
|
95
95
|
|
|
96
|
+
/** PEM-encoded certificate authority used to validate the SQL Server certificate. */
|
|
97
|
+
cacert?: string;
|
|
98
|
+
|
|
99
|
+
/** Server name to use when validating the SQL Server certificate. */
|
|
100
|
+
tls_servername?: string;
|
|
101
|
+
|
|
96
102
|
authentication?: Authentication;
|
|
97
103
|
|
|
98
104
|
lookup?: LookupFunction;
|
|
@@ -113,6 +119,12 @@ export const MSSQLConnectionConfig = service_types.configFile.DataSourceConfig.a
|
|
|
113
119
|
hostname: t.string.optional(),
|
|
114
120
|
port: service_types.configFile.portCodec.optional(),
|
|
115
121
|
|
|
122
|
+
/** PEM-encoded certificate authority used to validate the SQL Server certificate. */
|
|
123
|
+
cacert: t.string.optional(),
|
|
124
|
+
|
|
125
|
+
/** Server name to use when validating the SQL Server certificate. */
|
|
126
|
+
tls_servername: t.string.optional(),
|
|
127
|
+
|
|
116
128
|
authentication: Authentication.optional(),
|
|
117
129
|
|
|
118
130
|
reject_ip_ranges: t.array(t.string).optional(),
|
|
@@ -120,7 +132,7 @@ export const MSSQLConnectionConfig = service_types.configFile.DataSourceConfig.a
|
|
|
120
132
|
/**
|
|
121
133
|
* Interval in seconds between source connection heartbeats. Null or omitted uses the default.
|
|
122
134
|
*/
|
|
123
|
-
heartbeat_interval_seconds:
|
|
135
|
+
heartbeat_interval_seconds: service_types.orNull(t.number).optional()
|
|
124
136
|
})
|
|
125
137
|
);
|
|
126
138
|
|
|
@@ -179,6 +191,13 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
|
|
|
179
191
|
throw new ServiceError(ErrorCode.PSYNC_S1105, `MSSQL connection: database required`);
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
if (options.cacert && options.additionalConfig?.trustServerCertificate) {
|
|
195
|
+
throw new ServiceError(
|
|
196
|
+
ErrorCode.PSYNC_S1604,
|
|
197
|
+
'MSSQL connection: cacert cannot be used with trustServerCertificate because certificate validation would be disabled'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
182
201
|
const lookup = makeHostnameLookupFunction(hostname, { reject_ip_ranges: options.reject_ip_ranges ?? [] });
|
|
183
202
|
|
|
184
203
|
return {
|
|
@@ -190,6 +209,8 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
|
|
|
190
209
|
hostname,
|
|
191
210
|
port,
|
|
192
211
|
database,
|
|
212
|
+
cacert: options.cacert,
|
|
213
|
+
tls_servername: options.tls_servername,
|
|
193
214
|
|
|
194
215
|
lookup,
|
|
195
216
|
authentication: options.authentication,
|
package/src/utils/mssql.ts
CHANGED
|
@@ -351,7 +351,7 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
|
|
|
351
351
|
if (captureInstanceDetails && captureInstanceDetails.instances[0].pendingSchemaChanges.length > 0) {
|
|
352
352
|
schemaDriftError = {
|
|
353
353
|
level: 'warning',
|
|
354
|
-
message: `Source table ${toQualifiedTableName(schema, table.name)} has schema changes not reflected in the CDC capture instance.
|
|
354
|
+
message: `Source table ${toQualifiedTableName(schema, table.name)} has schema changes not reflected in the CDC capture instance. Create a replacement CDC capture instance with the intended columns, update the sync config if needed, and deploy a new sync config. If the change requires disabling CDC, re-enable CDC before deploying.
|
|
355
355
|
Pending schema changes: ${captureInstanceDetails.instances[0].pendingSchemaChanges.join(', \n')}`
|
|
356
356
|
};
|
|
357
357
|
}
|
package/src/utils/schema.ts
CHANGED
|
@@ -139,77 +139,76 @@ export async function getReplicationIdentityColumns(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export interface SourceTableChangeRef {
|
|
142
|
-
|
|
142
|
+
/**
|
|
143
|
+
* `sys.tables.object_id`, which SQL Server always reports as an int. Narrower than
|
|
144
|
+
* `SourceEntityDescriptor.objectId`, which has to accommodate other source databases.
|
|
145
|
+
*/
|
|
146
|
+
objectId: number;
|
|
143
147
|
schema: string;
|
|
144
148
|
name: string;
|
|
145
149
|
}
|
|
146
150
|
|
|
147
151
|
export type ResolvedTable = SourceTableChangeRef;
|
|
148
152
|
|
|
153
|
+
export const SCHEMA_WILDCARD_MESSAGE =
|
|
154
|
+
'Schema wildcards ("%") in table patterns are not supported for SQL Server connections.';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wildcards would allow the replicated table set to change between polls, so they are rejected.
|
|
158
|
+
*/
|
|
159
|
+
export function tableWildcardMessage(tablePattern: TablePattern): string {
|
|
160
|
+
return (
|
|
161
|
+
`Table wildcards ("%") are not supported for SQL Server connections: "${tablePattern.tablePattern}". ` +
|
|
162
|
+
`List each table explicitly.`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Returns the reason this pattern cannot be replicated, or null if it is supported.
|
|
168
|
+
*/
|
|
169
|
+
export function unsupportedTablePatternMessage(tablePattern: TablePattern): string | null {
|
|
170
|
+
if (tablePattern.isSchemaWildcard) {
|
|
171
|
+
return SCHEMA_WILDCARD_MESSAGE;
|
|
172
|
+
}
|
|
173
|
+
if (tablePattern.isWildcard) {
|
|
174
|
+
return tableWildcardMessage(tablePattern);
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
export async function getTablesFromPattern(
|
|
150
180
|
connectionManager: MSSQLConnectionManager,
|
|
151
181
|
tablePattern: TablePattern
|
|
152
182
|
): Promise<ResolvedTable[]> {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
'Schema wildcards ("%") in table patterns are not supported for SQL Server connections.'
|
|
157
|
-
);
|
|
183
|
+
const unsupported = unsupportedTablePatternMessage(tablePattern);
|
|
184
|
+
if (unsupported != null) {
|
|
185
|
+
throw new ServiceError(ErrorCode.PSYNC_R2201, unsupported);
|
|
158
186
|
}
|
|
159
187
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
|
|
188
|
+
const { recordset: tableResults } = await connectionManager.query(
|
|
189
|
+
`
|
|
190
|
+
SELECT
|
|
191
|
+
tbl.name AS [table],
|
|
192
|
+
sch.name AS [schema],
|
|
193
|
+
tbl.object_id AS object_id
|
|
194
|
+
FROM sys.tables tbl
|
|
195
|
+
JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
|
|
169
196
|
WHERE sch.name = @schema
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
})
|
|
186
|
-
.filter((table: ResolvedTable) => table.name.startsWith(tablePattern.tablePrefix));
|
|
187
|
-
} else {
|
|
188
|
-
const { recordset: tableResults } = await connectionManager.query(
|
|
189
|
-
`
|
|
190
|
-
SELECT
|
|
191
|
-
tbl.name AS [table],
|
|
192
|
-
sch.name AS [schema],
|
|
193
|
-
tbl.object_id AS object_id
|
|
194
|
-
FROM sys.tables tbl
|
|
195
|
-
JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
|
|
196
|
-
WHERE sch.name = @schema
|
|
197
|
-
AND tbl.name = @tablePattern
|
|
198
|
-
`,
|
|
199
|
-
[
|
|
200
|
-
{ name: 'schema', type: sql.VarChar(sql.MAX), value: tablePattern.schema },
|
|
201
|
-
{ name: 'tablePattern', type: sql.VarChar(sql.MAX), value: tablePattern.tablePattern }
|
|
202
|
-
]
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
return tableResults.map((row) => {
|
|
206
|
-
return {
|
|
207
|
-
objectId: row.object_id,
|
|
208
|
-
schema: row.schema,
|
|
209
|
-
name: row.table
|
|
210
|
-
};
|
|
211
|
-
});
|
|
212
|
-
}
|
|
197
|
+
AND tbl.name = @tablePattern
|
|
198
|
+
`,
|
|
199
|
+
[
|
|
200
|
+
{ name: 'schema', type: sql.VarChar(sql.MAX), value: tablePattern.schema },
|
|
201
|
+
{ name: 'tablePattern', type: sql.VarChar(sql.MAX), value: tablePattern.tablePattern }
|
|
202
|
+
]
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return tableResults.map((row) => {
|
|
206
|
+
return {
|
|
207
|
+
objectId: row.object_id,
|
|
208
|
+
schema: row.schema,
|
|
209
|
+
name: row.table
|
|
210
|
+
};
|
|
211
|
+
});
|
|
213
212
|
}
|
|
214
213
|
|
|
215
214
|
export interface GetPendingSchemaChangesOptions {
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { LSN } from '@module/common/LSN.js';
|
|
2
|
+
import { MSSQLSourceTable } from '@module/common/MSSQLSourceTable.js';
|
|
3
|
+
import { CDCEventHandler, CDCPoller } from '@module/replication/CDCPoller.js';
|
|
4
|
+
import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
|
|
5
|
+
import {
|
|
6
|
+
createCheckpoint,
|
|
7
|
+
escapeIdentifier,
|
|
8
|
+
getCaptureInstances,
|
|
9
|
+
getLatestLSN,
|
|
10
|
+
getLatestReplicatedLSN,
|
|
11
|
+
toQualifiedTableName
|
|
12
|
+
} from '@module/utils/mssql.js';
|
|
13
|
+
import { getReplicationIdentityColumns } from '@module/utils/schema.js';
|
|
14
|
+
import { SourceTable } from '@powersync/service-core';
|
|
15
|
+
import sql from 'mssql';
|
|
16
|
+
import timers from 'timers/promises';
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
clearTestDb,
|
|
20
|
+
createTestTableWithBasicId,
|
|
21
|
+
enableCDCForTable,
|
|
22
|
+
insertBasicIdTestData,
|
|
23
|
+
TEST_CONNECTION_OPTIONS,
|
|
24
|
+
waitForPendingCDCChanges
|
|
25
|
+
} from './util.js';
|
|
26
|
+
|
|
27
|
+
describe('CDCPoller tests', { timeout: 60_000 }, () => {
|
|
28
|
+
let connectionManager: MSSQLConnectionManager;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
connectionManager = new MSSQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
|
|
32
|
+
await clearTestDb(connectionManager);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await connectionManager.end();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('Deferred updates are collapsed into a single UPDATE', async () => {
|
|
40
|
+
const tableName = 'test_deferred_update';
|
|
41
|
+
await createDeferredUpdateTestTable(connectionManager, tableName);
|
|
42
|
+
const qualifiedName = toQualifiedTableName(connectionManager.schema, tableName);
|
|
43
|
+
|
|
44
|
+
const beforeInsertLSN = await getLatestLSN(connectionManager);
|
|
45
|
+
await connectionManager.query(`INSERT INTO ${qualifiedName} (id, code) VALUES (1, 'V1'), (2, 'V2')`);
|
|
46
|
+
await waitForPendingCDCChanges(beforeInsertLSN, connectionManager);
|
|
47
|
+
|
|
48
|
+
// Only replicate changes made after the initial insert.
|
|
49
|
+
const startLSN = await getLatestReplicatedLSN(connectionManager);
|
|
50
|
+
|
|
51
|
+
const beforeUpdateLSN = await getLatestLSN(connectionManager);
|
|
52
|
+
// Updating a column with a unique index, using the actual column value creates deferred updates
|
|
53
|
+
await connectionManager.query(`UPDATE ${qualifiedName} SET code = code + 'A'`);
|
|
54
|
+
await waitForPendingCDCChanges(beforeUpdateLSN, connectionManager);
|
|
55
|
+
|
|
56
|
+
const table = await resolveSourceTable(connectionManager, tableName);
|
|
57
|
+
const { operations } = await collectChanges({
|
|
58
|
+
connectionManager,
|
|
59
|
+
tables: [table],
|
|
60
|
+
startLSN,
|
|
61
|
+
expectedOperationCount: 2
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(operations).toMatchObject([
|
|
65
|
+
{ operation: 'update', rowBefore: { id: 1, code: 'V1' }, rowAfter: { id: 1, code: 'V1A' } },
|
|
66
|
+
{ operation: 'update', rowBefore: { id: 2, code: 'V2' }, rowAfter: { id: 2, code: 'V2A' } }
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('Normal deletes and inserts in the same transaction are not collapsed into updates', async () => {
|
|
71
|
+
const tableName = 'test_mixed_transaction';
|
|
72
|
+
await createTestTableWithBasicId(connectionManager, tableName);
|
|
73
|
+
const qualifiedName = toQualifiedTableName(connectionManager.schema, tableName);
|
|
74
|
+
|
|
75
|
+
const beforeInsertLSN = await getLatestLSN(connectionManager);
|
|
76
|
+
const initial = await insertBasicIdTestData(connectionManager, tableName);
|
|
77
|
+
await waitForPendingCDCChanges(beforeInsertLSN, connectionManager);
|
|
78
|
+
|
|
79
|
+
const startLSN = await getLatestReplicatedLSN(connectionManager);
|
|
80
|
+
|
|
81
|
+
const beforeUpdateLSN = await getLatestLSN(connectionManager);
|
|
82
|
+
const replacementDescription = 'replacement';
|
|
83
|
+
const { recordset } = await connectionManager.query(
|
|
84
|
+
`
|
|
85
|
+
BEGIN TRAN;
|
|
86
|
+
DELETE FROM ${qualifiedName} WHERE id = @id;
|
|
87
|
+
INSERT INTO ${qualifiedName} (description)
|
|
88
|
+
OUTPUT INSERTED.id, INSERTED.description
|
|
89
|
+
VALUES (@description);
|
|
90
|
+
COMMIT;
|
|
91
|
+
`,
|
|
92
|
+
[
|
|
93
|
+
{ name: 'id', type: sql.Int, value: initial.id },
|
|
94
|
+
{ name: 'description', type: sql.NVarChar(sql.MAX), value: replacementDescription }
|
|
95
|
+
]
|
|
96
|
+
);
|
|
97
|
+
const replacement = recordset[0];
|
|
98
|
+
await waitForPendingCDCChanges(beforeUpdateLSN, connectionManager);
|
|
99
|
+
|
|
100
|
+
const table = await resolveSourceTable(connectionManager, tableName);
|
|
101
|
+
const { operations } = await collectChanges({
|
|
102
|
+
connectionManager,
|
|
103
|
+
tables: [table],
|
|
104
|
+
startLSN,
|
|
105
|
+
expectedOperationCount: 2
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(operations).toMatchObject([
|
|
109
|
+
{ operation: 'delete', row: initial },
|
|
110
|
+
{ operation: 'insert', row: replacement }
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('In place updates emit a single UPDATE with the before and after rows', async () => {
|
|
115
|
+
const tableName = 'test_in_place_update';
|
|
116
|
+
await createTestTableWithBasicId(connectionManager, tableName);
|
|
117
|
+
const qualifiedName = toQualifiedTableName(connectionManager.schema, tableName);
|
|
118
|
+
|
|
119
|
+
const beforeInsertLSN = await getLatestLSN(connectionManager);
|
|
120
|
+
const initial = await insertBasicIdTestData(connectionManager, tableName);
|
|
121
|
+
await waitForPendingCDCChanges(beforeInsertLSN, connectionManager);
|
|
122
|
+
|
|
123
|
+
const startLSN = await getLatestReplicatedLSN(connectionManager);
|
|
124
|
+
|
|
125
|
+
const beforeUpdateLSN = await getLatestLSN(connectionManager);
|
|
126
|
+
const updated = { ...initial, description: 'updated' };
|
|
127
|
+
await connectionManager.query(`UPDATE ${qualifiedName} SET description = @description WHERE id = @id`, [
|
|
128
|
+
{ name: 'description', type: sql.NVarChar(sql.MAX), value: updated.description },
|
|
129
|
+
{ name: 'id', type: sql.Int, value: updated.id }
|
|
130
|
+
]);
|
|
131
|
+
await waitForPendingCDCChanges(beforeUpdateLSN, connectionManager);
|
|
132
|
+
|
|
133
|
+
const table = await resolveSourceTable(connectionManager, tableName);
|
|
134
|
+
const { operations } = await collectChanges({
|
|
135
|
+
connectionManager,
|
|
136
|
+
tables: [table],
|
|
137
|
+
startLSN,
|
|
138
|
+
expectedOperationCount: 1
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(operations).toMatchObject([{ operation: 'update', rowBefore: initial, rowAfter: updated }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('Committed transaction count covers transactions across all replicated tables', async () => {
|
|
145
|
+
const firstTableName = 'test_transaction_count_first';
|
|
146
|
+
const secondTableName = 'test_transaction_count_second';
|
|
147
|
+
await createTestTableWithBasicId(connectionManager, firstTableName);
|
|
148
|
+
await createTestTableWithBasicId(connectionManager, secondTableName);
|
|
149
|
+
|
|
150
|
+
const startLSN = await getLatestReplicatedLSN(connectionManager);
|
|
151
|
+
|
|
152
|
+
// Two separate transactions, each applying to only one of the tables. Previously this would have been undercounted as 1 transaction.
|
|
153
|
+
const first = await insertBasicIdTestData(connectionManager, firstTableName);
|
|
154
|
+
const second = await insertBasicIdTestData(connectionManager, secondTableName);
|
|
155
|
+
|
|
156
|
+
// CDC captures transactions in commit order, so waiting for a checkpoint written after both
|
|
157
|
+
// inserts guarantees both have been captured before polling starts. That in turn guarantees
|
|
158
|
+
// the poller sees both within a single polling cycle, which is what is being asserted here.
|
|
159
|
+
const beforeCheckpointLSN = await getLatestLSN(connectionManager);
|
|
160
|
+
await createCheckpoint(connectionManager);
|
|
161
|
+
await waitForPendingCDCChanges(beforeCheckpointLSN, connectionManager);
|
|
162
|
+
|
|
163
|
+
const { operations, commits } = await collectChanges({
|
|
164
|
+
connectionManager,
|
|
165
|
+
tables: [
|
|
166
|
+
await resolveSourceTable(connectionManager, firstTableName),
|
|
167
|
+
await resolveSourceTable(connectionManager, secondTableName)
|
|
168
|
+
],
|
|
169
|
+
startLSN,
|
|
170
|
+
expectedOperationCount: 2
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(operations).toMatchObject([
|
|
174
|
+
{ operation: 'insert', row: first },
|
|
175
|
+
{ operation: 'insert', row: second }
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
// The checkpoint table is not replicated here, so only the two inserts are counted.
|
|
179
|
+
const transactionCount = commits.reduce((total, commit) => total + commit.transactionCount, 0);
|
|
180
|
+
expect(transactionCount).toEqual(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('Refreshes the bound capture instance to use its latest metadata', async () => {
|
|
184
|
+
const tableName = 'test_capture_metadata';
|
|
185
|
+
await createTestTableWithBasicId(connectionManager, tableName);
|
|
186
|
+
const table = await resolveSourceTable(connectionManager, tableName);
|
|
187
|
+
const startupInstance = table.captureInstance;
|
|
188
|
+
console.log(startupInstance);
|
|
189
|
+
const beforeInsertLSN = await getLatestLSN(connectionManager);
|
|
190
|
+
const inserted = await insertBasicIdTestData(connectionManager, tableName);
|
|
191
|
+
await waitForPendingCDCChanges(beforeInsertLSN, connectionManager);
|
|
192
|
+
|
|
193
|
+
const captureInstances = await getCaptureInstances({
|
|
194
|
+
connectionManager,
|
|
195
|
+
table: { schema: connectionManager.schema, name: tableName }
|
|
196
|
+
});
|
|
197
|
+
console.log(captureInstances.values());
|
|
198
|
+
const refreshed = captureInstances.get(table.objectId)!.instances[0];
|
|
199
|
+
const eventHandler = new RecordingCDCEventHandler();
|
|
200
|
+
const poller = new CDCPoller({
|
|
201
|
+
connectionManager,
|
|
202
|
+
eventHandler,
|
|
203
|
+
getReplicatedTables: () => [table],
|
|
204
|
+
startLSN: LSN.fromString(LSN.ZERO),
|
|
205
|
+
additionalConfig: {
|
|
206
|
+
pollingBatchSize: 1,
|
|
207
|
+
pollingIntervalMs: 1,
|
|
208
|
+
trustServerCertificate: true
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
(poller as any).captureInstances = captureInstances;
|
|
212
|
+
|
|
213
|
+
const bounds = {
|
|
214
|
+
startLSN: LSN.fromString(LSN.ZERO),
|
|
215
|
+
endLSN: await getLatestReplicatedLSN(connectionManager)
|
|
216
|
+
};
|
|
217
|
+
await (poller as any).pollTable(table, bounds);
|
|
218
|
+
|
|
219
|
+
expect(startupInstance).not.toBe(refreshed);
|
|
220
|
+
expect(bounds.startLSN).toBe(refreshed.minLSN);
|
|
221
|
+
expect(table.captureInstance).toBe(refreshed);
|
|
222
|
+
expect(table.pinnedCaptureObjectId).toBe(refreshed.objectId);
|
|
223
|
+
expect(eventHandler.operations).toMatchObject([{ operation: 'insert', row: inserted }]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A change as it was handed to the CDCEventHandler. The CDC rows are kept as received, so that
|
|
229
|
+
* assertions can match on whichever source columns they care about.
|
|
230
|
+
*/
|
|
231
|
+
type RecordedOperation =
|
|
232
|
+
| { operation: 'insert'; row: any }
|
|
233
|
+
| { operation: 'delete'; row: any }
|
|
234
|
+
| { operation: 'update'; rowBefore: any; rowAfter: any };
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A commit as it was reported to the CDCEventHandler at the end of a polling cycle.
|
|
238
|
+
*/
|
|
239
|
+
interface RecordedCommit {
|
|
240
|
+
lsn: string;
|
|
241
|
+
transactionCount: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Records the operations emitted by the CDCPoller so that their relative order can be asserted.
|
|
246
|
+
*/
|
|
247
|
+
class RecordingCDCEventHandler implements CDCEventHandler {
|
|
248
|
+
readonly operations: RecordedOperation[] = [];
|
|
249
|
+
readonly commits: RecordedCommit[] = [];
|
|
250
|
+
|
|
251
|
+
async onInsert(row: any): Promise<void> {
|
|
252
|
+
this.operations.push({ operation: 'insert', row });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async onUpdate(rowAfter: any, rowBefore: any): Promise<void> {
|
|
256
|
+
this.operations.push({ operation: 'update', rowBefore, rowAfter });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async onDelete(row: any): Promise<void> {
|
|
260
|
+
this.operations.push({ operation: 'delete', row });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async onCommit(lsn: string, transactionCount: number): Promise<void> {
|
|
264
|
+
this.commits.push({ lsn, transactionCount });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async onSchemaChange(): Promise<void> {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* How long to wait for the expected operations before returning whatever arrived.
|
|
272
|
+
*/
|
|
273
|
+
const COLLECT_CHANGES_TIMEOUT_MS = 20_000;
|
|
274
|
+
|
|
275
|
+
interface CollectChangesOptions {
|
|
276
|
+
connectionManager: MSSQLConnectionManager;
|
|
277
|
+
tables: MSSQLSourceTable[];
|
|
278
|
+
startLSN: LSN;
|
|
279
|
+
expectedOperationCount: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Runs a CDCPoller from startLSN until the expected number of operations has been emitted, and
|
|
284
|
+
* returns the handler holding the operations and the commits in the order they were reported.
|
|
285
|
+
*/
|
|
286
|
+
async function collectChanges(options: CollectChangesOptions): Promise<RecordingCDCEventHandler> {
|
|
287
|
+
const { connectionManager, tables, startLSN, expectedOperationCount } = options;
|
|
288
|
+
const eventHandler = new RecordingCDCEventHandler();
|
|
289
|
+
|
|
290
|
+
const poller = new CDCPoller({
|
|
291
|
+
connectionManager,
|
|
292
|
+
eventHandler,
|
|
293
|
+
getReplicatedTables: () => tables,
|
|
294
|
+
startLSN,
|
|
295
|
+
additionalConfig: {
|
|
296
|
+
pollingBatchSize: 100,
|
|
297
|
+
pollingIntervalMs: 50,
|
|
298
|
+
trustServerCertificate: true
|
|
299
|
+
},
|
|
300
|
+
schemaCheckIntervalMs: 60_000
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const pollerPromise = poller.replicateUntilStopped();
|
|
304
|
+
try {
|
|
305
|
+
const deadline = Date.now() + COLLECT_CHANGES_TIMEOUT_MS;
|
|
306
|
+
while (eventHandler.operations.length < expectedOperationCount && Date.now() < deadline) {
|
|
307
|
+
// Rethrows if the poller fails instead of waiting out the full timeout.
|
|
308
|
+
await Promise.race([timers.setTimeout(50), pollerPromise]);
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
await poller.stop();
|
|
312
|
+
await pollerPromise;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return eventHandler;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Creates a table with a clustered primary key and a unique index over a separate column.
|
|
320
|
+
* A unique index is one of the prerequisites for triggering deferred updates in SQL Server.
|
|
321
|
+
*/
|
|
322
|
+
async function createDeferredUpdateTestTable(
|
|
323
|
+
connectionManager: MSSQLConnectionManager,
|
|
324
|
+
tableName: string
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const qualifiedName = toQualifiedTableName(connectionManager.schema, tableName);
|
|
327
|
+
await connectionManager.query(`
|
|
328
|
+
CREATE TABLE ${qualifiedName} (
|
|
329
|
+
id INT NOT NULL PRIMARY KEY,
|
|
330
|
+
code VARCHAR(100) NOT NULL
|
|
331
|
+
)
|
|
332
|
+
`);
|
|
333
|
+
await connectionManager.query(
|
|
334
|
+
`CREATE UNIQUE NONCLUSTERED INDEX ${escapeIdentifier(`UX_${tableName}_code`)} ON ${qualifiedName}(code)`
|
|
335
|
+
);
|
|
336
|
+
await enableCDCForTable({ connectionManager, table: tableName });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Builds the MSSQLSourceTable that the CDCPoller needs, including the persisted capture-instance binding
|
|
341
|
+
* that would normally be populated by source-table reconciliation.
|
|
342
|
+
*/
|
|
343
|
+
async function resolveSourceTable(
|
|
344
|
+
connectionManager: MSSQLConnectionManager,
|
|
345
|
+
tableName: string
|
|
346
|
+
): Promise<MSSQLSourceTable> {
|
|
347
|
+
const captureInstances = await getCaptureInstances({
|
|
348
|
+
connectionManager,
|
|
349
|
+
table: { schema: connectionManager.schema, name: tableName }
|
|
350
|
+
});
|
|
351
|
+
const details = [...captureInstances.values()][0];
|
|
352
|
+
if (details == null) {
|
|
353
|
+
throw new Error(`No CDC capture instance found for table ${tableName}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const replicaIdColumnsResult = await getReplicationIdentityColumns({
|
|
357
|
+
connectionManager,
|
|
358
|
+
tableName,
|
|
359
|
+
schema: connectionManager.schema
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const ref = {
|
|
363
|
+
connectionTag: connectionManager.connectionTag,
|
|
364
|
+
objectId: details.sourceTable.objectId,
|
|
365
|
+
schema: details.sourceTable.schema,
|
|
366
|
+
name: details.sourceTable.name,
|
|
367
|
+
replicaIdColumns: replicaIdColumnsResult.columns
|
|
368
|
+
};
|
|
369
|
+
const sourceTable = new SourceTable({
|
|
370
|
+
id: `${details.sourceTable.objectId}`,
|
|
371
|
+
ref,
|
|
372
|
+
objectId: details.sourceTable.objectId,
|
|
373
|
+
replicaIdColumns: replicaIdColumnsResult.columns,
|
|
374
|
+
snapshotComplete: true,
|
|
375
|
+
bucketDataSources: [],
|
|
376
|
+
parameterLookupSources: [],
|
|
377
|
+
sourceMetadata: { captureTableObjectId: details.instances[0].objectId }
|
|
378
|
+
});
|
|
379
|
+
const table = new MSSQLSourceTable(ref, [sourceTable]);
|
|
380
|
+
table.setCaptureInstance(details.instances);
|
|
381
|
+
return table;
|
|
382
|
+
}
|