@powersync/service-module-mongodb 0.15.4 → 0.17.0
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 +67 -0
- package/dist/api/MongoRouteAPIAdapter.js +12 -21
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +23 -42
- package/dist/replication/ChangeStream.js +363 -600
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js +2 -2
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/JsonBufferWriter.d.ts +80 -0
- package/dist/replication/JsonBufferWriter.js +342 -0
- package/dist/replication/JsonBufferWriter.js.map +1 -0
- package/dist/replication/MongoRelation.d.ts +1 -1
- package/dist/replication/MongoRelation.js +45 -21
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
- package/dist/replication/MongoSnapshotQuery.js +6 -3
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +81 -0
- package/dist/replication/MongoSnapshotter.js +594 -0
- package/dist/replication/MongoSnapshotter.js.map +1 -0
- package/dist/replication/RawChangeStream.d.ts +55 -0
- package/dist/replication/RawChangeStream.js +322 -0
- package/dist/replication/RawChangeStream.js.map +1 -0
- package/dist/replication/SourceRowConverter.d.ts +46 -0
- package/dist/replication/SourceRowConverter.js +42 -0
- package/dist/replication/SourceRowConverter.js.map +1 -0
- package/dist/replication/bufferToSqlite.d.ts +43 -0
- package/dist/replication/bufferToSqlite.js +740 -0
- package/dist/replication/bufferToSqlite.js.map +1 -0
- package/dist/replication/internal-mongodb-utils.d.ts +0 -12
- package/dist/replication/internal-mongodb-utils.js +0 -54
- package/dist/replication/internal-mongodb-utils.js.map +1 -1
- package/dist/replication/replication-index.d.ts +2 -0
- package/dist/replication/replication-index.js +2 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/package.json +11 -11
- package/scripts/benchmark-change-document-json.mts +358 -0
- package/scripts/benchmark-change-document.mts +370 -0
- package/src/api/MongoRouteAPIAdapter.ts +13 -21
- package/src/replication/ChangeStream.ts +421 -720
- package/src/replication/ChangeStreamReplicationJob.ts +2 -2
- package/src/replication/JsonBufferWriter.ts +390 -0
- package/src/replication/MongoRelation.ts +54 -25
- package/src/replication/MongoSnapshotQuery.ts +8 -5
- package/src/replication/MongoSnapshotter.ts +729 -0
- package/src/replication/RawChangeStream.ts +460 -0
- package/src/replication/SourceRowConverter.ts +65 -0
- package/src/replication/bufferToSqlite.ts +944 -0
- package/src/replication/internal-mongodb-utils.ts +0 -65
- package/src/replication/replication-index.ts +2 -0
- package/test/src/buffer_to_sqlite.test.ts +1146 -0
- package/test/src/change_stream.test.ts +259 -19
- package/test/src/change_stream_utils.ts +28 -27
- package/test/src/checkpoint_retry.test.ts +131 -0
- package/test/src/mongo_test.test.ts +66 -64
- package/test/src/parse_document_id.test.ts +54 -0
- package/test/src/raw_change_stream.test.ts +547 -0
- package/test/src/resume.test.ts +12 -2
- package/test/src/resuming_snapshots.test.ts +10 -6
- package/test/src/util.ts +56 -3
- package/test/tsconfig.json +0 -1
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/internal_mongodb_utils.test.ts +0 -103
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import {
|
|
3
|
-
applyRowContext,
|
|
4
3
|
CompatibilityContext,
|
|
5
4
|
CompatibilityEdition,
|
|
6
|
-
SqliteInputRow,
|
|
7
5
|
SqlSyncRules,
|
|
8
6
|
TimeValuePrecision
|
|
9
7
|
} from '@powersync/service-sync-rules';
|
|
10
8
|
import { describe, expect, test } from 'vitest';
|
|
11
9
|
|
|
12
10
|
import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { parseDocumentId } from '@module/replication/bufferToSqlite.js';
|
|
12
|
+
import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from '@module/replication/RawChangeStream.js';
|
|
13
|
+
import { DirectSourceRowConverter } from '@module/replication/SourceRowConverter.js';
|
|
15
14
|
import { PostImagesOption } from '@module/types/types.js';
|
|
16
15
|
import { clearTestDb, connectMongoData, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
17
16
|
|
|
18
17
|
describe('mongo data types', () => {
|
|
18
|
+
// These test the full data cycle by writing to mongodb, then checking the change stream and direct collection queries.
|
|
19
|
+
// More direct tests directly on the BSON values are in buffer_to_sqlite.test.ts.
|
|
20
|
+
|
|
19
21
|
async function setupTable(db: mongo.Db) {
|
|
20
22
|
await clearTestDb(db);
|
|
21
23
|
}
|
|
@@ -46,14 +48,7 @@ describe('mongo data types', () => {
|
|
|
46
48
|
maxKey: new mongo.MaxKey(),
|
|
47
49
|
symbol: new mongo.BSONSymbol('test'),
|
|
48
50
|
js: new mongo.Code('testcode'),
|
|
49
|
-
js2: new mongo.Code('testcode', { foo: 'bar' })
|
|
50
|
-
pointer: new mongo.DBRef('mycollection', mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb')),
|
|
51
|
-
pointer2: new mongo.DBRef(
|
|
52
|
-
'mycollection',
|
|
53
|
-
mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'),
|
|
54
|
-
'mydb',
|
|
55
|
-
{ foo: 'bar' }
|
|
56
|
-
)
|
|
51
|
+
js2: new mongo.Code('testcode', { foo: 'bar' })
|
|
57
52
|
},
|
|
58
53
|
{
|
|
59
54
|
_id: 6 as any,
|
|
@@ -139,14 +134,14 @@ describe('mongo data types', () => {
|
|
|
139
134
|
maxKey: [new mongo.MaxKey()],
|
|
140
135
|
symbol: [new mongo.BSONSymbol('test')],
|
|
141
136
|
js: [new mongo.Code('testcode')],
|
|
142
|
-
pointer: [new mongo.DBRef('mycollection', mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'))],
|
|
143
137
|
undefined: [undefined]
|
|
144
138
|
}
|
|
145
139
|
]);
|
|
146
140
|
}
|
|
147
141
|
|
|
148
|
-
function checkResults(
|
|
149
|
-
const
|
|
142
|
+
function checkResults(documents: Buffer[]) {
|
|
143
|
+
const converter = new DirectSourceRowConverter(CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY);
|
|
144
|
+
const sqliteValue = documents.map((d) => converter.rawToSqliteRow(d).row);
|
|
150
145
|
|
|
151
146
|
expect(sqliteValue[0]).toMatchObject({
|
|
152
147
|
_id: 1n,
|
|
@@ -180,9 +175,7 @@ describe('mongo data types', () => {
|
|
|
180
175
|
maxKey: null,
|
|
181
176
|
symbol: 'test',
|
|
182
177
|
js: '{"code":"testcode","scope":null}',
|
|
183
|
-
js2: '{"code":"testcode","scope":{"foo":"bar"}}'
|
|
184
|
-
pointer: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}',
|
|
185
|
-
pointer2: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","db":"mydb","fields":{"foo":"bar"}}'
|
|
178
|
+
js2: '{"code":"testcode","scope":{"foo":"bar"}}'
|
|
186
179
|
});
|
|
187
180
|
|
|
188
181
|
// This must specifically be null, and not undefined.
|
|
@@ -197,8 +190,9 @@ describe('mongo data types', () => {
|
|
|
197
190
|
});
|
|
198
191
|
}
|
|
199
192
|
|
|
200
|
-
function checkResultsNested(
|
|
201
|
-
const
|
|
193
|
+
function checkResultsNested(documents: Buffer[]) {
|
|
194
|
+
const converter = new DirectSourceRowConverter(CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY);
|
|
195
|
+
const sqliteValue = documents.map((d) => converter.rawToSqliteRow(d).row);
|
|
202
196
|
|
|
203
197
|
expect(sqliteValue[0]).toMatchObject({
|
|
204
198
|
_id: 1n,
|
|
@@ -245,7 +239,6 @@ describe('mongo data types', () => {
|
|
|
245
239
|
regexp: '[{"pattern":"test","options":"i"}]',
|
|
246
240
|
symbol: '["test"]',
|
|
247
241
|
js: '[{"code":"testcode","scope":null}]',
|
|
248
|
-
pointer: '[{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}]',
|
|
249
242
|
minKey: '[null]',
|
|
250
243
|
maxKey: '[null]'
|
|
251
244
|
});
|
|
@@ -262,12 +255,11 @@ describe('mongo data types', () => {
|
|
|
262
255
|
|
|
263
256
|
const rawResults = await db
|
|
264
257
|
.collection('test_data')
|
|
265
|
-
.find({}, { sort: { _id: 1 } })
|
|
258
|
+
.find<Buffer>({}, { sort: { _id: 1 }, raw: true })
|
|
266
259
|
.toArray();
|
|
267
260
|
// It is tricky to save "undefined" with mongo, so we check that it succeeded.
|
|
268
|
-
expect(rawResults[4].undefined).toBeUndefined();
|
|
269
|
-
|
|
270
|
-
checkResults(transformed);
|
|
261
|
+
expect(mongo.BSON.deserialize(rawResults[4]).undefined).toBeUndefined();
|
|
262
|
+
checkResults(rawResults);
|
|
271
263
|
} finally {
|
|
272
264
|
await client.close();
|
|
273
265
|
}
|
|
@@ -284,12 +276,11 @@ describe('mongo data types', () => {
|
|
|
284
276
|
|
|
285
277
|
const rawResults = await db
|
|
286
278
|
.collection('test_data_arrays')
|
|
287
|
-
.find({}, { sort: { _id: 1 } })
|
|
279
|
+
.find<Buffer>({}, { sort: { _id: 1 }, raw: true })
|
|
288
280
|
.toArray();
|
|
289
|
-
expect(rawResults[3].undefined).toEqual([undefined]);
|
|
290
|
-
const transformed = [...ChangeStream.getQueryData(rawResults)];
|
|
281
|
+
expect(mongo.BSON.deserialize(rawResults[3]).undefined).toEqual([undefined]);
|
|
291
282
|
|
|
292
|
-
checkResultsNested(
|
|
283
|
+
checkResultsNested(rawResults);
|
|
293
284
|
} finally {
|
|
294
285
|
await client.close();
|
|
295
286
|
}
|
|
@@ -303,19 +294,18 @@ describe('mongo data types', () => {
|
|
|
303
294
|
try {
|
|
304
295
|
await setupTable(db);
|
|
305
296
|
|
|
306
|
-
const stream = db
|
|
297
|
+
const stream = rawChangeStream(db, [{ $changeStream: { fullDocument: 'updateLookup' } }], {
|
|
307
298
|
maxAwaitTimeMS: 50,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
await stream.
|
|
299
|
+
maxTimeMS: 1000,
|
|
300
|
+
batchSize: 10
|
|
301
|
+
})[Symbol.asyncIterator]();
|
|
302
|
+
await stream.next();
|
|
312
303
|
|
|
313
304
|
await insert(collection);
|
|
314
305
|
await insertUndefined(db, 'test_data');
|
|
315
306
|
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
checkResults(transformed);
|
|
307
|
+
const documents = await getReplicationTx(stream, 6);
|
|
308
|
+
checkResults(documents);
|
|
319
309
|
} finally {
|
|
320
310
|
await client.close();
|
|
321
311
|
}
|
|
@@ -327,19 +317,18 @@ describe('mongo data types', () => {
|
|
|
327
317
|
try {
|
|
328
318
|
await setupTable(db);
|
|
329
319
|
|
|
330
|
-
const stream = db
|
|
320
|
+
const stream = rawChangeStream(db, [{ $changeStream: { fullDocument: 'updateLookup' } }], {
|
|
331
321
|
maxAwaitTimeMS: 50,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
await stream.
|
|
322
|
+
maxTimeMS: 1000,
|
|
323
|
+
batchSize: 10
|
|
324
|
+
})[Symbol.asyncIterator]();
|
|
325
|
+
await stream.next();
|
|
336
326
|
|
|
337
327
|
await insertNested(collection);
|
|
338
328
|
await insertUndefined(db, 'test_data_arrays', true);
|
|
339
329
|
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
checkResultsNested(transformed);
|
|
330
|
+
const documents = await getReplicationTx(stream, 6);
|
|
331
|
+
checkResultsNested(documents);
|
|
343
332
|
} finally {
|
|
344
333
|
await client.close();
|
|
345
334
|
}
|
|
@@ -382,9 +371,6 @@ describe('mongo data types', () => {
|
|
|
382
371
|
{ name: 'nested', sqlite_type: 2, internal_type: 'Object' },
|
|
383
372
|
{ name: 'null', sqlite_type: 0, internal_type: 'Null' },
|
|
384
373
|
{ name: 'objectId', sqlite_type: 2, internal_type: 'ObjectId' },
|
|
385
|
-
// We can fix these later
|
|
386
|
-
{ name: 'pointer', sqlite_type: 2, internal_type: 'Object' },
|
|
387
|
-
{ name: 'pointer2', sqlite_type: 2, internal_type: 'Object' },
|
|
388
374
|
{ name: 'regexp', sqlite_type: 2, internal_type: 'RegExp' },
|
|
389
375
|
// Can fix this later
|
|
390
376
|
{ name: 'symbol', sqlite_type: 2, internal_type: 'String' },
|
|
@@ -548,27 +534,31 @@ bucket_definitions:
|
|
|
548
534
|
.collection('test_data')
|
|
549
535
|
.find({}, { sort: { _id: 1 } })
|
|
550
536
|
.toArray();
|
|
551
|
-
const [row] =
|
|
537
|
+
const [row] = rawResults;
|
|
552
538
|
|
|
553
|
-
const oldFormat =
|
|
539
|
+
const oldFormat = new DirectSourceRowConverter(
|
|
540
|
+
CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY
|
|
541
|
+
).documentToSqliteRow(row);
|
|
554
542
|
expect(oldFormat).toMatchObject({
|
|
555
543
|
fraction: '2023-03-06 13:47:01.123Z',
|
|
556
544
|
noFraction: '2023-03-06 13:47:01.000Z'
|
|
557
545
|
});
|
|
558
546
|
|
|
559
|
-
const newFormat =
|
|
547
|
+
const newFormat = new DirectSourceRowConverter(
|
|
548
|
+
new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS })
|
|
549
|
+
).documentToSqliteRow(row);
|
|
560
550
|
expect(newFormat).toMatchObject({
|
|
561
551
|
fraction: '2023-03-06T13:47:01.123Z',
|
|
562
552
|
noFraction: '2023-03-06T13:47:01.000Z'
|
|
563
553
|
});
|
|
564
554
|
|
|
565
|
-
const reducedPrecisionFormat =
|
|
566
|
-
row,
|
|
555
|
+
const reducedPrecisionFormat = new DirectSourceRowConverter(
|
|
567
556
|
new CompatibilityContext({
|
|
568
557
|
edition: CompatibilityEdition.SYNC_STREAMS,
|
|
569
558
|
maxTimeValuePrecision: TimeValuePrecision.seconds
|
|
570
559
|
})
|
|
571
|
-
);
|
|
560
|
+
).documentToSqliteRow(row);
|
|
561
|
+
|
|
572
562
|
expect(reducedPrecisionFormat).toMatchObject({
|
|
573
563
|
fraction: '2023-03-06T13:47:01Z',
|
|
574
564
|
noFraction: '2023-03-06T13:47:01Z'
|
|
@@ -582,18 +572,30 @@ bucket_definitions:
|
|
|
582
572
|
/**
|
|
583
573
|
* Return all the inserts from the first transaction in the replication stream.
|
|
584
574
|
*/
|
|
585
|
-
async function getReplicationTx(replicationStream:
|
|
586
|
-
let
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
|
|
575
|
+
async function getReplicationTx(replicationStream: AsyncIterator<ChangeStreamBatch>, count: number): Promise<Buffer[]> {
|
|
576
|
+
let documents: Buffer[] = [];
|
|
577
|
+
while (true) {
|
|
578
|
+
const result = await replicationStream.next();
|
|
579
|
+
if (result.done) {
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
const batch = result.value;
|
|
583
|
+
for (let buffer of batch.events) {
|
|
584
|
+
const doc = parseChangeDocument(buffer);
|
|
585
|
+
// Specifically filter out map_input / map_output collections
|
|
586
|
+
if (!doc?.ns?.coll?.startsWith('test_data')) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
documents.push((doc as any).fullDocument);
|
|
590
|
+
if (documents.length == count) {
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
591
593
|
}
|
|
592
|
-
|
|
593
|
-
if (transformed.length == count) {
|
|
594
|
+
if (documents.length == count) {
|
|
594
595
|
break;
|
|
595
596
|
}
|
|
596
597
|
}
|
|
597
|
-
|
|
598
|
-
|
|
598
|
+
replicationStream.return?.();
|
|
599
|
+
documents.sort((a, b) => Number(parseDocumentId(a).id) - Number(parseDocumentId(b).id));
|
|
600
|
+
return documents;
|
|
599
601
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parseDocumentId } from '@module/replication/bufferToSqlite.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { bson } from '@powersync/service-core';
|
|
4
|
+
import { describe, expect, test } from 'vitest';
|
|
5
|
+
|
|
6
|
+
function expectSameId(actual: any, expected: any) {
|
|
7
|
+
expect(actual).toEqual(expected);
|
|
8
|
+
expect(bson.serialize({ id: actual })).toEqual(bson.serialize({ id: expected }));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('parseDocumentId', () => {
|
|
12
|
+
test('matches naive bson.deserialize for representative _id values', () => {
|
|
13
|
+
const ids = [
|
|
14
|
+
1,
|
|
15
|
+
2n,
|
|
16
|
+
1 / Math.PI,
|
|
17
|
+
'abc',
|
|
18
|
+
{ a: 1, b: { c: 2 } },
|
|
19
|
+
new mongo.ObjectId('0123456789abcdef01234567'),
|
|
20
|
+
mongo.Timestamp.fromBits(123, 456),
|
|
21
|
+
new mongo.Binary(Buffer.from([1, 2, 3]), 0),
|
|
22
|
+
mongo.Decimal128.fromString('1234.5678'),
|
|
23
|
+
new mongo.MinKey(),
|
|
24
|
+
new mongo.MaxKey()
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const id of ids) {
|
|
28
|
+
const source = bson.serialize({ before: 'x', _id: id, after: 1 }) as Buffer;
|
|
29
|
+
const parsed = parseDocumentId(source);
|
|
30
|
+
const naive = bson.deserialize(source, { useBigInt64: true })._id;
|
|
31
|
+
|
|
32
|
+
expectSameId(parsed.id, naive);
|
|
33
|
+
expect(parsed.idBuffer).toEqual(bson.serialize({ _id: naive }));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('finds _id regardless of field position', () => {
|
|
38
|
+
const id = { a: 1, b: 'two' };
|
|
39
|
+
const docs = [
|
|
40
|
+
{ _id: id, other: true },
|
|
41
|
+
{ other: true, _id: id },
|
|
42
|
+
{ first: 1, second: 'x', _id: id, third: false }
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const doc of docs) {
|
|
46
|
+
const source = bson.serialize(doc) as Buffer;
|
|
47
|
+
const parsed = parseDocumentId(source);
|
|
48
|
+
const naive = bson.deserialize(source, { useBigInt64: true })._id;
|
|
49
|
+
|
|
50
|
+
expectSameId(parsed.id, naive);
|
|
51
|
+
expect(parsed.idBuffer).toEqual(bson.serialize({ _id: naive }));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|