@powersync/service-module-mongodb 0.0.0-dev-20250122110924 → 0.0.0-dev-20250227082606

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 (39) hide show
  1. package/CHANGELOG.md +96 -11
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -1
  3. package/dist/api/MongoRouteAPIAdapter.js +39 -0
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/MongoLSN.d.ts +31 -0
  6. package/dist/common/MongoLSN.js +47 -0
  7. package/dist/common/MongoLSN.js.map +1 -0
  8. package/dist/module/MongoModule.js +2 -2
  9. package/dist/module/MongoModule.js.map +1 -1
  10. package/dist/replication/ChangeStream.d.ts +4 -3
  11. package/dist/replication/ChangeStream.js +130 -52
  12. package/dist/replication/ChangeStream.js.map +1 -1
  13. package/dist/replication/ChangeStreamReplicationJob.js +7 -6
  14. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  15. package/dist/replication/ChangeStreamReplicator.js +1 -0
  16. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  17. package/dist/replication/ConnectionManagerFactory.js +2 -0
  18. package/dist/replication/ConnectionManagerFactory.js.map +1 -1
  19. package/dist/replication/MongoErrorRateLimiter.js +5 -7
  20. package/dist/replication/MongoErrorRateLimiter.js.map +1 -1
  21. package/dist/replication/MongoManager.d.ts +0 -3
  22. package/dist/replication/MongoManager.js +9 -4
  23. package/dist/replication/MongoManager.js.map +1 -1
  24. package/dist/replication/MongoRelation.d.ts +4 -2
  25. package/dist/replication/MongoRelation.js +10 -15
  26. package/dist/replication/MongoRelation.js.map +1 -1
  27. package/package.json +10 -10
  28. package/src/api/MongoRouteAPIAdapter.ts +41 -1
  29. package/src/common/MongoLSN.ts +74 -0
  30. package/src/replication/ChangeStream.ts +138 -57
  31. package/src/replication/ChangeStreamReplicationJob.ts +6 -6
  32. package/src/replication/MongoManager.ts +4 -3
  33. package/src/replication/MongoRelation.ts +10 -16
  34. package/test/src/change_stream.test.ts +6 -0
  35. package/test/src/change_stream_utils.ts +9 -8
  36. package/test/src/mongo_test.test.ts +47 -12
  37. package/test/src/resume.test.ts +152 -0
  38. package/test/src/util.ts +2 -1
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -47,6 +47,13 @@ describe('mongo data types', () => {
47
47
  'mydb',
48
48
  { foo: 'bar' }
49
49
  )
50
+ },
51
+ {
52
+ _id: 6 as any,
53
+ int4: -1,
54
+ int8: -9007199254740993n,
55
+ float: -3.14,
56
+ decimal: new mongo.Decimal128('-3.14')
50
57
  }
51
58
  ]);
52
59
  }
@@ -109,6 +116,13 @@ describe('mongo data types', () => {
109
116
  },
110
117
  { _id: 2 as any, nested: [{ test: 'thing' }] },
111
118
  { _id: 3 as any, date: [new Date('2023-03-06 15:47+02')] },
119
+ {
120
+ _id: 6 as any,
121
+ int4: [-1],
122
+ int8: [-9007199254740993n],
123
+ float: [-3.14],
124
+ decimal: [new mongo.Decimal128('-3.14')]
125
+ },
112
126
  {
113
127
  _id: 10 as any,
114
128
  timestamp: [mongo.Timestamp.fromBits(123, 456)],
@@ -164,6 +178,14 @@ describe('mongo data types', () => {
164
178
 
165
179
  // This must specifically be null, and not undefined.
166
180
  expect(transformed[4].undefined).toBeNull();
181
+
182
+ expect(transformed[5]).toMatchObject({
183
+ _id: 6n,
184
+ int4: -1n,
185
+ int8: -9007199254740993n,
186
+ float: -3.14,
187
+ decimal: '-3.14'
188
+ });
167
189
  }
168
190
 
169
191
  function checkResultsNested(transformed: Record<string, any>[]) {
@@ -193,6 +215,19 @@ describe('mongo data types', () => {
193
215
  });
194
216
 
195
217
  expect(transformed[3]).toMatchObject({
218
+ _id: 5n,
219
+ undefined: '[null]'
220
+ });
221
+
222
+ expect(transformed[4]).toMatchObject({
223
+ _id: 6n,
224
+ int4: '[-1]',
225
+ int8: '[-9007199254740993]',
226
+ float: '[-3.14]',
227
+ decimal: '["-3.14"]'
228
+ });
229
+
230
+ expect(transformed[5]).toMatchObject({
196
231
  _id: 10n,
197
232
  objectId: '["66e834cc91d805df11fa0ecb"]',
198
233
  timestamp: '[1958505087099]',
@@ -203,10 +238,6 @@ describe('mongo data types', () => {
203
238
  minKey: '[null]',
204
239
  maxKey: '[null]'
205
240
  });
206
-
207
- expect(transformed[4]).toMatchObject({
208
- undefined: '[null]'
209
- });
210
241
  }
211
242
 
212
243
  test('test direct queries', async () => {
@@ -218,11 +249,13 @@ describe('mongo data types', () => {
218
249
  await insert(collection);
219
250
  await insertUndefined(db, 'test_data');
220
251
 
221
- const rawResults = await db.collection('test_data').find().toArray();
252
+ const rawResults = await db
253
+ .collection('test_data')
254
+ .find({}, { sort: { _id: 1 } })
255
+ .toArray();
222
256
  // It is tricky to save "undefined" with mongo, so we check that it succeeded.
223
257
  expect(rawResults[4].undefined).toBeUndefined();
224
258
  const transformed = [...ChangeStream.getQueryData(rawResults)];
225
-
226
259
  checkResults(transformed);
227
260
  } finally {
228
261
  await client.close();
@@ -238,8 +271,11 @@ describe('mongo data types', () => {
238
271
  await insertNested(collection);
239
272
  await insertUndefined(db, 'test_data_arrays', true);
240
273
 
241
- const rawResults = await db.collection('test_data_arrays').find().toArray();
242
- expect(rawResults[4].undefined).toEqual([undefined]);
274
+ const rawResults = await db
275
+ .collection('test_data_arrays')
276
+ .find({}, { sort: { _id: 1 } })
277
+ .toArray();
278
+ expect(rawResults[3].undefined).toEqual([undefined]);
243
279
  const transformed = [...ChangeStream.getQueryData(rawResults)];
244
280
 
245
281
  checkResultsNested(transformed);
@@ -257,7 +293,6 @@ describe('mongo data types', () => {
257
293
  await setupTable(db);
258
294
 
259
295
  const stream = db.watch([], {
260
- useBigInt64: true,
261
296
  maxAwaitTimeMS: 50,
262
297
  fullDocument: 'updateLookup'
263
298
  });
@@ -267,7 +302,7 @@ describe('mongo data types', () => {
267
302
  await insert(collection);
268
303
  await insertUndefined(db, 'test_data');
269
304
 
270
- const transformed = await getReplicationTx(stream, 5);
305
+ const transformed = await getReplicationTx(stream, 6);
271
306
 
272
307
  checkResults(transformed);
273
308
  } finally {
@@ -282,7 +317,6 @@ describe('mongo data types', () => {
282
317
  await setupTable(db);
283
318
 
284
319
  const stream = db.watch([], {
285
- useBigInt64: true,
286
320
  maxAwaitTimeMS: 50,
287
321
  fullDocument: 'updateLookup'
288
322
  });
@@ -292,7 +326,7 @@ describe('mongo data types', () => {
292
326
  await insertNested(collection);
293
327
  await insertUndefined(db, 'test_data_arrays', true);
294
328
 
295
- const transformed = await getReplicationTx(stream, 5);
329
+ const transformed = await getReplicationTx(stream, 6);
296
330
 
297
331
  checkResultsNested(transformed);
298
332
  } finally {
@@ -505,5 +539,6 @@ async function getReplicationTx(replicationStream: mongo.ChangeStream, count: nu
505
539
  break;
506
540
  }
507
541
  }
542
+ transformed.sort((a, b) => Number(a._id) - Number(b._id));
508
543
  return transformed;
509
544
  }
@@ -0,0 +1,152 @@
1
+ import { MongoLSN, ZERO_LSN } from '@module/common/MongoLSN.js';
2
+
3
+ import { MongoManager } from '@module/replication/MongoManager.js';
4
+ import { normalizeConnectionConfig } from '@module/types/types.js';
5
+ import { isMongoServerError, mongo } from '@powersync/lib-service-mongodb';
6
+ import { BucketStorageFactory, TestStorageOptions } from '@powersync/service-core';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { ChangeStreamTestContext } from './change_stream_utils.js';
9
+ import { env } from './env.js';
10
+ import { INITIALIZED_MONGO_STORAGE_FACTORY, INITIALIZED_POSTGRES_STORAGE_FACTORY } from './util.js';
11
+
12
+ describe('mongo lsn', () => {
13
+ test('LSN with resume tokens should be comparable', () => {
14
+ // Values without a resume token should be comparable
15
+ expect(
16
+ new MongoLSN({
17
+ timestamp: mongo.Timestamp.fromNumber(1)
18
+ }).comparable <
19
+ new MongoLSN({
20
+ timestamp: mongo.Timestamp.fromNumber(10)
21
+ }).comparable
22
+ ).true;
23
+
24
+ // Values with resume tokens should correctly compare
25
+ expect(
26
+ new MongoLSN({
27
+ timestamp: mongo.Timestamp.fromNumber(1),
28
+ resume_token: { _data: 'resume1' }
29
+ }).comparable <
30
+ new MongoLSN({
31
+ timestamp: mongo.Timestamp.fromNumber(10),
32
+ resume_token: { _data: 'resume2' }
33
+ }).comparable
34
+ ).true;
35
+
36
+ // The resume token should not affect comparison
37
+ expect(
38
+ new MongoLSN({
39
+ timestamp: mongo.Timestamp.fromNumber(1),
40
+ resume_token: { _data: '2' }
41
+ }).comparable <
42
+ new MongoLSN({
43
+ timestamp: mongo.Timestamp.fromNumber(10),
44
+ resume_token: { _data: '1' }
45
+ }).comparable
46
+ ).true;
47
+
48
+ // Resume token should not be required for comparison
49
+ expect(
50
+ new MongoLSN({
51
+ timestamp: mongo.Timestamp.fromNumber(10),
52
+ resume_token: { _data: '2' }
53
+ }).comparable > // Switching the order to test this case
54
+ new MongoLSN({
55
+ timestamp: mongo.Timestamp.fromNumber(9)
56
+ }).comparable
57
+ ).true;
58
+
59
+ // Comparison should be backwards compatible with old LSNs
60
+ expect(
61
+ new MongoLSN({
62
+ timestamp: mongo.Timestamp.fromNumber(10),
63
+ resume_token: { _data: '2' }
64
+ }).comparable > ZERO_LSN
65
+ ).true;
66
+ expect(
67
+ new MongoLSN({
68
+ timestamp: mongo.Timestamp.fromNumber(10),
69
+ resume_token: { _data: '2' }
70
+ }).comparable >
71
+ new MongoLSN({
72
+ timestamp: mongo.Timestamp.fromNumber(1)
73
+ }).comparable.split('|')[0] // Simulate an old LSN
74
+ ).true;
75
+ expect(
76
+ new MongoLSN({
77
+ timestamp: mongo.Timestamp.fromNumber(1),
78
+ resume_token: { _data: '2' }
79
+ }).comparable <
80
+ new MongoLSN({
81
+ timestamp: mongo.Timestamp.fromNumber(10)
82
+ }).comparable.split('|')[0] // Simulate an old LSN
83
+ ).true;
84
+ });
85
+ });
86
+
87
+ describe.skipIf(!env.TEST_MONGO_STORAGE)('MongoDB resume - mongo storage', () => {
88
+ defineResumeTest(INITIALIZED_MONGO_STORAGE_FACTORY);
89
+ });
90
+
91
+ describe.skipIf(!env.TEST_POSTGRES_STORAGE)('MongoDB resume - postgres storage', () => {
92
+ defineResumeTest(INITIALIZED_POSTGRES_STORAGE_FACTORY);
93
+ });
94
+
95
+ function defineResumeTest(factoryGenerator: (options?: TestStorageOptions) => Promise<BucketStorageFactory>) {
96
+ test('resuming with a different source database', async () => {
97
+ await using context = await ChangeStreamTestContext.open(factoryGenerator);
98
+ const { db } = context;
99
+
100
+ await context.updateSyncRules(/* yaml */
101
+ ` bucket_definitions:
102
+ global:
103
+ data:
104
+ - SELECT _id as id, description, num FROM "test_data"`);
105
+
106
+ await context.replicateSnapshot();
107
+
108
+ context.startStreaming();
109
+
110
+ const collection = db.collection('test_data');
111
+ await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
112
+
113
+ // Wait for the item above to be replicated. The commit should store a resume token.
114
+ await vi.waitFor(
115
+ async () => {
116
+ const checkpoint = await context.storage?.getCheckpoint();
117
+ expect(MongoLSN.fromSerialized(checkpoint!.lsn!).resumeToken).exist;
118
+ },
119
+ { timeout: 5000 }
120
+ );
121
+
122
+ // Done with this context for now
123
+ await context.dispose();
124
+
125
+ // Use the provided MongoDB url to connect to a different source database
126
+ const originalUrl = env.MONGO_TEST_URL;
127
+ // Change this to a different database
128
+ const url = new URL(originalUrl);
129
+ const parts = url.pathname.split('/');
130
+ parts[1] = 'differentDB'; // Replace the database name
131
+ url.pathname = parts.join('/');
132
+
133
+ // Point to a new source DB
134
+ const connectionManager = new MongoManager(
135
+ normalizeConnectionConfig({
136
+ type: 'mongodb',
137
+ uri: url.toString()
138
+ })
139
+ );
140
+ const factory = await factoryGenerator({ doNotClear: true });
141
+
142
+ // Create a new context without updating the sync rules
143
+ await using context2 = new ChangeStreamTestContext(factory, connectionManager);
144
+ const activeContent = await factory.getActiveSyncRulesContent();
145
+ context2.storage = factory.getInstance(activeContent!);
146
+
147
+ const error = await context2.startStreaming().catch((ex) => ex);
148
+ expect(error).exist;
149
+ // The ChangeStreamReplicationJob will detect this and throw a ChangeStreamInvalidatedError
150
+ expect(isMongoServerError(error) && error.hasErrorLabel('NonResumableChangeStreamError'));
151
+ });
152
+ }
package/test/src/util.ts CHANGED
@@ -4,6 +4,7 @@ import * as postgres_storage from '@powersync/service-module-postgres-storage';
4
4
 
5
5
  import * as types from '@module/types/types.js';
6
6
  import { env } from './env.js';
7
+ import { BSON_DESERIALIZE_DATA_OPTIONS } from '@powersync/service-core';
7
8
 
8
9
  export const TEST_URI = env.MONGO_TEST_DATA_URL;
9
10
 
@@ -30,7 +31,7 @@ export async function connectMongoData() {
30
31
  connectTimeoutMS: env.CI ? 15_000 : 5_000,
31
32
  socketTimeoutMS: env.CI ? 15_000 : 5_000,
32
33
  serverSelectionTimeoutMS: env.CI ? 15_000 : 2_500,
33
- useBigInt64: true
34
+ ...BSON_DESERIALIZE_DATA_OPTIONS
34
35
  });
35
36
  const dbname = new URL(env.MONGO_TEST_DATA_URL).pathname.substring(1);
36
37
  return { client, db: client.db(dbname) };