@powersync/service-module-mongodb 0.20.1 → 0.21.1
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 +53 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +62 -142
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +7 -0
- package/dist/api/infer-collection-schema.js +118 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/replication/ChangeStream.js +2 -4
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoSnapshotter.js +6 -6
- 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/types/types.js +2 -2
- package/dist/types/types.js.map +1 -1
- package/package.json +9 -9
- package/src/api/MongoRouteAPIAdapter.ts +68 -126
- package/src/api/infer-collection-schema.ts +127 -0
- package/src/replication/ChangeStream.ts +2 -4
- package/src/replication/MongoSnapshotter.ts +6 -6
- package/src/replication/RawChangeStream.ts +3 -30
- package/src/types/types.ts +2 -2
- package/test/src/change_stream.test.ts +73 -0
- 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 +180 -0
- package/test/src/util.ts +11 -29
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -5,7 +5,6 @@ import { getCursorBatchBytes } from '@module/replication/replication-index.js';
|
|
|
5
5
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
6
6
|
import { bson } from '@powersync/service-core';
|
|
7
7
|
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
8
|
-
import { testTimeout } from './test-timeouts.js';
|
|
9
8
|
import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
|
|
10
9
|
|
|
11
10
|
// DocumentDB only supports cluster-level change streams — collection- and
|
|
@@ -102,68 +101,6 @@ describe('internal mongodb utils', () => {
|
|
|
102
101
|
}
|
|
103
102
|
);
|
|
104
103
|
|
|
105
|
-
test(
|
|
106
|
-
'keeps getMore maxTimeMS when client-side maxAwaitTimeMS is enabled',
|
|
107
|
-
{ timeout: testTimeout(30_000, { cloudOverride: 150_000 }) },
|
|
108
|
-
async () => {
|
|
109
|
-
const { db, client } = await connectMongoData({ monitorCommands: true });
|
|
110
|
-
await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
|
|
111
|
-
await clearTestDb(db);
|
|
112
|
-
const collection = db.collection('test_data');
|
|
113
|
-
// Keep the local Mongo path fast, but give Azure DocumentDB enough time
|
|
114
|
-
// for slow cloud change-stream delivery when maxTimeMS is sent to getMore.
|
|
115
|
-
const maxAwaitTimeMS = testTimeout(50, { cloudOverride: 10_000 });
|
|
116
|
-
|
|
117
|
-
const started: any[] = [];
|
|
118
|
-
client.on('commandStarted', (event) => {
|
|
119
|
-
if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
|
|
120
|
-
started.push(event);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const stream = rawChangeStream(
|
|
125
|
-
DATABASE_TYPE == DatabaseType.DOCUMENTDB ? client.db('admin') : db,
|
|
126
|
-
[
|
|
127
|
-
{
|
|
128
|
-
$changeStream: {
|
|
129
|
-
fullDocument: 'updateLookup',
|
|
130
|
-
...(DATABASE_TYPE == DatabaseType.DOCUMENTDB ? { allChangesForCluster: true } : {})
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
...(DATABASE_TYPE == DatabaseType.DOCUMENTDB
|
|
134
|
-
? [
|
|
135
|
-
{
|
|
136
|
-
$match: {
|
|
137
|
-
'ns.db': db.databaseName,
|
|
138
|
-
'ns.coll': collection.collectionName
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
]
|
|
142
|
-
: [])
|
|
143
|
-
],
|
|
144
|
-
{
|
|
145
|
-
batchSize: 10,
|
|
146
|
-
maxAwaitTimeMS,
|
|
147
|
-
clientSideMaxAwaitTimeMS: true,
|
|
148
|
-
maxTimeMS: 1_000
|
|
149
|
-
}
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
await stream.next();
|
|
153
|
-
await collection.insertOne({ test: 1 });
|
|
154
|
-
const nextBatch = await readUntilNonEmptyBatch(stream);
|
|
155
|
-
await stream.return?.();
|
|
156
|
-
|
|
157
|
-
expect(nextBatch.events).toHaveLength(1);
|
|
158
|
-
|
|
159
|
-
const aggregate = started.find((event) => event.commandName == 'aggregate');
|
|
160
|
-
const getMore = started.find((event) => event.commandName == 'getMore');
|
|
161
|
-
|
|
162
|
-
expect(aggregate?.command.maxTimeMS).toEqual(1_000);
|
|
163
|
-
expect(getMore?.command.maxTimeMS).toEqual(maxAwaitTimeMS);
|
|
164
|
-
}
|
|
165
|
-
);
|
|
166
|
-
|
|
167
104
|
// This test uses configureFailPoint to inject a getMore timeout. Azure
|
|
168
105
|
// DocumentDB does not support configureFailPoint.
|
|
169
106
|
test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('fails schema inference on a query timeout', async (ctx) => {
|
|
158
|
+
await db.collection('timeout').insertOne({ value: 'text' });
|
|
159
|
+
await using adapter = new MongoRouteAPIAdapter({
|
|
160
|
+
type: 'mongodb',
|
|
161
|
+
...TEST_CONNECTION_OPTIONS,
|
|
162
|
+
database: db.databaseName
|
|
163
|
+
});
|
|
164
|
+
await using failCommand = await requireFailCommand(client, ctx);
|
|
165
|
+
await failCommand.configure({
|
|
166
|
+
mode: { times: 1 },
|
|
167
|
+
data: {
|
|
168
|
+
failCommands: ['aggregate'],
|
|
169
|
+
errorCode: 50 // MaxTimeMSExpired
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Exercise the real server/driver error path without waiting for the full timeout.
|
|
174
|
+
await expect(adapter.getConnectionSchema()).rejects.toMatchObject({
|
|
175
|
+
name: 'MongoServerError',
|
|
176
|
+
code: 50,
|
|
177
|
+
codeName: 'MaxTimeMSExpired'
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
package/test/src/util.ts
CHANGED
|
@@ -3,13 +3,9 @@ import * as mongo_storage from '@powersync/service-module-mongodb-storage';
|
|
|
3
3
|
import * as postgres_storage from '@powersync/service-module-postgres-storage';
|
|
4
4
|
|
|
5
5
|
import * as types from '@module/types/types.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
TestStorageConfig,
|
|
10
|
-
TestStorageFactory
|
|
11
|
-
} from '@powersync/service-core';
|
|
12
|
-
import { describe, TestContext, TestOptions } from 'vitest';
|
|
6
|
+
import { BSON_DESERIALIZE_DATA_OPTIONS, TestStorageFactory } from '@powersync/service-core';
|
|
7
|
+
import { describeStorageCombinations } from '@powersync/service-core-tests';
|
|
8
|
+
import { TestContext, TestOptions } from 'vitest';
|
|
13
9
|
import { env } from './env.js';
|
|
14
10
|
|
|
15
11
|
export const TEST_URI = env.MONGO_TEST_DATA_URL;
|
|
@@ -29,34 +25,20 @@ export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.
|
|
|
29
25
|
url: env.PG_STORAGE_TEST_URL
|
|
30
26
|
});
|
|
31
27
|
|
|
32
|
-
export const TEST_STORAGE_VERSIONS = SUPPORTED_STORAGE_VERSIONS;
|
|
33
|
-
|
|
34
28
|
export interface StorageVersionTestContext {
|
|
35
29
|
factory: TestStorageFactory;
|
|
36
30
|
storageVersion: number;
|
|
37
31
|
}
|
|
38
32
|
|
|
39
33
|
export function describeWithStorage(options: TestOptions, fn: (context: StorageVersionTestContext) => void) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
if (env.TEST_MONGO_STORAGE) {
|
|
54
|
-
describeFactory('mongodb', INITIALIZED_MONGO_STORAGE_FACTORY);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (env.TEST_POSTGRES_STORAGE) {
|
|
58
|
-
describeFactory('postgres', INITIALIZED_POSTGRES_STORAGE_FACTORY);
|
|
59
|
-
}
|
|
34
|
+
describeStorageCombinations(
|
|
35
|
+
{
|
|
36
|
+
mongodb: env.TEST_MONGO_STORAGE ? INITIALIZED_MONGO_STORAGE_FACTORY : undefined,
|
|
37
|
+
postgres: env.TEST_POSTGRES_STORAGE ? INITIALIZED_POSTGRES_STORAGE_FACTORY : undefined
|
|
38
|
+
},
|
|
39
|
+
options,
|
|
40
|
+
fn
|
|
41
|
+
);
|
|
60
42
|
}
|
|
61
43
|
|
|
62
44
|
export async function clearTestDb(db: mongo.Db) {
|