@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.
- package/dist/auth/services.d.ts +8 -0
- package/dist/db/MongoConditionBuilder.d.ts +28 -3
- package/dist/db/MongoDataService.d.ts +46 -3
- package/dist/index.es.js +266 -35
- package/dist/index.es.js.map +1 -1
- package/dist/services/MongoDriver.d.ts +16 -2
- package/dist/{websocket-C3iT6srl.js → websocket-BtK4_9ZS.js} +6 -5
- package/dist/websocket-BtK4_9ZS.js.map +1 -0
- package/package.json +5 -5
- package/dist/websocket-C3iT6srl.js.map +0 -1
package/dist/auth/services.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
3322
|
+
const { createMongoWebSocket } = await import("./websocket-BtK4_9ZS.js");
|
|
3092
3323
|
createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
|
|
3093
3324
|
}
|
|
3094
3325
|
};
|