@powersync/service-module-mssql 0.10.2 → 0.10.4
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 +63 -0
- package/dist/api/MSSQLRouteAPIAdapter.js +2 -1
- package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
- package/dist/module/MSSQLModule.d.ts +0 -1
- package/dist/module/MSSQLModule.js +1 -1
- package/dist/module/MSSQLModule.js.map +1 -1
- package/dist/replication/CDCStream.d.ts +2 -4
- package/dist/replication/CDCStream.js +54 -57
- package/dist/replication/CDCStream.js.map +1 -1
- package/dist/replication/MSSQLSnapshotQuery.d.ts +25 -15
- package/dist/replication/MSSQLSnapshotQuery.js +74 -47
- package/dist/replication/MSSQLSnapshotQuery.js.map +1 -1
- package/dist/utils/mssql.d.ts +0 -1
- package/dist/utils/mssql.js +0 -19
- package/dist/utils/mssql.js.map +1 -1
- package/package.json +9 -9
- package/src/api/MSSQLRouteAPIAdapter.ts +2 -1
- package/src/module/MSSQLModule.ts +1 -2
- package/src/replication/CDCStream.ts +56 -59
- package/src/replication/MSSQLSnapshotQuery.ts +107 -62
- package/src/utils/mssql.ts +0 -22
- package/test/src/CDCStream.test.ts +106 -16
- package/test/src/CDCStreamTestContext.ts +9 -6
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -5,61 +5,102 @@ import { MSSQLBaseType } from '../types/mssql-data-types.js';
|
|
|
5
5
|
import { escapeIdentifier } from '../utils/mssql.js';
|
|
6
6
|
|
|
7
7
|
export interface MSSQLSnapshotQuery {
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Returns the column metadata for the rows that would be returned for this query.
|
|
10
|
+
*/
|
|
11
|
+
getColumnMetadata(): Promise<sql.IColumnMetadata>;
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
|
-
* Returns an async
|
|
14
|
+
* Returns an async iterator for the next batch of rows, yielding at most the batch size.
|
|
15
|
+
* A batch smaller than the batch size indicates that there are no rows left.
|
|
12
16
|
*/
|
|
13
|
-
|
|
17
|
+
nextBatch(): AsyncIterableIterator<Record<string, any>>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cancels the query if it is still in progress.
|
|
21
|
+
*/
|
|
22
|
+
close(): Promise<void>;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
/**
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
* Helper class that encapsulates the streaming logic of the queries used for snapshots.
|
|
27
|
+
*/
|
|
28
|
+
class StreamingQuery {
|
|
29
|
+
readonly rows: AsyncIterableIterator<any>;
|
|
30
|
+
private readonly completed: Promise<void>;
|
|
31
|
+
private finished = false;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly request: sql.Request,
|
|
35
|
+
query: string
|
|
36
|
+
) {
|
|
37
|
+
const stream = request.toReadableStream();
|
|
38
|
+
// Errors are surfaced by iterating the rows. This listener prevents an unhandled 'error' event
|
|
39
|
+
// when the query fails after iteration has stopped, such as the cancellation error from cancel().
|
|
40
|
+
stream.on('error', () => {});
|
|
41
|
+
// In stream mode, errors are emitted on the stream instead of rejecting this promise.
|
|
42
|
+
this.completed = request.query(query).then(
|
|
43
|
+
() => {
|
|
44
|
+
this.finished = true;
|
|
45
|
+
},
|
|
46
|
+
() => {
|
|
47
|
+
this.finished = true;
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
this.rows = stream[Symbol.asyncIterator]();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async cancel(): Promise<void> {
|
|
54
|
+
if (!this.finished) {
|
|
55
|
+
this.request.cancel();
|
|
56
|
+
}
|
|
57
|
+
// Resolves once the driver has released the transaction's connection.
|
|
58
|
+
await this.completed;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Snapshot query using a plain SELECT * FROM table.
|
|
64
|
+
* This supports all tables but cannot resume the snapshot if the process is restarted.
|
|
21
65
|
*/
|
|
22
66
|
export class SimpleSnapshotQuery implements MSSQLSnapshotQuery {
|
|
67
|
+
private query: StreamingQuery | null = null;
|
|
68
|
+
|
|
23
69
|
public constructor(
|
|
24
70
|
private readonly transaction: sql.Transaction,
|
|
25
|
-
private readonly qualifiedTableName: string
|
|
71
|
+
private readonly qualifiedTableName: string,
|
|
72
|
+
private readonly batchSize: number = 10_000
|
|
26
73
|
) {}
|
|
27
74
|
|
|
28
|
-
public
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const metadataRequest = this.transaction.request();
|
|
32
|
-
metadataRequest.stream = true;
|
|
33
|
-
const metadataPromise = new Promise<sql.IColumnMetadata>((resolve, reject) => {
|
|
34
|
-
metadataRequest.on('recordset', resolve);
|
|
35
|
-
metadataRequest.on('error', reject);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
metadataRequest.query(`SELECT TOP(0) * FROM ${this.qualifiedTableName}`);
|
|
39
|
-
|
|
40
|
-
const columnMetadata: sql.IColumnMetadata = await metadataPromise;
|
|
41
|
-
yield columnMetadata;
|
|
42
|
-
|
|
43
|
-
const request = this.transaction.request();
|
|
44
|
-
const stream = request.toReadableStream();
|
|
45
|
-
|
|
46
|
-
request.query(`SELECT * FROM ${this.qualifiedTableName}`);
|
|
75
|
+
public getColumnMetadata(): Promise<sql.IColumnMetadata> {
|
|
76
|
+
return queryColumnMetadata(this.transaction, this.qualifiedTableName);
|
|
77
|
+
}
|
|
47
78
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
79
|
+
public async *nextBatch(): AsyncIterableIterator<Record<string, any>> {
|
|
80
|
+
// Opened on the first call, and iterated straight away so that the row iterator receives any query error.
|
|
81
|
+
this.query ??= new StreamingQuery(this.transaction.request(), `SELECT * FROM ${this.qualifiedTableName}`);
|
|
82
|
+
for (let i = 0; i < this.batchSize; i++) {
|
|
83
|
+
// MSSQL only streams one row at a time
|
|
84
|
+
const result = await this.query.rows.next();
|
|
85
|
+
if (result.done) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
yield result.value;
|
|
51
89
|
}
|
|
52
90
|
}
|
|
91
|
+
|
|
92
|
+
public async close(): Promise<void> {
|
|
93
|
+
await this.query?.cancel();
|
|
94
|
+
}
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
/**
|
|
56
98
|
* Performs a table snapshot query, batching by ranges of primary key data.
|
|
57
99
|
*
|
|
58
100
|
* This may miss some rows if they are modified during the snapshot query.
|
|
59
|
-
* In that case, replication will pick up those rows afterward
|
|
60
|
-
* possibly resulting in an IdSnapshotQuery.
|
|
101
|
+
* In that case, replication will pick up those rows afterward.
|
|
61
102
|
*
|
|
62
|
-
* Currently, this only supports
|
|
103
|
+
* Currently, this only supports tables with a single primary key column,
|
|
63
104
|
* of a select few types.
|
|
64
105
|
*/
|
|
65
106
|
export class BatchedSnapshotQuery implements MSSQLSnapshotQuery {
|
|
@@ -93,6 +134,7 @@ export class BatchedSnapshotQuery implements MSSQLSnapshotQuery {
|
|
|
93
134
|
}
|
|
94
135
|
|
|
95
136
|
private readonly key: ColumnDescriptor;
|
|
137
|
+
private query: StreamingQuery | null = null;
|
|
96
138
|
lastKey: string | bigint | null = null;
|
|
97
139
|
|
|
98
140
|
public constructor(
|
|
@@ -109,57 +151,52 @@ export class BatchedSnapshotQuery implements MSSQLSnapshotQuery {
|
|
|
109
151
|
}
|
|
110
152
|
}
|
|
111
153
|
|
|
112
|
-
public async
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
public getLastKeySerialized(): Uint8Array {
|
|
117
|
-
return bson.serialize({ [this.key.name]: this.lastKey });
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
public async *next(): AsyncIterableIterator<sql.IColumnMetadata | sql.IRecordSet<any>> {
|
|
121
|
-
const escapedKeyName = escapeIdentifier(this.key.name);
|
|
122
|
-
const metadataRequest = this.transaction.request();
|
|
123
|
-
metadataRequest.stream = true;
|
|
124
|
-
const metadataPromise = new Promise<sql.IColumnMetadata>((resolve, reject) => {
|
|
125
|
-
metadataRequest.on('recordset', resolve);
|
|
126
|
-
metadataRequest.on('error', reject);
|
|
127
|
-
});
|
|
128
|
-
metadataRequest.query(`SELECT TOP(0) * FROM ${this.qualifiedTableName}`);
|
|
129
|
-
|
|
130
|
-
const columnMetadata: sql.IColumnMetadata = await metadataPromise;
|
|
154
|
+
public async getColumnMetadata(): Promise<sql.IColumnMetadata> {
|
|
155
|
+
const columnMetadata = await queryColumnMetadata(this.transaction, this.qualifiedTableName);
|
|
131
156
|
|
|
132
157
|
const foundPrimaryKey = columnMetadata[this.key.name];
|
|
133
158
|
if (!foundPrimaryKey) {
|
|
134
159
|
throw new Error(
|
|
135
|
-
`Cannot find primary key column ${this.key.name} in results. Keys: ${Object.keys(columnMetadata
|
|
160
|
+
`Cannot find primary key column ${this.key.name} in results. Keys: ${Object.keys(columnMetadata).join(', ')}`
|
|
136
161
|
);
|
|
137
162
|
}
|
|
163
|
+
return columnMetadata;
|
|
164
|
+
}
|
|
138
165
|
|
|
139
|
-
|
|
166
|
+
public getLastKeySerialized(): Uint8Array {
|
|
167
|
+
return bson.serialize({ [this.key.name]: this.lastKey });
|
|
168
|
+
}
|
|
140
169
|
|
|
170
|
+
public async *nextBatch(): AsyncIterableIterator<Record<string, any>> {
|
|
171
|
+
const escapedKeyName = escapeIdentifier(this.key.name);
|
|
141
172
|
const request = this.transaction.request();
|
|
142
|
-
const stream = request.toReadableStream();
|
|
143
173
|
if (this.lastKey == null) {
|
|
144
|
-
|
|
174
|
+
this.query = new StreamingQuery(
|
|
175
|
+
request,
|
|
176
|
+
`SELECT TOP(${this.batchSize}) * FROM ${this.qualifiedTableName} ORDER BY ${escapedKeyName}`
|
|
177
|
+
);
|
|
145
178
|
} else {
|
|
146
179
|
if (this.key.typeId == null) {
|
|
147
180
|
throw new Error(`typeId required for primary key ${this.key.name}`);
|
|
148
181
|
}
|
|
149
|
-
request
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
182
|
+
request.input('lastKey', this.lastKey);
|
|
183
|
+
this.query = new StreamingQuery(
|
|
184
|
+
request,
|
|
185
|
+
`SELECT TOP(${this.batchSize}) * FROM ${this.qualifiedTableName} WHERE ${escapedKeyName} > @lastKey ORDER BY ${escapedKeyName}`
|
|
186
|
+
);
|
|
154
187
|
}
|
|
155
188
|
|
|
156
189
|
// MSSQL only streams one row at a time
|
|
157
|
-
for await (const row of
|
|
190
|
+
for await (const row of this.query.rows) {
|
|
158
191
|
this.lastKey = row[this.key.name];
|
|
159
192
|
yield row;
|
|
160
193
|
}
|
|
161
194
|
}
|
|
162
195
|
|
|
196
|
+
public async close(): Promise<void> {
|
|
197
|
+
await this.query?.cancel();
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
private deserializeKey(key: Uint8Array) {
|
|
164
201
|
const decoded = bson.deserialize(key, { useBigInt64: true });
|
|
165
202
|
const keys = Object.keys(decoded);
|
|
@@ -173,3 +210,11 @@ export class BatchedSnapshotQuery implements MSSQLSnapshotQuery {
|
|
|
173
210
|
return decoded[this.key.name];
|
|
174
211
|
}
|
|
175
212
|
}
|
|
213
|
+
|
|
214
|
+
async function queryColumnMetadata(
|
|
215
|
+
transaction: sql.Transaction,
|
|
216
|
+
qualifiedTableName: string
|
|
217
|
+
): Promise<sql.IColumnMetadata> {
|
|
218
|
+
const { recordset } = await transaction.request().query(`SELECT TOP(0) * FROM ${qualifiedTableName}`);
|
|
219
|
+
return recordset.columns;
|
|
220
|
+
}
|
package/src/utils/mssql.ts
CHANGED
|
@@ -251,28 +251,6 @@ export function toQualifiedTableName(schema: string, tableName: string): string
|
|
|
251
251
|
return `${escapeIdentifier(schema)}.${escapeIdentifier(tableName)}`;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
export function isIColumnMetadata(obj: any): obj is sql.IColumnMetadata {
|
|
255
|
-
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
let propertiesMatched = true;
|
|
260
|
-
for (const value of Object.values(obj)) {
|
|
261
|
-
const property = value as any;
|
|
262
|
-
propertiesMatched =
|
|
263
|
-
typeof property.index === 'number' &&
|
|
264
|
-
typeof property.name === 'string' &&
|
|
265
|
-
(typeof property.length === 'number' || typeof property.length === 'undefined') &&
|
|
266
|
-
(typeof property.type === 'function' || typeof property.type === 'object') &&
|
|
267
|
-
typeof property.nullable === 'boolean' &&
|
|
268
|
-
typeof property.caseSensitive === 'boolean' &&
|
|
269
|
-
typeof property.identity === 'boolean' &&
|
|
270
|
-
typeof property.readOnly === 'boolean';
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return propertiesMatched;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
254
|
export function addParameters(request: sql.Request, parameters: MSSQLParameter[]): sql.Request {
|
|
277
255
|
for (const param of parameters) {
|
|
278
256
|
if (param.type) {
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import { MSSQLConnectionManager } from '@module/replication/MSSQLConnectionManager.js';
|
|
1
2
|
import { getLatestLSN } from '@module/utils/mssql.js';
|
|
3
|
+
import { ReplicationAbortedError } from '@powersync/lib-services-framework';
|
|
2
4
|
import { storage } from '@powersync/service-core';
|
|
3
5
|
import { METRICS_HELPER, putOp, removeOp } from '@powersync/service-core-tests';
|
|
4
6
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
5
7
|
import sql from 'mssql';
|
|
6
|
-
import { describe, expect, test } from 'vitest';
|
|
8
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
7
9
|
import { CDCStreamTestContext } from './CDCStreamTestContext.js';
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createTestTable,
|
|
12
|
+
describeWithStorage,
|
|
13
|
+
enableCDCForTable,
|
|
14
|
+
insertTestData,
|
|
15
|
+
waitForPendingCDCChanges
|
|
16
|
+
} from './util.js';
|
|
9
17
|
|
|
10
18
|
const BASIC_SYNC_RULES = `
|
|
11
19
|
bucket_definitions:
|
|
@@ -14,6 +22,23 @@ bucket_definitions:
|
|
|
14
22
|
- SELECT id, description FROM "test_data"
|
|
15
23
|
`;
|
|
16
24
|
|
|
25
|
+
const COMPOSITE_KEY_SYNC_CONFIG = `
|
|
26
|
+
config:
|
|
27
|
+
edition: 3
|
|
28
|
+
streams:
|
|
29
|
+
global:
|
|
30
|
+
auto_subscribe: true
|
|
31
|
+
query: SELECT id_user AS id, id_user, id_visit FROM test_composite
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
async function getRowsReplicated() {
|
|
35
|
+
return (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function getTransactionsReplicated() {
|
|
39
|
+
return (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
describe('CDCStream tests', () => {
|
|
18
43
|
describeWithStorage({ timeout: 20_000 }, defineCDCStreamTests);
|
|
19
44
|
});
|
|
@@ -30,12 +55,12 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
30
55
|
const beforeLSN = await getLatestLSN(connectionManager);
|
|
31
56
|
const testData = await insertTestData(connectionManager, 'test_data');
|
|
32
57
|
await waitForPendingCDCChanges(beforeLSN, connectionManager);
|
|
33
|
-
const startRowCount =
|
|
58
|
+
const startRowCount = await getRowsReplicated();
|
|
34
59
|
|
|
35
60
|
await context.replicateSnapshot();
|
|
36
61
|
await context.startStreaming();
|
|
37
62
|
|
|
38
|
-
const endRowCount =
|
|
63
|
+
const endRowCount = await getRowsReplicated();
|
|
39
64
|
const data = await context.getBucketData('global[]');
|
|
40
65
|
expect(data).toMatchObject([putOp('test_data', testData)]);
|
|
41
66
|
expect(endRowCount - startRowCount).toEqual(1);
|
|
@@ -49,8 +74,8 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
49
74
|
await createTestTable(connectionManager, 'test_data');
|
|
50
75
|
await context.replicateSnapshot();
|
|
51
76
|
|
|
52
|
-
const startRowCount =
|
|
53
|
-
const startTxCount =
|
|
77
|
+
const startRowCount = await getRowsReplicated();
|
|
78
|
+
const startTxCount = await getTransactionsReplicated();
|
|
54
79
|
|
|
55
80
|
await context.startStreaming();
|
|
56
81
|
|
|
@@ -59,8 +84,8 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
59
84
|
const data = await context.getBucketData('global[]');
|
|
60
85
|
|
|
61
86
|
expect(data).toMatchObject([putOp('test_data', testData)]);
|
|
62
|
-
const endRowCount =
|
|
63
|
-
const endTxCount =
|
|
87
|
+
const endRowCount = await getRowsReplicated();
|
|
88
|
+
const endTxCount = await getTransactionsReplicated();
|
|
64
89
|
expect(endRowCount - startRowCount).toEqual(1);
|
|
65
90
|
expect(endTxCount - startTxCount).toEqual(1);
|
|
66
91
|
});
|
|
@@ -156,8 +181,8 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
156
181
|
|
|
157
182
|
await context.replicateSnapshot();
|
|
158
183
|
|
|
159
|
-
const startRowCount =
|
|
160
|
-
const startTxCount =
|
|
184
|
+
const startRowCount = await getRowsReplicated();
|
|
185
|
+
const startTxCount = await getTransactionsReplicated();
|
|
161
186
|
|
|
162
187
|
await context.startStreaming();
|
|
163
188
|
|
|
@@ -165,8 +190,8 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
165
190
|
const data = await context.getBucketData('global[]');
|
|
166
191
|
|
|
167
192
|
expect(data).toMatchObject([]);
|
|
168
|
-
const endRowCount =
|
|
169
|
-
const endTxCount =
|
|
193
|
+
const endRowCount = await getRowsReplicated();
|
|
194
|
+
const endTxCount = await getTransactionsReplicated();
|
|
170
195
|
|
|
171
196
|
// There was a transaction, but it is not counted since it is not for a table in the sync config
|
|
172
197
|
expect(endRowCount - startRowCount).toEqual(0);
|
|
@@ -187,8 +212,8 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
187
212
|
|
|
188
213
|
await context.replicateSnapshot();
|
|
189
214
|
|
|
190
|
-
const startRowCount =
|
|
191
|
-
const startTxCount =
|
|
215
|
+
const startRowCount = await getRowsReplicated();
|
|
216
|
+
const startTxCount = await getTransactionsReplicated();
|
|
192
217
|
|
|
193
218
|
await context.startStreaming();
|
|
194
219
|
|
|
@@ -196,9 +221,74 @@ function defineCDCStreamTests(config: storage.TestStorageConfig) {
|
|
|
196
221
|
const data = await context.getBucketData('global[]');
|
|
197
222
|
|
|
198
223
|
expect(data).toMatchObject([putOp('test_DATA', testData)]);
|
|
199
|
-
const endRowCount =
|
|
200
|
-
const endTxCount =
|
|
224
|
+
const endRowCount = await getRowsReplicated();
|
|
225
|
+
const endTxCount = await getTransactionsReplicated();
|
|
201
226
|
expect(endRowCount - startRowCount).toEqual(1);
|
|
202
227
|
expect(endTxCount - startTxCount).toBeGreaterThanOrEqual(1);
|
|
203
228
|
});
|
|
229
|
+
|
|
230
|
+
// 250 rows end with a partial batch, 300 rows end with an empty batch.
|
|
231
|
+
test.each([250, 300])(
|
|
232
|
+
'Initial snapshot for a SimpleSnapshotQuery table with %i rows, larger than the snapshot batch size.',
|
|
233
|
+
async (rowCount) => {
|
|
234
|
+
await using context = await CDCStreamTestContext.open(factory, { cdcStreamOptions: { snapshotBatchSize: 100 } });
|
|
235
|
+
const { connectionManager } = context;
|
|
236
|
+
await context.updateSyncRules(COMPOSITE_KEY_SYNC_CONFIG);
|
|
237
|
+
|
|
238
|
+
await createCompositeKeyTable(connectionManager);
|
|
239
|
+
await insertCompositeKeyTableRows(connectionManager, rowCount);
|
|
240
|
+
const startRowCount = await getRowsReplicated();
|
|
241
|
+
|
|
242
|
+
await context.replicateSnapshot();
|
|
243
|
+
await context.startStreaming();
|
|
244
|
+
|
|
245
|
+
const endRowCount = await getRowsReplicated();
|
|
246
|
+
expect(endRowCount - startRowCount).toEqual(rowCount);
|
|
247
|
+
expect(await context.getBucketData('global|0[]')).toHaveLength(rowCount);
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
test('Interrupting initial snapshot for a SimpleSnapshotQuery table between batches', async () => {
|
|
252
|
+
// The snapshot query is still in progress when the snapshot is interrupted between batches.
|
|
253
|
+
// It must be cancelled before the snapshot transaction can be rolled back.
|
|
254
|
+
await using context = await CDCStreamTestContext.open(factory, { cdcStreamOptions: { snapshotBatchSize: 100 } });
|
|
255
|
+
const { connectionManager } = context;
|
|
256
|
+
await context.updateSyncRules(COMPOSITE_KEY_SYNC_CONFIG);
|
|
257
|
+
await createCompositeKeyTable(connectionManager);
|
|
258
|
+
await insertCompositeKeyTableRows(connectionManager, 5000);
|
|
259
|
+
const startRowCount = await getRowsReplicated();
|
|
260
|
+
|
|
261
|
+
const snapshotError = context.replicateSnapshot().then(
|
|
262
|
+
() => null,
|
|
263
|
+
(e) => e
|
|
264
|
+
);
|
|
265
|
+
await vi.waitFor(async () => expect((await getRowsReplicated()) - startRowCount).toBeGreaterThanOrEqual(200), {
|
|
266
|
+
timeout: 10_000,
|
|
267
|
+
interval: 1
|
|
268
|
+
});
|
|
269
|
+
await context.dispose();
|
|
270
|
+
|
|
271
|
+
expect(await snapshotError).toBeInstanceOf(ReplicationAbortedError);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A composite primary key cannot use BatchedSnapshotQuery, so this table is snapshotted with SimpleSnapshotQuery.
|
|
277
|
+
*/
|
|
278
|
+
async function createCompositeKeyTable(connectionManager: MSSQLConnectionManager) {
|
|
279
|
+
await connectionManager.query(`
|
|
280
|
+
CREATE TABLE test_composite (
|
|
281
|
+
id_user INT NOT NULL,
|
|
282
|
+
id_visit INT NOT NULL,
|
|
283
|
+
CONSTRAINT PK_test_composite PRIMARY KEY (id_user, id_visit)
|
|
284
|
+
)`);
|
|
285
|
+
await enableCDCForTable({ connectionManager, table: 'test_composite' });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function insertCompositeKeyTableRows(connectionManager: MSSQLConnectionManager, rowCount: number) {
|
|
289
|
+
const beforeLSN = await getLatestLSN(connectionManager);
|
|
290
|
+
await connectionManager.query(`
|
|
291
|
+
WITH n AS (SELECT 1 AS i UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount})
|
|
292
|
+
INSERT INTO test_composite (id_user, id_visit) SELECT i, i % 10 FROM n OPTION (MAXRECURSION ${rowCount})`);
|
|
293
|
+
await waitForPendingCDCChanges(beforeLSN, connectionManager);
|
|
204
294
|
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
SyncRulesBucketStorage,
|
|
12
12
|
updateSyncRulesFromYaml
|
|
13
13
|
} from '@powersync/service-core';
|
|
14
|
-
import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
|
|
14
|
+
import { bucketRequest, getTestStorage, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
|
|
15
15
|
import timers from 'timers/promises';
|
|
16
16
|
import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
17
17
|
|
|
@@ -64,7 +64,9 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
64
64
|
|
|
65
65
|
async dispose() {
|
|
66
66
|
this.abortController.abort();
|
|
67
|
-
|
|
67
|
+
// Let an interrupted snapshot finish before closing its connections.
|
|
68
|
+
// Its error is handled by the caller of replicateSnapshot().
|
|
69
|
+
await this.snapshotPromise?.catch(() => {});
|
|
68
70
|
await this.streamPromise;
|
|
69
71
|
await this.connectionManager.end();
|
|
70
72
|
await this.factory?.[Symbol.asyncDispose]();
|
|
@@ -86,7 +88,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
86
88
|
updateSyncRulesFromYaml(content, { validate: true, storageVersion: LEGACY_STORAGE_VERSION })
|
|
87
89
|
);
|
|
88
90
|
this.syncRulesContent = replicationStream.syncConfigContent[0];
|
|
89
|
-
this.storage = this.factory
|
|
91
|
+
this.storage = await getTestStorage(this.factory, replicationStream);
|
|
90
92
|
return this.storage!;
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -97,7 +99,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
this.syncRulesContent = syncConfig.content;
|
|
100
|
-
this.storage = syncConfig.
|
|
102
|
+
this.storage = await getTestStorage(this.factory, syncConfig.replicationStream);
|
|
101
103
|
return this.storage!;
|
|
102
104
|
}
|
|
103
105
|
|
|
@@ -108,7 +110,7 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
this.syncRulesContent = syncConfig.content;
|
|
111
|
-
this.storage = syncConfig.
|
|
113
|
+
this.storage = await getTestStorage(this.factory, syncConfig.replicationStream);
|
|
112
114
|
return this.storage!;
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -154,7 +156,8 @@ export class CDCStreamTestContext implements AsyncDisposable {
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
async replicateSnapshot() {
|
|
157
|
-
|
|
159
|
+
this.snapshotPromise = this.cdcStream.initReplication();
|
|
160
|
+
await this.snapshotPromise;
|
|
158
161
|
this.replicationDone = true;
|
|
159
162
|
}
|
|
160
163
|
|