@powersync/service-module-mongodb 0.15.3 → 0.16.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/api/MongoRouteAPIAdapter.js +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +6 -6
  5. package/dist/replication/ChangeStream.js +300 -322
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.js +2 -2
  8. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  9. package/dist/replication/ChangeStreamReplicator.d.ts +1 -1
  10. package/dist/replication/ChangeStreamReplicator.js +1 -1
  11. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  12. package/dist/replication/JsonBufferWriter.d.ts +80 -0
  13. package/dist/replication/JsonBufferWriter.js +342 -0
  14. package/dist/replication/JsonBufferWriter.js.map +1 -0
  15. package/dist/replication/MongoManager.d.ts +1 -1
  16. package/dist/replication/MongoManager.js +1 -1
  17. package/dist/replication/MongoManager.js.map +1 -1
  18. package/dist/replication/MongoRelation.js +4 -0
  19. package/dist/replication/MongoRelation.js.map +1 -1
  20. package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
  21. package/dist/replication/MongoSnapshotQuery.js +6 -3
  22. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  23. package/dist/replication/RawChangeStream.d.ts +55 -0
  24. package/dist/replication/RawChangeStream.js +322 -0
  25. package/dist/replication/RawChangeStream.js.map +1 -0
  26. package/dist/replication/SourceRowConverter.d.ts +46 -0
  27. package/dist/replication/SourceRowConverter.js +42 -0
  28. package/dist/replication/SourceRowConverter.js.map +1 -0
  29. package/dist/replication/bufferToSqlite.d.ts +43 -0
  30. package/dist/replication/bufferToSqlite.js +740 -0
  31. package/dist/replication/bufferToSqlite.js.map +1 -0
  32. package/dist/replication/internal-mongodb-utils.d.ts +0 -12
  33. package/dist/replication/internal-mongodb-utils.js +0 -54
  34. package/dist/replication/internal-mongodb-utils.js.map +1 -1
  35. package/dist/replication/replication-index.d.ts +4 -2
  36. package/dist/replication/replication-index.js +4 -2
  37. package/dist/replication/replication-index.js.map +1 -1
  38. package/dist/replication/replication-utils.d.ts +1 -1
  39. package/dist/types/types.js.map +1 -1
  40. package/package.json +11 -11
  41. package/scripts/benchmark-change-document-json.mts +358 -0
  42. package/scripts/benchmark-change-document.mts +370 -0
  43. package/src/api/MongoRouteAPIAdapter.ts +2 -2
  44. package/src/replication/ChangeStream.ts +348 -371
  45. package/src/replication/ChangeStreamReplicationJob.ts +2 -2
  46. package/src/replication/ChangeStreamReplicator.ts +2 -5
  47. package/src/replication/JsonBufferWriter.ts +390 -0
  48. package/src/replication/MongoManager.ts +2 -2
  49. package/src/replication/MongoRelation.ts +5 -2
  50. package/src/replication/MongoSnapshotQuery.ts +8 -5
  51. package/src/replication/RawChangeStream.ts +460 -0
  52. package/src/replication/SourceRowConverter.ts +65 -0
  53. package/src/replication/bufferToSqlite.ts +944 -0
  54. package/src/replication/internal-mongodb-utils.ts +0 -66
  55. package/src/replication/replication-index.ts +4 -2
  56. package/src/replication/replication-utils.ts +2 -2
  57. package/src/types/types.ts +1 -1
  58. package/test/src/buffer_to_sqlite.test.ts +1146 -0
  59. package/test/src/change_stream.test.ts +49 -3
  60. package/test/src/change_stream_utils.ts +4 -10
  61. package/test/src/mongo_test.test.ts +66 -64
  62. package/test/src/parse_document_id.test.ts +54 -0
  63. package/test/src/raw_change_stream.test.ts +547 -0
  64. package/test/src/resume.test.ts +12 -2
  65. package/test/src/util.ts +56 -3
  66. package/test/tsconfig.json +0 -1
  67. package/tsconfig.scripts.json +13 -0
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/test/src/internal_mongodb_utils.test.ts +0 -103
@@ -10,7 +10,6 @@ import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
10
10
  import { PostImagesOption } from '@module/types/types.js';
11
11
  import { ChangeStreamTestContext } from './change_stream_utils.js';
12
12
  import { describeWithStorage, StorageVersionTestContext, TEST_CONNECTION_OPTIONS } from './util.js';
13
- import { createCheckpoint, STANDALONE_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
14
13
 
15
14
  const BASIC_SYNC_RULES = `
16
15
  bucket_definitions:
@@ -258,6 +257,53 @@ bucket_definitions:
258
257
  expect(data).toMatchObject([test_utils.putOp('test_DATA', { id: test_id, description: 'test1' })]);
259
258
  });
260
259
 
260
+ test('replicating from multiple databases in the same cluster', async () => {
261
+ await using context = await openContext();
262
+ const { client, db } = context;
263
+ const otherDb = client.db(`${db.databaseName}_other_${storageVersion}`);
264
+ await otherDb.dropDatabase();
265
+ await using _ = {
266
+ [Symbol.asyncDispose]: async () => {
267
+ await otherDb.dropDatabase();
268
+ }
269
+ };
270
+
271
+ await context.updateSyncRules(`
272
+ bucket_definitions:
273
+ global:
274
+ data:
275
+ - SELECT _id as id, description FROM "${db.databaseName}"."test_data_default"
276
+ - SELECT _id as id, description FROM "${otherDb.databaseName}"."test_data_other"
277
+ `);
278
+
279
+ await db.createCollection('test_data_default');
280
+ await otherDb.createCollection('test_data_other');
281
+ await context.replicateSnapshot();
282
+ context.startStreaming();
283
+
284
+ const defaultResult = await db.collection('test_data_default').insertOne({ description: 'default db' });
285
+ const otherResult = await otherDb.collection('test_data_other').insertOne({ description: 'other db' });
286
+
287
+ const data = await context.getBucketData('global[]');
288
+
289
+ expect(data).toEqual(
290
+ expect.arrayContaining([
291
+ expect.objectContaining(
292
+ test_utils.putOp('test_data_default', {
293
+ id: defaultResult.insertedId.toHexString(),
294
+ description: 'default db'
295
+ })
296
+ ),
297
+ expect.objectContaining(
298
+ test_utils.putOp('test_data_other', {
299
+ id: otherResult.insertedId.toHexString(),
300
+ description: 'other db'
301
+ })
302
+ )
303
+ ])
304
+ );
305
+ });
306
+
261
307
  test('replicating large values', async () => {
262
308
  await using context = await openContext();
263
309
  const { db } = context;
@@ -450,7 +496,7 @@ bucket_definitions:
450
496
  // The field appears twice in the ChangeStream event, so the total size
451
497
  // is > 16MB.
452
498
 
453
- // We don't actually have this description field in the sync rules,
499
+ // We don't actually have this description field in the sync config,
454
500
  // That causes other issues, not relevant for this specific test.
455
501
  const largeDescription = crypto.randomBytes(12000000 / 2).toString('hex');
456
502
 
@@ -479,7 +525,7 @@ bucket_definitions:
479
525
  });
480
526
  });
481
527
 
482
- test('collection not in sync rules', async () => {
528
+ test('collection not in sync config', async () => {
483
529
  await using context = await openContext();
484
530
  const { db } = context;
485
531
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -9,7 +9,6 @@ import {
9
9
  ProtocolOpId,
10
10
  ReplicationCheckpoint,
11
11
  storage,
12
- STORAGE_VERSION_CONFIG,
13
12
  SyncRulesBucketStorage,
14
13
  TestStorageOptions,
15
14
  updateSyncRulesFromYaml,
@@ -28,7 +27,6 @@ export class ChangeStreamTestContext {
28
27
  private _walStream?: ChangeStream;
29
28
  private abortController = new AbortController();
30
29
  private streamPromise?: Promise<PromiseSettledResult<void>>;
31
- private syncRulesId?: number;
32
30
  private syncRulesContent?: storage.PersistedSyncRulesContent;
33
31
  public storage?: SyncRulesBucketStorage;
34
32
 
@@ -55,17 +53,15 @@ export class ChangeStreamTestContext {
55
53
  }
56
54
 
57
55
  const storageVersion = options?.storageVersion ?? LEGACY_STORAGE_VERSION;
58
- const versionedBuckets = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false;
59
56
 
60
- return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion, versionedBuckets);
57
+ return new ChangeStreamTestContext(f, connectionManager, options?.streamOptions, storageVersion);
61
58
  }
62
59
 
63
60
  constructor(
64
61
  public factory: BucketStorageFactory,
65
62
  public connectionManager: MongoManager,
66
63
  private streamOptions: Partial<ChangeStreamOptions> = {},
67
- private storageVersion: number = LEGACY_STORAGE_VERSION,
68
- private versionedBuckets: boolean = STORAGE_VERSION_CONFIG[storageVersion]?.versionedBuckets ?? false
64
+ private storageVersion: number = LEGACY_STORAGE_VERSION
69
65
  ) {
70
66
  createCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
71
67
  initializeCoreReplicationMetrics(METRICS_HELPER.metricsEngine);
@@ -105,7 +101,6 @@ export class ChangeStreamTestContext {
105
101
  const syncRules = await this.factory.updateSyncRules(
106
102
  updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
107
103
  );
108
- this.syncRulesId = syncRules.id;
109
104
  this.syncRulesContent = syncRules;
110
105
  this.storage = this.factory.getInstance(syncRules);
111
106
  return this.storage!;
@@ -114,10 +109,9 @@ export class ChangeStreamTestContext {
114
109
  async loadNextSyncRules() {
115
110
  const syncRules = await this.factory.getNextSyncRulesContent();
116
111
  if (syncRules == null) {
117
- throw new Error(`Next sync rules not available`);
112
+ throw new Error(`Next sync config not available`);
118
113
  }
119
114
 
120
- this.syncRulesId = syncRules.id;
121
115
  this.syncRulesContent = syncRules;
122
116
  this.storage = this.factory.getInstance(syncRules);
123
117
  return this.storage!;
@@ -125,7 +119,7 @@ export class ChangeStreamTestContext {
125
119
 
126
120
  private getSyncRulesContent(): storage.PersistedSyncRulesContent {
127
121
  if (this.syncRulesContent == null) {
128
- throw new Error('Sync rules not configured - call updateSyncRules() first');
122
+ throw new Error('Sync config not configured - call updateSyncRules() first');
129
123
  }
130
124
  return this.syncRulesContent;
131
125
  }
@@ -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 { ChangeStream } from '@module/replication/ChangeStream.js';
14
- import { constructAfterRecord } from '@module/replication/MongoRelation.js';
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(transformed: SqliteInputRow[]) {
149
- const sqliteValue = transformed.map((e) => applyRowContext(e, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY));
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(transformed: SqliteInputRow[]) {
201
- const sqliteValue = transformed.map((e) => applyRowContext(e, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY));
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
- const transformed = [...ChangeStream.getQueryData(rawResults)];
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(transformed);
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.watch([], {
297
+ const stream = rawChangeStream(db, [{ $changeStream: { fullDocument: 'updateLookup' } }], {
307
298
  maxAwaitTimeMS: 50,
308
- fullDocument: 'updateLookup'
309
- });
310
-
311
- await stream.tryNext();
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 transformed = await getReplicationTx(stream, 6);
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.watch([], {
320
+ const stream = rawChangeStream(db, [{ $changeStream: { fullDocument: 'updateLookup' } }], {
331
321
  maxAwaitTimeMS: 50,
332
- fullDocument: 'updateLookup'
333
- });
334
-
335
- await stream.tryNext();
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 transformed = await getReplicationTx(stream, 6);
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] = [...ChangeStream.getQueryData(rawResults)];
537
+ const [row] = rawResults;
552
538
 
553
- const oldFormat = applyRowContext(row, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY);
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 = applyRowContext(row, new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }));
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 = applyRowContext(
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: mongo.ChangeStream, count: number) {
586
- let transformed: SqliteInputRow[] = [];
587
- for await (const doc of replicationStream) {
588
- // Specifically filter out map_input / map_output collections
589
- if (!(doc as any)?.ns?.coll?.startsWith('test_data')) {
590
- continue;
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
- transformed.push(constructAfterRecord((doc as any).fullDocument));
593
- if (transformed.length == count) {
594
+ if (documents.length == count) {
594
595
  break;
595
596
  }
596
597
  }
597
- transformed.sort((a, b) => Number(a._id) - Number(b._id));
598
- return transformed;
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
+ });