@rebasepro/server-mongo 0.13.0 → 0.13.1-canary.g1822133

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 +27 -2
  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 +496 -251
  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 +61 -17
  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
package/dist/index.es.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MongoClient, ObjectId } from "mongodb";
2
- import { EntityReference } from "@rebasepro/types";
2
+ import { ANONYMOUS_USER_ID, EntityReference, ListLimitError, getCollectionDataPath, isAnonymousUid, resolveClientListLimit } from "@rebasepro/types";
3
+ import { buildPropertyCallbacks, buildSdkData, checkOperation, normalizeEmail, securityRuleToConditions, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
3
4
  import { ApiError, logger } from "@rebasepro/server";
4
- import { buildPropertyCallbacks, buildSdkData, checkOperation, normalizeEmail, updateDateAutoValues } from "@rebasepro/common";
5
5
  import { mergeDeep } from "@rebasepro/utils";
6
6
  //#region \0rolldown/runtime.js
7
7
  var __defProp = Object.defineProperty;
@@ -90,9 +90,16 @@ function escapeRegExp$1(str) {
90
90
  */
91
91
  function likePatternToRegExp(pattern, caseInsensitive) {
92
92
  let body = "";
93
- for (const ch of String(pattern)) if (ch === "%") body += ".*";
94
- else if (ch === "_") body += ".";
95
- else body += escapeRegExp$1(ch);
93
+ let lastWasWildcard = false;
94
+ for (const ch of String(pattern)) {
95
+ if (ch === "%") {
96
+ if (!lastWasWildcard) body += ".*";
97
+ lastWasWildcard = true;
98
+ continue;
99
+ }
100
+ body += ch === "_" ? "." : escapeRegExp$1(ch);
101
+ lastWasWildcard = false;
102
+ }
96
103
  return new RegExp(`^${body}$`, caseInsensitive ? "i" : "");
97
104
  }
98
105
  /**
@@ -113,30 +120,61 @@ var MongoConditionBuilder = class {
113
120
  const conditions = [];
114
121
  for (const [field, filterParam] of Object.entries(filter)) {
115
122
  if (!filterParam) continue;
116
- const [op, value] = filterParam;
117
- if (op === "is-null") {
118
- conditions.push({ [field]: { $eq: null } });
119
- continue;
120
- }
121
- if (op === "is-not-null") {
122
- conditions.push({ [field]: { $ne: null } });
123
- continue;
124
- }
125
- if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
126
- const regex = likePatternToRegExp(value, op === "ilike" || op === "not-ilike");
127
- const negated = op === "not-like" || op === "not-ilike";
128
- conditions.push({ [field]: negated ? { $not: regex } : { $regex: regex } });
129
- continue;
130
- }
131
- const mongoOp = REBASE_TO_MONGO_OP[op];
132
- if (!mongoOp) {
133
- logger.warn(`Unsupported filter operator: ${op}`);
123
+ for (const [op, value] of toFilterTuples(filterParam)) conditions.push(this.buildCondition(field, op, value));
124
+ }
125
+ return conditions;
126
+ }
127
+ /**
128
+ * One field, one operator, one value.
129
+ *
130
+ * Extracted so that `logical` groups translate through exactly this code.
131
+ * A group written as a second dialect is a group where `array-contains` or
132
+ * `ilike` quietly means something else than it does in `filter`, which is
133
+ * the kind of difference nobody finds until a query returns the wrong rows.
134
+ *
135
+ * Always returns a condition or throws: there is no operator this can be
136
+ * given that legitimately means "no condition".
137
+ */
138
+ static buildCondition(field, op, value) {
139
+ if (op === "is-null") return { [field]: { $eq: null } };
140
+ if (op === "is-not-null") return { [field]: { $ne: null } };
141
+ if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
142
+ const regex = likePatternToRegExp(value, op === "ilike" || op === "not-ilike");
143
+ const negated = op === "not-like" || op === "not-ilike";
144
+ return { [field]: negated ? { $not: regex } : { $regex: regex } };
145
+ }
146
+ const mongoOp = REBASE_TO_MONGO_OP[op];
147
+ if (!mongoOp) {
148
+ logger.warn(`Unsupported filter operator '${op}' on field '${field}'`);
149
+ throw ApiError.badRequest(`Operator '${op}' is not supported on field '${field}' by the MongoDB driver.`, "UNSUPPORTED_FILTER_OPERATOR", {
150
+ field,
151
+ operator: op
152
+ });
153
+ }
154
+ if (op === "array-contains") return { [field]: { $elemMatch: { $eq: value } } };
155
+ return { [field]: { [mongoOp]: value } };
156
+ }
157
+ /**
158
+ * Translate an `or(...)` / `and(...)` group, nesting included.
159
+ *
160
+ * Returns `undefined` for a group with nothing in it. `$or: []` is an error
161
+ * in Mongo and `$and: []` matches every document, so neither is a
162
+ * defensible reading of "no conditions".
163
+ */
164
+ static buildLogicalConditions(logical) {
165
+ if (!logical || !Array.isArray(logical.conditions)) return void 0;
166
+ const parts = [];
167
+ for (const entry of logical.conditions) {
168
+ if (!entry) continue;
169
+ if ("type" in entry && "conditions" in entry) {
170
+ const nested = this.buildLogicalConditions(entry);
171
+ if (nested) parts.push(nested);
134
172
  continue;
135
173
  }
136
- if (op === "array-contains") conditions.push({ [field]: { $elemMatch: { $eq: value } } });
137
- else conditions.push({ [field]: { [mongoOp]: value } });
174
+ const { column, operator, value } = entry;
175
+ parts.push(this.buildCondition(column, operator, value));
138
176
  }
139
- return conditions;
177
+ return logical.type === "or" ? this.combineConditionsWithOr(parts) : this.combineConditionsWithAnd(parts);
140
178
  }
141
179
  /**
142
180
  * Build search conditions for text search
@@ -188,6 +226,8 @@ var MongoConditionBuilder = class {
188
226
  const filterConditions = this.buildFilterConditions(options.filter);
189
227
  conditions.push(...filterConditions);
190
228
  }
229
+ const logicalCondition = this.buildLogicalConditions(options.logical);
230
+ if (logicalCondition) conditions.push(logicalCondition);
191
231
  if (options.searchString && options.properties) {
192
232
  const searchConditions = this.buildSearchConditions(options.searchString, options.properties);
193
233
  if (searchConditions.length > 0) {
@@ -327,6 +367,7 @@ var MongoDataService = class {
327
367
  const collection = this.getCollection(collectionPath);
328
368
  const query = options.rawQuery ?? MongoConditionBuilder.buildQuery({
329
369
  filter: options.filter,
370
+ logical: options.logical,
330
371
  searchString: options.searchString,
331
372
  properties: options.collection?.properties ?? {}
332
373
  });
@@ -335,6 +376,7 @@ var MongoDataService = class {
335
376
  if (sort) findOptions.sort = sort;
336
377
  if (options.limit) findOptions.limit = options.limit;
337
378
  if (options.startAfter !== void 0) findOptions.skip = Number(options.startAfter);
379
+ else if (options.offset) findOptions.skip = options.offset;
338
380
  return (await collection.find(query, findOptions).toArray()).map((doc) => this.documentToRow(doc));
339
381
  }
340
382
  /**
@@ -351,11 +393,22 @@ var MongoDataService = class {
351
393
  */
352
394
  async count(collectionPath, options = {}) {
353
395
  const collection = this.getCollection(collectionPath);
354
- const query = options.rawQuery ?? (options.filter ? MongoConditionBuilder.buildQuery({ filter: options.filter }) : {});
396
+ const query = options.rawQuery ?? MongoConditionBuilder.buildQuery({
397
+ filter: options.filter,
398
+ logical: options.logical,
399
+ searchString: options.searchString,
400
+ properties: options.collection?.properties ?? {}
401
+ });
355
402
  return collection.countDocuments(query);
356
403
  }
357
404
  /**
358
405
  * Save an row (create or update)
406
+ *
407
+ * Returns the **stored** document, not the values that were sent. A partial
408
+ * update sends only the fields that changed, and returning those was a
409
+ * partial row everywhere it went: the REST response, `afterSave`, the
410
+ * history entry a revert restores from, and the row pushed to realtime
411
+ * subscribers. Postgres returns the whole row here (`RETURNING *`).
359
412
  */
360
413
  async save(collectionPath, values, id, _databaseId) {
361
414
  const collection = this.getCollection(collectionPath);
@@ -363,29 +416,44 @@ var MongoDataService = class {
363
416
  if (id) {
364
417
  const objectId = this.toObjectId(id);
365
418
  await collection.updateOne({ _id: objectId }, { $set: mongoValues }, { upsert: true });
366
- return {
419
+ return await this.readBack(collectionPath, objectId, {
367
420
  ...values,
368
421
  id: id.toString()
369
- };
422
+ });
370
423
  } else {
371
424
  const newId = new ObjectId();
372
425
  await collection.insertOne({
373
426
  _id: newId,
374
427
  ...mongoValues
375
428
  });
376
- return {
429
+ return await this.readBack(collectionPath, newId, {
377
430
  ...values,
378
431
  id: newId.toString()
379
- };
432
+ });
380
433
  }
381
434
  }
382
435
  /**
383
- * Delete an row by ID
436
+ * Read the document back after a write, falling back to the caller's own
437
+ * values if it has already been removed by a concurrent delete.
438
+ */
439
+ async readBack(collectionPath, objectId, fallback) {
440
+ const doc = await this.getCollection(collectionPath).findOne({ _id: objectId });
441
+ return doc ? this.documentToRow(doc) : fallback;
442
+ }
443
+ /**
444
+ * Delete a row by ID.
445
+ *
446
+ * Rejects when nothing matched, which is the `DataDriver.delete` contract:
447
+ * resolving means the row is gone because this call removed it. This used
448
+ * to log a warning and resolve, so a caller could not tell a delete from a
449
+ * no-op — and the same call on the Postgres driver threw. The wording is
450
+ * the Postgres driver's, verbatim, because two spellings of one answer is
451
+ * how the two came apart in the first place.
384
452
  */
385
453
  async delete(collectionPath, id, _databaseId) {
386
454
  const collection = this.getCollection(collectionPath);
387
455
  const objectId = this.toObjectId(id);
388
- if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) logger.warn(`Row ${id} not found in collection ${collectionPath}`);
456
+ if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
389
457
  }
390
458
  /**
391
459
  * Check if a field value is unique in a collection
@@ -421,11 +489,9 @@ var MongoRealtimeService = class {
421
489
  db;
422
490
  subscriptions = /* @__PURE__ */ new Map();
423
491
  clients = /* @__PURE__ */ new Map();
424
- dataService;
425
492
  driver;
426
493
  constructor(db) {
427
494
  this.db = db;
428
- this.dataService = new MongoDataService(db);
429
495
  }
430
496
  setDataDriver(driver) {
431
497
  this.driver = driver;
@@ -456,8 +522,7 @@ var MongoRealtimeService = class {
456
522
  type: "collection",
457
523
  config,
458
524
  changeStream,
459
- callback,
460
- authContext: config.authContext
525
+ callback
461
526
  };
462
527
  this.subscriptions.set(subscriptionId, subscription);
463
528
  this.fetchAndNotifyCollection(subscriptionId, config, callback);
@@ -472,8 +537,7 @@ var MongoRealtimeService = class {
472
537
  const subscription = {
473
538
  type: "collection",
474
539
  config,
475
- callback,
476
- authContext: config.authContext
540
+ callback
477
541
  };
478
542
  this.subscriptions.set(subscriptionId, subscription);
479
543
  this.fetchAndNotifyCollection(subscriptionId, config, callback);
@@ -484,31 +548,16 @@ var MongoRealtimeService = class {
484
548
  */
485
549
  async fetchAndNotifyCollection(subscriptionId, config, callback) {
486
550
  try {
487
- let rows;
488
551
  const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
489
- if (config.authContext && this.driver) {
490
- const mockUser = {
491
- uid: config.authContext.uid,
492
- roles: config.authContext.roles
493
- };
494
- rows = await (await this.driver.withAuth(mockUser)).fetchCollection({
495
- path: config.path,
496
- collection: registryCollection,
497
- filter: config.filter,
498
- orderBy: config.orderBy,
499
- order: config.order,
500
- limit: config.limit,
501
- startAfter: config.startAfter,
502
- searchString: config.searchString
503
- });
504
- } else rows = await this.dataService.fetchCollection(config.path, {
552
+ const rows = await (await this.scopedDriver(config.authContext)).fetchCollection({
553
+ path: config.path,
554
+ collection: registryCollection,
505
555
  filter: config.filter,
506
556
  orderBy: config.orderBy,
507
557
  order: config.order,
508
558
  limit: config.limit,
509
559
  startAfter: config.startAfter,
510
- searchString: config.searchString,
511
- collection: registryCollection
560
+ searchString: config.searchString
512
561
  });
513
562
  if (callback) callback(rows);
514
563
  } catch (error) {
@@ -516,6 +565,20 @@ var MongoRealtimeService = class {
516
565
  }
517
566
  }
518
567
  /**
568
+ * The driver scoped to a subscriber.
569
+ *
570
+ * Never the bare repository: everything a subscription delivers has to pass
571
+ * the same row authorization an HTTP read does.
572
+ */
573
+ async scopedDriver(authContext) {
574
+ if (!this.driver) throw new Error("MongoRealtimeService has no data driver — subscriptions cannot be authorized");
575
+ const user = {
576
+ uid: authContext?.uid ?? ANONYMOUS_USER_ID,
577
+ roles: authContext?.roles ?? []
578
+ };
579
+ return this.driver.withAuth(user);
580
+ }
581
+ /**
519
582
  * Subscribe to single row changes
520
583
  */
521
584
  subscribeToOne(subscriptionId, config, callback) {
@@ -537,8 +600,7 @@ var MongoRealtimeService = class {
537
600
  type: "single",
538
601
  config,
539
602
  changeStream,
540
- callback,
541
- authContext: config.authContext
603
+ callback
542
604
  };
543
605
  this.subscriptions.set(subscriptionId, subscription);
544
606
  this.fetchAndNotifyOne(subscriptionId, config, callback);
@@ -555,8 +617,7 @@ var MongoRealtimeService = class {
555
617
  const subscription = {
556
618
  type: "single",
557
619
  config,
558
- callback,
559
- authContext: config.authContext
620
+ callback
560
621
  };
561
622
  this.subscriptions.set(subscriptionId, subscription);
562
623
  this.fetchAndNotifyOne(subscriptionId, config, callback);
@@ -567,19 +628,12 @@ var MongoRealtimeService = class {
567
628
  */
568
629
  async fetchAndNotifyOne(subscriptionId, config, callback) {
569
630
  try {
570
- let row;
571
631
  const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
572
- if (config.authContext && this.driver) {
573
- const mockUser = {
574
- uid: config.authContext.uid,
575
- roles: config.authContext.roles
576
- };
577
- row = await (await this.driver.withAuth(mockUser)).fetchOne({
578
- path: config.path,
579
- id: config.id,
580
- collection: registryCollection
581
- });
582
- } else row = await this.dataService.fetchOne(config.path, config.id);
632
+ const row = await (await this.scopedDriver(config.authContext)).fetchOne({
633
+ path: config.path,
634
+ id: config.id,
635
+ collection: registryCollection
636
+ });
583
637
  if (callback) callback(row || null);
584
638
  } catch (error) {
585
639
  logger.error(`Error fetching row for subscription ${subscriptionId}`, { error });
@@ -602,9 +656,8 @@ var MongoRealtimeService = class {
602
656
  async notifyUpdate(path, id, row, _databaseId) {
603
657
  for (const [subscriptionId, subscription] of this.subscriptions) if (subscription.type === "single") {
604
658
  const config = subscription.config;
605
- if (config.path === path && config.id.toString() === id) {
606
- if (subscription.callback) subscription.callback(row);
607
- }
659
+ if (config.path === path && config.id.toString() === id) if (row === null) subscription.callback?.(null);
660
+ else await this.fetchAndNotifyOne(subscriptionId, config, subscription.callback);
608
661
  } else if (subscription.type === "collection") {
609
662
  const config = subscription.config;
610
663
  if (config.path === path) await this.fetchAndNotifyCollection(subscriptionId, config, subscription.callback);
@@ -658,13 +711,30 @@ var MongoRealtimeService = class {
658
711
  case "subscribe_collection": {
659
712
  const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
660
713
  if (!subscriptionId) return;
714
+ let boundedLimit;
715
+ try {
716
+ boundedLimit = resolveClientListLimit(message.payload?.limit);
717
+ } catch (e) {
718
+ if (!(e instanceof ListLimitError)) throw e;
719
+ logger.warn(`⚠️ [MongoRealtime] Refused subscription to '${message.payload?.path}': ${e.message}`);
720
+ ws.send(JSON.stringify({
721
+ type: "ERROR",
722
+ subscriptionId,
723
+ payload: { error: {
724
+ message: e.message,
725
+ code: "INVALID_LIMIT"
726
+ } },
727
+ error: e.message
728
+ }));
729
+ return;
730
+ }
661
731
  this.subscribeToCollection(subscriptionId, {
662
732
  clientId,
663
733
  path: message.payload?.path,
664
734
  filter: message.payload?.filter,
665
735
  orderBy: message.payload?.orderBy,
666
736
  order: message.payload?.order,
667
- limit: message.payload?.limit,
737
+ limit: boundedLimit,
668
738
  startAfter: message.payload?.startAfter,
669
739
  searchString: message.payload?.searchString,
670
740
  authContext
@@ -699,6 +769,18 @@ var MongoRealtimeService = class {
699
769
  if (subscriptionId) this.unsubscribe(subscriptionId);
700
770
  break;
701
771
  }
772
+ default:
773
+ logger.warn(`⚠️ [MongoRealtime] Unhandled realtime message type "${message.type}" — channels, presence and broadcast are not implemented by the Mongo driver.`);
774
+ ws.send(JSON.stringify({
775
+ type: "ERROR",
776
+ subscriptionId: message.subscriptionId,
777
+ payload: { error: {
778
+ message: `Realtime message type "${message.type}" is not supported by the Mongo driver`,
779
+ code: "REALTIME_UNSUPPORTED"
780
+ } },
781
+ error: `Realtime message type "${message.type}" is not supported by the Mongo driver`
782
+ }));
783
+ break;
702
784
  }
703
785
  }
704
786
  };
@@ -811,6 +893,251 @@ var MongoHistoryService = class {
811
893
  }
812
894
  };
813
895
  //#endregion
896
+ //#region src/db/securityRuleFilter.ts
897
+ /** Matches every document. */
898
+ var MATCH_ALL = {};
899
+ /**
900
+ * Matches no document. A distinct object rather than a `false` sentinel so it
901
+ * can be nested inside `$and`/`$or` like any other filter; identity is what
902
+ * {@link isMatchNone} tests, so never mutate or copy it.
903
+ */
904
+ var MATCH_NONE = { _id: { $exists: false } };
905
+ /** The expression has no MongoDB equivalent — the caller must refuse. */
906
+ var UNTRANSLATABLE = "untranslatable";
907
+ function isMatchAll(f) {
908
+ return f !== UNTRANSLATABLE && f !== MATCH_NONE && Object.keys(f).length === 0;
909
+ }
910
+ function isMatchNone(f) {
911
+ return f === MATCH_NONE;
912
+ }
913
+ /** The error code a caller sees when a rule cannot be honoured. */
914
+ var SECURITY_RULE_UNSUPPORTED = "SECURITY_RULE_UNSUPPORTED";
915
+ /**
916
+ * The refusal. Names the collection, the clause and the expression, because the
917
+ * only useful thing an operator can do with this is rewrite that rule — or move
918
+ * the collection to an engine that can enforce it.
919
+ */
920
+ function securityRuleUnsupported(collectionSlug, clause, detail) {
921
+ return ApiError.internal(`This collection's data source (MongoDB) cannot enforce a security rule on "${collectionSlug}": the \`${clause}\` expression ${detail} has no MongoDB equivalent. The request was refused rather than served without row authorization. Express the rule with \`access\`, \`ownerField\`, \`roles\`, or a structured \`condition\`/\`check\`, or move this collection to a Postgres data source.`, SECURITY_RULE_UNSUPPORTED);
922
+ }
923
+ /** Describe an expression well enough for the refusal message to be actionable. */
924
+ function describe(expr) {
925
+ switch (expr.kind) {
926
+ case "raw": return `\`${expr.sql}\``;
927
+ case "existsIn": return `a membership subquery over \`${expr.collection}\``;
928
+ case "not": return "a negated row predicate";
929
+ default: return `a \`${expr.kind}\` node`;
930
+ }
931
+ }
932
+ /** The first node of an expression tree this driver cannot translate, if any. */
933
+ function findUntranslatable(expr, hasRow) {
934
+ switch (expr.kind) {
935
+ case "and":
936
+ case "or":
937
+ for (const operand of expr.operands) {
938
+ const found = findUntranslatable(operand, hasRow);
939
+ if (found) return found;
940
+ }
941
+ return;
942
+ case "not": return hasRow ? findUntranslatable(expr.operand, hasRow) : referencesField(expr.operand) ? expr : void 0;
943
+ case "compare": return operandUntranslatable(expr.left) || operandUntranslatable(expr.right) ? expr : void 0;
944
+ case "existsIn":
945
+ case "raw": return expr;
946
+ default: return;
947
+ }
948
+ }
949
+ function operandUntranslatable(operand) {
950
+ return operand.kind === "outerField";
951
+ }
952
+ function referencesField(expr) {
953
+ switch (expr.kind) {
954
+ case "and":
955
+ case "or": return expr.operands.some(referencesField);
956
+ case "not": return referencesField(expr.operand);
957
+ case "compare": return expr.left.kind === "field" || expr.right.kind === "field" || expr.left.kind === "outerField" || expr.right.kind === "outerField";
958
+ default: return false;
959
+ }
960
+ }
961
+ function userContext(user) {
962
+ return {
963
+ uid: user?.uid || ANONYMOUS_USER_ID,
964
+ roles: user?.roles ?? []
965
+ };
966
+ }
967
+ var COMPARE_TO_MONGO = {
968
+ eq: "$eq",
969
+ neq: "$ne",
970
+ lt: "$lt",
971
+ lte: "$lte",
972
+ gt: "$gt",
973
+ gte: "$gte"
974
+ };
975
+ var INVERTED_COMPARE = {
976
+ eq: "eq",
977
+ neq: "neq",
978
+ lt: "gt",
979
+ lte: "gte",
980
+ gt: "lt",
981
+ gte: "lte"
982
+ };
983
+ function resolveOperand(operand, ctx) {
984
+ switch (operand.kind) {
985
+ case "literal": return {
986
+ kind: "value",
987
+ value: operand.value
988
+ };
989
+ case "authUid": return {
990
+ kind: "value",
991
+ value: ctx.uid
992
+ };
993
+ case "authRoles": return {
994
+ kind: "value",
995
+ value: ctx.roles
996
+ };
997
+ case "field": return {
998
+ kind: "field",
999
+ name: operand.name
1000
+ };
1001
+ case "outerField": return { kind: "unknown" };
1002
+ }
1003
+ }
1004
+ /**
1005
+ * Translate one {@link PolicyExpression} into a MongoDB filter, or
1006
+ * {@link UNTRANSLATABLE}.
1007
+ *
1008
+ * The JavaScript twin of `evaluatePolicy`, one level up: where that decides a
1009
+ * single row, this narrows a query. `"unknown"` there and `UNTRANSLATABLE` here
1010
+ * are the same condition, and both are resolved fail-closed by their callers.
1011
+ */
1012
+ function policyToMongoFilter(expr, user) {
1013
+ const ctx = userContext(user);
1014
+ switch (expr.kind) {
1015
+ case "true": return MATCH_ALL;
1016
+ case "false": return MATCH_NONE;
1017
+ case "and": {
1018
+ const parts = expr.operands.map((o) => policyToMongoFilter(o, user));
1019
+ if (parts.some(isMatchNone)) return MATCH_NONE;
1020
+ if (parts.some((p) => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
1021
+ const clauses = parts.filter((p) => !isMatchAll(p));
1022
+ if (clauses.length === 0) return MATCH_ALL;
1023
+ if (clauses.length === 1) return clauses[0];
1024
+ return { $and: clauses };
1025
+ }
1026
+ case "or": {
1027
+ const parts = expr.operands.map((o) => policyToMongoFilter(o, user));
1028
+ if (parts.some(isMatchAll)) return MATCH_ALL;
1029
+ if (parts.some((p) => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
1030
+ const clauses = parts.filter((p) => !isMatchNone(p));
1031
+ if (clauses.length === 0) return MATCH_NONE;
1032
+ if (clauses.length === 1) return clauses[0];
1033
+ return { $or: clauses };
1034
+ }
1035
+ case "not": {
1036
+ const inner = policyToMongoFilter(expr.operand, user);
1037
+ if (isMatchAll(inner)) return MATCH_NONE;
1038
+ if (isMatchNone(inner)) return MATCH_ALL;
1039
+ return UNTRANSLATABLE;
1040
+ }
1041
+ case "compare": {
1042
+ const left = resolveOperand(expr.left, ctx);
1043
+ const right = resolveOperand(expr.right, ctx);
1044
+ if (left.kind === "unknown" || right.kind === "unknown") return UNTRANSLATABLE;
1045
+ if (left.kind === "field" && right.kind === "value") return { [left.name]: { [COMPARE_TO_MONGO[expr.op]]: right.value } };
1046
+ if (left.kind === "value" && right.kind === "field") return { [right.name]: { [COMPARE_TO_MONGO[INVERTED_COMPARE[expr.op]]]: left.value } };
1047
+ if (left.kind === "field" && right.kind === "field") return { $expr: { [COMPARE_TO_MONGO[expr.op]]: [`$${left.name}`, `$${right.name}`] } };
1048
+ if (left.kind === "value" && right.kind === "value") return compareValues(expr.op, left.value, right.value);
1049
+ return UNTRANSLATABLE;
1050
+ }
1051
+ case "rolesOverlap": return expr.roles.some((r) => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
1052
+ case "rolesContain": return expr.roles.every((r) => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
1053
+ case "authenticated": return !isAnonymousUid(ctx.uid) ? MATCH_ALL : MATCH_NONE;
1054
+ case "serverContext": return MATCH_NONE;
1055
+ case "existsIn":
1056
+ case "raw": return UNTRANSLATABLE;
1057
+ }
1058
+ }
1059
+ function compareValues(op, a, b) {
1060
+ if (op === "eq") return a === b ? MATCH_ALL : MATCH_NONE;
1061
+ if (op === "neq") return a !== b ? MATCH_ALL : MATCH_NONE;
1062
+ if (typeof a === "string" && typeof b === "string" || typeof a === "number" && typeof b === "number") return (op === "lt" ? a < b : op === "lte" ? a <= b : op === "gt" ? a > b : a >= b) ? MATCH_ALL : MATCH_NONE;
1063
+ return UNTRANSLATABLE;
1064
+ }
1065
+ /** The rules that apply to `targetOperation`, mirroring `checkOperation`. */
1066
+ function applicableRules(collection, targetOperation) {
1067
+ const rules = collection?.securityRules;
1068
+ if (!rules || rules.length === 0) return [];
1069
+ return rules.filter((rule) => {
1070
+ const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
1071
+ return ops.includes(targetOperation) || ops.includes("all");
1072
+ });
1073
+ }
1074
+ /** Which clause of a rule constrains `targetOperation` — Postgres's own split. */
1075
+ function clauseFor(targetOperation) {
1076
+ return targetOperation === "insert" ? "withCheck" : "using";
1077
+ }
1078
+ /**
1079
+ * Refuse up front when this collection's rules cannot be enforced for
1080
+ * `targetOperation`.
1081
+ *
1082
+ * The row-in-hand paths (`fetchOne`, `save`, `delete`) resolve an undecidable
1083
+ * rule through `checkOperation`'s `onUnknown: "deny"`, which is safe but
1084
+ * indistinguishable from a plain "you may not do that". Calling this first
1085
+ * turns the same condition into the refusal an operator can act on.
1086
+ */
1087
+ function assertSecurityRulesEnforceable(collection, targetOperation) {
1088
+ for (const rule of applicableRules(collection, targetOperation)) {
1089
+ const conditions = securityRuleToConditions(rule);
1090
+ const clauses = targetOperation === "insert" ? ["withCheck"] : targetOperation === "update" ? ["using", "withCheck"] : ["using"];
1091
+ for (const clause of clauses) {
1092
+ const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
1093
+ if (!expr) continue;
1094
+ const offending = findUntranslatable(expr, true);
1095
+ if (offending) throw securityRuleUnsupported(collection?.slug ?? "unknown", clause, describe(offending));
1096
+ }
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Build the MongoDB filter that narrows a query to the rows `user` may see
1101
+ * under `collection`'s security rules.
1102
+ *
1103
+ * Returns `null` when no row can qualify (the caller answers with an empty
1104
+ * result), `{}` when the rules impose no narrowing, and throws
1105
+ * {@link SECURITY_RULE_UNSUPPORTED} when a rule cannot be translated.
1106
+ */
1107
+ function buildMongoFilterFromSecurityRules(collection, user, targetOperation) {
1108
+ const rules = applicableRules(collection, targetOperation);
1109
+ if (!collection?.securityRules || collection.securityRules.length === 0) return MATCH_ALL;
1110
+ if (rules.length === 0) return null;
1111
+ const clause = clauseFor(targetOperation);
1112
+ const permissive = [];
1113
+ const restrictive = [];
1114
+ for (const rule of rules) {
1115
+ const conditions = securityRuleToConditions(rule);
1116
+ const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
1117
+ const filter = expr === null ? MATCH_NONE : policyToMongoFilter(expr, user);
1118
+ if (filter === UNTRANSLATABLE) {
1119
+ const offending = expr === null ? void 0 : findUntranslatable(expr, false);
1120
+ throw securityRuleUnsupported(collection.slug, clause, offending ? describe(offending) : "this rule");
1121
+ }
1122
+ if ((rule.mode || "permissive") === "restrictive") restrictive.push(filter);
1123
+ else permissive.push(filter);
1124
+ }
1125
+ if (permissive.length === 0) return null;
1126
+ const parts = [];
1127
+ if (!permissive.some(isMatchAll)) {
1128
+ const granting = permissive.filter((p) => !isMatchNone(p));
1129
+ if (granting.length === 0) return null;
1130
+ parts.push(granting.length === 1 ? granting[0] : { $or: granting });
1131
+ }
1132
+ for (const rf of restrictive) {
1133
+ if (isMatchNone(rf)) return null;
1134
+ if (!isMatchAll(rf)) parts.push(rf);
1135
+ }
1136
+ if (parts.length === 0) return MATCH_ALL;
1137
+ if (parts.length === 1) return parts[0];
1138
+ return { $and: parts };
1139
+ }
1140
+ //#endregion
814
1141
  //#region src/services/MongoDriver.ts
815
1142
  /**
816
1143
  * MongoDB DataDriver Delegate
@@ -876,14 +1203,10 @@ var MongoDriver = class {
876
1203
  /**
877
1204
  * Fetch a collection of rows
878
1205
  */
879
- async fetchCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order }) {
1206
+ async fetchCollection(props) {
1207
+ const { path, collection, ...query } = props;
880
1208
  const rows = await this.dataService.fetchCollection(path, {
881
- filter,
882
- limit,
883
- startAfter,
884
- orderBy,
885
- order,
886
- searchString,
1209
+ ...query,
887
1210
  collection
888
1211
  });
889
1212
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
@@ -921,9 +1244,15 @@ var MongoDriver = class {
921
1244
  return rows;
922
1245
  }
923
1246
  /**
924
- * Listen to collection changes
1247
+ * Listen to collection changes.
1248
+ *
1249
+ * `authContext` is not part of `ListenCollectionProps`; it is supplied by
1250
+ * {@link AuthenticatedMongoDriver}, which is the only caller that has one.
1251
+ * It has to travel *into* the subscription config, because that config is
1252
+ * what every re-fetch reads — the wrapper used to stamp the field on the
1253
+ * `Subscription` object instead, and nothing has ever read that one.
925
1254
  */
926
- listenCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order, onUpdate, onError }) {
1255
+ listenCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order, onUpdate, onError }, authContext) {
927
1256
  const subscriptionId = this.generateSubscriptionId();
928
1257
  const callback = (rows) => {
929
1258
  try {
@@ -941,7 +1270,8 @@ var MongoDriver = class {
941
1270
  order,
942
1271
  limit,
943
1272
  startAfter,
944
- searchString
1273
+ searchString,
1274
+ authContext
945
1275
  }, callback);
946
1276
  return () => {
947
1277
  this.realtimeService.unsubscribe(subscriptionId);
@@ -987,7 +1317,7 @@ var MongoDriver = class {
987
1317
  /**
988
1318
  * Listen to row changes
989
1319
  */
990
- listenOne({ path, id, collection, onUpdate, onError }) {
1320
+ listenOne({ path, id, collection, onUpdate, onError }, authContext) {
991
1321
  const subscriptionId = this.generateSubscriptionId();
992
1322
  const callback = (row) => {
993
1323
  try {
@@ -1000,7 +1330,8 @@ var MongoDriver = class {
1000
1330
  this.realtimeService.subscribeToOne(subscriptionId, {
1001
1331
  clientId: "driver",
1002
1332
  path,
1003
- id
1333
+ id,
1334
+ authContext
1004
1335
  }, callback);
1005
1336
  return () => {
1006
1337
  this.realtimeService.unsubscribe(subscriptionId);
@@ -1257,8 +1588,13 @@ var MongoDriver = class {
1257
1588
  /**
1258
1589
  * Count rows in a collection
1259
1590
  */
1260
- async count({ path, collection, filter }) {
1261
- return this.dataService.count(path, { filter });
1591
+ async count({ path, collection, filter, logical, searchString }) {
1592
+ return this.dataService.count(path, {
1593
+ filter,
1594
+ logical,
1595
+ searchString,
1596
+ collection
1597
+ });
1262
1598
  }
1263
1599
  /**
1264
1600
  * Generate a unique subscription ID
@@ -1311,6 +1647,7 @@ var AuthenticatedMongoDriver = class {
1311
1647
  if (rlsFilter === null) return [];
1312
1648
  const userQuery = MongoConditionBuilder.buildQuery({
1313
1649
  filter: props.filter,
1650
+ logical: props.logical,
1314
1651
  searchString: props.searchString,
1315
1652
  properties: resolvedCollection?.properties
1316
1653
  });
@@ -1355,67 +1692,87 @@ var AuthenticatedMongoDriver = class {
1355
1692
  return rows;
1356
1693
  }
1357
1694
  listenCollection(props) {
1358
- const unsubscribe = this.delegate.listenCollection(props);
1359
- const authContext = {
1695
+ return this.delegate.listenCollection(props, this.authContext());
1696
+ }
1697
+ /** The acting user, in the shape the realtime subscriptions carry. */
1698
+ authContext() {
1699
+ return {
1360
1700
  uid: this.user.uid,
1361
1701
  roles: this.user.roles ?? []
1362
1702
  };
1363
- const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
1364
- const lastSub = Array.from(subscriptions.entries()).pop()?.[1];
1365
- if (lastSub && lastSub.config.clientId === "driver") lastSub.authContext = authContext;
1366
- return unsubscribe;
1703
+ }
1704
+ /**
1705
+ * Evaluate the collection's rules for one row, fail-closed.
1706
+ *
1707
+ * A path with no resolvable collection has no declared rules — the same
1708
+ * answer `buildMongoFilterFromSecurityRules` gives a listing on such a path,
1709
+ * so the two never disagree about whether this engine has row security.
1710
+ */
1711
+ authorize(collection, entity, operation, clauses) {
1712
+ if (!collection) return true;
1713
+ return checkOperation(collection, { user: this.user }, entity, operation, {
1714
+ onUnknown: "deny",
1715
+ clauses
1716
+ });
1367
1717
  }
1368
1718
  async fetchOne(props) {
1369
1719
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1720
+ assertSecurityRulesEnforceable(resolvedCollection, "select");
1370
1721
  const row = await this.delegate.fetchOne(props);
1371
- if (row) {
1372
- if (!checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(row, props.path), "select", { onUnknown: "deny" })) return;
1373
- }
1722
+ if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
1374
1723
  return row;
1375
1724
  }
1376
1725
  listenOne(props) {
1377
- const unsubscribe = this.delegate.listenOne(props);
1378
- const authContext = {
1379
- uid: this.user.uid,
1380
- roles: this.user.roles ?? []
1381
- };
1382
- const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
1383
- const lastSub = Array.from(subscriptions.entries()).pop()?.[1];
1384
- if (lastSub && lastSub.config.clientId === "driver") lastSub.authContext = authContext;
1385
- return unsubscribe;
1726
+ return this.delegate.listenOne(props, this.authContext());
1386
1727
  }
1728
+ /**
1729
+ * Save, with both halves of the rule checked *before* the write.
1730
+ *
1731
+ * There is no transaction here, so a check that runs after
1732
+ * `delegate.save` cannot undo anything: the document is written, history is
1733
+ * recorded and subscribers have been notified by then, and a 403 at that
1734
+ * point only misleads the caller about what happened. Postgres evaluates
1735
+ * `WITH CHECK` inside the transaction; the closest this driver can get is to
1736
+ * evaluate it against the row as it *will* be, and refuse before writing.
1737
+ */
1387
1738
  async save(props) {
1388
1739
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1389
1740
  if (props.status === "existing" && props.id) {
1741
+ assertSecurityRulesEnforceable(resolvedCollection, "update");
1390
1742
  const existing = await this.delegate.fetchOne({
1391
1743
  path: props.path,
1392
1744
  id: props.id,
1393
1745
  collection: resolvedCollection
1394
1746
  });
1395
- if (!existing || !checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(existing, props.path), "update", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1747
+ const projected = rowToEntityForCheck({
1748
+ ...existing,
1749
+ ...props.values,
1750
+ id: props.id
1751
+ }, props.path);
1752
+ if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.path), "update", "using") || !this.authorize(resolvedCollection, projected, "update", "withCheck")) throw ApiError.forbidden("Forbidden");
1396
1753
  } else {
1754
+ assertSecurityRulesEnforceable(resolvedCollection, "insert");
1397
1755
  const tempEntity = {
1398
1756
  id: props.id || "new",
1399
1757
  path: props.path,
1400
1758
  values: props.values
1401
1759
  };
1402
- if (!checkOperation(resolvedCollection, { user: this.user }, tempEntity, "insert", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1760
+ if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
1403
1761
  }
1404
- const saved = await this.delegate.save({
1762
+ return this.delegate.save({
1405
1763
  ...props,
1406
1764
  collection: resolvedCollection
1407
1765
  });
1408
- if (!checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(saved, props.path), props.status === "existing" ? "update" : "insert", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1409
- return saved;
1410
1766
  }
1411
1767
  async delete(props) {
1412
1768
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
1769
+ assertSecurityRulesEnforceable(resolvedCollection, "delete");
1413
1770
  const existing = await this.delegate.fetchOne({
1414
1771
  path: props.row.path,
1415
1772
  id: props.row.id,
1416
1773
  collection: resolvedCollection
1417
1774
  });
1418
- if (!existing || !checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(existing, props.row.path), "delete", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1775
+ if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
1419
1776
  return this.delegate.delete(props);
1420
1777
  }
1421
1778
  async checkUniqueField(path, name, value, id, collection) {
@@ -1430,6 +1787,7 @@ var AuthenticatedMongoDriver = class {
1430
1787
  if (rlsFilter === null) return 0;
1431
1788
  const userQuery = MongoConditionBuilder.buildQuery({
1432
1789
  filter: props.filter,
1790
+ logical: props.logical,
1433
1791
  searchString: props.searchString,
1434
1792
  properties: resolvedCollection?.properties
1435
1793
  });
@@ -1454,147 +1812,34 @@ function rowToEntityForCheck(row, path) {
1454
1812
  values: row
1455
1813
  };
1456
1814
  }
1457
- function getMongoFilterForSQL(sqlString, user) {
1458
- let cleanedSQL = sqlString.trim();
1459
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
1460
- let openCount = 0;
1461
- let isEnclosing = true;
1462
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
1463
- if (cleanedSQL[i] === "(") openCount++;
1464
- else if (cleanedSQL[i] === ")") openCount--;
1465
- if (openCount === 0) {
1466
- isEnclosing = false;
1467
- break;
1468
- }
1469
- }
1470
- if (isEnclosing) cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
1471
- else break;
1472
- }
1473
- const splitByTopLevel = (str, delimiter) => {
1474
- const parts = [];
1475
- let current = "";
1476
- let openCount = 0;
1477
- let i = 0;
1478
- while (i < str.length) {
1479
- if (str[i] === "(") openCount++;
1480
- else if (str[i] === ")") openCount--;
1481
- if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
1482
- parts.push(current);
1483
- current = "";
1484
- i += delimiter.length;
1485
- } else {
1486
- current += str[i];
1487
- i++;
1488
- }
1489
- }
1490
- parts.push(current);
1491
- return parts;
1492
- };
1493
- const orParts = splitByTopLevel(cleanedSQL, " OR ");
1494
- if (orParts.length > 1) {
1495
- const subFilters = orParts.map((part) => getMongoFilterForSQL(part, user)).filter((f) => f !== null);
1496
- if (subFilters.length === 0) return null;
1497
- if (subFilters.length === 1) return subFilters[0];
1498
- return { $or: subFilters };
1499
- }
1500
- const andParts = splitByTopLevel(cleanedSQL, " AND ");
1501
- if (andParts.length > 1) {
1502
- const subFilters = andParts.map((part) => getMongoFilterForSQL(part, user)).filter((f) => f !== null);
1503
- if (subFilters.length === 0) return null;
1504
- if (subFilters.length === 1) return subFilters[0];
1505
- return { $and: subFilters };
1506
- }
1507
- const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
1508
- if (roleIntersectMatch && roleIntersectMatch[1]) {
1509
- const requiredRoles = roleIntersectMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
1510
- const userRoles = user.roles || [];
1511
- return requiredRoles.some((r) => userRoles.includes(r)) ? {} : { _id: { $exists: false } };
1512
- }
1513
- const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
1514
- if (roleContainMatch && roleContainMatch[1]) {
1515
- const requiredRoles = roleContainMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
1516
- const userRoles = user.roles || [];
1517
- return requiredRoles.every((r) => userRoles.includes(r)) ? {} : { _id: { $exists: false } };
1518
- }
1519
- const pattern1 = /* @__PURE__ */ new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
1520
- const pattern2 = /* @__PURE__ */ new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
1521
- const match1 = cleanedSQL.match(pattern1);
1522
- if (match1 && match1[1]) return { [match1[1]]: user.uid };
1523
- const match2 = cleanedSQL.match(pattern2);
1524
- if (match2 && match2[1]) return { [match2[1]]: user.uid };
1525
- const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
1526
- if (simpleEqualityMatch) {
1527
- const field = simpleEqualityMatch[1];
1528
- const operator = simpleEqualityMatch[2];
1529
- const value = simpleEqualityMatch[3];
1530
- if (operator === "=") return { [field]: value };
1531
- if (operator === "!=") return { [field]: { $ne: value } };
1532
- }
1533
- return {};
1534
- }
1535
- function getMongoFilterForRule(rule, user) {
1536
- if (rule.access === "public") return {};
1537
- const filters = [];
1538
- if (rule.ownerField) filters.push({ [rule.ownerField]: user.uid });
1539
- if (rule.using) {
1540
- const f = getMongoFilterForSQL(rule.using, user);
1541
- if (f) filters.push(f);
1542
- }
1543
- if (rule.withCheck) {
1544
- const f = getMongoFilterForSQL(rule.withCheck, user);
1545
- if (f) filters.push(f);
1546
- }
1547
- if (filters.length === 0) return {};
1548
- if (filters.length === 1) return filters[0];
1549
- return { $and: filters };
1550
- }
1551
- function buildMongoFilterFromSecurityRules(collection, user, targetOperation) {
1552
- if (!collection || !collection.securityRules || collection.securityRules.length === 0) return {};
1553
- const applicableRules = collection.securityRules.filter((r) => r.operation === targetOperation || r.operation === "all" || r.operations?.includes(targetOperation) || r.operations?.includes("all"));
1554
- if (applicableRules.length === 0) return null;
1555
- const userRoles = [...user.roles ?? [], "public"];
1556
- const roleApplicableRules = applicableRules.filter((rule) => {
1557
- if (!rule.roles || rule.roles.length === 0) return true;
1558
- return rule.roles.some((r) => userRoles.includes(r));
1559
- });
1560
- if (roleApplicableRules.length === 0) return null;
1561
- const permissiveFilters = [];
1562
- const restrictiveFilters = [];
1563
- for (const rule of roleApplicableRules) {
1564
- const mode = rule.mode || "permissive";
1565
- const filter = getMongoFilterForRule(rule, user);
1566
- if (filter === null) {
1567
- if (mode === "restrictive") return null;
1568
- continue;
1569
- }
1570
- if (mode === "restrictive") restrictiveFilters.push(filter);
1571
- else permissiveFilters.push(filter);
1572
- }
1573
- const finalAnds = [];
1574
- if (permissiveFilters.length > 0) {
1575
- if (!permissiveFilters.some((f) => Object.keys(f).length === 0)) if (permissiveFilters.length === 1) finalAnds.push(permissiveFilters[0]);
1576
- else finalAnds.push({ $or: permissiveFilters });
1577
- } else return null;
1578
- if (restrictiveFilters.length > 0) {
1579
- for (const rf of restrictiveFilters) if (Object.keys(rf).length > 0) finalAnds.push(rf);
1580
- }
1581
- if (finalAnds.length === 0) return {};
1582
- if (finalAnds.length === 1) return finalAnds[0];
1583
- return { $and: finalAnds };
1584
- }
1585
1815
  //#endregion
1586
1816
  //#region src/factory.ts
1587
1817
  /**
1588
1818
  * Simple in-memory collection registry for MongoDB.
1589
1819
  */
1590
1820
  var MongoCollectionRegistry = class {
1821
+ /** Every addressable key → collection. See {@link register}. */
1591
1822
  collections = /* @__PURE__ */ new Map();
1823
+ /** Registration order, so `getCollections()` returns each collection once. */
1824
+ registered = [];
1592
1825
  _globalCallbacks;
1593
1826
  /**
1594
- * Register a collection
1827
+ * Register a collection under every name it can be addressed by.
1828
+ *
1829
+ * A Mongo collection has up to three: `slug` (the routing key), `path` (the
1830
+ * MongoDB collection-name override, which is what `getCollectionDataPath`
1831
+ * hands the driver) and `name` (the human label). Registering only `name`
1832
+ * meant every `getCollectionByPath` lookup missed — and the realtime path,
1833
+ * whose only source of the collection is this registry, ran with no
1834
+ * `securityRules`, no `properties` and no callbacks.
1595
1835
  */
1596
1836
  register(collection) {
1597
- this.collections.set(collection.name, collection);
1837
+ this.registered.push(collection);
1838
+ for (const key of [
1839
+ getCollectionDataPath(collection),
1840
+ collection.slug,
1841
+ collection.name
1842
+ ]) if (key) this.collections.set(key, collection);
1598
1843
  }
1599
1844
  /**
1600
1845
  * Get a collection by its path
@@ -1606,7 +1851,7 @@ var MongoCollectionRegistry = class {
1606
1851
  * Get all registered collections
1607
1852
  */
1608
1853
  getCollections() {
1609
- return Array.from(this.collections.values());
1854
+ return [...this.registered];
1610
1855
  }
1611
1856
  /**
1612
1857
  * Get the currently registered global callbacks, if any.
@@ -2481,7 +2726,7 @@ function createMongoBootstrapper(mongoConfig) {
2481
2726
  },
2482
2727
  async initializeAuth(config, driverResult) {
2483
2728
  const db = driverResult.internals.db;
2484
- const { ensureAuthCollectionsExist } = await import("./ensure-collections-Bkx_O5CQ.js");
2729
+ const { ensureAuthCollectionsExist } = await import("./ensure-collections-Bl12bSwO.js");
2485
2730
  await ensureAuthCollectionsExist(db);
2486
2731
  const { createEmailService } = await import("@rebasepro/server");
2487
2732
  const authConfig = config;
@@ -2553,9 +2798,9 @@ function createMongoBootstrapper(mongoConfig) {
2553
2798
  return admin;
2554
2799
  },
2555
2800
  mountRoutes() {},
2556
- async initializeWebsockets(server, realtimeService, driver, config) {
2557
- const { createMongoWebSocket } = await import("./websocket-74DDOdK0.js");
2558
- createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin);
2801
+ async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
2802
+ const { createMongoWebSocket } = await import("./websocket-Bs54-gR-.js");
2803
+ createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
2559
2804
  }
2560
2805
  };
2561
2806
  }