@powersync/service-module-mssql 0.0.0-dev-20260313100403 → 0.0.0-dev-20260515144844
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 +91 -6
- package/dist/api/MSSQLRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MSSQLRouteAPIAdapter.js +3 -3
- package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
- package/dist/common/CaptureInstance.d.ts +14 -0
- package/dist/common/CaptureInstance.js +2 -0
- package/dist/common/CaptureInstance.js.map +1 -0
- package/dist/common/MSSQLSourceTable.d.ts +16 -14
- package/dist/common/MSSQLSourceTable.js +35 -16
- package/dist/common/MSSQLSourceTable.js.map +1 -1
- package/dist/common/MSSQLSourceTableCache.js.map +1 -1
- package/dist/common/mssqls-to-sqlite.d.ts +1 -1
- package/dist/common/mssqls-to-sqlite.js +1 -1
- package/dist/common/mssqls-to-sqlite.js.map +1 -1
- package/dist/module/MSSQLModule.js +4 -4
- package/dist/module/MSSQLModule.js.map +1 -1
- package/dist/replication/CDCPoller.d.ts +45 -23
- package/dist/replication/CDCPoller.js +201 -61
- package/dist/replication/CDCPoller.js.map +1 -1
- package/dist/replication/CDCReplicationJob.d.ts +2 -2
- package/dist/replication/CDCReplicationJob.js +12 -4
- package/dist/replication/CDCReplicationJob.js.map +1 -1
- package/dist/replication/CDCReplicator.d.ts +2 -3
- package/dist/replication/CDCReplicator.js +1 -24
- package/dist/replication/CDCReplicator.js.map +1 -1
- package/dist/replication/CDCStream.d.ts +39 -16
- package/dist/replication/CDCStream.js +195 -102
- package/dist/replication/CDCStream.js.map +1 -1
- package/dist/replication/MSSQLConnectionManager.d.ts +1 -1
- package/dist/replication/MSSQLConnectionManager.js +17 -6
- package/dist/replication/MSSQLConnectionManager.js.map +1 -1
- package/dist/replication/MSSQLConnectionManagerFactory.d.ts +1 -1
- package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -1
- package/dist/replication/MSSQLSnapshotQuery.d.ts +1 -1
- package/dist/replication/MSSQLSnapshotQuery.js +2 -2
- package/dist/replication/MSSQLSnapshotQuery.js.map +1 -1
- package/dist/types/types.d.ts +4 -56
- package/dist/types/types.js +5 -24
- package/dist/types/types.js.map +1 -1
- package/dist/utils/deadlock.d.ts +9 -0
- package/dist/utils/deadlock.js +40 -0
- package/dist/utils/deadlock.js.map +1 -0
- package/dist/utils/mssql.d.ts +36 -18
- package/dist/utils/mssql.js +102 -100
- package/dist/utils/mssql.js.map +1 -1
- package/dist/utils/schema.d.ts +9 -0
- package/dist/utils/schema.js +34 -0
- package/dist/utils/schema.js.map +1 -1
- package/package.json +14 -14
- package/src/api/MSSQLRouteAPIAdapter.ts +4 -4
- package/src/common/CaptureInstance.ts +15 -0
- package/src/common/MSSQLSourceTable.ts +33 -24
- package/src/common/MSSQLSourceTableCache.ts +1 -1
- package/src/common/mssqls-to-sqlite.ts +1 -1
- package/src/module/MSSQLModule.ts +4 -4
- package/src/replication/CDCPoller.ts +275 -75
- package/src/replication/CDCReplicationJob.ts +13 -6
- package/src/replication/CDCReplicator.ts +2 -28
- package/src/replication/CDCStream.ts +265 -124
- package/src/replication/MSSQLConnectionManager.ts +17 -7
- package/src/replication/MSSQLConnectionManagerFactory.ts +1 -1
- package/src/replication/MSSQLSnapshotQuery.ts +3 -3
- package/src/types/types.ts +5 -28
- package/src/utils/deadlock.ts +44 -0
- package/src/utils/mssql.ts +163 -128
- package/src/utils/schema.ts +44 -1
- package/test/src/CDCStream.test.ts +7 -7
- package/test/src/CDCStreamTestContext.ts +14 -8
- package/test/src/CDCStream_resumable_snapshot.test.ts +6 -6
- package/test/src/LSN.test.ts +1 -1
- package/test/src/mssql-to-sqlite.test.ts +25 -17
- package/test/src/schema-changes.test.ts +474 -0
- package/test/src/util.ts +79 -22
- package/test/tsconfig.json +0 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { bson, ColumnDescriptor, SourceTable } from '@powersync/service-core';
|
|
2
1
|
import { ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
3
|
-
import {
|
|
2
|
+
import { bson, ColumnDescriptor, SourceTable } from '@powersync/service-core';
|
|
4
3
|
import sql from 'mssql';
|
|
5
|
-
import { escapeIdentifier } from '../utils/mssql.js';
|
|
6
4
|
import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
|
|
5
|
+
import { MSSQLBaseType } from '../types/mssql-data-types.js';
|
|
6
|
+
import { escapeIdentifier } from '../utils/mssql.js';
|
|
7
7
|
|
|
8
8
|
export interface MSSQLSnapshotQuery {
|
|
9
9
|
initialize(): Promise<void>;
|
package/src/types/types.ts
CHANGED
|
@@ -4,31 +4,10 @@ import { LookupFunction } from 'node:net';
|
|
|
4
4
|
import * as t from 'ts-codec';
|
|
5
5
|
import * as urijs from 'uri-js';
|
|
6
6
|
|
|
7
|
+
export const DEFAULT_POLLING_BATCH_SIZE = 10;
|
|
8
|
+
export const DEFAULT_POLLING_INTERVAL_MS = 1000;
|
|
7
9
|
export const MSSQL_CONNECTION_TYPE = 'mssql' as const;
|
|
8
10
|
|
|
9
|
-
export const AzureActiveDirectoryPasswordAuthentication = t.object({
|
|
10
|
-
type: t.literal('azure-active-directory-password'),
|
|
11
|
-
options: t.object({
|
|
12
|
-
/**
|
|
13
|
-
* A user need to provide `userName` associate to their account.
|
|
14
|
-
*/
|
|
15
|
-
userName: t.string,
|
|
16
|
-
/**
|
|
17
|
-
* A user need to provide `password` associate to their account.
|
|
18
|
-
*/
|
|
19
|
-
password: t.string,
|
|
20
|
-
/**
|
|
21
|
-
* A client id to use.
|
|
22
|
-
*/
|
|
23
|
-
clientId: t.string,
|
|
24
|
-
/**
|
|
25
|
-
* Azure tenant ID
|
|
26
|
-
*/
|
|
27
|
-
tenantId: t.string
|
|
28
|
-
})
|
|
29
|
-
});
|
|
30
|
-
export type AzureActiveDirectoryPasswordAuthentication = t.Decoded<typeof AzureActiveDirectoryPasswordAuthentication>;
|
|
31
|
-
|
|
32
11
|
export const AzureActiveDirectoryServicePrincipalSecret = t.object({
|
|
33
12
|
type: t.literal('azure-active-directory-service-principal-secret'),
|
|
34
13
|
options: t.object({
|
|
@@ -63,9 +42,7 @@ export const DefaultAuthentication = t.object({
|
|
|
63
42
|
});
|
|
64
43
|
export type DefaultAuthentication = t.Decoded<typeof DefaultAuthentication>;
|
|
65
44
|
|
|
66
|
-
export const Authentication = DefaultAuthentication.or(
|
|
67
|
-
AzureActiveDirectoryServicePrincipalSecret
|
|
68
|
-
);
|
|
45
|
+
export const Authentication = DefaultAuthentication.or(AzureActiveDirectoryServicePrincipalSecret);
|
|
69
46
|
export type Authentication = t.Decoded<typeof Authentication>;
|
|
70
47
|
|
|
71
48
|
export const AdditionalConfig = t.object({
|
|
@@ -208,8 +185,8 @@ export function normalizeConnectionConfig(options: MSSQLConnectionConfig): Norma
|
|
|
208
185
|
authentication: options.authentication,
|
|
209
186
|
|
|
210
187
|
additionalConfig: {
|
|
211
|
-
pollingIntervalMs: options.additionalConfig?.pollingIntervalMs ??
|
|
212
|
-
pollingBatchSize: options.additionalConfig?.pollingBatchSize ??
|
|
188
|
+
pollingIntervalMs: options.additionalConfig?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS,
|
|
189
|
+
pollingBatchSize: options.additionalConfig?.pollingBatchSize ?? DEFAULT_POLLING_BATCH_SIZE,
|
|
213
190
|
trustServerCertificate: options.additionalConfig?.trustServerCertificate ?? false
|
|
214
191
|
}
|
|
215
192
|
} satisfies NormalizedMSSQLConnectionConfig;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
2
|
+
import timers from 'timers/promises';
|
|
3
|
+
|
|
4
|
+
const MSSQL_DEADLOCK_RETRIES = 5;
|
|
5
|
+
const MSSQL_DEADLOCK_BACKOFF_FACTOR = 2;
|
|
6
|
+
const MSSQL_DEADLOCK_RETRY_DELAY_MS = 200;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Retries the given async function if it fails with a SQL Server deadlock error (1205).
|
|
10
|
+
* Deadlocks, while uncommon, can occur when CDC altering functions are being called whilst actively replicating
|
|
11
|
+
* using CDC functions.
|
|
12
|
+
*
|
|
13
|
+
* If the error is not a deadlock or all retries are exhausted, the error is re-thrown.
|
|
14
|
+
*/
|
|
15
|
+
export async function retryOnDeadlock<T>(fn: () => Promise<T>, operationName: string): Promise<T> {
|
|
16
|
+
let lastError: Error | null = null;
|
|
17
|
+
for (let attempt = 0; attempt <= MSSQL_DEADLOCK_RETRIES; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
return await fn();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
lastError = error;
|
|
22
|
+
if (!isDeadlockError(error) || attempt === MSSQL_DEADLOCK_RETRIES) {
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
const delay = MSSQL_DEADLOCK_RETRY_DELAY_MS * Math.pow(MSSQL_DEADLOCK_BACKOFF_FACTOR, attempt);
|
|
26
|
+
logger.warn(
|
|
27
|
+
`Deadlock detected during ${operationName} (attempt ${attempt + 1}/${MSSQL_DEADLOCK_RETRIES}). Retrying in ${delay}ms...`
|
|
28
|
+
);
|
|
29
|
+
await timers.setTimeout(delay);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
throw lastError;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isDeadlockError(error: unknown): boolean {
|
|
37
|
+
if (error != null && typeof error === 'object' && 'number' in error) {
|
|
38
|
+
// SQL Server deadlock victim error number.
|
|
39
|
+
// When SQL Server detects a deadlock, it chooses one of the participating transactions
|
|
40
|
+
// as the "deadlock victim" and terminates it with error 1205.
|
|
41
|
+
return (error as { number: unknown }).number === 1205;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
package/src/utils/mssql.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
2
|
+
import * as sync_rules from '@powersync/service-sync-rules';
|
|
3
|
+
import { SqlSyncRules, TablePattern } from '@powersync/service-sync-rules';
|
|
4
|
+
import * as service_types from '@powersync/service-types';
|
|
1
5
|
import sql from 'mssql';
|
|
2
6
|
import { coerce, gte } from 'semver';
|
|
3
|
-
import {
|
|
4
|
-
import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
|
|
7
|
+
import { CaptureInstance } from '../common/CaptureInstance.js';
|
|
5
8
|
import { LSN } from '../common/LSN.js';
|
|
6
|
-
import {
|
|
9
|
+
import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
|
|
10
|
+
import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
|
|
7
11
|
import { MSSQLParameter } from '../types/mssql-data-types.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
import { retryOnDeadlock } from './deadlock.js';
|
|
13
|
+
import {
|
|
14
|
+
getPendingSchemaChanges,
|
|
15
|
+
getReplicationIdentityColumns,
|
|
16
|
+
ReplicationIdentityColumnsResult,
|
|
17
|
+
ResolvedTable
|
|
18
|
+
} from './schema.js';
|
|
12
19
|
|
|
13
20
|
export const POWERSYNC_CHECKPOINTS_TABLE = '_powersync_checkpoints';
|
|
14
21
|
|
|
@@ -78,16 +85,16 @@ export async function checkSourceConfiguration(connectionManager: MSSQLConnectio
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
// 4) Check if the _powersync_checkpoints table is correctly configured
|
|
81
|
-
const checkpointTableErrors = await
|
|
88
|
+
const checkpointTableErrors = await checkPowerSyncCheckpointsTable(connectionManager);
|
|
82
89
|
errors.push(...checkpointTableErrors);
|
|
83
90
|
|
|
84
91
|
return errors;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
export async function
|
|
94
|
+
export async function checkPowerSyncCheckpointsTable(connectionManager: MSSQLConnectionManager): Promise<string[]> {
|
|
88
95
|
const errors: string[] = [];
|
|
89
96
|
try {
|
|
90
|
-
//
|
|
97
|
+
// Check if the table exists
|
|
91
98
|
const { recordset: checkpointsResult } = await connectionManager.query(
|
|
92
99
|
`
|
|
93
100
|
SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @tableName;
|
|
@@ -97,45 +104,22 @@ export async function ensurePowerSyncCheckpointsTable(connectionManager: MSSQLCo
|
|
|
97
104
|
{ name: 'tableName', type: sql.VarChar(sql.MAX), value: POWERSYNC_CHECKPOINTS_TABLE }
|
|
98
105
|
]
|
|
99
106
|
);
|
|
100
|
-
if (checkpointsResult.length
|
|
101
|
-
|
|
102
|
-
const isEnabled = await isTableEnabledForCDC({
|
|
103
|
-
connectionManager,
|
|
104
|
-
table: POWERSYNC_CHECKPOINTS_TABLE,
|
|
105
|
-
schema: connectionManager.schema
|
|
106
|
-
});
|
|
107
|
-
if (!isEnabled) {
|
|
108
|
-
// Enable CDC on the table
|
|
109
|
-
await enableCDCForTable({
|
|
110
|
-
connectionManager,
|
|
111
|
-
table: POWERSYNC_CHECKPOINTS_TABLE
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
return errors;
|
|
107
|
+
if (checkpointsResult.length === 0) {
|
|
108
|
+
throw new Error(`The ${POWERSYNC_CHECKPOINTS_TABLE} table does not exist. Please create it.`);
|
|
115
109
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Try to create the table
|
|
121
|
-
try {
|
|
122
|
-
await connectionManager.query(`
|
|
123
|
-
CREATE TABLE ${toQualifiedTableName(connectionManager.schema, POWERSYNC_CHECKPOINTS_TABLE)} (
|
|
124
|
-
id INT IDENTITY PRIMARY KEY,
|
|
125
|
-
last_updated DATETIME NOT NULL DEFAULT (GETDATE())
|
|
126
|
-
)`);
|
|
127
|
-
} catch (error) {
|
|
128
|
-
errors.push(`Failed to create ${POWERSYNC_CHECKPOINTS_TABLE} table: ${error}`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
// Enable CDC on the table if not already enabled
|
|
133
|
-
await enableCDCForTable({
|
|
110
|
+
// Check if CDC is enabled
|
|
111
|
+
const isEnabled = await isTableEnabledForCDC({
|
|
134
112
|
connectionManager,
|
|
135
|
-
table: POWERSYNC_CHECKPOINTS_TABLE
|
|
113
|
+
table: POWERSYNC_CHECKPOINTS_TABLE,
|
|
114
|
+
schema: connectionManager.schema
|
|
136
115
|
});
|
|
116
|
+
if (!isEnabled) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`The ${POWERSYNC_CHECKPOINTS_TABLE} table exists but is not enabled for CDC. Please enable CDC on this table.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
137
121
|
} catch (error) {
|
|
138
|
-
errors.push(`Failed
|
|
122
|
+
errors.push(`Failed ensure ${POWERSYNC_CHECKPOINTS_TABLE} table is correctly configured: ${error}`);
|
|
139
123
|
}
|
|
140
124
|
|
|
141
125
|
return errors;
|
|
@@ -165,36 +149,9 @@ export interface IsTableEnabledForCDCOptions {
|
|
|
165
149
|
export async function isTableEnabledForCDC(options: IsTableEnabledForCDCOptions): Promise<boolean> {
|
|
166
150
|
const { connectionManager, table, schema } = options;
|
|
167
151
|
|
|
168
|
-
const {
|
|
169
|
-
`
|
|
170
|
-
SELECT 1 FROM cdc.change_tables ct
|
|
171
|
-
JOIN sys.tables AS tbl ON tbl.object_id = ct.source_object_id
|
|
172
|
-
JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
|
|
173
|
-
WHERE sch.name = @schema
|
|
174
|
-
AND tbl.name = @tableName
|
|
175
|
-
`,
|
|
176
|
-
[
|
|
177
|
-
{ name: 'schema', type: sql.VarChar(sql.MAX), value: schema },
|
|
178
|
-
{ name: 'tableName', type: sql.VarChar(sql.MAX), value: table }
|
|
179
|
-
]
|
|
180
|
-
);
|
|
181
|
-
return checkResult.length > 0;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export interface EnableCDCForTableOptions {
|
|
185
|
-
connectionManager: MSSQLConnectionManager;
|
|
186
|
-
table: string;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function enableCDCForTable(options: EnableCDCForTableOptions): Promise<void> {
|
|
190
|
-
const { connectionManager, table } = options;
|
|
152
|
+
const captureInstance = await getCaptureInstance({ connectionManager, table: { schema, name: table } });
|
|
191
153
|
|
|
192
|
-
|
|
193
|
-
{ name: 'source_schema', value: connectionManager.schema },
|
|
194
|
-
{ name: 'source_name', value: table },
|
|
195
|
-
{ name: 'role_name', value: 'NULL' },
|
|
196
|
-
{ name: 'supports_net_changes', value: 1 }
|
|
197
|
-
]);
|
|
154
|
+
return captureInstance != null;
|
|
198
155
|
}
|
|
199
156
|
|
|
200
157
|
/**
|
|
@@ -216,23 +173,28 @@ export interface IsWithinRetentionThresholdOptions {
|
|
|
216
173
|
}
|
|
217
174
|
|
|
218
175
|
/**
|
|
219
|
-
* Checks that
|
|
176
|
+
* Checks that the given checkpoint LSN is still within the retention threshold of the source table capture instances.
|
|
220
177
|
* CDC periodically cleans up old data up to the retention threshold. If replication has been stopped for too long it is
|
|
221
178
|
* possible for the checkpoint LSN to be older than the minimum LSN in the CDC tables. In such a case we need to perform a new snapshot.
|
|
222
179
|
* @param options
|
|
223
180
|
*/
|
|
224
|
-
export async function
|
|
181
|
+
export async function checkRetentionThresholds(
|
|
182
|
+
options: IsWithinRetentionThresholdOptions
|
|
183
|
+
): Promise<MSSQLSourceTable[]> {
|
|
225
184
|
const { checkpointLSN, tables, connectionManager } = options;
|
|
185
|
+
const tablesOutsideRetentionThreshold: MSSQLSourceTable[] = [];
|
|
226
186
|
for (const table of tables) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
187
|
+
if (table.enabledForCDC()) {
|
|
188
|
+
const minLSN = await getMinLSN(connectionManager, table.captureInstance!.name);
|
|
189
|
+
if (minLSN > checkpointLSN) {
|
|
190
|
+
logger.warn(
|
|
191
|
+
`The checkpoint LSN:[${checkpointLSN}] is older than the minimum LSN:[${minLSN}] for table ${table.toQualifiedName()}. This indicates that the checkpoint LSN is outside of the retention window.`
|
|
192
|
+
);
|
|
193
|
+
tablesOutsideRetentionThreshold.push(table);
|
|
194
|
+
}
|
|
233
195
|
}
|
|
234
196
|
}
|
|
235
|
-
return
|
|
197
|
+
return tablesOutsideRetentionThreshold;
|
|
236
198
|
}
|
|
237
199
|
|
|
238
200
|
export async function getMinLSN(connectionManager: MSSQLConnectionManager, captureInstance: string): Promise<LSN> {
|
|
@@ -252,43 +214,6 @@ export async function incrementLSN(lsn: LSN, connectionManager: MSSQLConnectionM
|
|
|
252
214
|
return LSN.fromBinary(result[0].incremented_lsn);
|
|
253
215
|
}
|
|
254
216
|
|
|
255
|
-
export interface GetCaptureInstanceOptions {
|
|
256
|
-
connectionManager: MSSQLConnectionManager;
|
|
257
|
-
tableName: string;
|
|
258
|
-
schema: string;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export async function getCaptureInstance(options: GetCaptureInstanceOptions): Promise<CaptureInstance | null> {
|
|
262
|
-
const { connectionManager, tableName, schema } = options;
|
|
263
|
-
const { recordset: result } = await connectionManager.query(
|
|
264
|
-
`
|
|
265
|
-
SELECT
|
|
266
|
-
ct.capture_instance,
|
|
267
|
-
OBJECT_SCHEMA_NAME(ct.[object_id]) AS cdc_schema
|
|
268
|
-
FROM
|
|
269
|
-
sys.tables tbl
|
|
270
|
-
INNER JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
|
|
271
|
-
INNER JOIN cdc.change_tables ct ON ct.source_object_id = tbl.object_id
|
|
272
|
-
WHERE sch.name = @schema
|
|
273
|
-
AND tbl.name = @tableName
|
|
274
|
-
AND ct.end_lsn IS NULL;
|
|
275
|
-
`,
|
|
276
|
-
[
|
|
277
|
-
{ name: 'schema', type: sql.VarChar(sql.MAX), value: schema },
|
|
278
|
-
{ name: 'tableName', type: sql.VarChar(sql.MAX), value: tableName }
|
|
279
|
-
]
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
if (result.length === 0) {
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return {
|
|
287
|
-
name: result[0].capture_instance,
|
|
288
|
-
schema: result[0].cdc_schema
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
217
|
/**
|
|
293
218
|
* Return the LSN of the latest transaction recorded in the transaction log
|
|
294
219
|
* @param connectionManager
|
|
@@ -406,18 +331,28 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
|
|
|
406
331
|
selectError = { level: 'fatal', message: e.message };
|
|
407
332
|
}
|
|
408
333
|
|
|
409
|
-
// Check if CDC is enabled for the table
|
|
410
334
|
let cdcError: service_types.ReplicationError | null = null;
|
|
335
|
+
let schemaDriftError: service_types.ReplicationError | null = null;
|
|
411
336
|
try {
|
|
412
|
-
const
|
|
337
|
+
const captureInstanceDetails = await getCaptureInstance({
|
|
413
338
|
connectionManager: connectionManager,
|
|
414
|
-
table:
|
|
415
|
-
|
|
339
|
+
table: {
|
|
340
|
+
schema: schema,
|
|
341
|
+
name: table.name
|
|
342
|
+
}
|
|
416
343
|
});
|
|
417
|
-
if (
|
|
344
|
+
if (captureInstanceDetails == null) {
|
|
418
345
|
cdcError = {
|
|
419
|
-
level: '
|
|
420
|
-
message: `CDC is not enabled for table ${toQualifiedTableName(schema, table.name)}.
|
|
346
|
+
level: 'warning',
|
|
347
|
+
message: `CDC is not enabled for table ${toQualifiedTableName(schema, table.name)}. Please enable CDC on the table to capture changes.`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (captureInstanceDetails && captureInstanceDetails.instances[0].pendingSchemaChanges.length > 0) {
|
|
352
|
+
schemaDriftError = {
|
|
353
|
+
level: 'warning',
|
|
354
|
+
message: `Source table ${toQualifiedTableName(schema, table.name)} has schema changes not reflected in the CDC capture instance. Please disable and re-enable CDC on the source table to update the capture instance schema.
|
|
355
|
+
Pending schema changes: ${captureInstanceDetails.instances[0].pendingSchemaChanges.join(', \n')}`
|
|
421
356
|
};
|
|
422
357
|
}
|
|
423
358
|
} catch (e) {
|
|
@@ -433,6 +368,106 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
|
|
|
433
368
|
replication_id: idColumns.map((c) => c.name),
|
|
434
369
|
data_queries: syncData,
|
|
435
370
|
parameter_queries: syncParameters,
|
|
436
|
-
errors: [idColumnsError, selectError, cdcError].filter(
|
|
371
|
+
errors: [idColumnsError, selectError, cdcError, schemaDriftError].filter(
|
|
372
|
+
(error) => error != null
|
|
373
|
+
) as service_types.ReplicationError[]
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Describes the capture instances linked to a source table.
|
|
378
|
+
export interface CaptureInstanceDetails {
|
|
379
|
+
sourceTable: {
|
|
380
|
+
schema: string;
|
|
381
|
+
name: string;
|
|
382
|
+
objectId: number;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The capture instances for the source table.
|
|
387
|
+
* The instances are sorted by create date in descending order.
|
|
388
|
+
*/
|
|
389
|
+
instances: CaptureInstance[];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface GetCaptureInstancesOptions {
|
|
393
|
+
connectionManager: MSSQLConnectionManager;
|
|
394
|
+
table?: {
|
|
395
|
+
schema: string;
|
|
396
|
+
name: string;
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function getCaptureInstances(
|
|
401
|
+
options: GetCaptureInstancesOptions
|
|
402
|
+
): Promise<Map<number, CaptureInstanceDetails>> {
|
|
403
|
+
return retryOnDeadlock(async () => {
|
|
404
|
+
const { connectionManager, table } = options;
|
|
405
|
+
const instances = new Map<number, CaptureInstanceDetails>();
|
|
406
|
+
|
|
407
|
+
const { recordset: results } = table
|
|
408
|
+
? await connectionManager.execute('sys.sp_cdc_help_change_data_capture', [
|
|
409
|
+
{ name: 'source_schema', value: table.schema },
|
|
410
|
+
{ name: 'source_name', value: table.name }
|
|
411
|
+
])
|
|
412
|
+
: await connectionManager.execute('sys.sp_cdc_help_change_data_capture', []);
|
|
413
|
+
|
|
414
|
+
if (results.length === 0) {
|
|
415
|
+
return new Map<number, CaptureInstanceDetails>();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for (const row of results) {
|
|
419
|
+
const instance: CaptureInstance = {
|
|
420
|
+
name: row.capture_instance,
|
|
421
|
+
objectId: row.object_id,
|
|
422
|
+
minLSN: LSN.fromBinary(row.start_lsn),
|
|
423
|
+
createDate: new Date(row.create_date),
|
|
424
|
+
pendingSchemaChanges: []
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
instance.pendingSchemaChanges = await getPendingSchemaChanges({
|
|
428
|
+
connectionManager: connectionManager,
|
|
429
|
+
captureInstanceName: instance.name
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const sourceTable = {
|
|
433
|
+
schema: row.source_schema,
|
|
434
|
+
name: row.source_table,
|
|
435
|
+
objectId: row.source_object_id
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// There can only ever be 2 capture instances active at any given time for a source table.
|
|
439
|
+
if (instances.has(row.source_object_id)) {
|
|
440
|
+
if (instance.createDate > instances.get(row.source_object_id)!.instances[0].createDate) {
|
|
441
|
+
instances.get(row.source_object_id)!.instances.unshift(instance);
|
|
442
|
+
} else {
|
|
443
|
+
instances.get(row.source_object_id)!.instances.push(instance);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
instances.set(row.source_object_id, {
|
|
447
|
+
instances: [instance],
|
|
448
|
+
sourceTable
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return instances;
|
|
454
|
+
}, 'getCaptureInstances');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export interface GetCaptureInstanceOptions {
|
|
458
|
+
connectionManager: MSSQLConnectionManager;
|
|
459
|
+
table: {
|
|
460
|
+
schema: string;
|
|
461
|
+
name: string;
|
|
437
462
|
};
|
|
438
463
|
}
|
|
464
|
+
export async function getCaptureInstance(options: GetCaptureInstanceOptions): Promise<CaptureInstanceDetails | null> {
|
|
465
|
+
const { connectionManager, table } = options;
|
|
466
|
+
const instances = await getCaptureInstances({ connectionManager, table });
|
|
467
|
+
|
|
468
|
+
if (instances.size === 0) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return instances.values().next().value!;
|
|
473
|
+
}
|
package/src/utils/schema.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
1
2
|
import { SourceEntityDescriptor } from '@powersync/service-core';
|
|
2
3
|
import { TablePattern } from '@powersync/service-sync-rules';
|
|
4
|
+
import sql from 'mssql';
|
|
3
5
|
import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
|
|
4
6
|
import { MSSQLColumnDescriptor } from '../types/mssql-data-types.js';
|
|
5
|
-
import sql from 'mssql';
|
|
6
7
|
|
|
7
8
|
export interface GetColumnsOptions {
|
|
8
9
|
connectionManager: MSSQLConnectionManager;
|
|
@@ -198,3 +199,45 @@ export async function getTablesFromPattern(
|
|
|
198
199
|
});
|
|
199
200
|
}
|
|
200
201
|
}
|
|
202
|
+
|
|
203
|
+
export interface GetPendingSchemaChangesOptions {
|
|
204
|
+
connectionManager: MSSQLConnectionManager;
|
|
205
|
+
captureInstanceName: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Returns the DDL commands that have been applied to the source table since the capture instance was created.
|
|
210
|
+
*/
|
|
211
|
+
export async function getPendingSchemaChanges(options: GetPendingSchemaChangesOptions): Promise<string[]> {
|
|
212
|
+
const { connectionManager, captureInstanceName } = options;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const { recordset: results } = await connectionManager.execute('sys.sp_cdc_get_ddl_history', [
|
|
216
|
+
{ name: 'capture_instance', type: sql.VarChar(sql.MAX), value: captureInstanceName }
|
|
217
|
+
]);
|
|
218
|
+
return results.map((row) => row.ddl_command);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (isObjectNotExistError(e)) {
|
|
221
|
+
// Defensive check to cover the case where the capture instance metadata is temporarily unavailable.
|
|
222
|
+
logger.warn(`Unable to retrieve schema changes for capture instance: [${captureInstanceName}].`);
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
throw e;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isObjectNotExistError(error: unknown): boolean {
|
|
230
|
+
if (error != null && typeof error === 'object' && 'number' in error) {
|
|
231
|
+
// SQL Server Object does not exist or access is denied error number.
|
|
232
|
+
return (error as { number: unknown }).number === 22981;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function tableExists(tableId: number, connectionManager: MSSQLConnectionManager): Promise<boolean> {
|
|
238
|
+
const { recordset: results } = await connectionManager.query(`SELECT 1 FROM sys.tables WHERE object_id = @tableId`, [
|
|
239
|
+
{ name: 'tableId', type: sql.Int, value: tableId }
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
return results.length > 0;
|
|
243
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getLatestLSN } from '@module/utils/mssql.js';
|
|
2
|
+
import { storage } from '@powersync/service-core';
|
|
2
3
|
import { METRICS_HELPER, putOp, removeOp } from '@powersync/service-core-tests';
|
|
3
4
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
4
|
-
import { createTestTable, describeWithStorage, insertTestData, waitForPendingCDCChanges } from './util.js';
|
|
5
|
-
import { storage } from '@powersync/service-core';
|
|
6
|
-
import { CDCStreamTestContext } from './CDCStreamTestContext.js';
|
|
7
|
-
import { getLatestLSN } from '@module/utils/mssql.js';
|
|
8
5
|
import sql from 'mssql';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
import { CDCStreamTestContext } from './CDCStreamTestContext.js';
|
|
8
|
+
import { createTestTable, describeWithStorage, insertTestData, waitForPendingCDCChanges } from './util.js';
|
|
9
9
|
|
|
10
10
|
const BASIC_SYNC_RULES = `
|
|
11
11
|
bucket_definitions:
|
|
@@ -143,7 +143,7 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
143
143
|
]);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
test('Replication for tables not in the sync
|
|
146
|
+
test('Replication for tables not in the sync config are ignored', async () => {
|
|
147
147
|
await using context = await CDCStreamTestContext.open(factory);
|
|
148
148
|
const { connectionManager } = context;
|
|
149
149
|
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
@@ -164,7 +164,7 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
164
164
|
const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
165
165
|
const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
166
166
|
|
|
167
|
-
// There was a transaction, but it is not counted since it is not for a table in the sync
|
|
167
|
+
// There was a transaction, but it is not counted since it is not for a table in the sync config
|
|
168
168
|
expect(endRowCount - startRowCount).toEqual(0);
|
|
169
169
|
expect(endTxCount - startTxCount).toEqual(0);
|
|
170
170
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CDCStream, CDCStreamOptions } from '@module/replication/CDCStream.js';
|
|
2
|
+
import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
|
|
1
3
|
import {
|
|
2
4
|
BucketStorageFactory,
|
|
3
5
|
createCoreReplicationMetrics,
|
|
@@ -10,10 +12,8 @@ import {
|
|
|
10
12
|
updateSyncRulesFromYaml
|
|
11
13
|
} from '@powersync/service-core';
|
|
12
14
|
import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
|
|
13
|
-
import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
14
|
-
import { CDCStream, CDCStreamOptions } from '@module/replication/CDCStream.js';
|
|
15
|
-
import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
|
|
16
15
|
import timers from 'timers/promises';
|
|
16
|
+
import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Tests operating on the change data capture need to configure the stream and manage asynchronous
|
|
@@ -86,7 +86,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
86
86
|
async loadNextSyncRules() {
|
|
87
87
|
const syncRules = await this.factory.getNextSyncRulesContent();
|
|
88
88
|
if (syncRules == null) {
|
|
89
|
-
throw new Error(`Next
|
|
89
|
+
throw new Error(`Next replication stream not available`);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
this.syncRulesContent = syncRules;
|
|
@@ -97,7 +97,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
97
97
|
async loadActiveSyncRules() {
|
|
98
98
|
const syncRules = await this.factory.getActiveSyncRulesContent();
|
|
99
99
|
if (syncRules == null) {
|
|
100
|
-
throw new Error(`Active
|
|
100
|
+
throw new Error(`Active replication stream not available`);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
this.syncRulesContent = syncRules;
|
|
@@ -107,7 +107,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
107
107
|
|
|
108
108
|
private getSyncRulesContent(): storage.PersistedSyncRulesContent {
|
|
109
109
|
if (this.syncRulesContent == null) {
|
|
110
|
-
throw new Error('Sync
|
|
110
|
+
throw new Error('Sync config not configured - call updateSyncRules() first');
|
|
111
111
|
}
|
|
112
112
|
return this.syncRulesContent;
|
|
113
113
|
}
|
|
@@ -129,6 +129,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
129
129
|
pollingIntervalMs: 1000,
|
|
130
130
|
trustServerCertificate: true
|
|
131
131
|
},
|
|
132
|
+
schemaCheckIntervalMs: 500,
|
|
132
133
|
...this.cdcStreamOptions
|
|
133
134
|
};
|
|
134
135
|
this._cdcStream = new CDCStream(options);
|
|
@@ -140,7 +141,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
140
141
|
*/
|
|
141
142
|
async initializeReplication() {
|
|
142
143
|
await this.replicateSnapshot();
|
|
143
|
-
|
|
144
|
+
await this.startStreaming();
|
|
144
145
|
// Make sure we're up to date
|
|
145
146
|
await this.getCheckpoint();
|
|
146
147
|
}
|
|
@@ -150,11 +151,11 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
150
151
|
this.replicationDone = true;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
// TODO: Enable once streaming is implemented
|
|
154
154
|
startStreaming() {
|
|
155
155
|
if (!this.replicationDone) {
|
|
156
156
|
throw new Error('Call replicateSnapshot() before startStreaming()');
|
|
157
157
|
}
|
|
158
|
+
|
|
158
159
|
this.streamPromise = this.cdcStream.streamChanges();
|
|
159
160
|
// Wait for the replication to start before returning.
|
|
160
161
|
// This avoids a bunch of unpredictable race conditions that appear in testing
|
|
@@ -212,6 +213,11 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
212
213
|
return data;
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
async getFinalBucketState(bucket: string) {
|
|
217
|
+
const data = await this.getBucketData(bucket);
|
|
218
|
+
return test_utils.reduceBucket(data).slice(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
215
221
|
/**
|
|
216
222
|
* This does not wait for a client checkpoint.
|
|
217
223
|
*/
|