@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.
@@ -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
- private db: mongo.Db;
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
- // TODO: Implement
68
- return [];
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
- async getReplicationLag(syncRulesId: string): Promise<number> {
72
- // TODO: Implement
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
- return 0;
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
- // TODO: Implement
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
- return [];
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
@@ -0,0 +1,4 @@
1
+ export function escapeRegExp(string: string) {
2
+ // https://stackoverflow.com/a/3561711/214837
3
+ return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
4
+ }
@@ -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
  /**