@powersync/service-module-mongodb 0.0.0-dev-20241001150444 → 0.0.0-dev-20241002180742
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 +10 -5
- package/dist/api/MongoRouteAPIAdapter.d.ts +4 -2
- package/dist/api/MongoRouteAPIAdapter.js +203 -8
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +0 -1
- package/dist/replication/ChangeStream.js +1 -4
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ConnectionManagerFactory.js.map +1 -1
- package/dist/replication/MongoErrorRateLimiter.d.ts +0 -1
- package/dist/replication/MongoErrorRateLimiter.js.map +1 -1
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +5 -0
- package/dist/utils.js.map +1 -0
- package/package.json +5 -5
- package/src/api/MongoRouteAPIAdapter.ts +218 -12
- package/src/replication/ChangeStream.ts +1 -5
- package/src/utils.ts +4 -0
- package/test/src/mongo_test.test.ts +46 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { api, ParseSyncRulesOptions } from '@powersync/service-core';
|
|
1
|
+
import { api, ParseSyncRulesOptions, SourceTable } from '@powersync/service-core';
|
|
2
2
|
import * as mongo from 'mongodb';
|
|
3
3
|
|
|
4
4
|
import * as sync_rules from '@powersync/service-sync-rules';
|
|
5
5
|
import * as service_types from '@powersync/service-types';
|
|
6
6
|
import * as types from '../types/types.js';
|
|
7
7
|
import { MongoManager } from '../replication/MongoManager.js';
|
|
8
|
-
import { createCheckpoint, getMongoLsn } from '../replication/MongoRelation.js';
|
|
8
|
+
import { constructAfterRecord, createCheckpoint, getMongoLsn } from '../replication/MongoRelation.js';
|
|
9
|
+
import { escapeRegExp } from '../utils.js';
|
|
9
10
|
|
|
10
11
|
export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
11
12
|
protected client: mongo.MongoClient;
|
|
12
|
-
|
|
13
|
+
public db: mongo.Db;
|
|
13
14
|
|
|
14
15
|
connectionTag: string;
|
|
15
16
|
defaultSchema: string;
|
|
@@ -37,11 +38,21 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async getConnectionStatus(): Promise<service_types.ConnectionStatusV2> {
|
|
40
|
-
// TODO: Implement
|
|
41
41
|
const base = {
|
|
42
42
|
id: this.config.id,
|
|
43
43
|
uri: types.baseUri(this.config)
|
|
44
44
|
};
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await this.client.connect();
|
|
48
|
+
await this.db.command({ hello: 1 });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
connected: false,
|
|
53
|
+
errors: [{ level: 'fatal', message: e.message }]
|
|
54
|
+
};
|
|
55
|
+
}
|
|
45
56
|
return {
|
|
46
57
|
...base,
|
|
47
58
|
connected: true,
|
|
@@ -64,14 +75,100 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
64
75
|
tablePatterns: sync_rules.TablePattern[],
|
|
65
76
|
sqlSyncRules: sync_rules.SqlSyncRules
|
|
66
77
|
): Promise<api.PatternResult[]> {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
let result: api.PatternResult[] = [];
|
|
79
|
+
for (let tablePattern of tablePatterns) {
|
|
80
|
+
const schema = tablePattern.schema;
|
|
81
|
+
|
|
82
|
+
let patternResult: api.PatternResult = {
|
|
83
|
+
schema: schema,
|
|
84
|
+
pattern: tablePattern.tablePattern,
|
|
85
|
+
wildcard: tablePattern.isWildcard
|
|
86
|
+
};
|
|
87
|
+
result.push(patternResult);
|
|
88
|
+
|
|
89
|
+
let nameFilter: RegExp | string;
|
|
90
|
+
if (tablePattern.isWildcard) {
|
|
91
|
+
nameFilter = new RegExp('^' + escapeRegExp(tablePattern.tablePrefix));
|
|
92
|
+
} else {
|
|
93
|
+
nameFilter = tablePattern.name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if the collection exists
|
|
97
|
+
const collections = await this.client
|
|
98
|
+
.db(schema)
|
|
99
|
+
.listCollections(
|
|
100
|
+
{
|
|
101
|
+
name: nameFilter
|
|
102
|
+
},
|
|
103
|
+
{ nameOnly: true }
|
|
104
|
+
)
|
|
105
|
+
.toArray();
|
|
70
106
|
|
|
71
|
-
|
|
72
|
-
|
|
107
|
+
if (tablePattern.isWildcard) {
|
|
108
|
+
patternResult.tables = [];
|
|
109
|
+
for (let collection of collections) {
|
|
110
|
+
const sourceTable = new SourceTable(
|
|
111
|
+
0,
|
|
112
|
+
this.connectionTag,
|
|
113
|
+
collection.name,
|
|
114
|
+
schema,
|
|
115
|
+
collection.name,
|
|
116
|
+
[],
|
|
117
|
+
true
|
|
118
|
+
);
|
|
119
|
+
const syncData = sqlSyncRules.tableSyncsData(sourceTable);
|
|
120
|
+
const syncParameters = sqlSyncRules.tableSyncsParameters(sourceTable);
|
|
121
|
+
patternResult.tables.push({
|
|
122
|
+
schema,
|
|
123
|
+
name: collection.name,
|
|
124
|
+
replication_id: ['_id'],
|
|
125
|
+
data_queries: syncData,
|
|
126
|
+
parameter_queries: syncParameters,
|
|
127
|
+
errors: []
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const sourceTable = new SourceTable(
|
|
132
|
+
0,
|
|
133
|
+
this.connectionTag,
|
|
134
|
+
tablePattern.name,
|
|
135
|
+
schema,
|
|
136
|
+
tablePattern.name,
|
|
137
|
+
[],
|
|
138
|
+
true
|
|
139
|
+
);
|
|
73
140
|
|
|
74
|
-
|
|
141
|
+
const syncData = sqlSyncRules.tableSyncsData(sourceTable);
|
|
142
|
+
const syncParameters = sqlSyncRules.tableSyncsParameters(sourceTable);
|
|
143
|
+
|
|
144
|
+
if (collections.length == 1) {
|
|
145
|
+
patternResult.table = {
|
|
146
|
+
schema,
|
|
147
|
+
name: tablePattern.name,
|
|
148
|
+
replication_id: ['_id'],
|
|
149
|
+
data_queries: syncData,
|
|
150
|
+
parameter_queries: syncParameters,
|
|
151
|
+
errors: []
|
|
152
|
+
};
|
|
153
|
+
} else {
|
|
154
|
+
patternResult.table = {
|
|
155
|
+
schema,
|
|
156
|
+
name: tablePattern.name,
|
|
157
|
+
replication_id: ['_id'],
|
|
158
|
+
data_queries: syncData,
|
|
159
|
+
parameter_queries: syncParameters,
|
|
160
|
+
errors: [{ level: 'warning', message: `Collection ${schema}.${tablePattern.name} not found` }]
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getReplicationLag(syncRulesId: string): Promise<number | undefined> {
|
|
169
|
+
// There is no fast way to get replication lag in bytes in MongoDB.
|
|
170
|
+
// We can get replication lag in seconds, but need a different API for that.
|
|
171
|
+
return undefined;
|
|
75
172
|
}
|
|
76
173
|
|
|
77
174
|
async getReplicationHead(): Promise<string> {
|
|
@@ -79,8 +176,117 @@ export class MongoRouteAPIAdapter implements api.RouteAPI {
|
|
|
79
176
|
}
|
|
80
177
|
|
|
81
178
|
async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
|
|
82
|
-
|
|
179
|
+
const sampleSize = 50;
|
|
180
|
+
|
|
181
|
+
const databases = await this.db.admin().listDatabases({ authorizedDatabases: true, nameOnly: true });
|
|
182
|
+
const filteredDatabases = databases.databases.filter((db) => {
|
|
183
|
+
return !['local', 'admin', 'config'].includes(db.name);
|
|
184
|
+
});
|
|
185
|
+
return await Promise.all(
|
|
186
|
+
filteredDatabases.map(async (db) => {
|
|
187
|
+
const collections = await this.client.db(db.name).listCollections().toArray();
|
|
188
|
+
const filtered = collections.filter((c) => {
|
|
189
|
+
return !['_powersync_checkpoints'].includes(c.name);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const tables = await Promise.all(
|
|
193
|
+
filtered.map(async (collection) => {
|
|
194
|
+
const sampleDocuments = await this.db
|
|
195
|
+
.collection(collection.name)
|
|
196
|
+
.aggregate([{ $sample: { size: sampleSize } }])
|
|
197
|
+
.toArray();
|
|
198
|
+
|
|
199
|
+
if (sampleDocuments.length > 0) {
|
|
200
|
+
const columns = this.getColumnsFromDocuments(sampleDocuments);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
name: collection.name,
|
|
204
|
+
// Since documents are sampled in a random order, we need to sort
|
|
205
|
+
// to get a consistent order
|
|
206
|
+
columns: columns.sort((a, b) => a.name.localeCompare(b.name))
|
|
207
|
+
} satisfies service_types.TableSchema;
|
|
208
|
+
} else {
|
|
209
|
+
return {
|
|
210
|
+
name: collection.name,
|
|
211
|
+
columns: []
|
|
212
|
+
} satisfies service_types.TableSchema;
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
return {
|
|
217
|
+
name: db.name,
|
|
218
|
+
tables: tables
|
|
219
|
+
} satisfies service_types.DatabaseSchema;
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private getColumnsFromDocuments(documents: mongo.BSON.Document[]) {
|
|
225
|
+
let columns = new Map<string, { sqliteType: sync_rules.ExpressionType; bsonTypes: Set<string> }>();
|
|
226
|
+
for (const document of documents) {
|
|
227
|
+
const parsed = constructAfterRecord(document);
|
|
228
|
+
for (const key in parsed) {
|
|
229
|
+
const value = parsed[key];
|
|
230
|
+
const type = sync_rules.sqliteTypeOf(value);
|
|
231
|
+
const sqliteType = sync_rules.ExpressionType.fromTypeText(type);
|
|
232
|
+
let entry = columns.get(key);
|
|
233
|
+
if (entry == null) {
|
|
234
|
+
entry = { sqliteType, bsonTypes: new Set() };
|
|
235
|
+
columns.set(key, entry);
|
|
236
|
+
} else {
|
|
237
|
+
entry.sqliteType = entry.sqliteType.or(sqliteType);
|
|
238
|
+
}
|
|
239
|
+
const bsonType = this.getBsonType(document[key]);
|
|
240
|
+
if (bsonType != null) {
|
|
241
|
+
entry.bsonTypes.add(bsonType);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return [...columns.entries()].map(([key, value]) => {
|
|
246
|
+
return {
|
|
247
|
+
name: key,
|
|
248
|
+
sqlite_type: value.sqliteType.typeFlags,
|
|
249
|
+
internal_type: value.bsonTypes.size == 0 ? '' : [...value.bsonTypes].join(' | ')
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}
|
|
83
253
|
|
|
84
|
-
|
|
254
|
+
private getBsonType(data: any): string | null {
|
|
255
|
+
if (data == null) {
|
|
256
|
+
// null or undefined
|
|
257
|
+
return 'Null';
|
|
258
|
+
} else if (typeof data == 'string') {
|
|
259
|
+
return 'String';
|
|
260
|
+
} else if (typeof data == 'number') {
|
|
261
|
+
if (Number.isInteger(data)) {
|
|
262
|
+
return 'Integer';
|
|
263
|
+
} else {
|
|
264
|
+
return 'Double';
|
|
265
|
+
}
|
|
266
|
+
} else if (typeof data == 'bigint') {
|
|
267
|
+
return 'Long';
|
|
268
|
+
} else if (typeof data == 'boolean') {
|
|
269
|
+
return 'Boolean';
|
|
270
|
+
} else if (data instanceof mongo.ObjectId) {
|
|
271
|
+
return 'ObjectId';
|
|
272
|
+
} else if (data instanceof mongo.UUID) {
|
|
273
|
+
return 'UUID';
|
|
274
|
+
} else if (data instanceof Date) {
|
|
275
|
+
return 'Date';
|
|
276
|
+
} else if (data instanceof mongo.Timestamp) {
|
|
277
|
+
return 'Timestamp';
|
|
278
|
+
} else if (data instanceof mongo.Binary) {
|
|
279
|
+
return 'Binary';
|
|
280
|
+
} else if (data instanceof mongo.Long) {
|
|
281
|
+
return 'Long';
|
|
282
|
+
} else if (Array.isArray(data)) {
|
|
283
|
+
return 'Array';
|
|
284
|
+
} else if (data instanceof Uint8Array) {
|
|
285
|
+
return 'Binary';
|
|
286
|
+
} else if (typeof data == 'object') {
|
|
287
|
+
return 'Object';
|
|
288
|
+
} else {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
85
291
|
}
|
|
86
292
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getMongoRelation,
|
|
11
11
|
mongoLsnToTimestamp
|
|
12
12
|
} from './MongoRelation.js';
|
|
13
|
+
import { escapeRegExp } from '../utils.js';
|
|
13
14
|
|
|
14
15
|
export const ZERO_LSN = '0000000000000000';
|
|
15
16
|
|
|
@@ -496,8 +497,3 @@ async function touch() {
|
|
|
496
497
|
// or reduce PING_INTERVAL here.
|
|
497
498
|
return container.probes.touch();
|
|
498
499
|
}
|
|
499
|
-
|
|
500
|
-
function escapeRegExp(string: string) {
|
|
501
|
-
// https://stackoverflow.com/a/3561711/214837
|
|
502
|
-
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
503
|
-
}
|
package/src/utils.ts
ADDED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
|
|
1
2
|
import { ChangeStream } from '@module/replication/ChangeStream.js';
|
|
3
|
+
import { constructAfterRecord } from '@module/replication/MongoRelation.js';
|
|
4
|
+
import { SqliteRow } from '@powersync/service-sync-rules';
|
|
2
5
|
import * as mongo from 'mongodb';
|
|
3
6
|
import { describe, expect, test } from 'vitest';
|
|
4
|
-
import { clearTestDb, connectMongoData } from './util.js';
|
|
5
|
-
import { SqliteRow } from '@powersync/service-sync-rules';
|
|
6
|
-
import { constructAfterRecord } from '@module/replication/MongoRelation.js';
|
|
7
|
+
import { clearTestDb, connectMongoData, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
7
8
|
|
|
8
9
|
describe('mongo data types', () => {
|
|
9
10
|
async function setupTable(db: mongo.Db) {
|
|
@@ -202,6 +203,48 @@ describe('mongo data types', () => {
|
|
|
202
203
|
await client.close();
|
|
203
204
|
}
|
|
204
205
|
});
|
|
206
|
+
|
|
207
|
+
test('connection schema', async () => {
|
|
208
|
+
const adapter = new MongoRouteAPIAdapter({
|
|
209
|
+
type: 'mongodb',
|
|
210
|
+
...TEST_CONNECTION_OPTIONS
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
const db = adapter.db;
|
|
214
|
+
await clearTestDb(db);
|
|
215
|
+
|
|
216
|
+
const collection = db.collection('test_data');
|
|
217
|
+
await setupTable(db);
|
|
218
|
+
await insert(collection);
|
|
219
|
+
|
|
220
|
+
const schema = await adapter.getConnectionSchema();
|
|
221
|
+
const dbSchema = schema.filter((s) => s.name == TEST_CONNECTION_OPTIONS.database)[0];
|
|
222
|
+
expect(dbSchema).not.toBeNull();
|
|
223
|
+
expect(dbSchema.tables).toEqual([
|
|
224
|
+
{
|
|
225
|
+
name: 'test_data',
|
|
226
|
+
columns: [
|
|
227
|
+
{ name: '_id', sqlite_type: 4, internal_type: 'Integer' },
|
|
228
|
+
{ name: 'bool', sqlite_type: 4, internal_type: 'Boolean' },
|
|
229
|
+
{ name: 'bytea', sqlite_type: 1, internal_type: 'Binary' },
|
|
230
|
+
{ name: 'date', sqlite_type: 2, internal_type: 'Date' },
|
|
231
|
+
{ name: 'float', sqlite_type: 8, internal_type: 'Double' },
|
|
232
|
+
{ name: 'int2', sqlite_type: 4, internal_type: 'Integer' },
|
|
233
|
+
{ name: 'int4', sqlite_type: 4, internal_type: 'Integer' },
|
|
234
|
+
{ name: 'int8', sqlite_type: 4, internal_type: 'Long' },
|
|
235
|
+
{ name: 'nested', sqlite_type: 2, internal_type: 'Object' },
|
|
236
|
+
{ name: 'null', sqlite_type: 0, internal_type: 'Null' },
|
|
237
|
+
{ name: 'objectId', sqlite_type: 2, internal_type: 'ObjectId' },
|
|
238
|
+
{ name: 'text', sqlite_type: 2, internal_type: 'String' },
|
|
239
|
+
{ name: 'timestamp', sqlite_type: 4, internal_type: 'Timestamp' },
|
|
240
|
+
{ name: 'uuid', sqlite_type: 2, internal_type: 'UUID' }
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
]);
|
|
244
|
+
} finally {
|
|
245
|
+
await adapter.shutdown();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
205
248
|
});
|
|
206
249
|
|
|
207
250
|
/**
|