@rebasepro/server-mongo 0.22.0 → 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.
@@ -20,6 +20,14 @@ export declare class MongoUserService implements UserRepository {
20
20
  getUserIdentities(uid: string): Promise<UserIdentityData[]>;
21
21
  linkUserIdentity(uid: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void>;
22
22
  updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null>;
23
+ /**
24
+ * Everything keyed by the user goes with them. Postgres does this by
25
+ * foreign-key cascade; Mongo has none, so it is spelled out — and the
26
+ * refresh tokens are the part that matters: left behind, a deleted user's
27
+ * session kept minting access tokens for a uid that no longer exists.
28
+ * Sessions first, so a failure part-way leaves an account that cannot sign
29
+ * in rather than a session with no account.
30
+ */
23
31
  deleteUser(id: string): Promise<void>;
24
32
  listUsers(): Promise<UserData[]>;
25
33
  listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult>;
@@ -4,6 +4,7 @@
4
4
  * Translates Rebase filter conditions to MongoDB query operators.
5
5
  */
6
6
  import { CollectionConfig, FilterValues, LogicalCondition, OrderByTuple } from "@rebasepro/types";
7
+ import { type FieldViewer } from "@rebasepro/common";
7
8
  import { Filter, Document } from "mongodb";
8
9
  /**
9
10
  * MongoDB Condition Builder
@@ -29,16 +30,32 @@ export declare class MongoConditionBuilder {
29
30
  *
30
31
  * Always returns a condition or throws: there is no operator this can be
31
32
  * given that legitimately means "no condition".
33
+ *
34
+ * `negated` asks for the documents where the condition is *false* — not
35
+ * merely not true. The two differ by the documents where it is unknown,
36
+ * which in SQL is a comparison against NULL: `NOT (status = 'draft')` does
37
+ * not return a row whose status is NULL, and the Postgres driver is the
38
+ * reference every caller of this API writes against. So a comparison is
39
+ * false only where the field holds a value and the comparison fails on it.
40
+ * The null tests are never unknown, and negate to each other.
32
41
  */
33
42
  private static buildCondition;
34
43
  /**
35
- * Translate an `or(...)` / `and(...)` group, nesting included.
44
+ * Translate an `or(...)` / `and(...)` / `not(...)` group, nesting included.
45
+ *
46
+ * `not` negates the conjunction of its conditions — the rule stated on
47
+ * `LogicalCondition`. It used to fall through to the `and` branch, so
48
+ * `not(status = 'draft')` returned exactly the drafts. It is compiled by
49
+ * pushing the negation down to the leaves (De Morgan), each of which knows
50
+ * which documents make it false rather than merely not true — see
51
+ * {@link buildCondition}. A `$nor` around the group would be shorter and
52
+ * wrong: it also matches every document where the condition is unknown.
36
53
  *
37
54
  * Returns `undefined` for a group with nothing in it. `$or: []` is an error
38
55
  * in Mongo and `$and: []` matches every document, so neither is a
39
56
  * defensible reading of "no conditions".
40
57
  */
41
- static buildLogicalConditions(logical: LogicalCondition | undefined): Filter<Document> | undefined;
58
+ static buildLogicalConditions(logical: LogicalCondition | undefined, negated?: boolean): Filter<Document> | undefined;
42
59
  /**
43
60
  * Build search conditions for text search.
44
61
  *
@@ -50,10 +67,16 @@ export declare class MongoConditionBuilder {
50
67
  *
51
68
  * @param searchString - Text to search for
52
69
  * @param properties - The collection's properties, searched for string fields
70
+ * @param viewer - Who the search is for. A field they cannot read is not
71
+ * one they may search: the strip keeps its value out of the response, and
72
+ * without this the value is still recoverable a substring at a time by
73
+ * watching which searches return the row. Absent is the trusted server
74
+ * plane, which still does not search an `excludeFromApi` field — the same
75
+ * answer the Postgres builder gives.
53
76
  * @returns At most one MongoDB filter — callers OR what they get back, which
54
77
  * is right across fields and wrong across terms, so the AND is built here
55
78
  */
56
- static buildSearchConditions(searchString: string, properties: CollectionConfig["properties"]): Filter<Document>[];
79
+ static buildSearchConditions(searchString: string, properties: CollectionConfig["properties"], viewer?: FieldViewer): Filter<Document>[];
57
80
  /**
58
81
  * Combine multiple conditions with AND operator
59
82
  *
@@ -85,6 +108,8 @@ export declare class MongoConditionBuilder {
85
108
  logical?: LogicalCondition;
86
109
  searchString?: string;
87
110
  properties?: CollectionConfig["properties"];
111
+ /** Who the query is for — see {@link buildSearchConditions}. */
112
+ viewer?: FieldViewer;
88
113
  }): Filter<Document>;
89
114
  /**
90
115
  * The primary key's name in a stored document.
@@ -96,8 +96,39 @@ export declare class MongoDataService implements DataRepository {
96
96
  * partial row everywhere it went: the REST response, `afterSave`, the
97
97
  * history entry a revert restores from, and the row pushed to realtime
98
98
  * subscribers. Postgres returns the whole row here (`RETURNING *`).
99
+ *
100
+ * `mode` is which write the caller means when it names an `id`:
101
+ *
102
+ * - `"create"` inserts under that id, and a taken id is a 409. The driver
103
+ * authorizes a create as an insert, against the values sent — so a
104
+ * create that could land on an existing row is an update no update rule
105
+ * ever saw. It was one: an upsert with `$set`, which let a caller name
106
+ * another user's id with `status: "new"` and rewrite their row.
107
+ * - `"update"` updates that row and nothing else, and a missing one is a
108
+ * 404 — never a row conjured under an id nobody created.
109
+ * - absent, the repository's own "create or update": an upsert. Only the
110
+ * repository's direct callers reach it; the driver always says which.
111
+ *
112
+ * `values` may carry field operations (`{ views: { $inc: 1 } }`), which
113
+ * change the stored value in the same write rather than replace it — see
114
+ * {@link compileFieldOp}. They were written into the document as literal
115
+ * objects, so a counter became `{ "$inc": 1 }` behind a 200. Only an
116
+ * update can carry one: there is no stored value for an insert to act on,
117
+ * and an upsert may be an insert.
118
+ */
119
+ save<M extends Record<string, any>>(collectionPath: string, values: Partial<M>, id?: string | number, _databaseId?: string, mode?: "create" | "update"): Promise<Record<string, unknown>>;
120
+ /**
121
+ * The aggregation expression one field operation compiles to, in an update
122
+ * pipeline's `$set`.
123
+ *
124
+ * The same meaning the Postgres compiler gives each one, NULL-safety
125
+ * included: an unset or null counter increments from zero and an absent
126
+ * array is pushed onto as an empty one, rather than the write failing on
127
+ * the first use of a field. `$push` and `$pull` take a value or a list of
128
+ * values, `$pull` removes every occurrence, and `$merge` is shallow — a
129
+ * nested object replaces, it is not merged into.
99
130
  */
100
- save<M extends Record<string, any>>(collectionPath: string, values: Partial<M>, id?: string | number, _databaseId?: string): Promise<Record<string, unknown>>;
131
+ private compileFieldOp;
101
132
  /**
102
133
  * Read the document back after a write, falling back to the caller's own
103
134
  * values if it has already been removed by a concurrent delete.
@@ -115,9 +146,21 @@ export declare class MongoDataService implements DataRepository {
115
146
  */
116
147
  delete(collectionPath: string, id: string | number, _databaseId?: string): Promise<void>;
117
148
  /**
118
- * Check if a field value is unique in a collection
149
+ * Check if a field value is unique in a collection.
150
+ *
151
+ * An equality test on one field, and nothing else a query can express. The
152
+ * name and the value arrive from a socket frame, and this used to be
153
+ * `countDocuments({ [name]: value })`: a value of `{ $regex: "^123" }`
154
+ * answered, one prefix at a time, what a field of a row the caller cannot
155
+ * see begins with, and a name of `$expr` evaluated an aggregation
156
+ * expression. So an operator name is refused, the value must be a scalar,
157
+ * and it is compared with `$eq` — which reads an object as a literal even
158
+ * if one got this far.
159
+ *
160
+ * An absent value is unique, as it is on Postgres: an empty optional field
161
+ * is not "taken" by every row that also left it empty.
119
162
  */
120
- checkUniqueField(collectionPath: string, fieldName: string, value: any, excludeEntityId?: string, _databaseId?: string): Promise<boolean>;
163
+ checkUniqueField(collectionPath: string, fieldName: string, value: unknown, excludeEntityId?: string, _databaseId?: string): Promise<boolean>;
121
164
  /**
122
165
  * Generate a new row ID
123
166
  */
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 { assertBeforeQueryIsPostgresOnly, buildPropertyCallbacks, buildSdkData, callbackRefusal, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
3
+ import { assertBeforeQueryIsPostgresOnly, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, restrictedFieldNames, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
4
4
  import { mergeDeep, splitSearchTerms } from "@rebasepro/utils";
5
- import { ApiError, declaredErrorAnswer, logger } from "@rebasepro/server";
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,26 +166,35 @@ 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
200
  * Build search conditions for text search.
@@ -187,13 +207,27 @@ var MongoConditionBuilder = class MongoConditionBuilder {
187
207
  *
188
208
  * @param searchString - Text to search for
189
209
  * @param properties - The collection's properties, searched for string fields
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.
190
216
  * @returns At most one MongoDB filter — callers OR what they get back, which
191
217
  * is right across fields and wrong across terms, so the AND is built here
192
218
  */
193
- static buildSearchConditions(searchString, properties) {
219
+ static buildSearchConditions(searchString, properties, viewer) {
194
220
  if (!searchString) return [];
195
221
  const searchableFields = [];
196
- for (const [key, prop] of Object.entries(properties)) if (prop?.type === "string" || typeof prop === "string") searchableFields.push(key);
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 } }];
197
231
  if (searchableFields.length === 0) return [{ $text: { $search: searchString } }];
198
232
  const terms = splitSearchTerms(searchString);
199
233
  const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
@@ -240,7 +274,7 @@ var MongoConditionBuilder = class MongoConditionBuilder {
240
274
  const logicalCondition = this.buildLogicalConditions(options.logical);
241
275
  if (logicalCondition) conditions.push(logicalCondition);
242
276
  if (options.searchString && options.properties) {
243
- const searchConditions = this.buildSearchConditions(options.searchString, options.properties);
277
+ const searchConditions = this.buildSearchConditions(options.searchString, options.properties, options.viewer);
244
278
  if (searchConditions.length > 0) {
245
279
  const searchFilter = this.combineConditionsWithOr(searchConditions);
246
280
  if (searchFilter) conditions.push(searchFilter);
@@ -451,23 +485,56 @@ var MongoDataService = class {
451
485
  * partial row everywhere it went: the REST response, `afterSave`, the
452
486
  * history entry a revert restores from, and the row pushed to realtime
453
487
  * subscribers. Postgres returns the whole row here (`RETURNING *`).
454
- */
455
- 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) {
456
509
  const collection = this.getCollection(collectionPath);
457
- 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);
458
514
  if (id) {
459
515
  const objectId = this.toObjectId(id);
460
- 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
+ }
461
528
  return await this.readBack(collectionPath, objectId, {
462
529
  ...values,
463
530
  id: id.toString()
464
531
  });
465
532
  } else {
466
533
  const newId = new ObjectId();
467
- await collection.insertOne({
534
+ await refuseDuplicateKey(collectionPath, void 0, () => collection.insertOne({
468
535
  _id: newId,
469
536
  ...mongoValues
470
- });
537
+ }));
471
538
  return await this.readBack(collectionPath, newId, {
472
539
  ...values,
473
540
  id: newId.toString()
@@ -475,6 +542,32 @@ var MongoDataService = class {
475
542
  }
476
543
  }
477
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
+ /**
478
571
  * Read the document back after a write, falling back to the caller's own
479
572
  * values if it has already been removed by a concurrent delete.
480
573
  */
@@ -498,11 +591,27 @@ var MongoDataService = class {
498
591
  if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
499
592
  }
500
593
  /**
501
- * 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.
502
607
  */
503
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 });
504
613
  const collection = this.getCollection(collectionPath);
505
- const query = { [fieldName]: value };
614
+ const query = { [fieldName]: { $eq: value } };
506
615
  if (excludeEntityId) query._id = { $ne: this.toObjectId(excludeEntityId) };
507
616
  return await collection.countDocuments(query) === 0;
508
617
  }
@@ -513,6 +622,25 @@ var MongoDataService = class {
513
622
  return new ObjectId().toString();
514
623
  }
515
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
+ }
516
644
  //#endregion
517
645
  //#region src/services/MongoRealtimeService.ts
518
646
  /**
@@ -1591,7 +1719,7 @@ var MongoDriver = class {
1591
1719
  timestampNowValue: /* @__PURE__ */ new Date()
1592
1720
  });
1593
1721
  try {
1594
- 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");
1595
1723
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
1596
1724
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
1597
1725
  collection: resolvedCollection,
@@ -1829,13 +1957,16 @@ var AuthenticatedMongoDriver = class {
1829
1957
  }
1830
1958
  async fetchCollection(props) {
1831
1959
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1960
+ const viewer = this.viewer();
1961
+ assertQueryFieldsReadable(props, resolvedCollection, viewer);
1832
1962
  const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
1833
1963
  if (rlsFilter === null) return [];
1834
1964
  const userQuery = MongoConditionBuilder.buildQuery({
1835
1965
  filter: props.filter,
1836
1966
  logical: props.logical,
1837
1967
  searchString: props.searchString,
1838
- properties: resolvedCollection?.properties
1968
+ properties: resolvedCollection?.properties,
1969
+ viewer
1839
1970
  });
1840
1971
  const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
1841
1972
  const rows = await this.delegate.getDataService().fetchCollection(props.path, {
@@ -1866,10 +1997,10 @@ var AuthenticatedMongoDriver = class {
1866
1997
  row: fetched,
1867
1998
  context: contextForCallback
1868
1999
  }) ?? fetched;
1869
- return fetched;
2000
+ return stripUnreadable(fetched, resolvedCollection, viewer);
1870
2001
  }));
1871
2002
  }
1872
- return rows;
2003
+ return rows.map((row) => stripUnreadable(row, resolvedCollection, viewer));
1873
2004
  }
1874
2005
  listenCollection(props) {
1875
2006
  return this.delegate.listenCollection(props, this.authContext());
@@ -1890,6 +2021,16 @@ var AuthenticatedMongoDriver = class {
1890
2021
  };
1891
2022
  }
1892
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
+ /**
1893
2034
  * Evaluate the collection's rules for one row, fail-closed.
1894
2035
  *
1895
2036
  * A path with no resolvable collection has no declared rules — the same
@@ -1908,7 +2049,7 @@ var AuthenticatedMongoDriver = class {
1908
2049
  assertSecurityRulesEnforceable(resolvedCollection, "select");
1909
2050
  const row = await this.delegate.fetchOne(props);
1910
2051
  if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
1911
- return row;
2052
+ return row && stripUnreadable(row, resolvedCollection, this.viewer());
1912
2053
  }
1913
2054
  listenOne(props) {
1914
2055
  return this.delegate.listenOne(props, this.authContext());
@@ -1947,10 +2088,10 @@ var AuthenticatedMongoDriver = class {
1947
2088
  };
1948
2089
  if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
1949
2090
  }
1950
- return this.delegate.save({
2091
+ return stripUnreadable(await this.delegate.save({
1951
2092
  ...props,
1952
2093
  collection: resolvedCollection
1953
- });
2094
+ }), resolvedCollection, this.viewer());
1954
2095
  }
1955
2096
  async delete(props) {
1956
2097
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
@@ -1963,21 +2104,35 @@ var AuthenticatedMongoDriver = class {
1963
2104
  if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
1964
2105
  return this.delegate.delete(props);
1965
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
+ */
1966
2113
  async checkUniqueField(path, name, value, id, collection) {
1967
- 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);
1968
2120
  }
1969
2121
  generateId(path, collection) {
1970
2122
  return this.delegate.generateId(path, collection);
1971
2123
  }
1972
2124
  async count(props) {
1973
2125
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
2126
+ const viewer = this.viewer();
2127
+ assertQueryFieldsReadable(props, resolvedCollection, viewer);
1974
2128
  const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
1975
2129
  if (rlsFilter === null) return 0;
1976
2130
  const userQuery = MongoConditionBuilder.buildQuery({
1977
2131
  filter: props.filter,
1978
2132
  logical: props.logical,
1979
2133
  searchString: props.searchString,
1980
- properties: resolvedCollection?.properties
2134
+ properties: resolvedCollection?.properties,
2135
+ viewer
1981
2136
  });
1982
2137
  const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
1983
2138
  return this.delegate.getDataService().count(props.path, {
@@ -1990,6 +2145,54 @@ var AuthenticatedMongoDriver = class {
1990
2145
  }
1991
2146
  };
1992
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
+ /**
1993
2196
  * Wrap a flat row into the Entity shape expected by `checkOperation`,
1994
2197
  * which evaluates security rules against `row.values`.
1995
2198
  */
@@ -2326,10 +2529,20 @@ function toUser(doc) {
2326
2529
  emailVerified: doc.emailVerified ?? false,
2327
2530
  emailVerificationToken: doc.emailVerificationToken ?? null,
2328
2531
  emailVerificationSentAt: doc.emailVerificationSentAt ? new Date(doc.emailVerificationSentAt) : null,
2532
+ isAnonymous: doc.isAnonymous === true,
2533
+ metadata: doc.metadata ?? {},
2329
2534
  createdAt: new Date(doc.createdAt),
2330
2535
  updatedAt: new Date(doc.updatedAt)
2331
2536
  };
2332
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
+ }
2333
2546
  var MongoUserService = class {
2334
2547
  db;
2335
2548
  constructor(db) {
@@ -2358,13 +2571,15 @@ var MongoUserService = class {
2358
2571
  displayName: data.displayName ?? null,
2359
2572
  photoUrl: data.photoUrl ?? null,
2360
2573
  emailVerified: data.emailVerified ?? false,
2574
+ isAnonymous: data.isAnonymous ?? false,
2575
+ metadata: data.metadata ?? {},
2361
2576
  createdAt: now,
2362
2577
  updatedAt: now
2363
2578
  };
2364
2579
  try {
2365
2580
  await this.collection.insertOne(doc);
2366
2581
  } catch (error) {
2367
- if (error?.code === 11e3) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2582
+ if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2368
2583
  throw error;
2369
2584
  }
2370
2585
  return toUser(doc);
@@ -2422,10 +2637,26 @@ var MongoUserService = class {
2422
2637
  updatedAt: /* @__PURE__ */ new Date()
2423
2638
  };
2424
2639
  if (typeof updateData.email === "string") updateData.email = normalizeEmail(updateData.email);
2425
- 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
+ }
2426
2646
  return this.getUserById(id);
2427
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
+ */
2428
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 });
2429
2660
  await this.collection.deleteOne({ id });
2430
2661
  await this.identitiesCollection.deleteMany({ uid: id });
2431
2662
  await this.userRolesCollection.deleteMany({ uid: id });
@@ -3088,7 +3319,7 @@ function createMongoBootstrapper(mongoConfig) {
3088
3319
  },
3089
3320
  mountRoutes() {},
3090
3321
  async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
3091
- const { createMongoWebSocket } = await import("./websocket-C3iT6srl.js");
3322
+ const { createMongoWebSocket } = await import("./websocket-BtK4_9ZS.js");
3092
3323
  createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
3093
3324
  }
3094
3325
  };