@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716

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 (88) hide show
  1. package/CHANGELOG.md +38 -6
  2. package/ci/test-connection.yaml +12 -0
  3. package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
  4. package/dist/api/MongoRouteAPIAdapter.js +73 -143
  5. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  6. package/dist/api/infer-collection-schema.d.ts +12 -0
  7. package/dist/api/infer-collection-schema.js +142 -0
  8. package/dist/api/infer-collection-schema.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module/MongoModule.d.ts +1 -2
  13. package/dist/module/MongoModule.js +0 -1
  14. package/dist/module/MongoModule.js.map +1 -1
  15. package/dist/replication/ChangeStream.d.ts +10 -2
  16. package/dist/replication/ChangeStream.js +228 -317
  17. package/dist/replication/ChangeStream.js.map +1 -1
  18. package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
  19. package/dist/replication/ChangeStreamReplicationJob.js +4 -1
  20. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  21. package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
  22. package/dist/replication/ChangeStreamReplicator.js +3 -0
  23. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  24. package/dist/replication/MongoRelation.js +5 -2
  25. package/dist/replication/MongoRelation.js.map +1 -1
  26. package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
  27. package/dist/replication/MongoReplicationQueryProvider.js +23 -0
  28. package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
  29. package/dist/replication/MongoReplicationStream.d.ts +85 -0
  30. package/dist/replication/MongoReplicationStream.js +101 -0
  31. package/dist/replication/MongoReplicationStream.js.map +1 -0
  32. package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
  33. package/dist/replication/MongoSnapshotQuery.js +12 -2
  34. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  35. package/dist/replication/MongoSnapshotter.d.ts +7 -2
  36. package/dist/replication/MongoSnapshotter.js +58 -85
  37. package/dist/replication/MongoSnapshotter.js.map +1 -1
  38. package/dist/replication/RawChangeStream.d.ts +1 -1
  39. package/dist/replication/RawChangeStream.js +1 -1
  40. package/dist/replication/RawChangeStream.js.map +1 -1
  41. package/dist/replication/SourceRowConverter.d.ts +1 -0
  42. package/dist/replication/SourceRowConverter.js +4 -2
  43. package/dist/replication/SourceRowConverter.js.map +1 -1
  44. package/dist/replication/bufferToSqlite.d.ts +3 -1
  45. package/dist/replication/bufferToSqlite.js +35 -23
  46. package/dist/replication/bufferToSqlite.js.map +1 -1
  47. package/dist/replication/replication-index.d.ts +6 -0
  48. package/dist/replication/replication-index.js +6 -0
  49. package/dist/replication/replication-index.js.map +1 -1
  50. package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
  51. package/dist/test-utils/ChangeStreamTestContext.js +391 -0
  52. package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
  53. package/dist/test-utils/test-utils-index.d.ts +1 -0
  54. package/dist/test-utils/test-utils-index.js +2 -0
  55. package/dist/test-utils/test-utils-index.js.map +1 -0
  56. package/package.json +8 -8
  57. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  58. package/src/api/infer-collection-schema.ts +153 -0
  59. package/src/index.ts +1 -0
  60. package/src/module/MongoModule.ts +1 -3
  61. package/src/replication/ChangeStream.ts +253 -359
  62. package/src/replication/ChangeStreamReplicationJob.ts +7 -2
  63. package/src/replication/ChangeStreamReplicator.ts +5 -0
  64. package/src/replication/MongoRelation.ts +8 -1
  65. package/src/replication/MongoReplicationQueryProvider.ts +108 -0
  66. package/src/replication/MongoReplicationStream.ts +201 -0
  67. package/src/replication/MongoSnapshotQuery.ts +19 -3
  68. package/src/replication/MongoSnapshotter.ts +70 -92
  69. package/src/replication/RawChangeStream.ts +2 -2
  70. package/src/replication/SourceRowConverter.ts +4 -2
  71. package/src/replication/bufferToSqlite.ts +43 -23
  72. package/src/replication/replication-index.ts +6 -0
  73. package/src/test-utils/ChangeStreamTestContext.ts +520 -0
  74. package/src/test-utils/test-utils-index.ts +1 -0
  75. package/test/src/buffer_to_sqlite.test.ts +9 -0
  76. package/test/src/change_stream.test.ts +3 -3
  77. package/test/src/change_stream_test_setup.ts +38 -0
  78. package/test/src/chunked_snapshot.test.ts +3 -3
  79. package/test/src/documentdb_mode.test.ts +3 -3
  80. package/test/src/replication_stream.test.ts +311 -0
  81. package/test/src/resume.test.ts +10 -4
  82. package/test/src/resuming_snapshots.test.ts +3 -3
  83. package/test/src/schema.test.ts +223 -0
  84. package/test/src/slow_tests.test.ts +3 -3
  85. package/test/src/snapshot_query_provider.test.ts +95 -0
  86. package/test/src/stream_progress.test.ts +312 -0
  87. package/tsconfig.tsbuildinfo +1 -1
  88. package/test/src/change_stream_utils.ts +0 -375
@@ -0,0 +1,311 @@
1
+ import { ChangeStreamInvalidatedError } from '@module/replication/ChangeStream.js';
2
+ import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
3
+ import { openMongoReplicationStream, readMongoReplicationStream } from '@module/replication/MongoReplicationStream.js';
4
+ import { ChangeStreamBatch, mapChangeStreamError } from '@module/replication/RawChangeStream.js';
5
+ import { mongo } from '@powersync/lib-service-mongodb';
6
+ import { DatabaseConnectionError } from '@powersync/lib-services-framework';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { env } from './env.js';
9
+
10
+ /**
11
+ * Exercise cursor construction and stream decoding without contacting MongoDB. Cursor tests intercept
12
+ * the driver's command method; reader tests supply BSON batches with explicit events and resume tokens.
13
+ */
14
+ describe('MongoDB replication stream reader', () => {
15
+ test.each(['pre', 'post'])('invalidates replication when a required %s-image is missing', (image) => {
16
+ const error = new mongo.MongoServerError({
17
+ code: 47,
18
+ codeName: 'NoMatchingDocument',
19
+ errmsg: `Change stream was configured to require a ${image}-image, but the ${image}-image was not found for event: example`
20
+ });
21
+
22
+ // Invalidated streams trigger a fresh snapshot instead of retrying an unrecoverable position.
23
+ expect(() => mapChangeStreamError(error)).toThrow(ChangeStreamInvalidatedError);
24
+ });
25
+
26
+ test('does not invalidate replication for unrelated NoMatchingDocument errors', () => {
27
+ const error = new mongo.MongoServerError({
28
+ code: 47,
29
+ codeName: 'NoMatchingDocument',
30
+ errmsg: 'No matching document found'
31
+ });
32
+
33
+ expect(() => mapChangeStreamError(error)).toThrow(DatabaseConnectionError);
34
+ });
35
+
36
+ test.each([
37
+ { isDocumentDb: false, multipleDatabases: false, usePostImages: false },
38
+ { isDocumentDb: false, multipleDatabases: false, usePostImages: true },
39
+ { isDocumentDb: false, multipleDatabases: true, usePostImages: true },
40
+ { isDocumentDb: true, multipleDatabases: false, usePostImages: true }
41
+ ])('preserves the base cursor options for %j', async ({ isDocumentDb, multipleDatabases, usePostImages }) => {
42
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL);
43
+ await using clientDisposer = { [Symbol.asyncDispose]: () => client.close() };
44
+ const db = client.db('app');
45
+ const resumeAfter = { _data: 'exact-position' };
46
+ // Observe the real cursor-opening path, stopping before any network operation. An exact token must
47
+ // take precedence even if the stored position also contains its transaction's shared timestamp.
48
+ const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
49
+ try {
50
+ await expect(
51
+ collect(
52
+ openMongoReplicationStream({
53
+ db,
54
+ queryProvider: DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
55
+ namespaceFilter: { $match: { 'ns.coll': 'documents' }, multipleDatabases },
56
+ isDocumentDb,
57
+ usePostImages,
58
+ position: { resumeAfter, startAfter: new mongo.Timestamp({ t: 10, i: 1 }) },
59
+ skipInitialTimestamp: true,
60
+ options: { batchSize: 50, maxAwaitTimeMS: 200, maxTimeMS: 1000 }
61
+ })
62
+ )
63
+ ).rejects.toThrow('observed command');
64
+ expect(command).toHaveBeenCalledOnce();
65
+ expect((command.mock.contexts[0] as mongo.Db).databaseName).toBe(
66
+ isDocumentDb || multipleDatabases ? 'admin' : 'app'
67
+ );
68
+ expect(command.mock.calls[0][0]).toEqual({
69
+ aggregate: 1,
70
+ pipeline: [
71
+ {
72
+ $changeStream: {
73
+ fullDocument: !isDocumentDb && usePostImages ? 'required' : 'updateLookup',
74
+ ...(!isDocumentDb ? { showExpandedEvents: true } : {}),
75
+ ...(isDocumentDb || multipleDatabases ? { allChangesForCluster: true } : {}),
76
+ resumeAfter
77
+ }
78
+ },
79
+ { $match: { 'ns.coll': 'documents' } },
80
+ ...(!isDocumentDb ? [{ $changeStreamSplitLargeEvent: {} }] : [])
81
+ ],
82
+ cursor: { batchSize: 1 },
83
+ maxTimeMS: 1000
84
+ });
85
+ } finally {
86
+ command.mockRestore();
87
+ }
88
+ });
89
+
90
+ test('opens adapter stages after namespace selection, with pre-images and the shared resume settings', async () => {
91
+ // A provider requests pre-images and adds a stage. The shared opener must still supply the source
92
+ // position and post-image settings, and run the provider stage before large events are split.
93
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL);
94
+ await using clientDisposer = { [Symbol.asyncDispose]: () => client.close() };
95
+ const command = vi.spyOn(mongo.Db.prototype, 'command').mockRejectedValue(new Error('observed command'));
96
+ const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
97
+ try {
98
+ await expect(
99
+ collect(
100
+ openMongoReplicationStream({
101
+ db: client.db('app'),
102
+ queryProvider: {
103
+ ...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
104
+ openChangeStream: ({ open }) =>
105
+ open({
106
+ pipelineStages: [{ $set: { marker: 'keep' } }],
107
+ imageOptions: { fullDocumentBeforeChange: 'whenAvailable' }
108
+ })
109
+ },
110
+ namespaceFilter: { $match: { 'ns.coll': 'documents' }, multipleDatabases: false },
111
+ isDocumentDb: false,
112
+ usePostImages: true,
113
+ position: { startAfter: timestamp },
114
+ options: { batchSize: 50, maxAwaitTimeMS: 200, maxTimeMS: 1000 }
115
+ })
116
+ )
117
+ ).rejects.toThrow('observed command');
118
+ expect(command.mock.calls[0][0].pipeline).toEqual([
119
+ {
120
+ $changeStream: {
121
+ fullDocumentBeforeChange: 'whenAvailable',
122
+ fullDocument: 'required',
123
+ showExpandedEvents: true,
124
+ startAtOperationTime: timestamp
125
+ }
126
+ },
127
+ { $match: { 'ns.coll': 'documents' } },
128
+ { $set: { marker: 'keep' } },
129
+ { $changeStreamSplitLargeEvent: {} }
130
+ ]);
131
+ } finally {
132
+ command.mockRestore();
133
+ }
134
+ });
135
+
136
+ test('keeps raw rows, buffered checkpoint information and idle progress', async () => {
137
+ // Two changes arrive together, followed by an empty response. Preserve raw BSON document bodies
138
+ // and indicate whether another change is buffered, while exposing a resume boundary for each batch.
139
+ const onBatch = vi.fn();
140
+ const batches = [batch([event('first'), event('second')], 'data'), batch([], 'idle')];
141
+ const items = await collect(
142
+ readMongoReplicationStream({
143
+ batches: from(batches),
144
+ defaultSchema: 'app',
145
+ multipleDatabases: false,
146
+ onBatch
147
+ })
148
+ );
149
+ expect(items.map((item) => item.type)).toEqual(['change', 'change', 'progress', 'progress']);
150
+ expect(items[0]).toMatchObject({ type: 'change', hasBufferedChanges: true });
151
+ expect(items[1]).toMatchObject({ type: 'change', hasBufferedChanges: false });
152
+ if (items[0].type != 'change' || !('fullDocument' in items[0].event)) throw new Error('Expected a row');
153
+ expect(Buffer.isBuffer(items[0].event.fullDocument)).toBe(true);
154
+ expect(mongo.BSON.deserialize(items[0].event.fullDocument!)).toEqual({ _id: 'first', body: 'raw row' });
155
+ expect(items.slice(2)).toEqual([
156
+ { type: 'progress', resumeToken: { _data: 'data' }, filteredCount: 0 },
157
+ { type: 'progress', resumeToken: { _data: 'idle' }, filteredCount: 0 }
158
+ ]);
159
+ expect(onBatch.mock.calls.map(([value]) => value)).toEqual(batches);
160
+ });
161
+
162
+ test('withholds progress across split batches, including an idle response between fragments', async () => {
163
+ // A large event spans two nonempty batches with an idle response between them. Saving either
164
+ // intermediate token would lose the in-memory first fragment if replication restarted there.
165
+ const original = event('large');
166
+ const first = { ...original, fullDocument: undefined, splitEvent: { fragment: 1, of: 2 } };
167
+ const last = {
168
+ _id: { _data: 'fragment-2' },
169
+ fullDocument: original.fullDocument,
170
+ splitEvent: { fragment: 2, of: 2 }
171
+ };
172
+ const onBatch = vi.fn();
173
+ const items = await collect(
174
+ readMongoReplicationStream({
175
+ batches: from([
176
+ batch([event('before'), first], 'unsafe'),
177
+ batch([], 'still-unsafe'),
178
+ batch([last, event('after')], 'safe')
179
+ ]),
180
+ defaultSchema: 'app',
181
+ multipleDatabases: false,
182
+ onBatch
183
+ })
184
+ );
185
+ // A consumer must never save either partial token, even though all three transport batches are counted.
186
+ expect(items.map((item) => item.type)).toEqual(['change', 'change', 'change', 'progress']);
187
+ expect(items[1]).toMatchObject({
188
+ type: 'change',
189
+ event: { documentKey: { _id: 'large' }, _id: { _data: 'fragment-2' } }
190
+ });
191
+ expect(items[3]).toEqual({ type: 'progress', resumeToken: { _data: 'safe' }, filteredCount: 0 });
192
+ expect(onBatch).toHaveBeenCalledTimes(3);
193
+ });
194
+
195
+ test.each([
196
+ // The first fragment is missing.
197
+ [{ ...event('broken'), splitEvent: { fragment: 2, of: 2 } }],
198
+ // A fragment in the middle is missing.
199
+ [
200
+ { ...event('broken'), splitEvent: { fragment: 1, of: 3 } },
201
+ { _id: { _data: 'bad' }, splitEvent: { fragment: 3, of: 3 } }
202
+ ],
203
+ // An ordinary event interrupts reassembly.
204
+ [{ ...event('broken'), splitEvent: { fragment: 1, of: 2 } }, event('unrelated')],
205
+ // The source iterator ends before the final fragment arrives.
206
+ [{ ...event('broken'), splitEvent: { fragment: 1, of: 2 } }]
207
+ ])('fails closed on incomplete or out-of-order fragments %#', async (...events) => {
208
+ // Invalid fragment sequences must reject consumption and close the upstream iterator so a caller
209
+ // cannot mistake a partial document for a complete change or continue from its batch token.
210
+ const closed = vi.fn();
211
+ const stream = readMongoReplicationStream({
212
+ batches: from([batch(events, 'unsafe')], closed),
213
+ defaultSchema: 'app',
214
+ multipleDatabases: false
215
+ });
216
+ await expect(collect(stream)).rejects.toThrow('splitEvent');
217
+ expect(closed).toHaveBeenCalledOnce();
218
+ });
219
+
220
+ test.each(['return', 'abort'] as const)(
221
+ 'closes the upstream iterator on %s without acknowledging unread work',
222
+ async (end) => {
223
+ // Stop after the first change while another remains in the same batch. Both consumer return
224
+ // and signal cancellation must run upstream cleanup before the batch's progress is yielded.
225
+ const closed = vi.fn();
226
+ const controller = new AbortController();
227
+ const stream = readMongoReplicationStream({
228
+ batches: from([batch([event('first'), event('unread')], 'unsafe')], closed),
229
+ defaultSchema: 'app',
230
+ multipleDatabases: false,
231
+ signal: controller.signal
232
+ });
233
+ expect((await stream.next()).value).toMatchObject({ type: 'change' });
234
+ if (end == 'return') await stream.return(undefined);
235
+ else {
236
+ controller.abort(new Error('cancelled'));
237
+ await expect(stream.next()).rejects.toThrow('cancelled');
238
+ }
239
+ expect(closed).toHaveBeenCalledOnce();
240
+ }
241
+ );
242
+
243
+ test('normalizes Atlas Flex namespaces and deduplicates legacy positions before adapter evaluation', async () => {
244
+ // Legacy positions contain only a timestamp, so an event at that timestamp is already covered.
245
+ // The remaining event uses an Atlas Flex database prefix that must be removed before delivery.
246
+ const timestamp = new mongo.Timestamp({ t: 10, i: 1 });
247
+ const items = await collect(
248
+ readMongoReplicationStream({
249
+ batches: from([
250
+ batch(
251
+ [
252
+ { ...event('old'), clusterTime: timestamp },
253
+ { ...event('new'), ns: { db: 'prefix_app', coll: 'documents' } }
254
+ ],
255
+ 'safe'
256
+ )
257
+ ]),
258
+ defaultSchema: 'app',
259
+ multipleDatabases: false,
260
+ startAfter: timestamp
261
+ })
262
+ );
263
+ expect(items).toHaveLength(2);
264
+ expect(items[0]).toMatchObject({
265
+ type: 'change',
266
+ event: { ns: { db: 'app', coll: 'documents' }, documentKey: { _id: 'new' } }
267
+ });
268
+ });
269
+ });
270
+
271
+ /**
272
+ * Minimal insert event for the app.documents fixture namespace; callers override fields for each scenario.
273
+ */
274
+ function event(id: string): mongo.Document {
275
+ return {
276
+ _id: { _data: id },
277
+ operationType: 'insert',
278
+ ns: { db: 'app', coll: 'documents' },
279
+ documentKey: { _id: id },
280
+ fullDocument: { _id: id, body: 'raw row' }
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Encode events as raw BSON, matching the reader's transport input. Tokens are opaque test labels;
286
+ * the fixed byte count supports batch accounting assertions without depending on BSON payload size.
287
+ */
288
+ function batch(events: mongo.Document[], token: string): ChangeStreamBatch {
289
+ return {
290
+ events: events.map((event) => Buffer.from(mongo.BSON.serialize(event))),
291
+ resumeToken: { _data: token },
292
+ byteSize: 1234
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Supply deterministic source batches and expose iterator cleanup to cancellation and failure tests.
298
+ */
299
+ async function* from<T>(values: T[], closed?: () => void) {
300
+ try {
301
+ yield* values;
302
+ } finally {
303
+ closed?.();
304
+ }
305
+ }
306
+
307
+ async function collect<T>(stream: AsyncIterable<T>): Promise<T[]> {
308
+ const items: T[] = [];
309
+ for await (const item of stream) items.push(item);
310
+ return items;
311
+ }
@@ -1,8 +1,9 @@
1
1
  import { ChangeStreamInvalidatedError } from '@module/replication/ChangeStream.js';
2
2
  import { MongoManager } from '@module/replication/MongoManager.js';
3
3
  import { normalizeConnectionConfig } from '@module/types/types.js';
4
+ import { METRICS_HELPER } from '@powersync/service-core-tests';
4
5
  import { describe, expect, test } from 'vitest';
5
- import { ChangeStreamTestContext } from './change_stream_utils.js';
6
+ import { ChangeStreamTestContext, openChangeStreamTestContext } from './change_stream_test_setup.js';
6
7
  import { env } from './env.js';
7
8
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
8
9
 
@@ -11,8 +12,8 @@ describe('mongodb resuming replication', () => {
11
12
  });
12
13
 
13
14
  function defineResumeTest({ factory: factoryGenerator, storageVersion }: StorageVersionTestContext) {
14
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
15
- return ChangeStreamTestContext.open(factoryGenerator, { ...options, storageVersion });
15
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
16
+ return openChangeStreamTestContext(factoryGenerator, { ...options, storageVersion });
16
17
  };
17
18
 
18
19
  test.skip('resuming with a different source database', async () => {
@@ -66,7 +67,12 @@ function defineResumeTest({ factory: factoryGenerator, storageVersion }: Storage
66
67
  const factory = await factoryGenerator({ doNotClear: true });
67
68
 
68
69
  // Create a new context without updating the sync config
69
- await using context2 = new ChangeStreamTestContext(factory, connectionManager, {}, storageVersion);
70
+ await using context2 = new ChangeStreamTestContext({
71
+ factory,
72
+ connectionManager,
73
+ storageVersion,
74
+ metrics: METRICS_HELPER.metricsEngine
75
+ });
70
76
  const active = await factory.getActiveSyncConfig();
71
77
  context2.storage = active!.storage;
72
78
 
@@ -3,7 +3,7 @@ import { METRICS_HELPER } from '@powersync/service-core-tests';
3
3
  import { ReplicationMetric } from '@powersync/service-types';
4
4
  import * as timers from 'node:timers/promises';
5
5
  import { describe, expect, test } from 'vitest';
6
- import { ChangeStreamTestContext } from './change_stream_utils.js';
6
+ import { openChangeStreamTestContext } from './change_stream_test_setup.js';
7
7
  import { env } from './env.js';
8
8
  import { describeWithStorage } from './util.js';
9
9
 
@@ -35,7 +35,7 @@ async function testResumingReplication(factory: TestStorageFactory, storageVersi
35
35
  let startRowCount: number;
36
36
 
37
37
  {
38
- await using context = await ChangeStreamTestContext.open(factory, {
38
+ await using context = await openChangeStreamTestContext(factory, {
39
39
  storageVersion,
40
40
  streamOptions: { snapshotChunkLength: 1000 }
41
41
  });
@@ -88,7 +88,7 @@ async function testResumingReplication(factory: TestStorageFactory, storageVersi
88
88
 
89
89
  {
90
90
  // Bypass the usual "clear db on factory open" step.
91
- await using context2 = await ChangeStreamTestContext.open(factory, {
91
+ await using context2 = await openChangeStreamTestContext(factory, {
92
92
  doNotClear: true,
93
93
  storageVersion,
94
94
  streamOptions: { snapshotChunkLength: 1000 }
@@ -0,0 +1,223 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
3
+
4
+ import { inferCollectionSchema } from '@module/api/infer-collection-schema.js';
5
+ import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
6
+ import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
+ import { testTimeout } from './test-timeouts.js';
8
+ import { connectMongoData, requireFailCommand, TEST_CONNECTION_OPTIONS } from './util.js';
9
+
10
+ const isDocumentDb = DATABASE_TYPE == DatabaseType.DOCUMENTDB;
11
+
12
+ describe('collection schema inference', { timeout: testTimeout(20_000) }, () => {
13
+ let client: mongo.MongoClient;
14
+ let db: mongo.Db;
15
+
16
+ beforeEach(async () => {
17
+ ({ client } = await connectMongoData({ monitorCommands: true }));
18
+ db = client.db(`${TEST_CONNECTION_OPTIONS.database}_schema`);
19
+ await db.dropDatabase();
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await db.dropDatabase();
24
+ await client.close();
25
+ });
26
+
27
+ test('merges types across documents and only infers top-level fields', async () => {
28
+ const collection = db.collection('mixed');
29
+ await collection.insertMany([
30
+ {
31
+ _id: 1 as any,
32
+ value: null,
33
+ whole: new mongo.Double(42),
34
+ huge: new mongo.Double(1e100),
35
+ fractional: new mongo.Double(-1.25),
36
+ nan: NaN,
37
+ positiveInfinity: Infinity,
38
+ negativeInfinity: -Infinity,
39
+ long: mongo.Long.fromString('9007199254740993'),
40
+ array: [{ nested: 'value' }],
41
+ object: { nested: true }
42
+ },
43
+ { _id: 2 as any, value: 'text', whole: new mongo.Int32(-42), array: [] },
44
+ { _id: 3 as any, value: 42, whole: new mongo.Double(-0) },
45
+ { _id: 4 as any, value: 1.25 },
46
+ { _id: 5 as any, value: new mongo.Double(42) }
47
+ ]);
48
+
49
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toMatchObject([
50
+ { name: '_id', sqlite_type: 4, internal_type: 'Integer' },
51
+ { name: 'array', sqlite_type: 2, internal_type: 'Array' },
52
+ { name: 'fractional', sqlite_type: 8, internal_type: 'Double' },
53
+ { name: 'huge', sqlite_type: 4, internal_type: 'Integer' },
54
+ { name: 'long', sqlite_type: 4, internal_type: 'Long' },
55
+ { name: 'nan', sqlite_type: 8, internal_type: 'Double' },
56
+ { name: 'negativeInfinity', sqlite_type: 8, internal_type: 'Double' },
57
+ { name: 'object', sqlite_type: 2, internal_type: 'Object' },
58
+ { name: 'positiveInfinity', sqlite_type: 8, internal_type: 'Double' },
59
+ { name: 'value', sqlite_type: 14, internal_type: 'Double | Integer | Null | String' },
60
+ { name: 'whole', sqlite_type: 4, internal_type: 'Integer' }
61
+ ]);
62
+ });
63
+
64
+ test('distinguishes UUIDs from binary values by subtype and length', async () => {
65
+ const collection = db.collection('binary');
66
+ await collection.insertMany([
67
+ {
68
+ _id: 1 as any,
69
+ uuid: new mongo.UUID('00000000-0000-0000-0000-000000000000'),
70
+ binary: new mongo.Binary(Buffer.alloc(16)),
71
+ mixed: new mongo.UUID()
72
+ },
73
+ {
74
+ _id: 2 as any,
75
+ uuid: new mongo.UUID('ffffffff-ffff-ffff-ffff-ffffffffffff'),
76
+ binary: new mongo.Binary(Buffer.alloc(16, 255)),
77
+ mixed: new mongo.Binary(Buffer.alloc(16), mongo.Binary.SUBTYPE_UUID_OLD)
78
+ },
79
+ {
80
+ _id: 3 as any,
81
+ uuid: new mongo.UUID(),
82
+ binary: new mongo.Binary(Buffer.alloc(16), mongo.Binary.SUBTYPE_MD5)
83
+ },
84
+ { _id: 4 as any, binary: new mongo.Binary(Buffer.alloc(15, 255), mongo.Binary.SUBTYPE_USER_DEFINED) },
85
+ { _id: 5 as any, binary: new mongo.Binary(Buffer.alloc(17)) },
86
+ { _id: 6 as any, binary: new mongo.Binary(Buffer.alloc(0)) }
87
+ ]);
88
+
89
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toMatchObject([
90
+ { name: '_id', sqlite_type: 4, internal_type: 'Integer' },
91
+ { name: 'binary', sqlite_type: 1, internal_type: 'Binary' },
92
+ { name: 'mixed', sqlite_type: 3, internal_type: 'Binary | UUID' },
93
+ { name: 'uuid', sqlite_type: 2, internal_type: 'UUID' }
94
+ ]);
95
+ });
96
+
97
+ test('handles empty collections and documents with only an id', async () => {
98
+ const collection = await db.createCollection('empty');
99
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toEqual([]);
100
+
101
+ await collection.insertOne({});
102
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toEqual([
103
+ { name: '_id', sqlite_type: 2, type: 'ObjectId', internal_type: 'ObjectId', pg_type: 'ObjectId' }
104
+ ]);
105
+ });
106
+
107
+ test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
108
+ 'preserves field names with a non-simple collation',
109
+ async () => {
110
+ const collection = await db.createCollection('collation', { collation: { locale: 'en', strength: 1 } });
111
+ await collection.insertOne({ Name: 'text', name: 1, 'with.dot': true, $field: [] });
112
+
113
+ const columns = await inferCollectionSchema(collection, isDocumentDb);
114
+ expect(columns).toEqual(
115
+ expect.arrayContaining([
116
+ expect.objectContaining({ name: 'Name', sqlite_type: 2 }),
117
+ expect.objectContaining({ name: 'name', sqlite_type: 4 }),
118
+ expect.objectContaining({ name: 'with.dot', sqlite_type: 4 }),
119
+ expect.objectContaining({ name: '$field', internal_type: 'Array' })
120
+ ])
121
+ );
122
+ expect(columns).toHaveLength(5);
123
+ }
124
+ );
125
+
126
+ test('returns only small schema metadata for multi-megabyte documents', async () => {
127
+ const collection = db.collection('large');
128
+ const largeString = 'x'.repeat(2 * 1024 * 1024);
129
+ for (let i = 0; i < 4; i++) {
130
+ await collection.insertOne({ text: largeString, binary: Buffer.alloc(2 * 1024 * 1024), array: [largeString] });
131
+ }
132
+
133
+ const responseSizes: number[] = [];
134
+ const executionLimits: number[] = [];
135
+ client.on('commandStarted', (event: mongo.CommandStartedEvent) => {
136
+ if (event.commandName == 'aggregate') {
137
+ executionLimits.push(event.command.maxTimeMS);
138
+ }
139
+ });
140
+ client.on('commandSucceeded', (event: mongo.CommandSucceededEvent) => {
141
+ if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
142
+ responseSizes.push(mongo.BSON.calculateObjectSize(event.reply as mongo.Document));
143
+ }
144
+ });
145
+
146
+ expect(await inferCollectionSchema(collection, isDocumentDb)).toMatchObject([
147
+ { name: '_id', sqlite_type: 2, internal_type: 'ObjectId' },
148
+ { name: 'array', sqlite_type: 2, internal_type: 'Array' },
149
+ { name: 'binary', sqlite_type: 1, internal_type: 'Binary' },
150
+ { name: 'text', sqlite_type: 2, internal_type: 'String' }
151
+ ]);
152
+ expect(responseSizes.length).toBeGreaterThan(0);
153
+ expect(Math.max(...responseSizes)).toBeLessThan(4096);
154
+ expect(executionLimits).toEqual([30_000]);
155
+ });
156
+
157
+ // A collection we cannot sample should cost us that collection's fields, not the whole schema.
158
+ // Exercises the real server/driver error path without waiting for the limits to be reached.
159
+ describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('when a collection cannot be sampled', () => {
160
+ // 146 is ExceededMemoryLimit, 292 is QueryExceededMemoryLimitNoDiskUseAllowed, which is what
161
+ // a small collection of multi-megabyte documents produces when $sample has to sort them.
162
+ // The namespace filter below needs a server recent enough to honor it (CI runs 6.0+);
163
+ // older servers ignore it and fail an arbitrary collection's aggregation.
164
+ test.for([146, 292])('reports it without columns on error %i', async (errorCode, ctx) => {
165
+ await db.collection('readable').insertOne({ value: 'text' });
166
+ await db.collection('unreadable').insertOne({ value: 'text' });
167
+ await using adapter = new MongoRouteAPIAdapter({
168
+ type: 'mongodb',
169
+ ...TEST_CONNECTION_OPTIONS,
170
+ database: db.databaseName
171
+ });
172
+ await using failCommand = await requireFailCommand(client, ctx);
173
+ await failCommand.configure({
174
+ mode: { times: 1 },
175
+ data: {
176
+ failCommands: ['aggregate'],
177
+ namespace: `${db.databaseName}.unreadable`,
178
+ errorCode
179
+ }
180
+ });
181
+
182
+ const schema = await adapter.getConnectionSchema();
183
+ const tables = schema.find((s) => s.name == db.databaseName)?.tables;
184
+
185
+ expect(tables).toEqual(
186
+ expect.arrayContaining([
187
+ { name: 'unreadable', columns: [] },
188
+ expect.objectContaining({
189
+ name: 'readable',
190
+ columns: expect.arrayContaining([expect.objectContaining({ name: 'value' })])
191
+ })
192
+ ])
193
+ );
194
+ });
195
+
196
+ // 2 is BadValue, standing in for any unexpected error. 50 is MaxTimeMSExpired: timeouts
197
+ // usually mean the source is overloaded rather than one problem collection, so they keep
198
+ // failing the request instead of silently degrading every collection.
199
+ // Neither is retryable, so the driver surfaces them without re-running the aggregation.
200
+ test.for([2, 50])('still fails the request on error %i', async (errorCode, ctx) => {
201
+ await db.collection('broken').insertOne({ value: 'text' });
202
+ await using adapter = new MongoRouteAPIAdapter({
203
+ type: 'mongodb',
204
+ ...TEST_CONNECTION_OPTIONS,
205
+ database: db.databaseName
206
+ });
207
+ await using failCommand = await requireFailCommand(client, ctx);
208
+ await failCommand.configure({
209
+ mode: { times: 1 },
210
+ data: {
211
+ failCommands: ['aggregate'],
212
+ namespace: `${db.databaseName}.broken`,
213
+ errorCode
214
+ }
215
+ });
216
+
217
+ await expect(adapter.getConnectionSchema()).rejects.toMatchObject({
218
+ name: 'MongoServerError',
219
+ code: errorCode
220
+ });
221
+ });
222
+ });
223
+ });
@@ -2,7 +2,7 @@ import { setTimeout } from 'node:timers/promises';
2
2
  import { describe, expect, test } from 'vitest';
3
3
 
4
4
  import { mongo } from '@powersync/lib-service-mongodb';
5
- import { ChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_utils.js';
5
+ import { openChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_test_setup.js';
6
6
  import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
7
7
  import { env } from './env.js';
8
8
  import { describeWithStorage, StorageVersionTestContext } from './util.js';
@@ -12,8 +12,8 @@ describe.runIf(env.CI || env.SLOW_TESTS)('change stream slow tests', { timeout:
12
12
  });
13
13
 
14
14
  function defineSlowTests({ factory, storageVersion }: StorageVersionTestContext) {
15
- const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
16
- return ChangeStreamTestContext.open(factory, { ...options, storageVersion });
15
+ const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
16
+ return openChangeStreamTestContext(factory, { ...options, storageVersion });
17
17
  };
18
18
 
19
19
  // This test uses getParameter/setParameter for minSnapshotHistoryWindowInSeconds,