@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.
- package/CHANGELOG.md +41 -0
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/module/MongoModule.d.ts +0 -1
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.js +1 -4
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.js +0 -3
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +3 -11
- package/dist/replication/RawChangeStream.js +0 -17
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/package.json +9 -9
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/module/MongoModule.ts +0 -2
- package/src/replication/ChangeStream.ts +1 -4
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoSnapshotter.ts +0 -3
- package/src/replication/RawChangeStream.ts +3 -30
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream_utils.ts +4 -4
- package/test/src/documentdb_mode.test.ts +1 -2
- package/test/src/raw_change_stream.test.ts +0 -63
- package/test/src/schema.test.ts +223 -0
- 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
|
+
});
|