@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.
Files changed (35) hide show
  1. package/dist/MongoBootstrapper.d.ts.map +1 -1
  2. package/dist/auth/ensure-collections.d.ts.map +1 -1
  3. package/dist/db/MongoConditionBuilder.d.ts +28 -1
  4. package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
  5. package/dist/db/MongoDataService.d.ts +19 -1
  6. package/dist/db/MongoDataService.d.ts.map +1 -1
  7. package/dist/db/securityRuleFilter.d.ts +65 -0
  8. package/dist/db/securityRuleFilter.d.ts.map +1 -0
  9. package/dist/ensure-collections-Bl12bSwO.js +130 -0
  10. package/dist/ensure-collections-Bl12bSwO.js.map +1 -0
  11. package/dist/factory.d.ts +11 -1
  12. package/dist/factory.d.ts.map +1 -1
  13. package/dist/index.es.js +487 -249
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/services/MongoDriver.d.ts +37 -5
  16. package/dist/services/MongoDriver.d.ts.map +1 -1
  17. package/dist/services/MongoRealtimeService.d.ts +23 -14
  18. package/dist/services/MongoRealtimeService.d.ts.map +1 -1
  19. package/dist/{websocket-74DDOdK0.js → websocket-Bs54-gR-.js} +32 -11
  20. package/dist/websocket-Bs54-gR-.js.map +1 -0
  21. package/dist/websocket.d.ts +2 -2
  22. package/dist/websocket.d.ts.map +1 -1
  23. package/package.json +5 -5
  24. package/src/MongoBootstrapper.ts +10 -2
  25. package/src/auth/ensure-collections.ts +61 -13
  26. package/src/db/MongoConditionBuilder.ts +112 -40
  27. package/src/db/MongoDataService.ts +51 -14
  28. package/src/db/securityRuleFilter.ts +398 -0
  29. package/src/factory.ts +17 -4
  30. package/src/services/MongoDriver.ts +101 -262
  31. package/src/services/MongoRealtimeService.ts +124 -66
  32. package/src/websocket.ts +66 -15
  33. package/dist/ensure-collections-Bkx_O5CQ.js +0 -94
  34. package/dist/ensure-collections-Bkx_O5CQ.js.map +0 -1
  35. 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 === "%") body += ".*";
40
- else if (ch === "_") body += ".";
41
- else body += escapeRegExp(ch);
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
- const [op, value] = filterParam as [WhereFilterOp, any];
70
-
71
- // Null-testing operators ignore their value.
72
- if (op === "is-null") {
73
- conditions.push({ [field]: { $eq: null } });
74
- continue;
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
- // Pattern matching → regular expressions.
82
- if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
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
- const mongoOp = REBASE_TO_MONGO_OP[op];
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
- if (!mongoOp) {
95
- logger.warn(`Unsupported filter operator: ${op}`);
96
- continue;
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
- // Handle array-contains specially
100
- if (op === "array-contains") {
101
- conditions.push({
102
- [field]: { $elemMatch: { $eq: value } }
103
- });
104
- } else {
105
- conditions.push({
106
- [field]: { [mongoOp]: value }
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
- return conditions;
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 (skip-based for now, cursor-based would be better)
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
- const query = options.rawQuery ?? (options.filter
275
- ? MongoConditionBuilder.buildQuery<M>({ filter: options.filter })
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
- // Update existing row
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
- ...values,
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
- ...values,
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
  */