@powersync/service-module-mssql 0.0.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/LICENSE +67 -0
- package/README.md +3 -0
- package/ci/init-mssql.sql +50 -0
- package/dist/api/MSSQLRouteAPIAdapter.d.ts +21 -0
- package/dist/api/MSSQLRouteAPIAdapter.js +248 -0
- package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -0
- package/dist/common/LSN.d.ts +37 -0
- package/dist/common/LSN.js +64 -0
- package/dist/common/LSN.js.map +1 -0
- package/dist/common/MSSQLSourceTable.d.ts +27 -0
- package/dist/common/MSSQLSourceTable.js +35 -0
- package/dist/common/MSSQLSourceTable.js.map +1 -0
- package/dist/common/MSSQLSourceTableCache.d.ts +14 -0
- package/dist/common/MSSQLSourceTableCache.js +28 -0
- package/dist/common/MSSQLSourceTableCache.js.map +1 -0
- package/dist/common/mssqls-to-sqlite.d.ts +18 -0
- package/dist/common/mssqls-to-sqlite.js +143 -0
- package/dist/common/mssqls-to-sqlite.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/module/MSSQLModule.d.ts +15 -0
- package/dist/module/MSSQLModule.js +68 -0
- package/dist/module/MSSQLModule.js.map +1 -0
- package/dist/replication/CDCPoller.d.ts +67 -0
- package/dist/replication/CDCPoller.js +183 -0
- package/dist/replication/CDCPoller.js.map +1 -0
- package/dist/replication/CDCReplicationJob.d.ts +17 -0
- package/dist/replication/CDCReplicationJob.js +76 -0
- package/dist/replication/CDCReplicationJob.js.map +1 -0
- package/dist/replication/CDCReplicator.d.ts +18 -0
- package/dist/replication/CDCReplicator.js +55 -0
- package/dist/replication/CDCReplicator.js.map +1 -0
- package/dist/replication/CDCStream.d.ts +106 -0
- package/dist/replication/CDCStream.js +536 -0
- package/dist/replication/CDCStream.js.map +1 -0
- package/dist/replication/MSSQLConnectionManager.d.ts +23 -0
- package/dist/replication/MSSQLConnectionManager.js +97 -0
- package/dist/replication/MSSQLConnectionManager.js.map +1 -0
- package/dist/replication/MSSQLConnectionManagerFactory.d.ts +10 -0
- package/dist/replication/MSSQLConnectionManagerFactory.js +28 -0
- package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -0
- package/dist/replication/MSSQLErrorRateLimiter.d.ts +10 -0
- package/dist/replication/MSSQLErrorRateLimiter.js +34 -0
- package/dist/replication/MSSQLErrorRateLimiter.js.map +1 -0
- package/dist/replication/MSSQLSnapshotQuery.d.ts +71 -0
- package/dist/replication/MSSQLSnapshotQuery.js +190 -0
- package/dist/replication/MSSQLSnapshotQuery.js.map +1 -0
- package/dist/types/mssql-data-types.d.ts +66 -0
- package/dist/types/mssql-data-types.js +62 -0
- package/dist/types/mssql-data-types.js.map +1 -0
- package/dist/types/types.d.ts +177 -0
- package/dist/types/types.js +141 -0
- package/dist/types/types.js.map +1 -0
- package/dist/utils/mssql.d.ts +80 -0
- package/dist/utils/mssql.js +329 -0
- package/dist/utils/mssql.js.map +1 -0
- package/dist/utils/schema.d.ts +21 -0
- package/dist/utils/schema.js +131 -0
- package/dist/utils/schema.js.map +1 -0
- package/package.json +51 -0
- package/src/api/MSSQLRouteAPIAdapter.ts +283 -0
- package/src/common/LSN.ts +77 -0
- package/src/common/MSSQLSourceTable.ts +54 -0
- package/src/common/MSSQLSourceTableCache.ts +36 -0
- package/src/common/mssqls-to-sqlite.ts +151 -0
- package/src/index.ts +1 -0
- package/src/module/MSSQLModule.ts +82 -0
- package/src/replication/CDCPoller.ts +241 -0
- package/src/replication/CDCReplicationJob.ts +87 -0
- package/src/replication/CDCReplicator.ts +70 -0
- package/src/replication/CDCStream.ts +688 -0
- package/src/replication/MSSQLConnectionManager.ts +113 -0
- package/src/replication/MSSQLConnectionManagerFactory.ts +33 -0
- package/src/replication/MSSQLErrorRateLimiter.ts +36 -0
- package/src/replication/MSSQLSnapshotQuery.ts +230 -0
- package/src/types/mssql-data-types.ts +79 -0
- package/src/types/types.ts +224 -0
- package/src/utils/mssql.ts +420 -0
- package/src/utils/schema.ts +172 -0
- package/test/src/CDCStream.test.ts +206 -0
- package/test/src/CDCStreamTestContext.ts +212 -0
- package/test/src/CDCStream_resumable_snapshot.test.ts +152 -0
- package/test/src/env.ts +11 -0
- package/test/src/mssql-to-sqlite.test.ts +474 -0
- package/test/src/setup.ts +12 -0
- package/test/src/util.ts +189 -0
- package/test/tsconfig.json +28 -0
- package/test/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.json +26 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { SourceEntityDescriptor } from '@powersync/service-core';
|
|
2
|
+
import { TablePattern } from '@powersync/service-sync-rules';
|
|
3
|
+
import { MSSQLConnectionManager } from '../replication/MSSQLConnectionManager.js';
|
|
4
|
+
import { MSSQLColumnDescriptor } from '../types/mssql-data-types.js';
|
|
5
|
+
import { escapeIdentifier } from './mssql.js';
|
|
6
|
+
|
|
7
|
+
export interface GetColumnsOptions {
|
|
8
|
+
connectionManager: MSSQLConnectionManager;
|
|
9
|
+
schema: string;
|
|
10
|
+
tableName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function getColumns(options: GetColumnsOptions): Promise<MSSQLColumnDescriptor[]> {
|
|
14
|
+
const { connectionManager, schema, tableName } = options;
|
|
15
|
+
|
|
16
|
+
const { recordset: columnResults } = await connectionManager.query(`
|
|
17
|
+
SELECT
|
|
18
|
+
col.name AS [name],
|
|
19
|
+
typ.name AS [type],
|
|
20
|
+
typ.system_type_id AS type_id,
|
|
21
|
+
typ.user_type_id AS user_type_id
|
|
22
|
+
FROM sys.columns AS col
|
|
23
|
+
JOIN sys.tables AS tbl ON tbl.object_id = col.object_id
|
|
24
|
+
JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
|
|
25
|
+
JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
|
|
26
|
+
WHERE sch.name = '${schema}'
|
|
27
|
+
AND tbl.name = '${tableName}'
|
|
28
|
+
ORDER BY col.column_id;
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
return columnResults.map((row) => {
|
|
32
|
+
return {
|
|
33
|
+
name: row.name,
|
|
34
|
+
type: row.type,
|
|
35
|
+
typeId: row.type_id,
|
|
36
|
+
userTypeId: row.user_type_id
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GetReplicationIdentityColumnsOptions {
|
|
42
|
+
connectionManager: MSSQLConnectionManager;
|
|
43
|
+
schema: string;
|
|
44
|
+
tableName: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReplicationIdentityColumnsResult {
|
|
48
|
+
columns: MSSQLColumnDescriptor[];
|
|
49
|
+
identity: 'default' | 'nothing' | 'full' | 'index';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getReplicationIdentityColumns(
|
|
53
|
+
options: GetReplicationIdentityColumnsOptions
|
|
54
|
+
): Promise<ReplicationIdentityColumnsResult> {
|
|
55
|
+
const { connectionManager, schema, tableName } = options;
|
|
56
|
+
const { recordset: primaryKeyColumns } = await connectionManager.query(`
|
|
57
|
+
SELECT
|
|
58
|
+
col.name AS [name],
|
|
59
|
+
typ.name AS [type],
|
|
60
|
+
typ.system_type_id AS type_id,
|
|
61
|
+
typ.user_type_id AS user_type_id
|
|
62
|
+
FROM sys.tables AS tbl
|
|
63
|
+
JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
|
|
64
|
+
JOIN sys.indexes AS idx ON idx.object_id = tbl.object_id AND idx.is_primary_key = 1
|
|
65
|
+
JOIN sys.index_columns AS idx_col ON idx_col.object_id = idx.object_id AND idx_col.index_id = idx.index_id
|
|
66
|
+
JOIN sys.columns AS col ON col.object_id = idx_col.object_id AND col.column_id = idx_col.column_id
|
|
67
|
+
JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
|
|
68
|
+
WHERE sch.name = '${schema}'
|
|
69
|
+
AND tbl.name = '${tableName}'
|
|
70
|
+
ORDER BY idx_col.key_ordinal;
|
|
71
|
+
`);
|
|
72
|
+
|
|
73
|
+
if (primaryKeyColumns.length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
columns: primaryKeyColumns.map((row) => ({
|
|
76
|
+
name: row.name,
|
|
77
|
+
type: row.type,
|
|
78
|
+
typeId: row.type_id,
|
|
79
|
+
userTypeId: row.user_type_id
|
|
80
|
+
})),
|
|
81
|
+
identity: 'default'
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No primary key, check if any of the columns have a unique constraint we can use
|
|
86
|
+
const { recordset: uniqueKeyColumns } = await connectionManager.query(`
|
|
87
|
+
SELECT
|
|
88
|
+
col.name AS [name],
|
|
89
|
+
typ.name AS [type],
|
|
90
|
+
typ.system_type_id AS type_id,
|
|
91
|
+
typ.user_type_id AS user_type_id
|
|
92
|
+
FROM sys.tables AS tbl
|
|
93
|
+
JOIN sys.schemas AS sch ON sch.schema_id = tbl.schema_id
|
|
94
|
+
JOIN sys.indexes AS idx ON idx.object_id = tbl.object_id AND idx.is_unique_constraint = 1
|
|
95
|
+
JOIN sys.index_columns AS idx_col ON idx_col.object_id = idx.object_id AND idx_col.index_id = idx.index_id
|
|
96
|
+
JOIN sys.columns AS col ON col.object_id = idx_col.object_id AND col.column_id = idx_col.column_id
|
|
97
|
+
JOIN sys.types AS typ ON typ.user_type_id = col.user_type_id
|
|
98
|
+
WHERE sch.name = '${schema}'
|
|
99
|
+
AND tbl.name = '${tableName}'
|
|
100
|
+
ORDER BY idx_col.key_ordinal;
|
|
101
|
+
`);
|
|
102
|
+
|
|
103
|
+
if (uniqueKeyColumns.length > 0) {
|
|
104
|
+
return {
|
|
105
|
+
columns: uniqueKeyColumns.map((row) => ({
|
|
106
|
+
name: row.name,
|
|
107
|
+
type: row.type,
|
|
108
|
+
typeId: row.type_id,
|
|
109
|
+
userTypeId: row.user_type_id
|
|
110
|
+
})),
|
|
111
|
+
identity: 'index'
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const allColumns = await getColumns(options);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
columns: allColumns,
|
|
119
|
+
identity: 'full'
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ResolvedTable = Omit<SourceEntityDescriptor, 'replicaIdColumns'>;
|
|
124
|
+
|
|
125
|
+
export async function getTablesFromPattern(
|
|
126
|
+
connectionManager: MSSQLConnectionManager,
|
|
127
|
+
tablePattern: TablePattern
|
|
128
|
+
): Promise<ResolvedTable[]> {
|
|
129
|
+
if (tablePattern.isWildcard) {
|
|
130
|
+
const { recordset: tableResults } = await connectionManager.query(`
|
|
131
|
+
SELECT
|
|
132
|
+
tbl.name AS [table],
|
|
133
|
+
sch.name AS [schema],
|
|
134
|
+
tbl.object_id AS object_id
|
|
135
|
+
FROM sys.tables tbl
|
|
136
|
+
JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
|
|
137
|
+
WHERE sch.name = '${tablePattern.schema}'
|
|
138
|
+
AND tbl.name LIKE '${tablePattern.tablePattern}'
|
|
139
|
+
`);
|
|
140
|
+
|
|
141
|
+
return tableResults
|
|
142
|
+
.map((row) => {
|
|
143
|
+
return {
|
|
144
|
+
objectId: row.object_id,
|
|
145
|
+
schema: row.schema,
|
|
146
|
+
name: row.table
|
|
147
|
+
};
|
|
148
|
+
})
|
|
149
|
+
.filter((table: ResolvedTable) => table.name.startsWith(tablePattern.tablePrefix));
|
|
150
|
+
} else {
|
|
151
|
+
const { recordset: tableResults } = await connectionManager.query(
|
|
152
|
+
`
|
|
153
|
+
SELECT
|
|
154
|
+
tbl.name AS [table],
|
|
155
|
+
sch.name AS [schema],
|
|
156
|
+
tbl.object_id AS object_id
|
|
157
|
+
FROM sys.tables tbl
|
|
158
|
+
JOIN sys.schemas sch ON tbl.schema_id = sch.schema_id
|
|
159
|
+
WHERE sch.name = '${tablePattern.schema}'
|
|
160
|
+
AND tbl.name = '${tablePattern.name}'
|
|
161
|
+
`
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return tableResults.map((row) => {
|
|
165
|
+
return {
|
|
166
|
+
objectId: row.object_id,
|
|
167
|
+
schema: row.schema,
|
|
168
|
+
name: row.table
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { METRICS_HELPER, putOp, removeOp } from '@powersync/service-core-tests';
|
|
3
|
+
import { ReplicationMetric } from '@powersync/service-types';
|
|
4
|
+
import {
|
|
5
|
+
createTestTable,
|
|
6
|
+
describeWithStorage,
|
|
7
|
+
INITIALIZED_MONGO_STORAGE_FACTORY,
|
|
8
|
+
insertTestData,
|
|
9
|
+
waitForPendingCDCChanges
|
|
10
|
+
} from './util.js';
|
|
11
|
+
import { storage } from '@powersync/service-core';
|
|
12
|
+
import { CDCStreamTestContext } from './CDCStreamTestContext.js';
|
|
13
|
+
import { getLatestReplicatedLSN } from '@module/utils/mssql.js';
|
|
14
|
+
import sql from 'mssql';
|
|
15
|
+
|
|
16
|
+
const BASIC_SYNC_RULES = `
|
|
17
|
+
bucket_definitions:
|
|
18
|
+
global:
|
|
19
|
+
data:
|
|
20
|
+
- SELECT id, description FROM "test_data"
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
// describe('CDCStream tests', () => {
|
|
24
|
+
// describeWithStorage({ timeout: 20_000 }, defineCDCStreamTests);
|
|
25
|
+
// });
|
|
26
|
+
|
|
27
|
+
defineCDCStreamTests(INITIALIZED_MONGO_STORAGE_FACTORY);
|
|
28
|
+
|
|
29
|
+
function defineCDCStreamTests(factory: storage.TestStorageFactory) {
|
|
30
|
+
test('Initial snapshot sync', async () => {
|
|
31
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
32
|
+
const { connectionManager } = context;
|
|
33
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
34
|
+
|
|
35
|
+
await createTestTable(connectionManager, 'test_data');
|
|
36
|
+
const beforeLSN = await getLatestReplicatedLSN(connectionManager);
|
|
37
|
+
const testData = await insertTestData(connectionManager, 'test_data');
|
|
38
|
+
await waitForPendingCDCChanges(beforeLSN, connectionManager);
|
|
39
|
+
const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
40
|
+
|
|
41
|
+
await context.replicateSnapshot();
|
|
42
|
+
await context.startStreaming();
|
|
43
|
+
|
|
44
|
+
const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
45
|
+
const data = await context.getBucketData('global[]');
|
|
46
|
+
expect(data).toMatchObject([putOp('test_data', testData)]);
|
|
47
|
+
expect(endRowCount - startRowCount).toEqual(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('Replicate basic values', async () => {
|
|
51
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
52
|
+
const { connectionManager } = context;
|
|
53
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
54
|
+
|
|
55
|
+
await createTestTable(connectionManager, 'test_data');
|
|
56
|
+
await context.replicateSnapshot();
|
|
57
|
+
|
|
58
|
+
const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
59
|
+
const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
60
|
+
|
|
61
|
+
await context.startStreaming();
|
|
62
|
+
|
|
63
|
+
const testData = await insertTestData(connectionManager, 'test_data');
|
|
64
|
+
|
|
65
|
+
const data = await context.getBucketData('global[]');
|
|
66
|
+
|
|
67
|
+
expect(data).toMatchObject([putOp('test_data', testData)]);
|
|
68
|
+
const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
69
|
+
const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
70
|
+
expect(endRowCount - startRowCount).toEqual(1);
|
|
71
|
+
expect(endTxCount - startTxCount).toEqual(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('Replicate row updates', async () => {
|
|
75
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
76
|
+
const { connectionManager } = context;
|
|
77
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
78
|
+
|
|
79
|
+
await createTestTable(connectionManager, 'test_data');
|
|
80
|
+
const beforeLSN = await getLatestReplicatedLSN(connectionManager);
|
|
81
|
+
const testData = await insertTestData(connectionManager, 'test_data');
|
|
82
|
+
await waitForPendingCDCChanges(beforeLSN, connectionManager);
|
|
83
|
+
await context.replicateSnapshot();
|
|
84
|
+
|
|
85
|
+
await context.startStreaming();
|
|
86
|
+
|
|
87
|
+
const updatedTestData = { ...testData };
|
|
88
|
+
updatedTestData.description = 'updated';
|
|
89
|
+
await connectionManager.query(`UPDATE test_data SET description = @description WHERE id = @id`, [
|
|
90
|
+
{ name: 'description', type: sql.NVarChar(sql.MAX), value: updatedTestData.description },
|
|
91
|
+
{ name: 'id', type: sql.UniqueIdentifier, value: updatedTestData.id }
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const data = await context.getBucketData('global[]');
|
|
95
|
+
expect(data).toMatchObject([putOp('test_data', testData), putOp('test_data', updatedTestData)]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('Replicate row deletions', async () => {
|
|
99
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
100
|
+
const { connectionManager } = context;
|
|
101
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
102
|
+
|
|
103
|
+
await createTestTable(connectionManager, 'test_data');
|
|
104
|
+
const beforeLSN = await getLatestReplicatedLSN(connectionManager);
|
|
105
|
+
const testData = await insertTestData(connectionManager, 'test_data');
|
|
106
|
+
await waitForPendingCDCChanges(beforeLSN, connectionManager);
|
|
107
|
+
await context.replicateSnapshot();
|
|
108
|
+
|
|
109
|
+
await context.startStreaming();
|
|
110
|
+
|
|
111
|
+
await connectionManager.query(`DELETE FROM test_data WHERE id = @id`, [
|
|
112
|
+
{ name: 'id', type: sql.UniqueIdentifier, value: testData.id }
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const data = await context.getBucketData('global[]');
|
|
116
|
+
expect(data).toMatchObject([putOp('test_data', testData), removeOp('test_data', testData.id)]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('Replicate matched wild card tables in sync rules', async () => {
|
|
120
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
121
|
+
const { connectionManager } = context;
|
|
122
|
+
await context.updateSyncRules(`
|
|
123
|
+
bucket_definitions:
|
|
124
|
+
global:
|
|
125
|
+
data:
|
|
126
|
+
- SELECT id, description FROM "test_data_%"`);
|
|
127
|
+
|
|
128
|
+
await createTestTable(connectionManager, 'test_data_1');
|
|
129
|
+
await createTestTable(connectionManager, 'test_data_2');
|
|
130
|
+
|
|
131
|
+
const beforeLSN = await getLatestReplicatedLSN(connectionManager);
|
|
132
|
+
const testData11 = await insertTestData(connectionManager, 'test_data_1');
|
|
133
|
+
const testData21 = await insertTestData(connectionManager, 'test_data_2');
|
|
134
|
+
await waitForPendingCDCChanges(beforeLSN, connectionManager);
|
|
135
|
+
|
|
136
|
+
await context.replicateSnapshot();
|
|
137
|
+
await context.startStreaming();
|
|
138
|
+
|
|
139
|
+
const testData12 = await insertTestData(connectionManager, 'test_data_1');
|
|
140
|
+
const testData22 = await insertTestData(connectionManager, 'test_data_2');
|
|
141
|
+
|
|
142
|
+
const data = await context.getBucketData('global[]');
|
|
143
|
+
|
|
144
|
+
expect(data).toMatchObject([
|
|
145
|
+
putOp('test_data_1', testData11),
|
|
146
|
+
putOp('test_data_2', testData21),
|
|
147
|
+
putOp('test_data_1', testData12),
|
|
148
|
+
putOp('test_data_2', testData22)
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('Replication for tables not in the sync rules are ignored', async () => {
|
|
153
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
154
|
+
const { connectionManager } = context;
|
|
155
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
156
|
+
|
|
157
|
+
await createTestTable(connectionManager, 'test_donotsync');
|
|
158
|
+
|
|
159
|
+
await context.replicateSnapshot();
|
|
160
|
+
|
|
161
|
+
const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
162
|
+
const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
163
|
+
|
|
164
|
+
await context.startStreaming();
|
|
165
|
+
|
|
166
|
+
await insertTestData(connectionManager, 'test_donotsync');
|
|
167
|
+
const data = await context.getBucketData('global[]');
|
|
168
|
+
|
|
169
|
+
expect(data).toMatchObject([]);
|
|
170
|
+
const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
171
|
+
const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
172
|
+
|
|
173
|
+
// There was a transaction, but it is not counted since it is not for a table in the sync rules
|
|
174
|
+
expect(endRowCount - startRowCount).toEqual(0);
|
|
175
|
+
expect(endTxCount - startTxCount).toEqual(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('Replicate case sensitive table', async () => {
|
|
179
|
+
await using context = await CDCStreamTestContext.open(factory);
|
|
180
|
+
const { connectionManager } = context;
|
|
181
|
+
await context.updateSyncRules(`
|
|
182
|
+
bucket_definitions:
|
|
183
|
+
global:
|
|
184
|
+
data:
|
|
185
|
+
- SELECT id, description FROM "test_DATA"
|
|
186
|
+
`);
|
|
187
|
+
|
|
188
|
+
await createTestTable(connectionManager, 'test_DATA');
|
|
189
|
+
|
|
190
|
+
await context.replicateSnapshot();
|
|
191
|
+
|
|
192
|
+
const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
193
|
+
const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
194
|
+
|
|
195
|
+
await context.startStreaming();
|
|
196
|
+
|
|
197
|
+
const testData = await insertTestData(connectionManager, 'test_DATA');
|
|
198
|
+
const data = await context.getBucketData('global[]');
|
|
199
|
+
|
|
200
|
+
expect(data).toMatchObject([putOp('test_DATA', testData)]);
|
|
201
|
+
const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
202
|
+
const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
203
|
+
expect(endRowCount - startRowCount).toEqual(1);
|
|
204
|
+
expect(endTxCount - startTxCount).toBeGreaterThanOrEqual(1);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BucketStorageFactory,
|
|
3
|
+
createCoreReplicationMetrics,
|
|
4
|
+
initializeCoreReplicationMetrics,
|
|
5
|
+
InternalOpId,
|
|
6
|
+
OplogEntry,
|
|
7
|
+
storage,
|
|
8
|
+
SyncRulesBucketStorage
|
|
9
|
+
} from '@powersync/service-core';
|
|
10
|
+
import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
|
|
11
|
+
import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
12
|
+
import { CDCStream, CDCStreamOptions } from '@module/replication/CDCStream.js';
|
|
13
|
+
import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
|
|
14
|
+
import timers from 'timers/promises';
|
|
15
|
+
import { CDCPollingOptions } from '@module/types/types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tests operating on the change data capture need to configure the stream and manage asynchronous
|
|
19
|
+
* replication, which gets a little tricky.
|
|
20
|
+
*
|
|
21
|
+
* This wraps all the context required for testing, and tears it down afterward
|
|
22
|
+
* by using `await using`.
|
|
23
|
+
*/
|
|
24
|
+
export class CDCStreamTestContext implements AsyncDisposable {
|
|
25
|
+
private _cdcStream?: CDCStream;
|
|
26
|
+
private abortController = new AbortController();
|
|
27
|
+
private streamPromise?: Promise<void>;
|
|
28
|
+
public storage?: SyncRulesBucketStorage;
|
|
29
|
+
private snapshotPromise?: Promise<void>;
|
|
30
|
+
private replicationDone = false;
|
|
31
|
+
|
|
32
|
+
static async open(
|
|
33
|
+
factory: (options: storage.TestStorageOptions) => Promise<BucketStorageFactory>,
|
|
34
|
+
options?: { doNotClear?: boolean; cdcStreamOptions?: Partial<CDCStreamOptions> }
|
|
35
|
+
) {
|
|
36
|
+
const f = await factory({ doNotClear: options?.doNotClear });
|
|
37
|
+
const connectionManager = new MSSQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
|
|
38
|
+
|
|
39
|
+
if (!options?.doNotClear) {
|
|
40
|
+
await clearTestDb(connectionManager);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new CDCStreamTestContext(f, connectionManager, options?.cdcStreamOptions);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
public factory: BucketStorageFactory,
|
|
48
|
+
public connectionManager: MSSQLConnectionManager,
|
|
49
|
+
private cdcStreamOptions?: Partial<CDCStreamOptions>
|
|
50
|
+
) {
|
|
51
|
+
createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
|
|
52
|
+
initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async [Symbol.asyncDispose]() {
|
|
56
|
+
try {
|
|
57
|
+
await this.dispose();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('Error disposing CDCStreamTestContext', err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async dispose() {
|
|
64
|
+
this.abortController.abort();
|
|
65
|
+
await this.snapshotPromise;
|
|
66
|
+
await this.streamPromise;
|
|
67
|
+
await this.connectionManager.end();
|
|
68
|
+
await this.factory?.[Symbol.asyncDispose]();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get connectionTag() {
|
|
72
|
+
return this.connectionManager.connectionTag;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async updateSyncRules(content: string) {
|
|
76
|
+
const syncRules = await this.factory.updateSyncRules({ content: content, validate: true });
|
|
77
|
+
this.storage = this.factory.getInstance(syncRules);
|
|
78
|
+
return this.storage!;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async loadNextSyncRules() {
|
|
82
|
+
const syncRules = await this.factory.getNextSyncRulesContent();
|
|
83
|
+
if (syncRules == null) {
|
|
84
|
+
throw new Error(`Next sync rules not available`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.storage = this.factory.getInstance(syncRules);
|
|
88
|
+
return this.storage!;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async loadActiveSyncRules() {
|
|
92
|
+
const syncRules = await this.factory.getActiveSyncRulesContent();
|
|
93
|
+
if (syncRules == null) {
|
|
94
|
+
throw new Error(`Active sync rules not available`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.storage = this.factory.getInstance(syncRules);
|
|
98
|
+
return this.storage!;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get cdcStream() {
|
|
102
|
+
if (this.storage == null) {
|
|
103
|
+
throw new Error('updateSyncRules() first');
|
|
104
|
+
}
|
|
105
|
+
if (this._cdcStream) {
|
|
106
|
+
return this._cdcStream;
|
|
107
|
+
}
|
|
108
|
+
const options: CDCStreamOptions = {
|
|
109
|
+
storage: this.storage,
|
|
110
|
+
metrics: METRICS_HELPER.metricsEngine,
|
|
111
|
+
connections: this.connectionManager,
|
|
112
|
+
abortSignal: this.abortController.signal,
|
|
113
|
+
pollingOptions: {
|
|
114
|
+
batchSize: 10,
|
|
115
|
+
intervalMs: 1000
|
|
116
|
+
} satisfies CDCPollingOptions,
|
|
117
|
+
...this.cdcStreamOptions
|
|
118
|
+
};
|
|
119
|
+
this._cdcStream = new CDCStream(options);
|
|
120
|
+
return this._cdcStream!;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Replicate a snapshot, start streaming, and wait for a consistent checkpoint.
|
|
125
|
+
*/
|
|
126
|
+
async initializeReplication() {
|
|
127
|
+
await this.replicateSnapshot();
|
|
128
|
+
// TODO: renable this.startStreaming();
|
|
129
|
+
// Make sure we're up to date
|
|
130
|
+
await this.getCheckpoint();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async replicateSnapshot() {
|
|
134
|
+
await this.cdcStream.initReplication();
|
|
135
|
+
this.replicationDone = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// TODO: Enable once streaming is implemented
|
|
139
|
+
startStreaming() {
|
|
140
|
+
if (!this.replicationDone) {
|
|
141
|
+
throw new Error('Call replicateSnapshot() before startStreaming()');
|
|
142
|
+
}
|
|
143
|
+
this.streamPromise = this.cdcStream.streamChanges();
|
|
144
|
+
// Wait for the replication to start before returning.
|
|
145
|
+
// This avoids a bunch of unpredictable race conditions that appear in testing
|
|
146
|
+
return new Promise<void>(async (resolve) => {
|
|
147
|
+
while (this.cdcStream.isStartingReplication) {
|
|
148
|
+
await timers.setTimeout(50);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resolve();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getCheckpoint(options?: { timeout?: number }) {
|
|
156
|
+
let checkpoint = await Promise.race([
|
|
157
|
+
getClientCheckpoint(this.connectionManager, this.factory, { timeout: options?.timeout ?? 15_000 }),
|
|
158
|
+
this.streamPromise
|
|
159
|
+
]);
|
|
160
|
+
if (checkpoint == null) {
|
|
161
|
+
// This indicates an issue with the test setup - streamingPromise completed instead
|
|
162
|
+
// of getClientCheckpoint()
|
|
163
|
+
throw new Error('Test failure - streamingPromise completed');
|
|
164
|
+
}
|
|
165
|
+
return checkpoint;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
|
|
169
|
+
let checkpoint = await this.getCheckpoint(options);
|
|
170
|
+
const map = new Map<string, InternalOpId>(Object.entries(buckets));
|
|
171
|
+
return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* This waits for a client checkpoint.
|
|
176
|
+
*/
|
|
177
|
+
async getBucketData(bucket: string, start?: InternalOpId | string | undefined, options?: { timeout?: number }) {
|
|
178
|
+
start ??= 0n;
|
|
179
|
+
if (typeof start == 'string') {
|
|
180
|
+
start = BigInt(start);
|
|
181
|
+
}
|
|
182
|
+
const checkpoint = await this.getCheckpoint(options);
|
|
183
|
+
const map = new Map<string, InternalOpId>([[bucket, start]]);
|
|
184
|
+
let data: OplogEntry[] = [];
|
|
185
|
+
while (true) {
|
|
186
|
+
const batch = this.storage!.getBucketDataBatch(checkpoint, map);
|
|
187
|
+
|
|
188
|
+
const batches = await test_utils.fromAsync(batch);
|
|
189
|
+
data = data.concat(batches[0]?.chunkData.data ?? []);
|
|
190
|
+
if (batches.length == 0 || !batches[0]!.chunkData.has_more) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
map.set(bucket, BigInt(batches[0]!.chunkData.next_after));
|
|
194
|
+
}
|
|
195
|
+
return data;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* This does not wait for a client checkpoint.
|
|
200
|
+
*/
|
|
201
|
+
async getCurrentBucketData(bucket: string, start?: InternalOpId | string | undefined) {
|
|
202
|
+
start ??= 0n;
|
|
203
|
+
if (typeof start == 'string') {
|
|
204
|
+
start = BigInt(start);
|
|
205
|
+
}
|
|
206
|
+
const { checkpoint } = await this.storage!.getCheckpoint();
|
|
207
|
+
const map = new Map<string, InternalOpId>([[bucket, start]]);
|
|
208
|
+
const batch = this.storage!.getBucketDataBatch(checkpoint, map);
|
|
209
|
+
const batches = await test_utils.fromAsync(batch);
|
|
210
|
+
return batches[0]?.chunkData.data ?? [];
|
|
211
|
+
}
|
|
212
|
+
}
|