@rebasepro/server-mongo 0.21.2-canary.g1ea48be → 0.23.0

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/index.es.js CHANGED
@@ -1,8 +1,8 @@
1
- import { MongoClient, ObjectId } from "mongodb";
1
+ import { MongoClient, MongoServerError, ObjectId } from "mongodb";
2
2
  import { ANONYMOUS_USER_ID, EntityReference, ListLimitError, getCollectionDataPath, isAnonymousUid, resolveClientListLimit } from "@rebasepro/types";
3
- import { buildPropertyCallbacks, buildSdkData, callbackRefusal, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
4
- import { ApiError, declaredErrorAnswer, logger } from "@rebasepro/server";
5
- import { mergeDeep } from "@rebasepro/utils";
3
+ import { assertBeforeQueryIsPostgresOnly, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, restrictedFieldNames, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
4
+ import { mergeDeep, splitSearchTerms } from "@rebasepro/utils";
5
+ import { ApiError, declaredErrorAnswer, logger, splitFieldOps } from "@rebasepro/server";
6
6
  //#region \0rolldown/runtime.js
7
7
  var __defProp = Object.defineProperty;
8
8
  var __exportAll = (all, no_symbols) => {
@@ -134,10 +134,21 @@ var MongoConditionBuilder = class MongoConditionBuilder {
134
134
  *
135
135
  * Always returns a condition or throws: there is no operator this can be
136
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 } };
137
+ *
138
+ * `negated` asks for the documents where the condition is *false* — not
139
+ * merely not true. The two differ by the documents where it is unknown,
140
+ * which in SQL is a comparison against NULL: `NOT (status = 'draft')` does
141
+ * not return a row whose status is NULL, and the Postgres driver is the
142
+ * reference every caller of this API writes against. So a comparison is
143
+ * false only where the field holds a value and the comparison fails on it.
144
+ * The null tests are never unknown, and negate to each other.
145
+ */
146
+ static buildCondition(field, op, value, negated = false) {
147
+ if (op === "is-null" || op === "is-not-null") {
148
+ const isNull = op === "is-null" !== negated;
149
+ return { [field]: isNull ? { $eq: null } : { $ne: null } };
150
+ }
151
+ if (negated) return { $and: [{ [field]: { $ne: null } }, { $nor: [this.buildCondition(field, op, value)] }] };
141
152
  if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
142
153
  const regex = likePatternToRegExp(value, op === "ilike" || op === "not-ilike");
143
154
  const negated = op === "not-like" || op === "not-ilike";
@@ -155,42 +166,76 @@ var MongoConditionBuilder = class MongoConditionBuilder {
155
166
  return { [field]: { [mongoOp]: value } };
156
167
  }
157
168
  /**
158
- * Translate an `or(...)` / `and(...)` group, nesting included.
169
+ * Translate an `or(...)` / `and(...)` / `not(...)` group, nesting included.
170
+ *
171
+ * `not` negates the conjunction of its conditions — the rule stated on
172
+ * `LogicalCondition`. It used to fall through to the `and` branch, so
173
+ * `not(status = 'draft')` returned exactly the drafts. It is compiled by
174
+ * pushing the negation down to the leaves (De Morgan), each of which knows
175
+ * which documents make it false rather than merely not true — see
176
+ * {@link buildCondition}. A `$nor` around the group would be shorter and
177
+ * wrong: it also matches every document where the condition is unknown.
159
178
  *
160
179
  * Returns `undefined` for a group with nothing in it. `$or: []` is an error
161
180
  * in Mongo and `$and: []` matches every document, so neither is a
162
181
  * defensible reading of "no conditions".
163
182
  */
164
- static buildLogicalConditions(logical) {
183
+ static buildLogicalConditions(logical, negated = false) {
165
184
  if (!logical || !Array.isArray(logical.conditions)) return void 0;
185
+ const negateParts = logical.type === "not" ? !negated : negated;
166
186
  const parts = [];
167
187
  for (const entry of logical.conditions) {
168
188
  if (!entry) continue;
169
189
  if ("type" in entry && "conditions" in entry) {
170
- const nested = this.buildLogicalConditions(entry);
190
+ const nested = this.buildLogicalConditions(entry, negateParts);
171
191
  if (nested) parts.push(nested);
172
192
  continue;
173
193
  }
174
194
  const { column, operator, value } = entry;
175
- parts.push(this.buildCondition(column, operator, value));
195
+ parts.push(this.buildCondition(column, operator, value, negateParts));
176
196
  }
177
- return logical.type === "or" ? this.combineConditionsWithOr(parts) : this.combineConditionsWithAnd(parts);
197
+ return logical.type === "or" !== negateParts ? this.combineConditionsWithOr(parts) : this.combineConditionsWithAnd(parts);
178
198
  }
179
199
  /**
180
- * Build search conditions for text search
200
+ * Build search conditions for text search.
201
+ *
202
+ * Terms are split on whitespace and AND-ed, while the fields are OR-ed
203
+ * within each term: a person typing `sebastian melendez` into a search box
204
+ * means both words, and the two live in different fields, so matching the
205
+ * whole string per field finds nothing. See {@link splitSearchTerms}; the
206
+ * Postgres driver's fallback path answers the same way.
181
207
  *
182
208
  * @param searchString - Text to search for
183
209
  * @param properties - The collection's properties, searched for string fields
184
- * @returns Array of MongoDB filter objects for text search
185
- */
186
- static buildSearchConditions(searchString, properties) {
210
+ * @param viewer - Who the search is for. A field they cannot read is not
211
+ * one they may search: the strip keeps its value out of the response, and
212
+ * without this the value is still recoverable a substring at a time by
213
+ * watching which searches return the row. Absent is the trusted server
214
+ * plane, which still does not search an `excludeFromApi` field — the same
215
+ * answer the Postgres builder gives.
216
+ * @returns At most one MongoDB filter — callers OR what they get back, which
217
+ * is right across fields and wrong across terms, so the AND is built here
218
+ */
219
+ static buildSearchConditions(searchString, properties, viewer) {
187
220
  if (!searchString) return [];
188
- const orConditions = [];
189
- const escapedSearch = escapeRegExp$1(searchString);
190
- const searchRegex = new RegExp(escapedSearch, "i");
191
- for (const [key, prop] of Object.entries(properties)) if (prop?.type === "string" || typeof prop === "string") orConditions.push({ [key]: { $regex: searchRegex } });
192
- if (orConditions.length === 0) return [{ $text: { $search: searchString } }];
193
- return orConditions;
221
+ const searchableFields = [];
222
+ let withheldStringFields = 0;
223
+ for (const [key, prop] of Object.entries(properties)) {
224
+ if (prop?.type === "string" && !canReadField(prop, viewer)) {
225
+ withheldStringFields++;
226
+ continue;
227
+ }
228
+ if (prop?.type === "string" || typeof prop === "string") searchableFields.push(key);
229
+ }
230
+ if (searchableFields.length === 0 && withheldStringFields > 0) return [{ _id: { $exists: false } }];
231
+ if (searchableFields.length === 0) return [{ $text: { $search: searchString } }];
232
+ const terms = splitSearchTerms(searchString);
233
+ const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
234
+ const regex = new RegExp(escapeRegExp$1(term), "i");
235
+ return this.combineConditionsWithOr(searchableFields.map((field) => ({ [field]: { $regex: regex } })));
236
+ });
237
+ const combined = this.combineConditionsWithAnd(perTerm);
238
+ return combined ? [combined] : [];
194
239
  }
195
240
  /**
196
241
  * Combine multiple conditions with AND operator
@@ -229,7 +274,7 @@ var MongoConditionBuilder = class MongoConditionBuilder {
229
274
  const logicalCondition = this.buildLogicalConditions(options.logical);
230
275
  if (logicalCondition) conditions.push(logicalCondition);
231
276
  if (options.searchString && options.properties) {
232
- const searchConditions = this.buildSearchConditions(options.searchString, options.properties);
277
+ const searchConditions = this.buildSearchConditions(options.searchString, options.properties, options.viewer);
233
278
  if (searchConditions.length > 0) {
234
279
  const searchFilter = this.combineConditionsWithOr(searchConditions);
235
280
  if (searchFilter) conditions.push(searchFilter);
@@ -440,23 +485,56 @@ var MongoDataService = class {
440
485
  * partial row everywhere it went: the REST response, `afterSave`, the
441
486
  * history entry a revert restores from, and the row pushed to realtime
442
487
  * subscribers. Postgres returns the whole row here (`RETURNING *`).
443
- */
444
- async save(collectionPath, values, id, _databaseId) {
488
+ *
489
+ * `mode` is which write the caller means when it names an `id`:
490
+ *
491
+ * - `"create"` inserts under that id, and a taken id is a 409. The driver
492
+ * authorizes a create as an insert, against the values sent — so a
493
+ * create that could land on an existing row is an update no update rule
494
+ * ever saw. It was one: an upsert with `$set`, which let a caller name
495
+ * another user's id with `status: "new"` and rewrite their row.
496
+ * - `"update"` updates that row and nothing else, and a missing one is a
497
+ * 404 — never a row conjured under an id nobody created.
498
+ * - absent, the repository's own "create or update": an upsert. Only the
499
+ * repository's direct callers reach it; the driver always says which.
500
+ *
501
+ * `values` may carry field operations (`{ views: { $inc: 1 } }`), which
502
+ * change the stored value in the same write rather than replace it — see
503
+ * {@link compileFieldOp}. They were written into the document as literal
504
+ * objects, so a counter became `{ "$inc": 1 }` behind a 200. Only an
505
+ * update can carry one: there is no stored value for an insert to act on,
506
+ * and an upsert may be an insert.
507
+ */
508
+ async save(collectionPath, values, id, _databaseId, mode) {
445
509
  const collection = this.getCollection(collectionPath);
446
- const mongoValues = this.convertToMongoValues(values);
510
+ const { values: plainValues, fieldOps } = splitFieldOps(values);
511
+ const opFields = Object.keys(fieldOps);
512
+ if (opFields.length > 0 && !(id && mode === "update")) throw ApiError.badRequest(`Field operations (${opFields.map((k) => `'${k}'`).join(", ")}) need an existing row to act on, and this write to "${collectionPath}" ${id && mode === void 0 ? "may be" : "is"} an insert. Send the value itself instead.`, "INVALID_FIELD_OPERATION");
513
+ const mongoValues = this.convertToMongoValues(plainValues);
447
514
  if (id) {
448
515
  const objectId = this.toObjectId(id);
449
- await collection.updateOne({ _id: objectId }, { $set: mongoValues }, { upsert: true });
516
+ if (mode === "create") await refuseDuplicateKey(collectionPath, id, () => collection.insertOne({
517
+ ...mongoValues,
518
+ _id: objectId
519
+ }));
520
+ else {
521
+ const update = opFields.length > 0 ? [{ $set: {
522
+ ...Object.fromEntries(Object.entries(mongoValues).map(([key, value]) => [key, { $literal: value }])),
523
+ ...Object.fromEntries(opFields.map((key) => [key, this.compileFieldOp(key, fieldOps[key])]))
524
+ } }] : { $set: mongoValues };
525
+ const result = await refuseDuplicateKey(collectionPath, id, () => collection.updateOne({ _id: objectId }, update, { upsert: mode !== "update" }));
526
+ if (mode === "update" && result.matchedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
527
+ }
450
528
  return await this.readBack(collectionPath, objectId, {
451
529
  ...values,
452
530
  id: id.toString()
453
531
  });
454
532
  } else {
455
533
  const newId = new ObjectId();
456
- await collection.insertOne({
534
+ await refuseDuplicateKey(collectionPath, void 0, () => collection.insertOne({
457
535
  _id: newId,
458
536
  ...mongoValues
459
- });
537
+ }));
460
538
  return await this.readBack(collectionPath, newId, {
461
539
  ...values,
462
540
  id: newId.toString()
@@ -464,6 +542,32 @@ var MongoDataService = class {
464
542
  }
465
543
  }
466
544
  /**
545
+ * The aggregation expression one field operation compiles to, in an update
546
+ * pipeline's `$set`.
547
+ *
548
+ * The same meaning the Postgres compiler gives each one, NULL-safety
549
+ * included: an unset or null counter increments from zero and an absent
550
+ * array is pushed onto as an empty one, rather than the write failing on
551
+ * the first use of a field. `$push` and `$pull` take a value or a list of
552
+ * values, `$pull` removes every occurrence, and `$merge` is shallow — a
553
+ * nested object replaces, it is not merged into.
554
+ */
555
+ compileFieldOp(field, op) {
556
+ const current = `$${field}`;
557
+ const operand = this.convertToMongoValue(op.operand);
558
+ const asList = (value) => Array.isArray(value) ? value : [value];
559
+ switch (op.operator) {
560
+ case "$inc": return { $add: [{ $ifNull: [current, 0] }, { $literal: operand }] };
561
+ case "$push": return { $concatArrays: [{ $ifNull: [current, []] }, { $literal: asList(operand) }] };
562
+ case "$pull": return { $filter: {
563
+ input: { $ifNull: [current, []] },
564
+ as: "element",
565
+ cond: { $not: [{ $in: ["$$element", { $literal: asList(operand) }] }] }
566
+ } };
567
+ case "$merge": return { $mergeObjects: [{ $ifNull: [current, {}] }, { $literal: operand }] };
568
+ }
569
+ }
570
+ /**
467
571
  * Read the document back after a write, falling back to the caller's own
468
572
  * values if it has already been removed by a concurrent delete.
469
573
  */
@@ -487,11 +591,27 @@ var MongoDataService = class {
487
591
  if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
488
592
  }
489
593
  /**
490
- * Check if a field value is unique in a collection
594
+ * Check if a field value is unique in a collection.
595
+ *
596
+ * An equality test on one field, and nothing else a query can express. The
597
+ * name and the value arrive from a socket frame, and this used to be
598
+ * `countDocuments({ [name]: value })`: a value of `{ $regex: "^123" }`
599
+ * answered, one prefix at a time, what a field of a row the caller cannot
600
+ * see begins with, and a name of `$expr` evaluated an aggregation
601
+ * expression. So an operator name is refused, the value must be a scalar,
602
+ * and it is compared with `$eq` — which reads an object as a literal even
603
+ * if one got this far.
604
+ *
605
+ * An absent value is unique, as it is on Postgres: an empty optional field
606
+ * is not "taken" by every row that also left it empty.
491
607
  */
492
608
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
609
+ if (typeof fieldName !== "string" || fieldName === "" || fieldName.startsWith("$")) throw ApiError.badRequest(`A uniqueness check names a field, and ${JSON.stringify(fieldName)} is not one.`, "INVALID_UNIQUE_CHECK");
610
+ if (excludeEntityId !== void 0 && typeof excludeEntityId !== "string" && typeof excludeEntityId !== "number") throw ApiError.badRequest("A uniqueness check excludes one row, named by its id.", "INVALID_UNIQUE_CHECK");
611
+ if (value === void 0 || value === null) return true;
612
+ if (!(typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof Date)) throw ApiError.badRequest(`A uniqueness check on '${fieldName}' compares one value — a string, number, boolean or date — and was given something else.`, "INVALID_UNIQUE_CHECK", { field: fieldName });
493
613
  const collection = this.getCollection(collectionPath);
494
- const query = { [fieldName]: value };
614
+ const query = { [fieldName]: { $eq: value } };
495
615
  if (excludeEntityId) query._id = { $ne: this.toObjectId(excludeEntityId) };
496
616
  return await collection.countDocuments(query) === 0;
497
617
  }
@@ -502,6 +622,25 @@ var MongoDataService = class {
502
622
  return new ObjectId().toString();
503
623
  }
504
624
  };
625
+ /**
626
+ * Run a write, answering a duplicate key (E11000) as the 409 it is.
627
+ *
628
+ * A unique index refusing a write is the request's fault, not the server's —
629
+ * Postgres answers its `23505` the same way. On `_id` it means the row a
630
+ * create named is already there; on any other index it names the fields that
631
+ * collided, never the values.
632
+ */
633
+ async function refuseDuplicateKey(collectionPath, id, write) {
634
+ try {
635
+ return await write();
636
+ } catch (error) {
637
+ if (!(error instanceof MongoServerError) || error.code !== 11e3) throw error;
638
+ const pattern = error.keyPattern;
639
+ const fields = pattern && typeof pattern === "object" ? Object.keys(pattern) : [];
640
+ if (id !== void 0 && (fields.length === 0 || fields.includes("_id"))) throw ApiError.conflict(`A row "${id}" already exists in "${collectionPath}".`);
641
+ throw ApiError.conflict(`This write to "${collectionPath}" would duplicate a value that must be unique` + (fields.length > 0 ? ` (${fields.join(", ")}).` : "."), "CONFLICT", fields.length > 0 ? { fields } : void 0);
642
+ }
643
+ }
505
644
  //#endregion
506
645
  //#region src/services/MongoRealtimeService.ts
507
646
  /**
@@ -1580,7 +1719,7 @@ var MongoDriver = class {
1580
1719
  timestampNowValue: /* @__PURE__ */ new Date()
1581
1720
  });
1582
1721
  try {
1583
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
1722
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, status === "existing" ? "update" : "create");
1584
1723
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
1585
1724
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
1586
1725
  collection: resolvedCollection,
@@ -1818,13 +1957,16 @@ var AuthenticatedMongoDriver = class {
1818
1957
  }
1819
1958
  async fetchCollection(props) {
1820
1959
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1960
+ const viewer = this.viewer();
1961
+ assertQueryFieldsReadable(props, resolvedCollection, viewer);
1821
1962
  const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
1822
1963
  if (rlsFilter === null) return [];
1823
1964
  const userQuery = MongoConditionBuilder.buildQuery({
1824
1965
  filter: props.filter,
1825
1966
  logical: props.logical,
1826
1967
  searchString: props.searchString,
1827
- properties: resolvedCollection?.properties
1968
+ properties: resolvedCollection?.properties,
1969
+ viewer
1828
1970
  });
1829
1971
  const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
1830
1972
  const rows = await this.delegate.getDataService().fetchCollection(props.path, {
@@ -1855,10 +1997,10 @@ var AuthenticatedMongoDriver = class {
1855
1997
  row: fetched,
1856
1998
  context: contextForCallback
1857
1999
  }) ?? fetched;
1858
- return fetched;
2000
+ return stripUnreadable(fetched, resolvedCollection, viewer);
1859
2001
  }));
1860
2002
  }
1861
- return rows;
2003
+ return rows.map((row) => stripUnreadable(row, resolvedCollection, viewer));
1862
2004
  }
1863
2005
  listenCollection(props) {
1864
2006
  return this.delegate.listenCollection(props, this.authContext());
@@ -1879,6 +2021,16 @@ var AuthenticatedMongoDriver = class {
1879
2021
  };
1880
2022
  }
1881
2023
  /**
2024
+ * The caller per-field `access` is judged against.
2025
+ *
2026
+ * Never `undefined`: that is the trusted server plane, which satisfies
2027
+ * every non-empty role list, and every call through this driver is made
2028
+ * for a user — an anonymous one included, as `roles: ["anon"]`.
2029
+ */
2030
+ viewer() {
2031
+ return { roles: this.user.roles ?? [] };
2032
+ }
2033
+ /**
1882
2034
  * Evaluate the collection's rules for one row, fail-closed.
1883
2035
  *
1884
2036
  * A path with no resolvable collection has no declared rules — the same
@@ -1897,7 +2049,7 @@ var AuthenticatedMongoDriver = class {
1897
2049
  assertSecurityRulesEnforceable(resolvedCollection, "select");
1898
2050
  const row = await this.delegate.fetchOne(props);
1899
2051
  if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
1900
- return row;
2052
+ return row && stripUnreadable(row, resolvedCollection, this.viewer());
1901
2053
  }
1902
2054
  listenOne(props) {
1903
2055
  return this.delegate.listenOne(props, this.authContext());
@@ -1936,10 +2088,10 @@ var AuthenticatedMongoDriver = class {
1936
2088
  };
1937
2089
  if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
1938
2090
  }
1939
- return this.delegate.save({
2091
+ return stripUnreadable(await this.delegate.save({
1940
2092
  ...props,
1941
2093
  collection: resolvedCollection
1942
- });
2094
+ }), resolvedCollection, this.viewer());
1943
2095
  }
1944
2096
  async delete(props) {
1945
2097
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
@@ -1952,21 +2104,35 @@ var AuthenticatedMongoDriver = class {
1952
2104
  if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
1953
2105
  return this.delegate.delete(props);
1954
2106
  }
2107
+ /**
2108
+ * Whether `value` is free in `name`, asked about a field this caller could
2109
+ * have read — anything else is an oracle over a value the strip withholds,
2110
+ * one guess at a time. A registered collection names its fields, so an
2111
+ * undeclared name is refused rather than counted.
2112
+ */
1955
2113
  async checkUniqueField(path, name, value, id, collection) {
1956
- return this.delegate.checkUniqueField(path, name, value, id, collection);
2114
+ const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(collection, path);
2115
+ if (resolvedCollection?.properties) {
2116
+ if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(resolvedCollection.properties, name)) throw ApiError.badRequest(`'${String(name)}' is not a declared property of '${resolvedCollection.slug}', so it has no uniqueness to check.`, "INVALID_UNIQUE_CHECK", { field: name });
2117
+ assertQueryFieldsReadable({ filter: { [name]: ["==", value] } }, resolvedCollection, this.viewer());
2118
+ }
2119
+ return this.delegate.checkUniqueField(path, name, value, id, resolvedCollection);
1957
2120
  }
1958
2121
  generateId(path, collection) {
1959
2122
  return this.delegate.generateId(path, collection);
1960
2123
  }
1961
2124
  async count(props) {
1962
2125
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
2126
+ const viewer = this.viewer();
2127
+ assertQueryFieldsReadable(props, resolvedCollection, viewer);
1963
2128
  const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
1964
2129
  if (rlsFilter === null) return 0;
1965
2130
  const userQuery = MongoConditionBuilder.buildQuery({
1966
2131
  filter: props.filter,
1967
2132
  logical: props.logical,
1968
2133
  searchString: props.searchString,
1969
- properties: resolvedCollection?.properties
2134
+ properties: resolvedCollection?.properties,
2135
+ viewer
1970
2136
  });
1971
2137
  const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
1972
2138
  return this.delegate.getDataService().count(props.path, {
@@ -1979,6 +2145,54 @@ var AuthenticatedMongoDriver = class {
1979
2145
  }
1980
2146
  };
1981
2147
  /**
2148
+ * Drop every field `viewer` may not read — what the Postgres row pipeline's
2149
+ * `stripUnreadable` does, applied where rows leave this driver for a caller.
2150
+ *
2151
+ * `excludeFromApi` needs no branch of its own: `restrictedFieldNames` reads it
2152
+ * as `read: []`, which no caller satisfies. Deleted, never nulled — a withheld
2153
+ * value that arrives as `null` is indistinguishable from a stored one, and an
2154
+ * update that echoes the row back would overwrite the real value with it.
2155
+ */
2156
+ function stripUnreadable(row, collection, viewer) {
2157
+ if (!collection) return row;
2158
+ const { refused } = restrictedFieldNames(collection, viewer, "read");
2159
+ if (refused.size === 0) return row;
2160
+ const visible = { ...row };
2161
+ for (const name of refused) delete visible[name];
2162
+ return visible;
2163
+ }
2164
+ /**
2165
+ * Refuse a read that filters or sorts on a field the caller cannot read.
2166
+ *
2167
+ * The strip keeps the value off the wire; this keeps it from being read a bit
2168
+ * at a time — `salary > 100000` returns exactly the rows whose withheld salary
2169
+ * is above it, and a sort on it returns them in its order. REST refuses the
2170
+ * same request before it reaches a driver; the socket and subscriptions reach
2171
+ * this driver directly, so the rule is applied here too, in the same words.
2172
+ */
2173
+ function assertQueryFieldsReadable(props, collection, viewer) {
2174
+ if (!collection) return;
2175
+ const { refused } = restrictedFieldNames(collection, viewer, "read");
2176
+ if (refused.size === 0) return;
2177
+ const named = /* @__PURE__ */ new Set();
2178
+ const consider = (field) => {
2179
+ if (typeof field === "string" && refused.has(field)) named.add(field);
2180
+ };
2181
+ Object.keys(props.filter ?? {}).forEach(consider);
2182
+ const walk = (logical) => {
2183
+ for (const entry of logical?.conditions ?? []) if (entry && "conditions" in entry) walk(entry);
2184
+ else consider(entry?.column);
2185
+ };
2186
+ walk(props.logical);
2187
+ for (const key of normalizeDriverOrderBy(props.orderBy, props.order) ?? []) consider(Array.isArray(key) ? key[0] : key);
2188
+ if (named.size === 0) return;
2189
+ const fields = [...named];
2190
+ throw ApiError.badRequest(`${fields.map((f) => `'${f}'`).join(", ")} ${fields.length > 1 ? "are" : "is"} not readable on '${collection.slug}' with your roles, so ${fields.length > 1 ? "they" : "it"} cannot be used to filter or sort.`, "FIELD_NOT_READABLE", {
2191
+ collection: collection.slug,
2192
+ fields
2193
+ });
2194
+ }
2195
+ /**
1982
2196
  * Wrap a flat row into the Entity shape expected by `checkOperation`,
1983
2197
  * which evaluates security rules against `row.values`.
1984
2198
  */
@@ -2315,10 +2529,20 @@ function toUser(doc) {
2315
2529
  emailVerified: doc.emailVerified ?? false,
2316
2530
  emailVerificationToken: doc.emailVerificationToken ?? null,
2317
2531
  emailVerificationSentAt: doc.emailVerificationSentAt ? new Date(doc.emailVerificationSentAt) : null,
2532
+ isAnonymous: doc.isAnonymous === true,
2533
+ metadata: doc.metadata ?? {},
2318
2534
  createdAt: new Date(doc.createdAt),
2319
2535
  updatedAt: new Date(doc.updatedAt)
2320
2536
  };
2321
2537
  }
2538
+ /**
2539
+ * Mongo's duplicate-key error. The unique index on `email` in
2540
+ * `ensure-collections.ts` is what raises it, and the answer to it is the same
2541
+ * 409 Postgres gives for its 23505 — see `UserRepository.createUser`.
2542
+ */
2543
+ function isDuplicateKey(error) {
2544
+ return error?.code === 11e3;
2545
+ }
2322
2546
  var MongoUserService = class {
2323
2547
  db;
2324
2548
  constructor(db) {
@@ -2347,13 +2571,15 @@ var MongoUserService = class {
2347
2571
  displayName: data.displayName ?? null,
2348
2572
  photoUrl: data.photoUrl ?? null,
2349
2573
  emailVerified: data.emailVerified ?? false,
2574
+ isAnonymous: data.isAnonymous ?? false,
2575
+ metadata: data.metadata ?? {},
2350
2576
  createdAt: now,
2351
2577
  updatedAt: now
2352
2578
  };
2353
2579
  try {
2354
2580
  await this.collection.insertOne(doc);
2355
2581
  } catch (error) {
2356
- if (error?.code === 11e3) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2582
+ if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2357
2583
  throw error;
2358
2584
  }
2359
2585
  return toUser(doc);
@@ -2411,10 +2637,26 @@ var MongoUserService = class {
2411
2637
  updatedAt: /* @__PURE__ */ new Date()
2412
2638
  };
2413
2639
  if (typeof updateData.email === "string") updateData.email = normalizeEmail(updateData.email);
2414
- await this.collection.updateOne({ id }, { $set: updateData });
2640
+ try {
2641
+ await this.collection.updateOne({ id }, { $set: updateData });
2642
+ } catch (error) {
2643
+ if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2644
+ throw error;
2645
+ }
2415
2646
  return this.getUserById(id);
2416
2647
  }
2648
+ /**
2649
+ * Everything keyed by the user goes with them. Postgres does this by
2650
+ * foreign-key cascade; Mongo has none, so it is spelled out — and the
2651
+ * refresh tokens are the part that matters: left behind, a deleted user's
2652
+ * session kept minting access tokens for a uid that no longer exists.
2653
+ * Sessions first, so a failure part-way leaves an account that cannot sign
2654
+ * in rather than a session with no account.
2655
+ */
2417
2656
  async deleteUser(id) {
2657
+ await this.db.collection("rebase_refresh_tokens").deleteMany({ uid: id });
2658
+ await this.db.collection("rebase_password_reset_tokens").deleteMany({ uid: id });
2659
+ await this.db.collection("magic_link_tokens").deleteMany({ uid: id });
2418
2660
  await this.collection.deleteOne({ id });
2419
2661
  await this.identitiesCollection.deleteMany({ uid: id });
2420
2662
  await this.userRolesCollection.deleteMany({ uid: id });
@@ -2976,7 +3218,10 @@ function createMongoBootstrapper(mongoConfig) {
2976
3218
  async initializeDriver(config) {
2977
3219
  const { collections } = config;
2978
3220
  const registry = new MongoCollectionRegistry();
2979
- if (collections) collections.forEach((collection) => registry.register(collection));
3221
+ if (collections) {
3222
+ assertBeforeQueryIsPostgresOnly(collections);
3223
+ collections.forEach((collection) => registry.register(collection));
3224
+ }
2980
3225
  const db = mongoConfig.connection;
2981
3226
  const client = mongoConfig.client;
2982
3227
  try {
@@ -3074,7 +3319,7 @@ function createMongoBootstrapper(mongoConfig) {
3074
3319
  },
3075
3320
  mountRoutes() {},
3076
3321
  async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
3077
- const { createMongoWebSocket } = await import("./websocket-C3iT6srl.js");
3322
+ const { createMongoWebSocket } = await import("./websocket-BtK4_9ZS.js");
3078
3323
  createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
3079
3324
  }
3080
3325
  };