@powersync/service-module-mongodb 0.0.0-dev-20241111122558 → 0.1.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 (32) hide show
  1. package/CHANGELOG.md +18 -17
  2. package/dist/api/MongoRouteAPIAdapter.d.ts +1 -0
  3. package/dist/api/MongoRouteAPIAdapter.js +54 -21
  4. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  5. package/dist/replication/ChangeStream.d.ts +23 -2
  6. package/dist/replication/ChangeStream.js +178 -42
  7. package/dist/replication/ChangeStream.js.map +1 -1
  8. package/dist/replication/ChangeStreamReplicationJob.js +7 -4
  9. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  10. package/dist/replication/MongoErrorRateLimiter.js +0 -6
  11. package/dist/replication/MongoErrorRateLimiter.js.map +1 -1
  12. package/dist/replication/MongoRelation.js +5 -2
  13. package/dist/replication/MongoRelation.js.map +1 -1
  14. package/dist/replication/replication-utils.d.ts +1 -0
  15. package/dist/replication/replication-utils.js +1 -0
  16. package/dist/replication/replication-utils.js.map +1 -1
  17. package/dist/types/types.d.ts +35 -0
  18. package/dist/types/types.js +38 -2
  19. package/dist/types/types.js.map +1 -1
  20. package/package.json +6 -9
  21. package/src/api/MongoRouteAPIAdapter.ts +53 -21
  22. package/src/replication/ChangeStream.ts +277 -121
  23. package/src/replication/ChangeStreamReplicationJob.ts +6 -4
  24. package/src/replication/MongoErrorRateLimiter.ts +1 -8
  25. package/src/replication/MongoRelation.ts +5 -2
  26. package/src/replication/replication-utils.ts +2 -1
  27. package/src/types/types.ts +43 -3
  28. package/test/src/change_stream.test.ts +442 -231
  29. package/test/src/change_stream_utils.ts +54 -27
  30. package/test/src/mongo_test.test.ts +180 -46
  31. package/test/src/slow_tests.test.ts +109 -0
  32. 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(bucket: string, start?: string, options?: { timeout?: number }) {
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) {
@@ -245,58 +246,191 @@ describe('mongo data types', () => {
245
246
  });
246
247
 
247
248
  test('connection schema', async () => {
248
- const adapter = new MongoRouteAPIAdapter({
249
+ await using adapter = new MongoRouteAPIAdapter({
249
250
  type: 'mongodb',
250
251
  ...TEST_CONNECTION_OPTIONS
251
252
  });
252
- try {
253
- const db = adapter.db;
254
- await clearTestDb(db);
253
+ const db = adapter.db;
254
+ await clearTestDb(db);
255
255
 
256
- const collection = db.collection('test_data');
257
- await setupTable(db);
258
- await insert(collection);
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
259
318
 
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([
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: [
264
338
  {
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
- ]
339
+ level: 'fatal',
340
+ message: 'changeStreamPreAndPostImages not enabled on powersync_test_data.test_data'
295
341
  }
296
- ]);
297
- } finally {
298
- await adapter.shutdown();
299
- }
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
+ });
300
434
  });
301
435
  });
302
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
+ }