@rebasepro/server-mongo 0.13.0 → 0.13.1-canary.g06dbe5b
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/dist/MongoBootstrapper.d.ts.map +1 -1
- package/dist/auth/ensure-collections.d.ts.map +1 -1
- package/dist/db/MongoConditionBuilder.d.ts +28 -1
- package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
- package/dist/db/MongoDataService.d.ts +19 -1
- package/dist/db/MongoDataService.d.ts.map +1 -1
- package/dist/db/securityRuleFilter.d.ts +65 -0
- package/dist/db/securityRuleFilter.d.ts.map +1 -0
- package/dist/ensure-collections-Bl12bSwO.js +130 -0
- package/dist/ensure-collections-Bl12bSwO.js.map +1 -0
- package/dist/factory.d.ts +11 -1
- package/dist/factory.d.ts.map +1 -1
- package/dist/index.es.js +487 -249
- package/dist/index.es.js.map +1 -1
- package/dist/services/MongoDriver.d.ts +37 -5
- package/dist/services/MongoDriver.d.ts.map +1 -1
- package/dist/services/MongoRealtimeService.d.ts +23 -14
- package/dist/services/MongoRealtimeService.d.ts.map +1 -1
- package/dist/{websocket-74DDOdK0.js → websocket-Bs54-gR-.js} +32 -11
- package/dist/websocket-Bs54-gR-.js.map +1 -0
- package/dist/websocket.d.ts +2 -2
- package/dist/websocket.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/MongoBootstrapper.ts +10 -2
- package/src/auth/ensure-collections.ts +61 -13
- package/src/db/MongoConditionBuilder.ts +112 -40
- package/src/db/MongoDataService.ts +51 -14
- package/src/db/securityRuleFilter.ts +398 -0
- package/src/factory.ts +17 -4
- package/src/services/MongoDriver.ts +101 -262
- package/src/services/MongoRealtimeService.ts +124 -66
- package/src/websocket.ts +66 -15
- package/dist/ensure-collections-Bkx_O5CQ.js +0 -94
- package/dist/ensure-collections-Bkx_O5CQ.js.map +0 -1
- package/dist/websocket-74DDOdK0.js.map +0 -1
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Translates Rebase filter conditions to MongoDB query operators.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { CollectionConfig, FilterValues, WhereFilterOp } from "@rebasepro/types";
|
|
7
|
+
import { CollectionConfig, FilterCondition, FilterValues, LogicalCondition, WhereFilterOp } from "@rebasepro/types";
|
|
8
|
+
import { toFilterTuples } from "@rebasepro/common";
|
|
8
9
|
import { Filter, Document } from "mongodb";
|
|
9
|
-
import { logger } from "@rebasepro/server";
|
|
10
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Mapping from Rebase filter operators to MongoDB query operators
|
|
@@ -35,10 +36,22 @@ function escapeRegExp(str: string): string {
|
|
|
35
36
|
*/
|
|
36
37
|
function likePatternToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
|
|
37
38
|
let body = "";
|
|
39
|
+
// Runs of `%` collapse to one. `%%%%X` means exactly what `%X` means, but as
|
|
40
|
+
// a regular expression it is four adjacent unbounded quantifiers, and on a
|
|
41
|
+
// subject that does not match, the engine tries every way of splitting the
|
|
42
|
+
// subject between them — exponential time. The pattern comes from a public
|
|
43
|
+
// filter operator over HTTP (`?title=like.%25%25%25…`), and this expression
|
|
44
|
+
// is handed to MongoDB as `$regex`, so the time is spent on a database
|
|
45
|
+
// thread rather than the caller's.
|
|
46
|
+
let lastWasWildcard = false;
|
|
38
47
|
for (const ch of String(pattern)) {
|
|
39
|
-
if (ch === "%")
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
if (ch === "%") {
|
|
49
|
+
if (!lastWasWildcard) body += ".*";
|
|
50
|
+
lastWasWildcard = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
body += ch === "_" ? "." : escapeRegExp(ch);
|
|
54
|
+
lastWasWildcard = false;
|
|
42
55
|
}
|
|
43
56
|
return new RegExp(`^${body}$`, caseInsensitive ? "i" : "");
|
|
44
57
|
}
|
|
@@ -66,49 +79,98 @@ export class MongoConditionBuilder {
|
|
|
66
79
|
for (const [field, filterParam] of Object.entries(filter)) {
|
|
67
80
|
if (!filterParam) continue;
|
|
68
81
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (op === "is-not-null") {
|
|
77
|
-
conditions.push({ [field]: { $ne: null } });
|
|
78
|
-
continue;
|
|
82
|
+
// One tuple or an array of them. This destructured the param
|
|
83
|
+
// directly, so `{ age: [[">=", 18], ["<", 65]] }` bound `op` to the
|
|
84
|
+
// tuple `[">=", 18]`, matched no operator, and dropped **both**
|
|
85
|
+
// conditions — a filtered read answering 200 with the whole
|
|
86
|
+
// collection. The grammar is shared with the Postgres compiler now.
|
|
87
|
+
for (const [op, value] of toFilterTuples(filterParam)) {
|
|
88
|
+
conditions.push(this.buildCondition(field, op, value));
|
|
79
89
|
}
|
|
90
|
+
}
|
|
80
91
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const caseInsensitive = op === "ilike" || op === "not-ilike";
|
|
84
|
-
const regex = likePatternToRegExp(value, caseInsensitive);
|
|
85
|
-
const negated = op === "not-like" || op === "not-ilike";
|
|
86
|
-
conditions.push({
|
|
87
|
-
[field]: negated ? { $not: regex } : { $regex: regex }
|
|
88
|
-
});
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
92
|
+
return conditions;
|
|
93
|
+
}
|
|
91
94
|
|
|
92
|
-
|
|
95
|
+
/**
|
|
96
|
+
* One field, one operator, one value.
|
|
97
|
+
*
|
|
98
|
+
* Extracted so that `logical` groups translate through exactly this code.
|
|
99
|
+
* A group written as a second dialect is a group where `array-contains` or
|
|
100
|
+
* `ilike` quietly means something else than it does in `filter`, which is
|
|
101
|
+
* the kind of difference nobody finds until a query returns the wrong rows.
|
|
102
|
+
*
|
|
103
|
+
* Always returns a condition or throws: there is no operator this can be
|
|
104
|
+
* given that legitimately means "no condition".
|
|
105
|
+
*/
|
|
106
|
+
private static buildCondition(
|
|
107
|
+
field: string,
|
|
108
|
+
op: WhereFilterOp,
|
|
109
|
+
value: any
|
|
110
|
+
): Filter<Document> {
|
|
111
|
+
// Null-testing operators ignore their value.
|
|
112
|
+
if (op === "is-null") return { [field]: { $eq: null } };
|
|
113
|
+
if (op === "is-not-null") return { [field]: { $ne: null } };
|
|
93
114
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
// Pattern matching → regular expressions.
|
|
116
|
+
if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
|
|
117
|
+
const caseInsensitive = op === "ilike" || op === "not-ilike";
|
|
118
|
+
const regex = likePatternToRegExp(value, caseInsensitive);
|
|
119
|
+
const negated = op === "not-like" || op === "not-ilike";
|
|
120
|
+
return { [field]: negated ? { $not: regex } : { $regex: regex } };
|
|
121
|
+
}
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
const mongoOp = REBASE_TO_MONGO_OP[op];
|
|
124
|
+
|
|
125
|
+
if (!mongoOp) {
|
|
126
|
+
// A filter that cannot be compiled must not compile to "no filter".
|
|
127
|
+
// Returning `undefined` here dropped the condition and widened the
|
|
128
|
+
// read — inside an `or(...)` group it drops a branch, which widens
|
|
129
|
+
// it further — and the only trace was a line in the server log
|
|
130
|
+
// behind a 200.
|
|
131
|
+
logger.warn(`Unsupported filter operator '${op}' on field '${field}'`);
|
|
132
|
+
throw ApiError.badRequest(
|
|
133
|
+
`Operator '${op}' is not supported on field '${field}' by the MongoDB driver.`,
|
|
134
|
+
"UNSUPPORTED_FILTER_OPERATOR",
|
|
135
|
+
{ field, operator: op }
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Handle array-contains specially
|
|
140
|
+
if (op === "array-contains") {
|
|
141
|
+
return { [field]: { $elemMatch: { $eq: value } } };
|
|
142
|
+
}
|
|
143
|
+
return { [field]: { [mongoOp]: value } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Translate an `or(...)` / `and(...)` group, nesting included.
|
|
148
|
+
*
|
|
149
|
+
* Returns `undefined` for a group with nothing in it. `$or: []` is an error
|
|
150
|
+
* in Mongo and `$and: []` matches every document, so neither is a
|
|
151
|
+
* defensible reading of "no conditions".
|
|
152
|
+
*/
|
|
153
|
+
static buildLogicalConditions(logical: LogicalCondition | undefined): Filter<Document> | undefined {
|
|
154
|
+
if (!logical || !Array.isArray(logical.conditions)) return undefined;
|
|
155
|
+
|
|
156
|
+
const parts: Filter<Document>[] = [];
|
|
157
|
+
for (const entry of logical.conditions) {
|
|
158
|
+
if (!entry) continue;
|
|
159
|
+
if ("type" in entry && "conditions" in entry) {
|
|
160
|
+
const nested = this.buildLogicalConditions(entry as LogicalCondition);
|
|
161
|
+
if (nested) parts.push(nested);
|
|
162
|
+
continue;
|
|
108
163
|
}
|
|
164
|
+
const { column, operator, value } = entry as FilterCondition;
|
|
165
|
+
parts.push(this.buildCondition(column, operator, value));
|
|
109
166
|
}
|
|
110
167
|
|
|
111
|
-
|
|
168
|
+
// Through the same combiners the rest of this class uses, so a
|
|
169
|
+
// one-condition group reads as the bare condition — identical to how
|
|
170
|
+
// `filter` would have expressed it — rather than as `{ $and: [x] }`.
|
|
171
|
+
return logical.type === "or"
|
|
172
|
+
? this.combineConditionsWithOr(parts)
|
|
173
|
+
: this.combineConditionsWithAnd(parts);
|
|
112
174
|
}
|
|
113
175
|
|
|
114
176
|
/**
|
|
@@ -191,6 +253,13 @@ export class MongoConditionBuilder {
|
|
|
191
253
|
*/
|
|
192
254
|
static buildQuery<M extends Record<string, any>>(options: {
|
|
193
255
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
256
|
+
/**
|
|
257
|
+
* An `or(...)`/`and(...)` group, AND-ed with `filter` and
|
|
258
|
+
* `searchString` — the three are independent, as `FindParams`
|
|
259
|
+
* documents. Absent here until now, so a group that reached this
|
|
260
|
+
* driver was dropped and the read ran unfiltered.
|
|
261
|
+
*/
|
|
262
|
+
logical?: LogicalCondition;
|
|
194
263
|
searchString?: string;
|
|
195
264
|
properties?: CollectionConfig["properties"];
|
|
196
265
|
}): Filter<Document> {
|
|
@@ -202,6 +271,9 @@ export class MongoConditionBuilder {
|
|
|
202
271
|
conditions.push(...filterConditions);
|
|
203
272
|
}
|
|
204
273
|
|
|
274
|
+
const logicalCondition = this.buildLogicalConditions(options.logical);
|
|
275
|
+
if (logicalCondition) conditions.push(logicalCondition);
|
|
276
|
+
|
|
205
277
|
// Add search conditions
|
|
206
278
|
if (options.searchString && options.properties) {
|
|
207
279
|
const searchConditions = this.buildSearchConditions(
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Db, ObjectId, Collection, Document, FindOptions, Filter } from "mongodb";
|
|
9
|
-
import { FilterValues, DataRepository, CollectionConfig, EntityReference } from "@rebasepro/types";
|
|
9
|
+
import { FilterValues, DataRepository, CollectionConfig, EntityReference, LogicalCondition } from "@rebasepro/types";
|
|
10
10
|
import { MongoConditionBuilder } from "./MongoConditionBuilder";
|
|
11
11
|
import { logger } from "@rebasepro/server";
|
|
12
12
|
|
|
@@ -193,9 +193,12 @@ export class MongoDataService implements DataRepository {
|
|
|
193
193
|
collectionPath: string,
|
|
194
194
|
options: {
|
|
195
195
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
196
|
+
/** An `or(...)`/`and(...)` group, AND-ed with `filter`. */
|
|
197
|
+
logical?: LogicalCondition;
|
|
196
198
|
orderBy?: string;
|
|
197
199
|
order?: "desc" | "asc";
|
|
198
200
|
limit?: number;
|
|
201
|
+
offset?: number;
|
|
199
202
|
startAfter?: any;
|
|
200
203
|
searchString?: string;
|
|
201
204
|
databaseId?: string;
|
|
@@ -208,6 +211,7 @@ export class MongoDataService implements DataRepository {
|
|
|
208
211
|
// Build query
|
|
209
212
|
const query = options.rawQuery ?? MongoConditionBuilder.buildQuery<M>({
|
|
210
213
|
filter: options.filter,
|
|
214
|
+
logical: options.logical,
|
|
211
215
|
searchString: options.searchString,
|
|
212
216
|
properties: options.collection?.properties ?? {}
|
|
213
217
|
});
|
|
@@ -226,9 +230,14 @@ export class MongoDataService implements DataRepository {
|
|
|
226
230
|
findOptions.limit = options.limit;
|
|
227
231
|
}
|
|
228
232
|
|
|
229
|
-
// Apply pagination
|
|
233
|
+
// Apply pagination. `offset` is the parameter the REST layer and the
|
|
234
|
+
// SDK both speak; it was absent here, so every `?offset=` reaching this
|
|
235
|
+
// driver was discarded and page three served page one. `startAfter`
|
|
236
|
+
// stays as the older spelling and wins when both are given.
|
|
230
237
|
if (options.startAfter !== undefined) {
|
|
231
238
|
findOptions.skip = Number(options.startAfter);
|
|
239
|
+
} else if (options.offset) {
|
|
240
|
+
findOptions.skip = options.offset;
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
const docs = await collection.find(query, findOptions).toArray();
|
|
@@ -265,21 +274,37 @@ export class MongoDataService implements DataRepository {
|
|
|
265
274
|
collectionPath: string,
|
|
266
275
|
options: {
|
|
267
276
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
277
|
+
/** An `or(...)`/`and(...)` group, AND-ed with `filter`. */
|
|
278
|
+
logical?: LogicalCondition;
|
|
279
|
+
searchString?: string;
|
|
280
|
+
collection?: CollectionConfig;
|
|
268
281
|
databaseId?: string;
|
|
269
282
|
rawQuery?: Filter<Document>;
|
|
270
283
|
} = {}
|
|
271
284
|
): Promise<number> {
|
|
272
285
|
const collection = this.getCollection(collectionPath);
|
|
273
286
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
287
|
+
// Every narrowing the listing applies has to apply here too, or the
|
|
288
|
+
// count describes a different query than the one it is reported
|
|
289
|
+
// against. `logical` and `searchString` were both missing.
|
|
290
|
+
const query = options.rawQuery ?? MongoConditionBuilder.buildQuery<M>({
|
|
291
|
+
filter: options.filter,
|
|
292
|
+
logical: options.logical,
|
|
293
|
+
searchString: options.searchString,
|
|
294
|
+
properties: options.collection?.properties ?? {}
|
|
295
|
+
});
|
|
277
296
|
|
|
278
297
|
return collection.countDocuments(query);
|
|
279
298
|
}
|
|
280
299
|
|
|
281
300
|
/**
|
|
282
301
|
* Save an row (create or update)
|
|
302
|
+
*
|
|
303
|
+
* Returns the **stored** document, not the values that were sent. A partial
|
|
304
|
+
* update sends only the fields that changed, and returning those was a
|
|
305
|
+
* partial row everywhere it went: the REST response, `afterSave`, the
|
|
306
|
+
* history entry a revert restores from, and the row pushed to realtime
|
|
307
|
+
* subscribers. Postgres returns the whole row here (`RETURNING *`).
|
|
283
308
|
*/
|
|
284
309
|
async save<M extends Record<string, any>>(
|
|
285
310
|
collectionPath: string,
|
|
@@ -291,7 +316,10 @@ export class MongoDataService implements DataRepository {
|
|
|
291
316
|
const mongoValues = this.convertToMongoValues(values as Record<string, any>);
|
|
292
317
|
|
|
293
318
|
if (id) {
|
|
294
|
-
//
|
|
319
|
+
// Still an upsert: this is also the call that creates a row with a
|
|
320
|
+
// client-chosen id. Addressing an id that does not exist is caught
|
|
321
|
+
// above — the REST `PUT` 404s and the authenticated driver refuses
|
|
322
|
+
// — so the upsert only ever lands as the create it is meant to be.
|
|
295
323
|
const objectId = this.toObjectId(id);
|
|
296
324
|
await collection.updateOne(
|
|
297
325
|
{ _id: objectId } as Filter<Document>,
|
|
@@ -299,10 +327,8 @@ export class MongoDataService implements DataRepository {
|
|
|
299
327
|
{ upsert: true }
|
|
300
328
|
);
|
|
301
329
|
|
|
302
|
-
return {
|
|
303
|
-
|
|
304
|
-
id: id.toString()
|
|
305
|
-
};
|
|
330
|
+
return await this.readBack(collectionPath, objectId, { ...values,
|
|
331
|
+
id: id.toString() });
|
|
306
332
|
} else {
|
|
307
333
|
// Create new row
|
|
308
334
|
const newId = new ObjectId();
|
|
@@ -311,13 +337,24 @@ export class MongoDataService implements DataRepository {
|
|
|
311
337
|
...mongoValues
|
|
312
338
|
});
|
|
313
339
|
|
|
314
|
-
return {
|
|
315
|
-
|
|
316
|
-
id: newId.toString()
|
|
317
|
-
};
|
|
340
|
+
return await this.readBack(collectionPath, newId, { ...values,
|
|
341
|
+
id: newId.toString() });
|
|
318
342
|
}
|
|
319
343
|
}
|
|
320
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Read the document back after a write, falling back to the caller's own
|
|
347
|
+
* values if it has already been removed by a concurrent delete.
|
|
348
|
+
*/
|
|
349
|
+
private async readBack(
|
|
350
|
+
collectionPath: string,
|
|
351
|
+
objectId: ObjectId | string | number,
|
|
352
|
+
fallback: Record<string, unknown>
|
|
353
|
+
): Promise<Record<string, unknown>> {
|
|
354
|
+
const doc = await this.getCollection(collectionPath).findOne({ _id: objectId } as Filter<Document>);
|
|
355
|
+
return doc ? this.documentToRow(doc) : fallback;
|
|
356
|
+
}
|
|
357
|
+
|
|
321
358
|
/**
|
|
322
359
|
* Delete an row by ID
|
|
323
360
|
*/
|