@powersync/service-module-mongodb 0.0.0-dev-20241107065634 → 0.0.0-dev-20241119082750
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 +6 -5
- package/dist/api/MongoRouteAPIAdapter.d.ts +1 -0
- package/dist/api/MongoRouteAPIAdapter.js +54 -21
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +23 -2
- package/dist/replication/ChangeStream.js +178 -42
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +7 -4
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/MongoRelation.js +8 -5
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/replication-utils.d.ts +1 -0
- package/dist/replication/replication-utils.js +1 -0
- package/dist/replication/replication-utils.js.map +1 -1
- package/dist/types/types.d.ts +35 -0
- package/dist/types/types.js +38 -2
- package/dist/types/types.js.map +1 -1
- package/package.json +5 -5
- package/src/api/MongoRouteAPIAdapter.ts +53 -21
- package/src/replication/ChangeStream.ts +277 -121
- package/src/replication/ChangeStreamReplicationJob.ts +6 -4
- package/src/replication/MongoRelation.ts +8 -5
- package/src/replication/replication-utils.ts +2 -1
- package/src/types/types.ts +43 -3
- package/test/src/change_stream.test.ts +442 -231
- package/test/src/change_stream_utils.ts +54 -27
- package/test/src/mongo_test.test.ts +212 -41
- package/test/src/slow_tests.test.ts +109 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -6,30 +6,7 @@ import { MongoManager } from '@module/replication/MongoManager.js';
|
|
|
6
6
|
import { ChangeStream, ChangeStreamOptions } from '@module/replication/ChangeStream.js';
|
|
7
7
|
import * as mongo from 'mongodb';
|
|
8
8
|
import { createCheckpoint } from '@module/replication/MongoRelation.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Tests operating on the mongo change stream need to configure the stream and manage asynchronous
|
|
12
|
-
* replication, which gets a little tricky.
|
|
13
|
-
*
|
|
14
|
-
* This wraps a test in a function that configures all the context, and tears it down afterwards.
|
|
15
|
-
*/
|
|
16
|
-
export function changeStreamTest(
|
|
17
|
-
factory: () => Promise<BucketStorageFactory>,
|
|
18
|
-
test: (context: ChangeStreamTestContext) => Promise<void>
|
|
19
|
-
): () => Promise<void> {
|
|
20
|
-
return async () => {
|
|
21
|
-
const f = await factory();
|
|
22
|
-
const connectionManager = new MongoManager(TEST_CONNECTION_OPTIONS);
|
|
23
|
-
|
|
24
|
-
await clearTestDb(connectionManager.db);
|
|
25
|
-
const context = new ChangeStreamTestContext(f, connectionManager);
|
|
26
|
-
try {
|
|
27
|
-
await test(context);
|
|
28
|
-
} finally {
|
|
29
|
-
await context.dispose();
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
}
|
|
9
|
+
import { NormalizedMongoConnectionConfig } from '@module/types/types.js';
|
|
33
10
|
|
|
34
11
|
export class ChangeStreamTestContext {
|
|
35
12
|
private _walStream?: ChangeStream;
|
|
@@ -37,6 +14,20 @@ export class ChangeStreamTestContext {
|
|
|
37
14
|
private streamPromise?: Promise<void>;
|
|
38
15
|
public storage?: SyncRulesBucketStorage;
|
|
39
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Tests operating on the mongo change stream need to configure the stream and manage asynchronous
|
|
19
|
+
* replication, which gets a little tricky.
|
|
20
|
+
*
|
|
21
|
+
* This configures all the context, and tears it down afterwards.
|
|
22
|
+
*/
|
|
23
|
+
static async open(factory: () => Promise<BucketStorageFactory>, options?: Partial<NormalizedMongoConnectionConfig>) {
|
|
24
|
+
const f = await factory();
|
|
25
|
+
const connectionManager = new MongoManager({ ...TEST_CONNECTION_OPTIONS, ...options });
|
|
26
|
+
|
|
27
|
+
await clearTestDb(connectionManager.db);
|
|
28
|
+
return new ChangeStreamTestContext(f, connectionManager);
|
|
29
|
+
}
|
|
30
|
+
|
|
40
31
|
constructor(
|
|
41
32
|
public factory: BucketStorageFactory,
|
|
42
33
|
public connectionManager: MongoManager
|
|
@@ -48,6 +39,10 @@ export class ChangeStreamTestContext {
|
|
|
48
39
|
await this.connectionManager.destroy();
|
|
49
40
|
}
|
|
50
41
|
|
|
42
|
+
async [Symbol.asyncDispose]() {
|
|
43
|
+
await this.dispose();
|
|
44
|
+
}
|
|
45
|
+
|
|
51
46
|
get client() {
|
|
52
47
|
return this.connectionManager.client;
|
|
53
48
|
}
|
|
@@ -96,7 +91,7 @@ export class ChangeStreamTestContext {
|
|
|
96
91
|
getClientCheckpoint(this.client, this.db, this.factory, { timeout: options?.timeout ?? 15_000 }),
|
|
97
92
|
this.streamPromise
|
|
98
93
|
]);
|
|
99
|
-
if (typeof checkpoint == undefined) {
|
|
94
|
+
if (typeof checkpoint == 'undefined') {
|
|
100
95
|
// This indicates an issue with the test setup - streamingPromise completed instead
|
|
101
96
|
// of getClientCheckpoint()
|
|
102
97
|
throw new Error('Test failure - streamingPromise completed');
|
|
@@ -110,14 +105,32 @@ export class ChangeStreamTestContext {
|
|
|
110
105
|
return fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
|
|
111
106
|
}
|
|
112
107
|
|
|
113
|
-
async getBucketData(
|
|
108
|
+
async getBucketData(
|
|
109
|
+
bucket: string,
|
|
110
|
+
start?: string,
|
|
111
|
+
options?: { timeout?: number; limit?: number; chunkLimitBytes?: number }
|
|
112
|
+
) {
|
|
114
113
|
start ??= '0';
|
|
115
114
|
let checkpoint = await this.getCheckpoint(options);
|
|
116
115
|
const map = new Map<string, string>([[bucket, start]]);
|
|
117
|
-
const batch = this.storage!.getBucketDataBatch(checkpoint, map
|
|
116
|
+
const batch = this.storage!.getBucketDataBatch(checkpoint, map, {
|
|
117
|
+
limit: options?.limit,
|
|
118
|
+
chunkLimitBytes: options?.chunkLimitBytes
|
|
119
|
+
});
|
|
118
120
|
const batches = await fromAsync(batch);
|
|
119
121
|
return batches[0]?.batch.data ?? [];
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
async getChecksums(buckets: string[], options?: { timeout?: number }) {
|
|
125
|
+
let checkpoint = await this.getCheckpoint(options);
|
|
126
|
+
return this.storage!.getChecksums(checkpoint, buckets);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async getChecksum(bucket: string, options?: { timeout?: number }) {
|
|
130
|
+
let checkpoint = await this.getCheckpoint(options);
|
|
131
|
+
const map = await this.storage!.getChecksums(checkpoint, [bucket]);
|
|
132
|
+
return map.get(bucket);
|
|
133
|
+
}
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
export async function getClientCheckpoint(
|
|
@@ -149,3 +162,17 @@ export async function getClientCheckpoint(
|
|
|
149
162
|
|
|
150
163
|
throw new Error(`Timeout while waiting for checkpoint ${lsn}. Last checkpoint: ${lastCp?.lsn}`);
|
|
151
164
|
}
|
|
165
|
+
|
|
166
|
+
export async function setSnapshotHistorySeconds(client: mongo.MongoClient, seconds: number) {
|
|
167
|
+
const { minSnapshotHistoryWindowInSeconds: currentValue } = await client
|
|
168
|
+
.db('admin')
|
|
169
|
+
.command({ getParameter: 1, minSnapshotHistoryWindowInSeconds: 1 });
|
|
170
|
+
|
|
171
|
+
await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: seconds });
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
async [Symbol.asyncDispose]() {
|
|
175
|
+
await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: currentValue });
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
|
|
2
2
|
import { ChangeStream } from '@module/replication/ChangeStream.js';
|
|
3
3
|
import { constructAfterRecord } from '@module/replication/MongoRelation.js';
|
|
4
|
-
import { SqliteRow } from '@powersync/service-sync-rules';
|
|
4
|
+
import { SqliteRow, SqlSyncRules } from '@powersync/service-sync-rules';
|
|
5
5
|
import * as mongo from 'mongodb';
|
|
6
6
|
import { describe, expect, test } from 'vitest';
|
|
7
7
|
import { clearTestDb, connectMongoData, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
8
|
+
import { PostImagesOption } from '@module/types/types.js';
|
|
8
9
|
|
|
9
10
|
describe('mongo data types', () => {
|
|
10
11
|
async function setupTable(db: mongo.Db) {
|
|
@@ -34,7 +35,18 @@ describe('mongo data types', () => {
|
|
|
34
35
|
objectId: mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'),
|
|
35
36
|
regexp: new mongo.BSONRegExp('test', 'i'),
|
|
36
37
|
minKey: new mongo.MinKey(),
|
|
37
|
-
maxKey: new mongo.MaxKey()
|
|
38
|
+
maxKey: new mongo.MaxKey(),
|
|
39
|
+
symbol: new mongo.BSONSymbol('test'),
|
|
40
|
+
js: new mongo.Code('testcode'),
|
|
41
|
+
js2: new mongo.Code('testcode', { foo: 'bar' }),
|
|
42
|
+
pointer: new mongo.DBRef('mycollection', mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb')),
|
|
43
|
+
pointer2: new mongo.DBRef(
|
|
44
|
+
'mycollection',
|
|
45
|
+
mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'),
|
|
46
|
+
'mydb',
|
|
47
|
+
{ foo: 'bar' }
|
|
48
|
+
),
|
|
49
|
+
undefined: undefined
|
|
38
50
|
}
|
|
39
51
|
]);
|
|
40
52
|
}
|
|
@@ -62,7 +74,11 @@ describe('mongo data types', () => {
|
|
|
62
74
|
objectId: [mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb')],
|
|
63
75
|
regexp: [new mongo.BSONRegExp('test', 'i')],
|
|
64
76
|
minKey: [new mongo.MinKey()],
|
|
65
|
-
maxKey: [new mongo.MaxKey()]
|
|
77
|
+
maxKey: [new mongo.MaxKey()],
|
|
78
|
+
symbol: [new mongo.BSONSymbol('test')],
|
|
79
|
+
js: [new mongo.Code('testcode')],
|
|
80
|
+
pointer: [new mongo.DBRef('mycollection', mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'))],
|
|
81
|
+
undefined: [undefined]
|
|
66
82
|
}
|
|
67
83
|
]);
|
|
68
84
|
}
|
|
@@ -97,7 +113,13 @@ describe('mongo data types', () => {
|
|
|
97
113
|
timestamp: 1958505087099n,
|
|
98
114
|
regexp: '{"pattern":"test","options":"i"}',
|
|
99
115
|
minKey: null,
|
|
100
|
-
maxKey: null
|
|
116
|
+
maxKey: null,
|
|
117
|
+
symbol: 'test',
|
|
118
|
+
js: '{"code":"testcode","scope":null}',
|
|
119
|
+
js2: '{"code":"testcode","scope":{"foo":"bar"}}',
|
|
120
|
+
pointer: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}',
|
|
121
|
+
pointer2: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","db":"mydb","fields":{"foo":"bar"}}',
|
|
122
|
+
undefined: null
|
|
101
123
|
});
|
|
102
124
|
}
|
|
103
125
|
|
|
@@ -130,7 +152,14 @@ describe('mongo data types', () => {
|
|
|
130
152
|
expect(transformed[3]).toMatchObject({
|
|
131
153
|
_id: 10n,
|
|
132
154
|
objectId: '["66e834cc91d805df11fa0ecb"]',
|
|
133
|
-
timestamp: '[1958505087099]'
|
|
155
|
+
timestamp: '[1958505087099]',
|
|
156
|
+
regexp: '[{"pattern":"test","options":"i"}]',
|
|
157
|
+
symbol: '["test"]',
|
|
158
|
+
js: '[{"code":"testcode","scope":null}]',
|
|
159
|
+
pointer: '[{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}]',
|
|
160
|
+
minKey: '[null]',
|
|
161
|
+
maxKey: '[null]',
|
|
162
|
+
undefined: '[null]'
|
|
134
163
|
});
|
|
135
164
|
}
|
|
136
165
|
|
|
@@ -217,49 +246,191 @@ describe('mongo data types', () => {
|
|
|
217
246
|
});
|
|
218
247
|
|
|
219
248
|
test('connection schema', async () => {
|
|
220
|
-
|
|
249
|
+
await using adapter = new MongoRouteAPIAdapter({
|
|
221
250
|
type: 'mongodb',
|
|
222
251
|
...TEST_CONNECTION_OPTIONS
|
|
223
252
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
await clearTestDb(db);
|
|
253
|
+
const db = adapter.db;
|
|
254
|
+
await clearTestDb(db);
|
|
227
255
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
const collection = db.collection('test_data');
|
|
257
|
+
await setupTable(db);
|
|
258
|
+
await insert(collection);
|
|
259
|
+
|
|
260
|
+
const schema = await adapter.getConnectionSchema();
|
|
261
|
+
const dbSchema = schema.filter((s) => s.name == TEST_CONNECTION_OPTIONS.database)[0];
|
|
262
|
+
expect(dbSchema).not.toBeNull();
|
|
263
|
+
expect(dbSchema.tables).toMatchObject([
|
|
264
|
+
{
|
|
265
|
+
name: 'test_data',
|
|
266
|
+
columns: [
|
|
267
|
+
{ name: '_id', sqlite_type: 4, internal_type: 'Integer' },
|
|
268
|
+
{ name: 'bool', sqlite_type: 4, internal_type: 'Boolean' },
|
|
269
|
+
{ name: 'bytea', sqlite_type: 1, internal_type: 'Binary' },
|
|
270
|
+
{ name: 'date', sqlite_type: 2, internal_type: 'Date' },
|
|
271
|
+
{ name: 'decimal', sqlite_type: 2, internal_type: 'Decimal' },
|
|
272
|
+
{ name: 'float', sqlite_type: 8, internal_type: 'Double' },
|
|
273
|
+
{ name: 'int2', sqlite_type: 4, internal_type: 'Integer' },
|
|
274
|
+
{ name: 'int4', sqlite_type: 4, internal_type: 'Integer' },
|
|
275
|
+
{ name: 'int8', sqlite_type: 4, internal_type: 'Long' },
|
|
276
|
+
// We can fix these later
|
|
277
|
+
{ name: 'js', sqlite_type: 2, internal_type: 'Object' },
|
|
278
|
+
{ name: 'js2', sqlite_type: 2, internal_type: 'Object' },
|
|
279
|
+
{ name: 'maxKey', sqlite_type: 0, internal_type: 'MaxKey' },
|
|
280
|
+
{ name: 'minKey', sqlite_type: 0, internal_type: 'MinKey' },
|
|
281
|
+
{ name: 'nested', sqlite_type: 2, internal_type: 'Object' },
|
|
282
|
+
{ name: 'null', sqlite_type: 0, internal_type: 'Null' },
|
|
283
|
+
{ name: 'objectId', sqlite_type: 2, internal_type: 'ObjectId' },
|
|
284
|
+
// We can fix these later
|
|
285
|
+
{ name: 'pointer', sqlite_type: 2, internal_type: 'Object' },
|
|
286
|
+
{ name: 'pointer2', sqlite_type: 2, internal_type: 'Object' },
|
|
287
|
+
{ name: 'regexp', sqlite_type: 2, internal_type: 'RegExp' },
|
|
288
|
+
// Can fix this later
|
|
289
|
+
{ name: 'symbol', sqlite_type: 2, internal_type: 'String' },
|
|
290
|
+
{ name: 'text', sqlite_type: 2, internal_type: 'String' },
|
|
291
|
+
{ name: 'timestamp', sqlite_type: 4, internal_type: 'Timestamp' },
|
|
292
|
+
{ name: 'undefined', sqlite_type: 0, internal_type: 'Null' },
|
|
293
|
+
{ name: 'uuid', sqlite_type: 2, internal_type: 'UUID' }
|
|
294
|
+
]
|
|
295
|
+
}
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('validate postImages', async () => {
|
|
300
|
+
await using adapter = new MongoRouteAPIAdapter({
|
|
301
|
+
type: 'mongodb',
|
|
302
|
+
...TEST_CONNECTION_OPTIONS,
|
|
303
|
+
postImages: PostImagesOption.READ_ONLY
|
|
304
|
+
});
|
|
305
|
+
const db = adapter.db;
|
|
306
|
+
await clearTestDb(db);
|
|
307
|
+
|
|
308
|
+
const collection = db.collection('test_data');
|
|
309
|
+
await setupTable(db);
|
|
310
|
+
await insert(collection);
|
|
311
|
+
|
|
312
|
+
const rules = SqlSyncRules.fromYaml(
|
|
313
|
+
`
|
|
314
|
+
bucket_definitions:
|
|
315
|
+
global:
|
|
316
|
+
data:
|
|
317
|
+
- select _id as id, * from test_data
|
|
231
318
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
319
|
+
`,
|
|
320
|
+
{
|
|
321
|
+
...adapter.getParseSyncRulesOptions(),
|
|
322
|
+
// No schema-based validation at this point
|
|
323
|
+
schema: undefined
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
const source_table_patterns = rules.getSourceTables();
|
|
327
|
+
const results = await adapter.getDebugTablesInfo(source_table_patterns, rules);
|
|
328
|
+
|
|
329
|
+
const result = results[0];
|
|
330
|
+
expect(result).not.toBeNull();
|
|
331
|
+
expect(result.table).toMatchObject({
|
|
332
|
+
schema: 'powersync_test_data',
|
|
333
|
+
name: 'test_data',
|
|
334
|
+
replication_id: ['_id'],
|
|
335
|
+
data_queries: true,
|
|
336
|
+
parameter_queries: false,
|
|
337
|
+
errors: [
|
|
236
338
|
{
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
{ name: '_id', sqlite_type: 4, internal_type: 'Integer' },
|
|
240
|
-
{ name: 'bool', sqlite_type: 4, internal_type: 'Boolean' },
|
|
241
|
-
{ name: 'bytea', sqlite_type: 1, internal_type: 'Binary' },
|
|
242
|
-
{ name: 'date', sqlite_type: 2, internal_type: 'Date' },
|
|
243
|
-
{ name: 'decimal', sqlite_type: 2, internal_type: 'Decimal' },
|
|
244
|
-
{ name: 'float', sqlite_type: 8, internal_type: 'Double' },
|
|
245
|
-
{ name: 'int2', sqlite_type: 4, internal_type: 'Integer' },
|
|
246
|
-
{ name: 'int4', sqlite_type: 4, internal_type: 'Integer' },
|
|
247
|
-
{ name: 'int8', sqlite_type: 4, internal_type: 'Long' },
|
|
248
|
-
{ name: 'maxKey', sqlite_type: 0, internal_type: 'MaxKey' },
|
|
249
|
-
{ name: 'minKey', sqlite_type: 0, internal_type: 'MinKey' },
|
|
250
|
-
{ name: 'nested', sqlite_type: 2, internal_type: 'Object' },
|
|
251
|
-
{ name: 'null', sqlite_type: 0, internal_type: 'Null' },
|
|
252
|
-
{ name: 'objectId', sqlite_type: 2, internal_type: 'ObjectId' },
|
|
253
|
-
{ name: 'regexp', sqlite_type: 2, internal_type: 'RegExp' },
|
|
254
|
-
{ name: 'text', sqlite_type: 2, internal_type: 'String' },
|
|
255
|
-
{ name: 'timestamp', sqlite_type: 4, internal_type: 'Timestamp' },
|
|
256
|
-
{ name: 'uuid', sqlite_type: 2, internal_type: 'UUID' }
|
|
257
|
-
]
|
|
339
|
+
level: 'fatal',
|
|
340
|
+
message: 'changeStreamPreAndPostImages not enabled on powersync_test_data.test_data'
|
|
258
341
|
}
|
|
259
|
-
]
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
342
|
+
]
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('validate postImages - auto-configure', async () => {
|
|
347
|
+
await using adapter = new MongoRouteAPIAdapter({
|
|
348
|
+
type: 'mongodb',
|
|
349
|
+
...TEST_CONNECTION_OPTIONS,
|
|
350
|
+
postImages: PostImagesOption.AUTO_CONFIGURE
|
|
351
|
+
});
|
|
352
|
+
const db = adapter.db;
|
|
353
|
+
await clearTestDb(db);
|
|
354
|
+
|
|
355
|
+
const collection = db.collection('test_data');
|
|
356
|
+
await setupTable(db);
|
|
357
|
+
await insert(collection);
|
|
358
|
+
|
|
359
|
+
const rules = SqlSyncRules.fromYaml(
|
|
360
|
+
`
|
|
361
|
+
bucket_definitions:
|
|
362
|
+
global:
|
|
363
|
+
data:
|
|
364
|
+
- select _id as id, * from test_data
|
|
365
|
+
|
|
366
|
+
`,
|
|
367
|
+
{
|
|
368
|
+
...adapter.getParseSyncRulesOptions(),
|
|
369
|
+
// No schema-based validation at this point
|
|
370
|
+
schema: undefined
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
const source_table_patterns = rules.getSourceTables();
|
|
374
|
+
const results = await adapter.getDebugTablesInfo(source_table_patterns, rules);
|
|
375
|
+
|
|
376
|
+
const result = results[0];
|
|
377
|
+
expect(result).not.toBeNull();
|
|
378
|
+
expect(result.table).toMatchObject({
|
|
379
|
+
schema: 'powersync_test_data',
|
|
380
|
+
name: 'test_data',
|
|
381
|
+
replication_id: ['_id'],
|
|
382
|
+
data_queries: true,
|
|
383
|
+
parameter_queries: false,
|
|
384
|
+
errors: [
|
|
385
|
+
{
|
|
386
|
+
level: 'warning',
|
|
387
|
+
message:
|
|
388
|
+
'changeStreamPreAndPostImages not enabled on powersync_test_data.test_data, will be enabled automatically'
|
|
389
|
+
}
|
|
390
|
+
]
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('validate postImages - off', async () => {
|
|
395
|
+
await using adapter = new MongoRouteAPIAdapter({
|
|
396
|
+
type: 'mongodb',
|
|
397
|
+
...TEST_CONNECTION_OPTIONS,
|
|
398
|
+
postImages: PostImagesOption.OFF
|
|
399
|
+
});
|
|
400
|
+
const db = adapter.db;
|
|
401
|
+
await clearTestDb(db);
|
|
402
|
+
|
|
403
|
+
const collection = db.collection('test_data');
|
|
404
|
+
await setupTable(db);
|
|
405
|
+
await insert(collection);
|
|
406
|
+
|
|
407
|
+
const rules = SqlSyncRules.fromYaml(
|
|
408
|
+
`
|
|
409
|
+
bucket_definitions:
|
|
410
|
+
global:
|
|
411
|
+
data:
|
|
412
|
+
- select _id as id, * from test_data
|
|
413
|
+
|
|
414
|
+
`,
|
|
415
|
+
{
|
|
416
|
+
...adapter.getParseSyncRulesOptions(),
|
|
417
|
+
// No schema-based validation at this point
|
|
418
|
+
schema: undefined
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
const source_table_patterns = rules.getSourceTables();
|
|
422
|
+
const results = await adapter.getDebugTablesInfo(source_table_patterns, rules);
|
|
423
|
+
|
|
424
|
+
const result = results[0];
|
|
425
|
+
expect(result).not.toBeNull();
|
|
426
|
+
expect(result.table).toMatchObject({
|
|
427
|
+
schema: 'powersync_test_data',
|
|
428
|
+
name: 'test_data',
|
|
429
|
+
replication_id: ['_id'],
|
|
430
|
+
data_queries: true,
|
|
431
|
+
parameter_queries: false,
|
|
432
|
+
errors: []
|
|
433
|
+
});
|
|
263
434
|
});
|
|
264
435
|
});
|
|
265
436
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { MONGO_STORAGE_FACTORY } from '@core-tests/util.js';
|
|
2
|
+
import { BucketStorageFactory } from '@powersync/service-core';
|
|
3
|
+
import * as mongo from 'mongodb';
|
|
4
|
+
import { setTimeout } from 'node:timers/promises';
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
import { ChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_utils.js';
|
|
7
|
+
import { env } from './env.js';
|
|
8
|
+
|
|
9
|
+
type StorageFactory = () => Promise<BucketStorageFactory>;
|
|
10
|
+
|
|
11
|
+
const BASIC_SYNC_RULES = `
|
|
12
|
+
bucket_definitions:
|
|
13
|
+
global:
|
|
14
|
+
data:
|
|
15
|
+
- SELECT _id as id, description FROM "test_data"
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
describe('change stream slow tests - mongodb', { timeout: 60_000 }, function () {
|
|
19
|
+
if (env.CI || env.SLOW_TESTS) {
|
|
20
|
+
defineSlowTests(MONGO_STORAGE_FACTORY);
|
|
21
|
+
} else {
|
|
22
|
+
// Need something in this file.
|
|
23
|
+
test('no-op', () => {});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function defineSlowTests(factory: StorageFactory) {
|
|
28
|
+
test('replicating snapshot with lots of data', async () => {
|
|
29
|
+
await using context = await ChangeStreamTestContext.open(factory);
|
|
30
|
+
// Test with low minSnapshotHistoryWindowInSeconds, to trigger:
|
|
31
|
+
// > Read timestamp .. is older than the oldest available timestamp.
|
|
32
|
+
// This happened when we had {snapshot: true} in the initial
|
|
33
|
+
// snapshot session.
|
|
34
|
+
await using _ = await setSnapshotHistorySeconds(context.client, 1);
|
|
35
|
+
const { db } = context;
|
|
36
|
+
await context.updateSyncRules(`
|
|
37
|
+
bucket_definitions:
|
|
38
|
+
global:
|
|
39
|
+
data:
|
|
40
|
+
- SELECT _id as id, description, num FROM "test_data1"
|
|
41
|
+
- SELECT _id as id, description, num FROM "test_data2"
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
const collection1 = db.collection('test_data1');
|
|
45
|
+
const collection2 = db.collection('test_data2');
|
|
46
|
+
|
|
47
|
+
let operations: mongo.AnyBulkWriteOperation[] = [];
|
|
48
|
+
for (let i = 0; i < 10_000; i++) {
|
|
49
|
+
operations.push({ insertOne: { document: { description: `pre${i}`, num: i } } });
|
|
50
|
+
}
|
|
51
|
+
await collection1.bulkWrite(operations);
|
|
52
|
+
await collection2.bulkWrite(operations);
|
|
53
|
+
|
|
54
|
+
await context.replicateSnapshot();
|
|
55
|
+
context.startStreaming();
|
|
56
|
+
const checksum = await context.getChecksum('global[]');
|
|
57
|
+
expect(checksum).toMatchObject({
|
|
58
|
+
count: 20_000
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('writes concurrently with snapshot', async () => {
|
|
63
|
+
// If there is an issue with snapshotTime (the start LSN for the
|
|
64
|
+
// changestream), we may miss updates, which this test would
|
|
65
|
+
// hopefully catch.
|
|
66
|
+
|
|
67
|
+
await using context = await ChangeStreamTestContext.open(factory);
|
|
68
|
+
const { db } = context;
|
|
69
|
+
await context.updateSyncRules(`
|
|
70
|
+
bucket_definitions:
|
|
71
|
+
global:
|
|
72
|
+
data:
|
|
73
|
+
- SELECT _id as id, description, num FROM "test_data"
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
const collection = db.collection('test_data');
|
|
77
|
+
|
|
78
|
+
let operations: mongo.AnyBulkWriteOperation[] = [];
|
|
79
|
+
for (let i = 0; i < 5_000; i++) {
|
|
80
|
+
operations.push({ insertOne: { document: { description: `pre${i}`, num: i } } });
|
|
81
|
+
}
|
|
82
|
+
await collection.bulkWrite(operations);
|
|
83
|
+
|
|
84
|
+
const snapshotPromise = context.replicateSnapshot();
|
|
85
|
+
|
|
86
|
+
for (let i = 49; i >= 0; i--) {
|
|
87
|
+
await collection.updateMany(
|
|
88
|
+
{ num: { $gte: i * 100, $lt: i * 100 + 100 } },
|
|
89
|
+
{ $set: { description: 'updated' + i } }
|
|
90
|
+
);
|
|
91
|
+
await setTimeout(20);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await snapshotPromise;
|
|
95
|
+
context.startStreaming();
|
|
96
|
+
|
|
97
|
+
const data = await context.getBucketData('global[]', undefined, { limit: 50_000, chunkLimitBytes: 60_000_000 });
|
|
98
|
+
|
|
99
|
+
const preDocuments = data.filter((d) => JSON.parse(d.data! as string).description.startsWith('pre')).length;
|
|
100
|
+
const updatedDocuments = data.filter((d) => JSON.parse(d.data! as string).description.startsWith('updated')).length;
|
|
101
|
+
|
|
102
|
+
// If the test works properly, preDocuments should be around 2000-3000.
|
|
103
|
+
// The total should be around 9000-9900.
|
|
104
|
+
// However, it is very sensitive to timing, so we allow a wide range.
|
|
105
|
+
// updatedDocuments must be strictly >= 5000, otherwise something broke.
|
|
106
|
+
expect(updatedDocuments).toBeGreaterThanOrEqual(5_000);
|
|
107
|
+
expect(preDocuments).toBeLessThanOrEqual(5_000);
|
|
108
|
+
});
|
|
109
|
+
}
|