@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.
- package/CHANGELOG.md +38 -6
- 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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/module/MongoModule.d.ts +1 -2
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +10 -2
- package/dist/replication/ChangeStream.js +228 -317
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
- package/dist/replication/ChangeStreamReplicator.js +3 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +85 -0
- package/dist/replication/MongoReplicationStream.js +101 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
- package/dist/replication/MongoSnapshotQuery.js +12 -2
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +7 -2
- package/dist/replication/MongoSnapshotter.js +58 -85
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +1 -1
- package/dist/replication/RawChangeStream.js +1 -1
- 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/dist/replication/replication-index.d.ts +6 -0
- package/dist/replication/replication-index.js +6 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
- package/dist/test-utils/ChangeStreamTestContext.js +391 -0
- package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +2 -0
- package/dist/test-utils/test-utils-index.js.map +1 -0
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +1 -3
- package/src/replication/ChangeStream.ts +253 -359
- package/src/replication/ChangeStreamReplicationJob.ts +7 -2
- package/src/replication/ChangeStreamReplicator.ts +5 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +108 -0
- package/src/replication/MongoReplicationStream.ts +201 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +70 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/replication-index.ts +6 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/documentdb_mode.test.ts +3 -3
- package/test/src/replication_stream.test.ts +311 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +223 -0
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_query_provider.test.ts +95 -0
- package/test/src/stream_progress.test.ts +312 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -8,10 +8,12 @@ import { logger } from '@powersync/lib-services-framework';
|
|
|
8
8
|
import { CheckpointImplementation } from '../replication/checkpoints/CheckpointImplementation.js';
|
|
9
9
|
import { createCheckpointImplementation } from '../replication/checkpoints/create-checkpoint-implementation.js';
|
|
10
10
|
import { MongoManager } from '../replication/MongoManager.js';
|
|
11
|
-
import { constructAfterRecord } from '../replication/MongoRelation.js';
|
|
12
11
|
import { CHECKPOINTS_COLLECTION, detectDocumentDb } from '../replication/replication-utils.js';
|
|
13
12
|
import * as types from '../types/types.js';
|
|
14
13
|
import { escapeRegExp } from '../utils.js';
|
|
14
|
+
import { inferCollectionSchema, isSchemaInferenceFailure } from './infer-collection-schema.js';
|
|
15
|
+
|
|
16
|
+
const SCHEMA_INFERENCE_CONCURRENCY = 4;
|
|
15
17
|
|
|
16
18
|
export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
17
19
|
protected client: mongo.MongoClient;
|
|
@@ -21,6 +23,7 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
21
23
|
defaultSchema: string;
|
|
22
24
|
|
|
23
25
|
private checkpointImplementation: CheckpointImplementation | null = null;
|
|
26
|
+
private documentDbDetection: Promise<boolean> | null = null;
|
|
24
27
|
|
|
25
28
|
constructor(protected config: types.ResolvedConnectionConfig) {
|
|
26
29
|
const manager = new MongoManager(config);
|
|
@@ -147,7 +150,7 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
147
150
|
};
|
|
148
151
|
let errors: service_types.ReplicationError[] = [];
|
|
149
152
|
if (collection.type == 'view') {
|
|
150
|
-
errors.push({ level: 'warning', message: `Collection ${schema}.${
|
|
153
|
+
errors.push({ level: 'warning', message: `Collection ${schema}.${collection.name} is a view` });
|
|
151
154
|
} else {
|
|
152
155
|
errors.push(...validatePostImages(schema, collection));
|
|
153
156
|
}
|
|
@@ -206,9 +209,18 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
206
209
|
return checkpointImplementation.createReplicationHead(callback);
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
private isDocumentDb(): Promise<boolean> {
|
|
213
|
+
// Share in-flight detection and cache its result for this adapter's lifetime.
|
|
214
|
+
return (this.documentDbDetection ??= detectDocumentDb(this.db).catch((error) => {
|
|
215
|
+
// Allow a later request to retry after a transient connection error.
|
|
216
|
+
this.documentDbDetection = null;
|
|
217
|
+
throw error;
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
209
221
|
private async getCheckpointImplementation(): Promise<CheckpointImplementation> {
|
|
210
222
|
if (this.checkpointImplementation == null) {
|
|
211
|
-
const isDocumentDb = await
|
|
223
|
+
const isDocumentDb = await this.isDocumentDb();
|
|
212
224
|
this.checkpointImplementation = createCheckpointImplementation(isDocumentDb, {
|
|
213
225
|
client: this.client,
|
|
214
226
|
db: this.db,
|
|
@@ -226,32 +238,38 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
|
|
229
|
-
const
|
|
230
|
-
|
|
241
|
+
const isDocumentDb = await this.isDocumentDb();
|
|
231
242
|
const databases = await this.db.admin().listDatabases({ nameOnly: true });
|
|
232
243
|
const filteredDatabases = databases.databases.filter((db) => {
|
|
233
244
|
return !['local', 'admin', 'config'].includes(db.name);
|
|
234
245
|
});
|
|
235
|
-
const databaseSchemas =
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
throw e;
|
|
246
|
+
const databaseSchemas: service_types.DatabaseSchema[] = [];
|
|
247
|
+
for (const db of filteredDatabases) {
|
|
248
|
+
/**
|
|
249
|
+
* Filtering the list of database with `authorizedDatabases: true`
|
|
250
|
+
* does not produce the full list of databases under some circumstances.
|
|
251
|
+
* This catches any potential auth errors.
|
|
252
|
+
*/
|
|
253
|
+
let collections: mongo.CollectionInfo[];
|
|
254
|
+
try {
|
|
255
|
+
collections = await this.client.db(db.name).listCollections().toArray();
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (lib_mongo.isMongoServerError(e) && e.codeName == 'Unauthorized') {
|
|
258
|
+
// Ignore databases we're not authorized to query
|
|
259
|
+
continue;
|
|
251
260
|
}
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
252
263
|
|
|
253
|
-
|
|
254
|
-
|
|
264
|
+
const tables: (service_types.TableSchema | undefined)[] = new Array(collections.length);
|
|
265
|
+
const pendingCollections = collections.entries();
|
|
266
|
+
let failed = false;
|
|
267
|
+
const inferCollections = async () => {
|
|
268
|
+
// Reading from the iterator automatically manages the queue
|
|
269
|
+
for (const [index, collection] of pendingCollections) {
|
|
270
|
+
if (failed) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
255
273
|
if ([CHECKPOINTS_COLLECTION].includes(collection.name)) {
|
|
256
274
|
continue;
|
|
257
275
|
}
|
|
@@ -264,121 +282,55 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
264
282
|
continue;
|
|
265
283
|
}
|
|
266
284
|
try {
|
|
267
|
-
const
|
|
268
|
-
.collection(collection.name)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const columns = this.getColumnsFromDocuments(sampleDocuments);
|
|
274
|
-
|
|
275
|
-
tables.push({
|
|
276
|
-
name: collection.name,
|
|
277
|
-
// Since documents are sampled in a random order, we need to sort
|
|
278
|
-
// to get a consistent order
|
|
279
|
-
columns: columns.sort((a, b) => a.name.localeCompare(b.name))
|
|
280
|
-
});
|
|
281
|
-
} else {
|
|
282
|
-
tables.push({
|
|
283
|
-
name: collection.name,
|
|
284
|
-
columns: []
|
|
285
|
-
});
|
|
286
|
-
}
|
|
285
|
+
const columns = await inferCollectionSchema(
|
|
286
|
+
this.client.db(db.name).collection(collection.name),
|
|
287
|
+
isDocumentDb
|
|
288
|
+
);
|
|
289
|
+
// Preserve collection order even when queries finish out of order.
|
|
290
|
+
tables[index] = { name: collection.name, columns };
|
|
287
291
|
} catch (e) {
|
|
288
292
|
if (lib_mongo.isMongoServerError(e) && e.codeName == 'Unauthorized') {
|
|
289
293
|
// Ignore collections we're not authorized to query
|
|
290
294
|
continue;
|
|
291
295
|
}
|
|
296
|
+
if (isSchemaInferenceFailure(e)) {
|
|
297
|
+
// We know the collection is there, we just could not read its fields. Report it
|
|
298
|
+
// without columns rather than failing: one collection we cannot sample should not
|
|
299
|
+
// block validation and deployment for every sync config on the instance. Queries
|
|
300
|
+
// naming its columns warn about unknown columns; the result is indistinguishable
|
|
301
|
+
// from an empty collection, so a `SELECT *` query still validates cleanly.
|
|
302
|
+
logger.warn(`Could not infer the schema of ${db.name}.${collection.name}`, e);
|
|
303
|
+
tables[index] = { name: collection.name, columns: [] };
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// Fail the whole request on unexpected errors: the response cannot indicate
|
|
307
|
+
// partial results, so omitting a collection would make an incomplete schema
|
|
308
|
+
// appear complete and could produce an incorrect generated client schema.
|
|
309
|
+
failed = true;
|
|
292
310
|
throw e;
|
|
293
311
|
}
|
|
294
312
|
}
|
|
295
|
-
|
|
296
|
-
return {
|
|
297
|
-
name: db.name,
|
|
298
|
-
tables: tables
|
|
299
|
-
} satisfies service_types.DatabaseSchema;
|
|
300
|
-
})
|
|
301
|
-
);
|
|
302
|
-
return databaseSchemas.filter((schema) => !!schema);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
private getColumnsFromDocuments(documents: mongo.BSON.Document[]) {
|
|
306
|
-
let columns = new Map<string, { sqliteType: sync_rules.ExpressionType; bsonTypes: Set<string> }>();
|
|
307
|
-
for (const document of documents) {
|
|
308
|
-
const parsed = constructAfterRecord(document);
|
|
309
|
-
for (const key in parsed) {
|
|
310
|
-
const value = parsed[key];
|
|
311
|
-
const type = sync_rules.sqliteTypeOf(value);
|
|
312
|
-
const sqliteType = sync_rules.ExpressionType.fromTypeText(type);
|
|
313
|
-
let entry = columns.get(key);
|
|
314
|
-
if (entry == null) {
|
|
315
|
-
entry = { sqliteType, bsonTypes: new Set() };
|
|
316
|
-
columns.set(key, entry);
|
|
317
|
-
} else {
|
|
318
|
-
entry.sqliteType = entry.sqliteType.or(sqliteType);
|
|
319
|
-
}
|
|
320
|
-
const bsonType = this.getBsonType(document[key]);
|
|
321
|
-
if (bsonType != null) {
|
|
322
|
-
entry.bsonTypes.add(bsonType);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return [...columns.entries()].map(([key, value]) => {
|
|
327
|
-
const internal_type = value.bsonTypes.size == 0 ? '' : [...value.bsonTypes].join(' | ');
|
|
328
|
-
return {
|
|
329
|
-
name: key,
|
|
330
|
-
type: internal_type,
|
|
331
|
-
sqlite_type: value.sqliteType.typeFlags,
|
|
332
|
-
internal_type,
|
|
333
|
-
pg_type: internal_type
|
|
334
313
|
};
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
314
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
315
|
+
// Each worker holds only schema metadata, with a bounded number of source queries.
|
|
316
|
+
const workers = Array.from(
|
|
317
|
+
{ length: Math.min(SCHEMA_INFERENCE_CONCURRENCY, collections.length) },
|
|
318
|
+
inferCollections
|
|
319
|
+
);
|
|
320
|
+
try {
|
|
321
|
+
await Promise.all(workers);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
// Finish in-flight queries before rejecting the request. Workers stop taking
|
|
324
|
+
// new collections as soon as one encounters a non-authorization error.
|
|
325
|
+
await Promise.allSettled(workers);
|
|
326
|
+
throw e;
|
|
349
327
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return 'ObjectId';
|
|
356
|
-
} else if (data instanceof mongo.UUID) {
|
|
357
|
-
return 'UUID';
|
|
358
|
-
} else if (data instanceof Date) {
|
|
359
|
-
return 'Date';
|
|
360
|
-
} else if (data instanceof mongo.Timestamp) {
|
|
361
|
-
return 'Timestamp';
|
|
362
|
-
} else if (data instanceof mongo.Binary) {
|
|
363
|
-
return 'Binary';
|
|
364
|
-
} else if (data instanceof mongo.Long) {
|
|
365
|
-
return 'Long';
|
|
366
|
-
} else if (data instanceof RegExp) {
|
|
367
|
-
return 'RegExp';
|
|
368
|
-
} else if (data instanceof mongo.MinKey) {
|
|
369
|
-
return 'MinKey';
|
|
370
|
-
} else if (data instanceof mongo.MaxKey) {
|
|
371
|
-
return 'MaxKey';
|
|
372
|
-
} else if (data instanceof mongo.Decimal128) {
|
|
373
|
-
return 'Decimal';
|
|
374
|
-
} else if (Array.isArray(data)) {
|
|
375
|
-
return 'Array';
|
|
376
|
-
} else if (data instanceof Uint8Array) {
|
|
377
|
-
return 'Binary';
|
|
378
|
-
} else if (typeof data == 'object') {
|
|
379
|
-
return 'Object';
|
|
380
|
-
} else {
|
|
381
|
-
return null;
|
|
328
|
+
|
|
329
|
+
databaseSchemas.push({
|
|
330
|
+
name: db.name,
|
|
331
|
+
tables: tables.filter((table) => table != null)
|
|
332
|
+
});
|
|
382
333
|
}
|
|
334
|
+
return databaseSchemas;
|
|
383
335
|
}
|
|
384
336
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { ExpressionType } from '@powersync/service-sync-rules';
|
|
4
|
+
import { TableSchema } from '@powersync/service-types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server errors that mean this one collection could not be inspected, rather than the
|
|
8
|
+
* connection being unusable.
|
|
9
|
+
*
|
|
10
|
+
* These come from the memory ceiling {@link inferCollectionSchema} imposes on itself by
|
|
11
|
+
* refusing to spill to disk, and are deterministic per collection. Timeouts are
|
|
12
|
+
* deliberately excluded: they usually indicate an overloaded source, where degrading
|
|
13
|
+
* would silently empty the schema of every collection at once.
|
|
14
|
+
*/
|
|
15
|
+
const SCHEMA_INFERENCE_FAILURE_CODES = new Set([
|
|
16
|
+
// ExceededMemoryLimit - a blocking stage ran out of memory with disk spill disabled.
|
|
17
|
+
146,
|
|
18
|
+
// QueryExceededMemoryLimitNoDiskUseAllowed - as above, reported by the sort $sample
|
|
19
|
+
// uses on collections too small for its random cursor.
|
|
20
|
+
292
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the error means we could not determine this collection's fields, as opposed to
|
|
25
|
+
* something that makes the rest of the schema unreliable too.
|
|
26
|
+
*/
|
|
27
|
+
export function isSchemaInferenceFailure(e: unknown): boolean {
|
|
28
|
+
return lib_mongo.isMongoServerError(e) && SCHEMA_INFERENCE_FAILURE_CODES.has(e.code as number);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Match the types exposed after BSON deserialization and conversion to sync rules values.
|
|
32
|
+
const BSON_TYPES: Record<string, { name: string; sqliteType: ExpressionType }> = {
|
|
33
|
+
int: { name: 'Integer', sqliteType: ExpressionType.INTEGER },
|
|
34
|
+
long: { name: 'Long', sqliteType: ExpressionType.INTEGER },
|
|
35
|
+
double: { name: 'Double', sqliteType: ExpressionType.REAL },
|
|
36
|
+
decimal: { name: 'Decimal', sqliteType: ExpressionType.TEXT },
|
|
37
|
+
string: { name: 'String', sqliteType: ExpressionType.TEXT },
|
|
38
|
+
symbol: { name: 'String', sqliteType: ExpressionType.TEXT },
|
|
39
|
+
bool: { name: 'Boolean', sqliteType: ExpressionType.INTEGER },
|
|
40
|
+
objectId: { name: 'ObjectId', sqliteType: ExpressionType.TEXT },
|
|
41
|
+
uuid: { name: 'UUID', sqliteType: ExpressionType.TEXT },
|
|
42
|
+
binData: { name: 'Binary', sqliteType: ExpressionType.BLOB },
|
|
43
|
+
date: { name: 'Date', sqliteType: ExpressionType.TEXT },
|
|
44
|
+
timestamp: { name: 'Timestamp', sqliteType: ExpressionType.INTEGER },
|
|
45
|
+
regex: { name: 'RegExp', sqliteType: ExpressionType.TEXT },
|
|
46
|
+
object: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
47
|
+
array: { name: 'Array', sqliteType: ExpressionType.TEXT },
|
|
48
|
+
javascript: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
49
|
+
javascriptWithScope: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
50
|
+
dbPointer: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
51
|
+
null: { name: 'Null', sqliteType: ExpressionType.NONE },
|
|
52
|
+
undefined: { name: 'Null', sqliteType: ExpressionType.NONE },
|
|
53
|
+
minKey: { name: 'MinKey', sqliteType: ExpressionType.NONE },
|
|
54
|
+
maxKey: { name: 'MaxKey', sqliteType: ExpressionType.NONE }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Infer top-level fields without transferring sampled document values to the service.
|
|
59
|
+
* Memory on the service scales with the inferred schema, not the size of those values.
|
|
60
|
+
*/
|
|
61
|
+
export async function inferCollectionSchema(
|
|
62
|
+
collection: mongo.Collection,
|
|
63
|
+
isDocumentDb: boolean
|
|
64
|
+
): Promise<TableSchema['columns']> {
|
|
65
|
+
const fields = await collection
|
|
66
|
+
.aggregate<{ _id: string; types: string[] }>(
|
|
67
|
+
[
|
|
68
|
+
// Keep this first so MongoDB can use its random cursor on large collections.
|
|
69
|
+
{ $sample: { size: 50 } },
|
|
70
|
+
{
|
|
71
|
+
$project: {
|
|
72
|
+
_id: 0,
|
|
73
|
+
fields: {
|
|
74
|
+
$map: {
|
|
75
|
+
input: { $objectToArray: '$$ROOT' },
|
|
76
|
+
as: 'field',
|
|
77
|
+
in: {
|
|
78
|
+
name: '$$field.k',
|
|
79
|
+
type: {
|
|
80
|
+
$let: {
|
|
81
|
+
vars: { bsonType: { $type: '$$field.v' } },
|
|
82
|
+
in: {
|
|
83
|
+
$switch: {
|
|
84
|
+
branches: [
|
|
85
|
+
{
|
|
86
|
+
case: { $eq: ['$$bsonType', 'double'] },
|
|
87
|
+
// Whole-number doubles are replicated as SQLite integers.
|
|
88
|
+
then: { $cond: [{ $eq: [{ $mod: ['$$field.v', 1] }, 0] }, 'int', 'double'] }
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
case: { $eq: ['$$bsonType', 'binData'] },
|
|
92
|
+
then: {
|
|
93
|
+
$cond: [
|
|
94
|
+
// BinData compares by length, then subtype, then bytes. Only
|
|
95
|
+
// 16-byte values of subtype 4 fall within this UUID range.
|
|
96
|
+
// This works without transferring binary values or requiring
|
|
97
|
+
// the newer MongoDB binary conversion operators.
|
|
98
|
+
// https://www.mongodb.com/docs/manual/reference/bson-type-comparison-order/#bindata
|
|
99
|
+
{
|
|
100
|
+
$and: [
|
|
101
|
+
{ $gte: ['$$field.v', new mongo.UUID('00000000-0000-0000-0000-000000000000')] },
|
|
102
|
+
{ $lte: ['$$field.v', new mongo.UUID('ffffffff-ffff-ffff-ffff-ffffffffffff')] }
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
'uuid',
|
|
106
|
+
'binData'
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
default: '$$bsonType'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
// Discard values before unwinding so large documents aren't duplicated per field.
|
|
122
|
+
{ $unwind: '$fields' },
|
|
123
|
+
{ $group: { _id: '$fields.name', types: { $addToSet: '$fields.type' } } }
|
|
124
|
+
],
|
|
125
|
+
{
|
|
126
|
+
// Bound execution per collection below the default 60-second socket timeout
|
|
127
|
+
// so MongoDB can return a query timeout before the connection times out.
|
|
128
|
+
maxTimeMS: 30_000,
|
|
129
|
+
// Small collections can require $sample to sort full documents. Disable
|
|
130
|
+
// disk spill explicitly so concurrent schema queries fail at the memory
|
|
131
|
+
// limit without adding temporary-file I/O on the source database.
|
|
132
|
+
allowDiskUse: false,
|
|
133
|
+
// Field names are case-sensitive even when the collection's default collation isn't.
|
|
134
|
+
// DocumentDB rejects the collation option, including simple collation.
|
|
135
|
+
...(isDocumentDb ? {} : { collation: { locale: 'simple' } })
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
.toArray();
|
|
139
|
+
|
|
140
|
+
return fields
|
|
141
|
+
.map(({ _id: name, types }) => {
|
|
142
|
+
let sqliteType = ExpressionType.NONE;
|
|
143
|
+
const bsonTypes = new Set<string>();
|
|
144
|
+
for (const type of types) {
|
|
145
|
+
const inferred = BSON_TYPES[type];
|
|
146
|
+
sqliteType = sqliteType.or(inferred.sqliteType);
|
|
147
|
+
bsonTypes.add(inferred.name);
|
|
148
|
+
}
|
|
149
|
+
const internal_type = [...bsonTypes].sort().join(' | ');
|
|
150
|
+
return { name, type: internal_type, sqlite_type: sqliteType.typeFlags, internal_type, pg_type: internal_type };
|
|
151
|
+
})
|
|
152
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,12 +44,10 @@ export class MongoModule extends replication.ReplicationModule<types.MongoConnec
|
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
async onInitialized(context: system.ServiceContextContainer): Promise<void> {}
|
|
48
|
-
|
|
49
47
|
/**
|
|
50
48
|
* Combines base config with normalized connection settings
|
|
51
49
|
*/
|
|
52
|
-
|
|
50
|
+
protected resolveConfig(config: types.MongoConnectionConfig): types.ResolvedConnectionConfig {
|
|
53
51
|
return {
|
|
54
52
|
...config,
|
|
55
53
|
...types.normalizeConnectionConfig(config)
|