@powersync/service-module-mongodb 0.21.0 → 0.21.2

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 (42) hide show
  1. package/CHANGELOG.md +41 -0
  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/module/MongoModule.d.ts +0 -1
  10. package/dist/module/MongoModule.js +0 -1
  11. package/dist/module/MongoModule.js.map +1 -1
  12. package/dist/replication/ChangeStream.js +1 -4
  13. package/dist/replication/ChangeStream.js.map +1 -1
  14. package/dist/replication/MongoRelation.js +5 -2
  15. package/dist/replication/MongoRelation.js.map +1 -1
  16. package/dist/replication/MongoSnapshotter.js +0 -3
  17. package/dist/replication/MongoSnapshotter.js.map +1 -1
  18. package/dist/replication/RawChangeStream.d.ts +3 -11
  19. package/dist/replication/RawChangeStream.js +0 -17
  20. package/dist/replication/RawChangeStream.js.map +1 -1
  21. package/dist/replication/SourceRowConverter.d.ts +1 -0
  22. package/dist/replication/SourceRowConverter.js +4 -2
  23. package/dist/replication/SourceRowConverter.js.map +1 -1
  24. package/dist/replication/bufferToSqlite.d.ts +3 -1
  25. package/dist/replication/bufferToSqlite.js +35 -23
  26. package/dist/replication/bufferToSqlite.js.map +1 -1
  27. package/package.json +9 -9
  28. package/src/api/MongoRouteAPIAdapter.ts +79 -127
  29. package/src/api/infer-collection-schema.ts +153 -0
  30. package/src/module/MongoModule.ts +0 -2
  31. package/src/replication/ChangeStream.ts +1 -4
  32. package/src/replication/MongoRelation.ts +8 -1
  33. package/src/replication/MongoSnapshotter.ts +0 -3
  34. package/src/replication/RawChangeStream.ts +3 -30
  35. package/src/replication/SourceRowConverter.ts +4 -2
  36. package/src/replication/bufferToSqlite.ts +43 -23
  37. package/test/src/buffer_to_sqlite.test.ts +9 -0
  38. package/test/src/change_stream_utils.ts +4 -4
  39. package/test/src/documentdb_mode.test.ts +1 -2
  40. package/test/src/raw_change_stream.test.ts +0 -63
  41. package/test/src/schema.test.ts +223 -0
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ });