@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.
@@ -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
- BSON_DESERIALIZE_DATA_OPTIONS,
8
- SUPPORTED_STORAGE_VERSIONS,
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
- const describeFactory = (storageName: string, config: TestStorageConfig) => {
41
- describe(`${storageName} storage`, options, function () {
42
- for (const storageVersion of TEST_STORAGE_VERSIONS) {
43
- describe(`storage v${storageVersion}`, function () {
44
- fn({
45
- factory: config.factory,
46
- storageVersion
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) {