@rapidrest/service-core 2.0.0 → 2.1.1
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/lib/BackgroundServiceManager.js +51 -9
- package/dist/lib/BackgroundServiceManager.js.map +1 -1
- package/dist/lib/EventListenerManager.js +35 -2
- package/dist/lib/EventListenerManager.js.map +1 -1
- package/dist/lib/NetUtils.js +215 -21
- package/dist/lib/NetUtils.js.map +1 -1
- package/dist/lib/ObjectFactory.js +7 -2
- package/dist/lib/ObjectFactory.js.map +1 -1
- package/dist/lib/RateLimiter.js.map +1 -1
- package/dist/lib/Server.js +121 -80
- package/dist/lib/Server.js.map +1 -1
- package/dist/lib/auth/AuthMiddleware.js +160 -100
- package/dist/lib/auth/AuthMiddleware.js.map +1 -1
- package/dist/lib/auth/JWTStrategy.js +6 -2
- package/dist/lib/auth/JWTStrategy.js.map +1 -1
- package/dist/lib/database/ConnectionManager.js +50 -2
- package/dist/lib/database/ConnectionManager.js.map +1 -1
- package/dist/lib/database/DatabaseErrors.js +88 -0
- package/dist/lib/database/DatabaseErrors.js.map +1 -0
- package/dist/lib/database/MongoRepository.js +31 -3
- package/dist/lib/database/MongoRepository.js.map +1 -1
- package/dist/lib/database/MongoSchemaSync.js +7 -1
- package/dist/lib/database/MongoSchemaSync.js.map +1 -1
- package/dist/lib/database/TypeOrmSupport.js +49 -15
- package/dist/lib/database/TypeOrmSupport.js.map +1 -1
- package/dist/lib/database/index.js +1 -0
- package/dist/lib/database/index.js.map +1 -1
- package/dist/lib/decorators/PersistenceDecorators.js +23 -0
- package/dist/lib/decorators/PersistenceDecorators.js.map +1 -1
- package/dist/lib/decorators/RouteDecorators.js +4 -2
- package/dist/lib/decorators/RouteDecorators.js.map +1 -1
- package/dist/lib/http/bun/BunRouter.js +119 -9
- package/dist/lib/http/bun/BunRouter.js.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/index.js.map +1 -1
- package/dist/lib/http/session/sessionMiddleware.js +62 -14
- package/dist/lib/http/session/sessionMiddleware.js.map +1 -1
- package/dist/lib/http/types.js +10 -1
- package/dist/lib/http/types.js.map +1 -1
- package/dist/lib/http/uWS/Adapters.js +31 -13
- package/dist/lib/http/uWS/Adapters.js.map +1 -1
- package/dist/lib/http/uWS/Router.js +80 -16
- package/dist/lib/http/uWS/Router.js.map +1 -1
- package/dist/lib/http/uWS/WebSocket.js +4 -2
- package/dist/lib/http/uWS/WebSocket.js.map +1 -1
- package/dist/lib/models/ModelUtils.js +255 -81
- package/dist/lib/models/ModelUtils.js.map +1 -1
- package/dist/lib/models/RepoUtils.js +863 -266
- package/dist/lib/models/RepoUtils.js.map +1 -1
- package/dist/lib/routes/BaseAdminRoute.js +5 -4
- package/dist/lib/routes/BaseAdminRoute.js.map +1 -1
- package/dist/lib/routes/BasePushRoute.js +104 -40
- package/dist/lib/routes/BasePushRoute.js.map +1 -1
- package/dist/lib/routes/CRUDRoute.js +31 -17
- package/dist/lib/routes/CRUDRoute.js.map +1 -1
- package/dist/lib/routes/RouteUtils.js +120 -29
- package/dist/lib/routes/RouteUtils.js.map +1 -1
- package/dist/lib/security/ACLUtils.js +170 -34
- package/dist/lib/security/ACLUtils.js.map +1 -1
- package/dist/types/BackgroundServiceManager.d.ts +6 -0
- package/dist/types/EventListenerManager.d.ts +4 -0
- package/dist/types/NetUtils.d.ts +65 -6
- package/dist/types/RateLimiter.d.ts +4 -3
- package/dist/types/Server.d.ts +33 -2
- package/dist/types/auth/AuthMiddleware.d.ts +35 -4
- package/dist/types/database/ConnectionManager.d.ts +18 -0
- package/dist/types/database/DatabaseErrors.d.ts +26 -0
- package/dist/types/database/MongoRepository.d.ts +21 -2
- package/dist/types/database/TypeOrmSupport.d.ts +11 -2
- package/dist/types/database/index.d.ts +1 -0
- package/dist/types/decorators/PersistenceDecorators.d.ts +31 -0
- package/dist/types/decorators/RouteDecorators.d.ts +4 -2
- package/dist/types/http/bun/BunRouter.d.ts +23 -2
- package/dist/types/http/index.d.ts +2 -1
- package/dist/types/http/session/sessionMiddleware.d.ts +14 -4
- package/dist/types/http/types.d.ts +40 -0
- package/dist/types/http/uWS/Adapters.d.ts +10 -1
- package/dist/types/http/uWS/Router.d.ts +15 -2
- package/dist/types/models/ModelUtils.d.ts +96 -1
- package/dist/types/models/RepoUtils.d.ts +240 -4
- package/dist/types/routes/BasePushRoute.d.ts +5 -0
- package/dist/types/routes/CRUDRoute.d.ts +10 -0
- package/dist/types/routes/RouteUtils.d.ts +37 -1
- package/dist/types/security/ACLUtils.d.ts +68 -7
- package/package.json +1 -1
|
@@ -18,16 +18,55 @@ import { isSqlDataSource } from "../database/ConnectionKinds.js";
|
|
|
18
18
|
import { resolveCollectionName } from "../database/NamingUtils.js";
|
|
19
19
|
import { ModelUtils } from "../models/ModelUtils.js";
|
|
20
20
|
import { BaseEntity } from "../models/BaseEntity.js";
|
|
21
|
-
import { BaseMongoEntity } from "../models/BaseMongoEntity.js";
|
|
22
21
|
import { ApiErrorMessages, ApiErrors } from "../ApiErrors.js";
|
|
23
22
|
import { ApiError, ObjectDecorators, ObjectUtils, UserUtils } from "@rapidrest/core";
|
|
24
23
|
import { NotificationUtils } from "../NotificationUtils.js";
|
|
25
24
|
import { RecoverableBaseEntity } from "./RecoverableBaseEntity.js";
|
|
26
|
-
import { ACLAction } from "../security/index.js";
|
|
25
|
+
import { ACLAction, AccessControlListMongo, AccessControlListSQL } from "../security/index.js";
|
|
27
26
|
import { ConnectionManager, RedisCache } from "../database/index.js";
|
|
27
|
+
import { isDuplicateKeyError, isIdentityDuplicate } from "../database/DatabaseErrors.js";
|
|
28
28
|
import { registerRollbackHook, Transactional, transactionContext } from "../decorators/DatabaseDecorators.js";
|
|
29
|
+
import { getColumnMetadata } from "../decorators/PersistenceDecorators.js";
|
|
29
30
|
const { Config, Init, Inject, Logger } = ObjectDecorators;
|
|
30
31
|
const _hashCache = new Map();
|
|
32
|
+
/** Per model class: the `@Column`s holding a date, and whether each is date-only (`"date"`) or a full date/time. */
|
|
33
|
+
const _dateColumnCache = new WeakMap();
|
|
34
|
+
/** `YYYY-MM-DD`. */
|
|
35
|
+
const REGEX_ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
36
|
+
/** `YYYY-MM-DDTHH:mm[:ss[.fraction]][zone]`, where zone is `Z`, `±HH`, `±HHmm` or `±HH:mm`. */
|
|
37
|
+
const REGEX_ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
|
|
38
|
+
/**
|
|
39
|
+
* The smallest magnitude accepted for a numeric (epoch milliseconds) date. A smaller number is far more likely to
|
|
40
|
+
* be epoch *seconds* (which would land in January 1970) than a real date between late 1966 and early 1973.
|
|
41
|
+
*/
|
|
42
|
+
const MIN_EPOCH_MS_MAGNITUDE = 1e11;
|
|
43
|
+
/** 0001-01-01T00:00:00.000Z and 9999-12-31T23:59:59.999Z. */
|
|
44
|
+
const MIN_EPOCH_MS = -62135596800000;
|
|
45
|
+
const MAX_EPOCH_MS = 253402300799999;
|
|
46
|
+
/** The actions `RepoUtils.create()` grants a record's creator on a freshly created per-record ACL. */
|
|
47
|
+
const CREATOR_ACTIONS = [
|
|
48
|
+
ACLAction.COUNT,
|
|
49
|
+
ACLAction.CREATE,
|
|
50
|
+
ACLAction.DELETE,
|
|
51
|
+
ACLAction.EXISTS,
|
|
52
|
+
ACLAction.READ,
|
|
53
|
+
ACLAction.LIST,
|
|
54
|
+
ACLAction.TRUNCATE,
|
|
55
|
+
ACLAction.UPDATE,
|
|
56
|
+
];
|
|
57
|
+
/** The explicit `@Column({ type })` values that store a date/time (compared lower-cased). */
|
|
58
|
+
const DATE_COLUMN_TYPES = new Set([
|
|
59
|
+
"date",
|
|
60
|
+
"datetime",
|
|
61
|
+
"datetime2",
|
|
62
|
+
"datetimeoffset",
|
|
63
|
+
"smalldatetime",
|
|
64
|
+
"timestamp",
|
|
65
|
+
"timestamptz",
|
|
66
|
+
"timestamp with time zone",
|
|
67
|
+
"timestamp without time zone",
|
|
68
|
+
"timestamp with local time zone",
|
|
69
|
+
]);
|
|
31
70
|
/**
|
|
32
71
|
* @author Jean-Philippe Steinmetz
|
|
33
72
|
*/
|
|
@@ -99,28 +138,91 @@ export class RepoUtils {
|
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
140
|
/**
|
|
102
|
-
* Retrieves
|
|
103
|
-
* baked into it by `ModelUtils.buildSearchQuery
|
|
104
|
-
*
|
|
105
|
-
* `
|
|
141
|
+
* Retrieves the uids matching the given (already-built) search query, ignoring any pagination `take`/`page`
|
|
142
|
+
* baked into it by `ModelUtils.buildSearchQuery`, unless `cap` is given.
|
|
143
|
+
*
|
|
144
|
+
* Without `cap` the whole matching set is returned. Pass `cap` whenever each uid will cost a per-record ACL check
|
|
145
|
+
* on behalf of a client that acts on the records (`truncate()` on a `recordACL` model): otherwise a single anonymous
|
|
146
|
+
* request can trigger unbounded ACL work, and on MongoDB a `distinct` over enough uids exceeds the 16MB reply limit.
|
|
147
|
+
* `count()` must report the true total, so it never uses this - see `countPermittedUids()`.
|
|
148
|
+
*
|
|
149
|
+
* @param cap The maximum number of uids to return.
|
|
106
150
|
*/
|
|
107
|
-
async findAllUids(searchQuery, options) {
|
|
151
|
+
async findAllUids(searchQuery, options, cap) {
|
|
108
152
|
const txInfo = this.getTransaction(options);
|
|
109
153
|
if (this.repo instanceof MongoRepository) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
const match = Array.isArray(searchQuery)
|
|
155
|
+
? searchQuery[0].$match
|
|
156
|
+
: searchQuery["$match"]
|
|
157
|
+
? searchQuery["$match"]
|
|
158
|
+
: searchQuery;
|
|
159
|
+
// Plain `distinct()` + a client-side slice: no aggregation pipelines (see NOTES.md).
|
|
160
|
+
const uids = await this.repo.distinct("uid", match, { session: txInfo?.session });
|
|
161
|
+
return cap !== undefined ? uids.slice(0, cap) : uids;
|
|
162
|
+
}
|
|
163
|
+
// Only the uid column is needed, and pagination must not clip the result set here unless capped.
|
|
118
164
|
const uidQuery = { ...searchQuery, select: { uid: true } };
|
|
119
165
|
delete uidQuery.take;
|
|
120
166
|
delete uidQuery.page;
|
|
167
|
+
if (cap !== undefined) {
|
|
168
|
+
uidQuery.take = cap;
|
|
169
|
+
}
|
|
121
170
|
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
122
171
|
const rows = (await repo.find(uidQuery));
|
|
123
|
-
|
|
172
|
+
// A trackChanges table holds one row per version, so the same uid can appear more than once.
|
|
173
|
+
return Array.from(new Set(rows.map((obj) => obj.uid)));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Returns the maximum number of records a client request may have checked individually against record-level
|
|
177
|
+
* ACLs: the `take` that `ModelUtils.buildSearchQuerySQL()` already resolved onto the built SQL query. A MongoDB
|
|
178
|
+
* query carries no `take`, so the same rule is read from `ModelUtils.resolvePagination()`, the helper that
|
|
179
|
+
* documents it for MongoDB callers.
|
|
180
|
+
*/
|
|
181
|
+
recordACLCap(searchQuery, query) {
|
|
182
|
+
// Only `truncate()` uses this; `count()` is never capped.
|
|
183
|
+
if (typeof searchQuery?.take === "number") {
|
|
184
|
+
return searchQuery.take;
|
|
185
|
+
}
|
|
186
|
+
return ModelUtils.resolvePagination(query).take;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Determines whether a built search query can match soft-deleted records: every OR branch must pin `deleted` to
|
|
190
|
+
* exactly `false` for it not to. Decided from the compiled query rather than the raw client value, so any
|
|
191
|
+
* spelling of the filter (`true`, `eq(true)`, `in(true,false)`, `ne(false)`, a repeated parameter, a `$or`
|
|
192
|
+
* branch) is recognized. Always `false` for a model that isn't recoverable.
|
|
193
|
+
*/
|
|
194
|
+
queryIncludesDeleted(searchQuery) {
|
|
195
|
+
if (!(this.modelClass?.prototype instanceof RecoverableBaseEntity)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const isFalse = (value) => {
|
|
199
|
+
if (value === false) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (value && typeof value === "object") {
|
|
203
|
+
// TypeORM FindOperator: Equal(false), or an And(...) that contains one.
|
|
204
|
+
if (value.type === "equal") {
|
|
205
|
+
return value.value === false;
|
|
206
|
+
}
|
|
207
|
+
if (value.type === "and" && Array.isArray(value.value)) {
|
|
208
|
+
return value.value.some(isFalse);
|
|
209
|
+
}
|
|
210
|
+
// MongoDB: { $eq: false }.
|
|
211
|
+
return Object.keys(value).length === 1 && value.$eq === false;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
};
|
|
215
|
+
if (this.repo instanceof MongoRepository) {
|
|
216
|
+
const excludes = (match) => !!match &&
|
|
217
|
+
(isFalse(match.deleted) ||
|
|
218
|
+
(Array.isArray(match.$or) && match.$or.length > 0 && match.$or.every(excludes)) ||
|
|
219
|
+
(Array.isArray(match.$and) && match.$and.some(excludes)));
|
|
220
|
+
const match = Array.isArray(searchQuery) ? searchQuery[0]?.$match : (searchQuery?.$match ?? searchQuery);
|
|
221
|
+
return !excludes(match);
|
|
222
|
+
}
|
|
223
|
+
// `buildSearchQuerySQL()` always compiles `where` to an array of OR branches (or omits it: no conditions at all).
|
|
224
|
+
const where = searchQuery?.where;
|
|
225
|
+
return !Array.isArray(where) || where.length === 0 || !where.every((branch) => isFalse(branch?.deleted));
|
|
124
226
|
}
|
|
125
227
|
/**
|
|
126
228
|
* Filters the given uids down to those the user has `action` permission for, checking in bounded-size
|
|
@@ -141,6 +243,62 @@ export class RepoUtils {
|
|
|
141
243
|
}
|
|
142
244
|
return permitted;
|
|
143
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Counts the records matched by an (already-built) search query that the caller holds the given permission(s) on,
|
|
248
|
+
* for a `recordACL` model. Unlike `findAllUids()` this is never capped - a count must be the true total - but it
|
|
249
|
+
* never holds the whole matching set either: MongoDB streams uids from a projected cursor instead of a `distinct`
|
|
250
|
+
* (whose single reply is limited to 16MB), and permissions are checked in bounded batches as the uids arrive.
|
|
251
|
+
*
|
|
252
|
+
* @param actions Every action the caller must hold on a record for it to be counted.
|
|
253
|
+
*/
|
|
254
|
+
async countPermittedUids(searchQuery, actions, options) {
|
|
255
|
+
const txInfo = this.getTransaction(options);
|
|
256
|
+
const batchSize = 100;
|
|
257
|
+
// A trackChanges collection holds one row per version, so the same uid can appear more than once.
|
|
258
|
+
const seen = this.modelClass.trackChanges ? new Set() : undefined;
|
|
259
|
+
let batch = [];
|
|
260
|
+
let total = 0;
|
|
261
|
+
const flush = async () => {
|
|
262
|
+
let permitted = batch;
|
|
263
|
+
for (const action of actions) {
|
|
264
|
+
permitted = await this.filterPermittedUids(permitted, action, options);
|
|
265
|
+
}
|
|
266
|
+
total += permitted.length;
|
|
267
|
+
batch = [];
|
|
268
|
+
};
|
|
269
|
+
const add = async (uid) => {
|
|
270
|
+
if (seen) {
|
|
271
|
+
if (seen.has(uid)) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
seen.add(uid);
|
|
275
|
+
}
|
|
276
|
+
batch.push(uid);
|
|
277
|
+
if (batch.length >= batchSize) {
|
|
278
|
+
await flush();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
if (this.repo instanceof MongoRepository) {
|
|
282
|
+
const match = Array.isArray(searchQuery)
|
|
283
|
+
? searchQuery[0].$match
|
|
284
|
+
: searchQuery["$match"]
|
|
285
|
+
? searchQuery["$match"]
|
|
286
|
+
: searchQuery;
|
|
287
|
+
const cursor = this.repo.find(match, { projection: { uid: 1 }, session: txInfo?.session });
|
|
288
|
+
for await (const doc of cursor) {
|
|
289
|
+
await add(doc.uid);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
for (const uid of await this.findAllUids(searchQuery, options)) {
|
|
294
|
+
await add(uid);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (batch.length > 0) {
|
|
298
|
+
await flush();
|
|
299
|
+
}
|
|
300
|
+
return total;
|
|
301
|
+
}
|
|
144
302
|
async count(query, options) {
|
|
145
303
|
if (!this.repo) {
|
|
146
304
|
throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
|
|
@@ -156,18 +314,19 @@ export class RepoUtils {
|
|
|
156
314
|
throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
|
|
157
315
|
}
|
|
158
316
|
}
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
317
|
+
let searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
|
|
318
|
+
// A client-supplied `deleted` filter (in any form: `true`, `eq(true)`, `ne(false)`, a repeated parameter, a
|
|
319
|
+
// `$or` branch, ...) overrides `buildSearchQuery()`'s default exclusion of soft-deleted rows. Counting a
|
|
320
|
+
// matched soft-deleted row requires the DELETE+UPDATE permissions, so this is decided from the built query.
|
|
321
|
+
const clientRequestsDeleted = this.queryIncludesDeleted(searchQuery);
|
|
162
322
|
const recordACL = !!this.modelClass.recordACL;
|
|
163
|
-
let effectiveQuery = query;
|
|
164
323
|
if (clientRequestsDeleted && this.aclUtils?.enabled && !options?.ignoreACL && !recordACL) {
|
|
165
324
|
if (!(await this.canViewDeleted(options?.user, this.defaultACLUid))) {
|
|
166
|
-
|
|
167
|
-
|
|
325
|
+
// A top-level `deleted: false` is ANDed with every other condition (including any `$or` branch's own
|
|
326
|
+
// `deleted`) on both backends, so no soft-deleted row can match.
|
|
327
|
+
searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, { ...query, deleted: false }, true, options?.user);
|
|
168
328
|
}
|
|
169
329
|
}
|
|
170
|
-
const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, effectiveQuery, true, options?.user);
|
|
171
330
|
// `buildSearchQuery()` auto-excludes soft-deleted rows for a RecoverableBaseEntity by default. We strip
|
|
172
331
|
// that out of the query rather than trying to influence the exclusion via the input `query` object.
|
|
173
332
|
if (options?.includeDeleted) {
|
|
@@ -186,19 +345,10 @@ export class RepoUtils {
|
|
|
186
345
|
// Record-level ACLs aren't reflected in the query itself, so the matched uids must be checked
|
|
187
346
|
// individually and counted rather than delegating the count to the database.
|
|
188
347
|
if (this.aclUtils?.enabled && !options?.ignoreACL && recordACL) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// bar per-record rather than the ordinary `action`.
|
|
194
|
-
const deleteOk = new Set(await this.filterPermittedUids(uids, ACLAction.DELETE, options));
|
|
195
|
-
const updateOk = await this.filterPermittedUids(uids, ACLAction.UPDATE, options);
|
|
196
|
-
permitted = updateOk.filter((uid) => deleteOk.has(uid));
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
permitted = await this.filterPermittedUids(uids, action, options);
|
|
200
|
-
}
|
|
201
|
-
return permitted.length;
|
|
348
|
+
// Never capped: a count is the true total of matching records the caller may see. When the query can
|
|
349
|
+
// match soft-deleted records, conservatively apply the restore bar (DELETE+UPDATE) to every matched record
|
|
350
|
+
// rather than the ordinary `action`.
|
|
351
|
+
return await this.countPermittedUids(searchQuery, clientRequestsDeleted ? [ACLAction.DELETE, ACLAction.UPDATE] : [action], options);
|
|
202
352
|
}
|
|
203
353
|
if (this.repo instanceof MongoRepository) {
|
|
204
354
|
if (Array.isArray(searchQuery)) {
|
|
@@ -316,6 +466,13 @@ export class RepoUtils {
|
|
|
316
466
|
const clazz = this.getClassType(obj);
|
|
317
467
|
const newObj = obj instanceof clazz ? obj : this.instantiateObject(obj, clazz);
|
|
318
468
|
const repo = this.repo;
|
|
469
|
+
// A create must never let its input pick an existing document. A caller-supplied `_id` would otherwise make
|
|
470
|
+
// `MongoRepository.save()` replace (upsert) whichever document owns that `_id` - any record, of any owner.
|
|
471
|
+
if (!options?.preserveId && newObj._id !== undefined) {
|
|
472
|
+
delete newObj._id;
|
|
473
|
+
}
|
|
474
|
+
// JSON has no date type - store Date-typed properties as real dates, not strings.
|
|
475
|
+
this.coerceDateProperties(newObj, clazz);
|
|
319
476
|
// Make sure an existing object doesn't already exist with the same identifiers
|
|
320
477
|
const ids = [];
|
|
321
478
|
const idProps = ModelUtils.getIdPropertyNames(clazz);
|
|
@@ -325,10 +482,7 @@ export class RepoUtils {
|
|
|
325
482
|
ids.push(val);
|
|
326
483
|
}
|
|
327
484
|
}
|
|
328
|
-
const
|
|
329
|
-
const count = this.repo instanceof MongoRepository
|
|
330
|
-
? await this.repo.count(query, { session: txInfo?.session })
|
|
331
|
-
: await (txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo).count(query);
|
|
485
|
+
const count = await this.countById(ids, txInfo, clazz);
|
|
332
486
|
if (!this.modelClass.trackChanges && count > 0) {
|
|
333
487
|
throw new ApiError(ApiErrors.IDENTIFIER_EXISTS, 400, ApiErrorMessages.IDENTIFIER_EXISTS);
|
|
334
488
|
}
|
|
@@ -347,7 +501,8 @@ export class RepoUtils {
|
|
|
347
501
|
// must always run.
|
|
348
502
|
throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
|
|
349
503
|
}
|
|
350
|
-
// Override the date and version fields with their defaults
|
|
504
|
+
// Override the date and version fields with their defaults. Whatever the caller supplied for these is
|
|
505
|
+
// discarded, so a create can't forge a record's history or optimistic-lock state.
|
|
351
506
|
if (newObj instanceof BaseEntity) {
|
|
352
507
|
newObj.dateCreated = new Date();
|
|
353
508
|
newObj.dateModified = new Date();
|
|
@@ -357,90 +512,72 @@ export class RepoUtils {
|
|
|
357
512
|
if (newObj instanceof BaseEntity && this.modelClass.trackChanges === 0) {
|
|
358
513
|
newObj.version = 0;
|
|
359
514
|
}
|
|
515
|
+
// Resolve the record-level ACL *before* the record is written, so that a create which isn't entitled to its
|
|
516
|
+
// uid's ACL never writes a record at all (not even one that a transaction rollback would have to undo).
|
|
517
|
+
const freshAclUid = this.aclUtils?.enabled && this.modelClass.recordACL
|
|
518
|
+
? await this.claimRecordACL(newObj.uid, count, options)
|
|
519
|
+
: undefined;
|
|
360
520
|
// HAX We shouldn't be casting obj to any here but this is the only way to get it to compile
|
|
361
521
|
// since T extends BaseEntity.
|
|
362
522
|
let saved;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
523
|
+
try {
|
|
524
|
+
if (this.repo instanceof MongoRepository) {
|
|
525
|
+
// `insertOnly`: a create always inserts, even when trusted code preserved an `_id` (`preserveId`).
|
|
526
|
+
saved = await this.repo.save(newObj, { session: txInfo?.session, insertOnly: true });
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
const repo = txInfo?.entityManager
|
|
530
|
+
? txInfo.entityManager.getRepository(this.modelClass)
|
|
531
|
+
: this.repo;
|
|
532
|
+
// `insert()` rather than `save()`: TypeORM's `save()` becomes an UPDATE of the existing row when a
|
|
533
|
+
// concurrent create of the same identifier lands between the count check above and this write.
|
|
534
|
+
// `insert()` merges generated columns back into `newObj`.
|
|
535
|
+
await repo.insert(newObj);
|
|
536
|
+
saved = newObj;
|
|
537
|
+
}
|
|
369
538
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
};
|
|
382
|
-
// Look for an existing record for the creator. We only search the immediate ACL
|
|
383
|
-
// and not the parent chain and we perform an exact match.
|
|
384
|
-
let found = !!this.aclUtils.getRecord(acl, options?.user, { maxDepth: 0, specificity: "exact" });
|
|
385
|
-
let modifiedExistingAcl = false;
|
|
386
|
-
// Always grant the creator CRUD access, unless the user is a superuser.
|
|
387
|
-
if (!found && options?.user && !UserUtils.hasRoles(options?.user, this.trustedRoles)) {
|
|
388
|
-
acl.records.push({
|
|
389
|
-
userOrRoleId: options.user.uid,
|
|
390
|
-
actions: [
|
|
391
|
-
ACLAction.COUNT,
|
|
392
|
-
ACLAction.CREATE,
|
|
393
|
-
ACLAction.DELETE,
|
|
394
|
-
ACLAction.EXISTS,
|
|
395
|
-
ACLAction.READ,
|
|
396
|
-
ACLAction.LIST,
|
|
397
|
-
ACLAction.TRUNCATE,
|
|
398
|
-
ACLAction.UPDATE,
|
|
399
|
-
],
|
|
400
|
-
});
|
|
401
|
-
modifiedExistingAcl = !isFreshAcl;
|
|
402
|
-
}
|
|
403
|
-
await this.aclUtils.saveACL(acl);
|
|
404
|
-
// `saveACL()` commits independently, on the `acl` connection's own transaction (see its doc
|
|
405
|
-
// comment). That means it can't be rolled back by this (the entity-side) transaction's own abort if this
|
|
406
|
-
// transaction fails later. Register a compensating action so a later failure doesn't leave an orphaned
|
|
407
|
-
// ACL behind.
|
|
408
|
-
if (isFreshAcl) {
|
|
409
|
-
const newAclUid = acl.uid;
|
|
410
|
-
registerRollbackHook(async () => {
|
|
411
|
-
try {
|
|
412
|
-
await this.aclUtils.removeACL(newAclUid);
|
|
413
|
-
}
|
|
414
|
-
catch (err) {
|
|
415
|
-
this.logger?.warn(`RepoUtils: Failed to roll back orphaned ACL ${newAclUid} after a failed create().`);
|
|
416
|
-
this.logger?.debug(err);
|
|
417
|
-
}
|
|
418
|
-
});
|
|
539
|
+
catch (err) {
|
|
540
|
+
// Don't leave the ACL claimed above behind for a record that was never written. This can't be left to a
|
|
541
|
+
// rollback hook alone: without transaction support there is no rollback to run one.
|
|
542
|
+
if (freshAclUid) {
|
|
543
|
+
try {
|
|
544
|
+
await this.aclUtils.removeACL(freshAclUid);
|
|
545
|
+
}
|
|
546
|
+
catch (removeErr) {
|
|
547
|
+
this.logger?.warn(`RepoUtils: Failed to remove ACL ${freshAclUid} after a failed create().`);
|
|
548
|
+
this.logger?.debug(removeErr);
|
|
549
|
+
}
|
|
419
550
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
registerRollbackHook(async () => {
|
|
425
|
-
this.logger?.warn(`RepoUtils: create() failed after modifying existing ACL ${modifiedAclUid} — that change was not automatically reverted.`);
|
|
426
|
-
});
|
|
551
|
+
// A duplicate key (a concurrent create of the same identifier, a preserved `_id` that's already taken, or a
|
|
552
|
+
// value of some other unique column) is an identifier conflict, not an internal error.
|
|
553
|
+
if (isDuplicateKeyError(err)) {
|
|
554
|
+
throw new ApiError(ApiErrors.IDENTIFIER_EXISTS, 400, ApiErrorMessages.IDENTIFIER_EXISTS);
|
|
427
555
|
}
|
|
556
|
+
throw err;
|
|
428
557
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
558
|
+
const result = this.instantiateObject(saved);
|
|
559
|
+
// `saveACL()` commits independently, on the `acl` connection's own transaction (see its doc comment). That
|
|
560
|
+
// means it can't be rolled back by this (the entity-side) transaction's own abort if this transaction fails
|
|
561
|
+
// later. Register a compensating action so a later failure doesn't leave an orphaned ACL behind.
|
|
562
|
+
if (freshAclUid) {
|
|
563
|
+
registerRollbackHook(async () => {
|
|
564
|
+
try {
|
|
565
|
+
await this.aclUtils.removeACL(freshAclUid);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
this.logger?.warn(`RepoUtils: Failed to roll back orphaned ACL ${freshAclUid} after a failed create().`);
|
|
569
|
+
this.logger?.debug(err);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
435
572
|
}
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
//
|
|
573
|
+
// Cache the object for faster retrieval (a copy, so stripping scoped fields below can't alter the cached one).
|
|
574
|
+
this.cacheRecord(result);
|
|
575
|
+
// An ACL document written through the ACL routes must not be shadowed by a stale ACLUtils cache entry.
|
|
576
|
+
await this.invalidateACLCache([result.uid]);
|
|
577
|
+
// Broadcast to push subscribers, then remove the properties scoped with @RequiresScope that the user does not
|
|
578
|
+
// have access to from what is returned.
|
|
579
|
+
this.publish([result.uid], "create", result, options);
|
|
439
580
|
ObjectUtils.deleteScopedProps(result, options?.user, this.modelClass);
|
|
440
|
-
if (!options?.skipPush) {
|
|
441
|
-
let channels = [result.uid].concat(options?.pushChannels || []);
|
|
442
|
-
this.notificationUtils?.sendMessage(channels, this.modelClass.name, "create", result);
|
|
443
|
-
}
|
|
444
581
|
return result;
|
|
445
582
|
}
|
|
446
583
|
async delete(uid, options) {
|
|
@@ -459,6 +596,8 @@ export class RepoUtils {
|
|
|
459
596
|
// Delete must be able to target a record regardless of its current `deleted` state (the default) —
|
|
460
597
|
// otherwise an already soft-deleted record could never be purged, nor a soft-delete repeated idempotently.
|
|
461
598
|
const query = ModelUtils.buildIdSearchQuery(this.repo, this.modelClass, uid, options.version ? Number(options.version) : undefined);
|
|
599
|
+
// The cache keys of every stored version of this record, collected before the rows go away.
|
|
600
|
+
const versionKeys = await this.versionCacheKeys([uid], options);
|
|
462
601
|
// If the object(s) are being permenantly removed from the database do so and then clear the accompanying
|
|
463
602
|
// ACL(s). If the class type is recoverable and purge isn't desired, simply mark the object(s) as deleted.
|
|
464
603
|
if (isPurge) {
|
|
@@ -469,14 +608,19 @@ export class RepoUtils {
|
|
|
469
608
|
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
470
609
|
await repo.delete(query.where);
|
|
471
610
|
}
|
|
472
|
-
|
|
611
|
+
// Purging a single version of a trackChanges record leaves the record (and so its ACL) in place.
|
|
612
|
+
const recordRemains = !!options.version && (await this.countById(uid, txInfo)) > 0;
|
|
613
|
+
if (this.aclUtils?.enabled && this.modelClass.recordACL && !recordRemains) {
|
|
473
614
|
// `removeACL()` returns the exact document it deleted (captured atomically, not via a separate
|
|
474
615
|
// earlier read - see its doc comment) - used as the restore snapshot below. `removeACL()`
|
|
475
616
|
// commits independently, on the `acl` connection's own transaction; if this (the entity-side)
|
|
476
617
|
// transaction later fails, its own abort can't undo that removal, so the rollback hook restores
|
|
477
618
|
// the snapshot in that case. `preserveVersion` restores its exact prior version instead of
|
|
478
619
|
// bumping it, and refuses (rather than clobbers) if something already exists at this uid.
|
|
479
|
-
|
|
620
|
+
// `unlessProtected`: a record that shares the uid of a class/route/default ACL must never remove it.
|
|
621
|
+
const removedAcl = await this.aclUtils.removeACL(uid, {
|
|
622
|
+
unlessProtected: true,
|
|
623
|
+
});
|
|
480
624
|
if (removedAcl) {
|
|
481
625
|
registerRollbackHook(async () => {
|
|
482
626
|
try {
|
|
@@ -505,21 +649,11 @@ export class RepoUtils {
|
|
|
505
649
|
});
|
|
506
650
|
}
|
|
507
651
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
.delete(this.hashQuery(this.searchIdQuery(uid)))
|
|
514
|
-
.catch((err) => this.logCacheError("delete", err));
|
|
515
|
-
}
|
|
516
|
-
if (!options?.skipPush) {
|
|
517
|
-
let channels = [uid].concat(options?.pushChannels || []);
|
|
518
|
-
this.notificationUtils?.sendMessage(channels, this.modelClass.name, "delete", {
|
|
519
|
-
uid,
|
|
520
|
-
version: options.version,
|
|
521
|
-
});
|
|
522
|
-
}
|
|
652
|
+
// Delete the object from cache (list results read their records through these same keys).
|
|
653
|
+
this.uncacheRecords([uid], versionKeys);
|
|
654
|
+
// An ACL document removed through the ACL routes must stop granting anything right away.
|
|
655
|
+
await this.invalidateACLCache([uid]);
|
|
656
|
+
this.publish([uid], "delete", { uid, version: options.version }, options);
|
|
523
657
|
}
|
|
524
658
|
/**
|
|
525
659
|
* Retrieves an array of objects from the datasource matching the given search query. This function will first
|
|
@@ -545,23 +679,21 @@ export class RepoUtils {
|
|
|
545
679
|
const limit = options?.limit ? Math.min(options?.limit, 1000) : 100;
|
|
546
680
|
const page = options?.page ? Number(options?.page) : 0;
|
|
547
681
|
let results = [];
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
682
|
+
// Build (and so validate) the query before consulting the cache: a query that must be rejected (e.g. a forged
|
|
683
|
+
// `$literal` key, or `me` without a user) is rejected even when an equivalent query's results are cached.
|
|
684
|
+
const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
|
|
685
|
+
// The cache key covers the pagination, and the requesting user whenever the query can refer to them via `me`
|
|
686
|
+
// (substituted by `buildSearchQuery()`), since the same raw query then means something different per user.
|
|
687
|
+
const queryKey = this.queryCacheKey({
|
|
688
|
+
query: { ...query, limit, page },
|
|
689
|
+
user: RepoUtils.REGEX_ME.test(JSON.stringify(query) ?? "") ? (options?.user?.uid ?? null) : undefined,
|
|
554
690
|
});
|
|
555
691
|
// Pull from the cache if available
|
|
556
692
|
if (!options?.skipCache && this.cache) {
|
|
557
|
-
|
|
558
|
-
if (cached) {
|
|
559
|
-
results = cached.filter((obj) => obj !== undefined);
|
|
560
|
-
}
|
|
693
|
+
results = await this.loadCachedResults(queryKey);
|
|
561
694
|
}
|
|
562
695
|
// If the query wasn't cached retrieve from the database
|
|
563
696
|
if (results.length === 0) {
|
|
564
|
-
const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
|
|
565
697
|
if (this.repo instanceof MongoRepository) {
|
|
566
698
|
const skip = page * limit;
|
|
567
699
|
if (Array.isArray(searchQuery)) {
|
|
@@ -591,10 +723,7 @@ export class RepoUtils {
|
|
|
591
723
|
}
|
|
592
724
|
// Cache the results for future requests. Don't bother if there were no results.
|
|
593
725
|
if (results.length > 0 && this.cache) {
|
|
594
|
-
this.
|
|
595
|
-
// Also seed each individual object's own cache entry.
|
|
596
|
-
const ids = results.map((obj) => this.hashQuery(this.searchIdQuery(obj.uid)));
|
|
597
|
-
this.cache.saveMany(ids, results).catch((err) => this.logCacheError("saveMany", err));
|
|
726
|
+
this.cacheResults(queryKey, results);
|
|
598
727
|
}
|
|
599
728
|
}
|
|
600
729
|
// Record-level ACLs aren't reflected in the query itself (nor in cached results, which are shared across
|
|
@@ -619,7 +748,8 @@ export class RepoUtils {
|
|
|
619
748
|
results = results.filter((_obj, i) => permitted[i]);
|
|
620
749
|
}
|
|
621
750
|
// Process the results to remove any properties that have been scoped with @RequiresScope that the user
|
|
622
|
-
// does not have access to.
|
|
751
|
+
// does not have access to. Done on copies: the cache holds (and keeps serving) the objects themselves.
|
|
752
|
+
results = results.map((obj) => this.copyRecord(obj));
|
|
623
753
|
ObjectUtils.deleteScopedProps(results, options?.user, this.modelClass);
|
|
624
754
|
return results;
|
|
625
755
|
}
|
|
@@ -637,12 +767,17 @@ export class RepoUtils {
|
|
|
637
767
|
let existing = undefined;
|
|
638
768
|
const txInfo = this.getTransaction(options);
|
|
639
769
|
// Deliberately uses the default (includeDeleted: true) query shape here — this result is cached under
|
|
640
|
-
// a key shared with create()/update()/find()'s cache-seeding, all of which also
|
|
641
|
-
//
|
|
642
|
-
//
|
|
770
|
+
// a key shared with create()/update()/find()'s cache-seeding, all of which also cache regardless of the
|
|
771
|
+
// deleted state. Soft-deleted records are filtered out below instead, after the cache/DB read, regardless of
|
|
772
|
+
// which one produced the result.
|
|
643
773
|
const query = this.searchIdQuery(id, options?.version);
|
|
644
|
-
|
|
645
|
-
|
|
774
|
+
const version = this.parseVersion(options?.version);
|
|
775
|
+
// `undefined` when this lookup can't be cached (a specific version of a model whose versions aren't kept).
|
|
776
|
+
const cacheKey = this.cache ? this.recordCacheKey(id, version) : undefined;
|
|
777
|
+
if (!options?.skipCache && cacheKey) {
|
|
778
|
+
const cached = await this.cache.load(cacheKey);
|
|
779
|
+
// Only accept an entry that really is the record asked for.
|
|
780
|
+
existing = cached && this.matchesId(cached, id, version) ? cached : undefined;
|
|
646
781
|
}
|
|
647
782
|
if (!existing) {
|
|
648
783
|
if (this.repo instanceof MongoRepository) {
|
|
@@ -676,9 +811,9 @@ export class RepoUtils {
|
|
|
676
811
|
existing = null;
|
|
677
812
|
}
|
|
678
813
|
if (existing) {
|
|
679
|
-
if (
|
|
814
|
+
if (cacheKey) {
|
|
680
815
|
// Cache the object for faster retrieval
|
|
681
|
-
this.cache.save(
|
|
816
|
+
this.cache.save(cacheKey, existing).catch((err) => this.logCacheError("save", err));
|
|
682
817
|
}
|
|
683
818
|
// Check user permissions
|
|
684
819
|
if (this.aclUtils?.enabled && !options?.ignoreACL) {
|
|
@@ -698,6 +833,7 @@ export class RepoUtils {
|
|
|
698
833
|
}
|
|
699
834
|
}
|
|
700
835
|
}
|
|
836
|
+
// A new object, so stripping it below never alters the cached one.
|
|
701
837
|
const result = existing ? this.instantiateObject(existing) : undefined;
|
|
702
838
|
// Process the result to remove any properties that have been scoped with @RequiresScope that the user
|
|
703
839
|
// does not have access to.
|
|
@@ -707,6 +843,202 @@ export class RepoUtils {
|
|
|
707
843
|
// Make sure we return the correct data type
|
|
708
844
|
return result;
|
|
709
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* Resolves the per-record ACL for a record about to be created by `create()` under `uid`. Returns `uid` when a
|
|
848
|
+
* fresh ACL was created for it (so the caller can clean it up if the create fails), or `undefined` when an
|
|
849
|
+
* existing ACL is legitimately reused as-is.
|
|
850
|
+
*
|
|
851
|
+
* ACLs live in one global collection keyed only by uid, shared by every model and by the class/route ACLs, and a
|
|
852
|
+
* create's uid can come from the client. An ACL that already exists at `uid` may guard a record of another model,
|
|
853
|
+
* a whole model or route, or have been planted there by another user ahead of time - adopting it would hand
|
|
854
|
+
* whoever holds rights on it the new record, or hand the creator whatever it protects. So:
|
|
855
|
+
* - a code-defined ACL uid (a class, route or endpoint ACL, or any `default_*` uid; see
|
|
856
|
+
* `ACLUtils.isReservedUid()`/`isProtectedACL()`) is never claimed or reused, whoever the caller is;
|
|
857
|
+
* - an existing ACL is reused, unchanged, only for a trackChanges "new version" of an existing record
|
|
858
|
+
* (`count > 0`; `create()` has already verified the caller's UPDATE right on that record), or when trusted
|
|
859
|
+
* server code passed `allowExistingACL`. Neither a trusted role nor already holding rights on it is enough.
|
|
860
|
+
*
|
|
861
|
+
* Otherwise the create is refused with `IDENTIFIER_EXISTS`, the same error as a record identifier collision.
|
|
862
|
+
* A genuinely orphaned ACL can't be reliably told apart from one guarding another model's record, so no attempt
|
|
863
|
+
* is made to replace it. A fresh ACL is claimed with `saveACL()`'s `createOnly` mode, so two concurrent creates
|
|
864
|
+
* (of the same or of different models) can't both claim the same uid either.
|
|
865
|
+
*/
|
|
866
|
+
async claimRecordACL(uid, count, options) {
|
|
867
|
+
const aclUtils = this.aclUtils;
|
|
868
|
+
const refuse = () => new ApiError(ApiErrors.IDENTIFIER_EXISTS, 400, ApiErrorMessages.IDENTIFIER_EXISTS);
|
|
869
|
+
if (aclUtils.isReservedUid(uid)) {
|
|
870
|
+
throw refuse();
|
|
871
|
+
}
|
|
872
|
+
// Bypass the cache: this is an authorization decision about the ACL's current state. Only the ACL itself is
|
|
873
|
+
// inspected, so its parent chain isn't loaded.
|
|
874
|
+
const existingAcl = await aclUtils.findACL(uid, [], {
|
|
875
|
+
skipCache: true,
|
|
876
|
+
skipParents: true,
|
|
877
|
+
});
|
|
878
|
+
if (existingAcl) {
|
|
879
|
+
if (aclUtils.isProtectedACL(existingAcl) || !(count > 0 || options?.allowExistingACL)) {
|
|
880
|
+
throw refuse();
|
|
881
|
+
}
|
|
882
|
+
return undefined;
|
|
883
|
+
}
|
|
884
|
+
const acl = {
|
|
885
|
+
uid,
|
|
886
|
+
parentUid: options?.acl?.parentUid || this.defaultACLUid,
|
|
887
|
+
records: [...(options?.acl?.records || [])],
|
|
888
|
+
};
|
|
889
|
+
// Look for an existing record for the creator. We only search the immediate ACL
|
|
890
|
+
// and not the parent chain and we perform an exact match.
|
|
891
|
+
const found = !!aclUtils.getRecord(acl, options?.user, { maxDepth: 0, specificity: "exact" });
|
|
892
|
+
// Always grant the creator CRUD access, unless the user is a superuser.
|
|
893
|
+
if (!found && options?.user && !UserUtils.hasRoles(options?.user, this.trustedRoles)) {
|
|
894
|
+
acl.records.push({ userOrRoleId: options.user.uid, actions: [...CREATOR_ACTIONS] });
|
|
895
|
+
}
|
|
896
|
+
await aclUtils.saveACL(acl, { createOnly: true });
|
|
897
|
+
return uid;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Converts every `Date`-typed property of `obj` that holds a string or number (e.g. an ISO 8601 string from a
|
|
901
|
+
* JSON request body) to a real `Date`, in place. Without this a MongoDB document stores the string itself, and
|
|
902
|
+
* date range queries (which compare against `Date` operands) never match it.
|
|
903
|
+
*
|
|
904
|
+
* A property counts as `Date`-typed when its `@Column` declares an explicit date/time `type`, or otherwise when
|
|
905
|
+
* TypeScript's emitted `design:type` is `Date`. TypeScript reflects a union-typed property (e.g. `Date | null`)
|
|
906
|
+
* as `Object`, so such a property is only converted when its `@Column` sets `type` explicitly. Properties that
|
|
907
|
+
* aren't `@Column`s are never touched.
|
|
908
|
+
*
|
|
909
|
+
* Accepted values (anything else, including a `Date` column holding a boolean or an object, is a 400):
|
|
910
|
+
* - an ISO 8601 date (`YYYY-MM-DD`) or date-time (`YYYY-MM-DDTHH:mm[:ss[.fff]]`, `T` or a space), with a zone
|
|
911
|
+
* designator of `Z`, `±HH`, `±HHmm` or `±HH:mm`. A date-time without a zone is read as UTC, never as the server's
|
|
912
|
+
* local time;
|
|
913
|
+
* - a finite number of epoch milliseconds between years 1 and 9999 whose magnitude is at least `1e11` (a smaller
|
|
914
|
+
* number is ambiguous with epoch seconds). Numeric strings are not accepted;
|
|
915
|
+
* - a `Date`, `null` or `undefined`, which are left as they are.
|
|
916
|
+
*
|
|
917
|
+
* A SQL date-only column (`@Column({ type: "date" })`) is validated as a `YYYY-MM-DD` string but kept as that
|
|
918
|
+
* string: TypeORM writes a `Date` into such a column using the server's local calendar date, so converting it
|
|
919
|
+
* would store the previous day on any server west of UTC. On MongoDB, which has no date-only type, it is
|
|
920
|
+
* converted to midnight UTC like any other date.
|
|
921
|
+
*
|
|
922
|
+
* @param obj The object whose properties to convert.
|
|
923
|
+
* @param clazz The model class describing `obj`.
|
|
924
|
+
* @throws ApiError `INVALID_REQUEST` (400) when a value isn't a valid date.
|
|
925
|
+
*/
|
|
926
|
+
coerceDateProperties(obj, clazz) {
|
|
927
|
+
if (!obj || typeof obj !== "object" || !clazz) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const isSql = !(this.repo instanceof MongoRepository);
|
|
931
|
+
for (const column of RepoUtils.getDateColumns(clazz)) {
|
|
932
|
+
const value = obj[column.propertyName];
|
|
933
|
+
if (value === undefined || value === null || value instanceof Date) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const invalid = () => new ApiError(ApiErrors.INVALID_REQUEST, 400, `Property ${column.propertyName} is invalid. Expected a valid ${column.dateOnly && isSql ? "YYYY-MM-DD date" : "date"}.`);
|
|
937
|
+
if (column.dateOnly && isSql) {
|
|
938
|
+
if (typeof value !== "string" || !RepoUtils.parseISODate(value, true)) {
|
|
939
|
+
throw invalid();
|
|
940
|
+
}
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const date = RepoUtils.parseDateInput(value);
|
|
944
|
+
if (!date) {
|
|
945
|
+
throw invalid();
|
|
946
|
+
}
|
|
947
|
+
obj[column.propertyName] = date;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Returns the date-typed `@Column`s of `clazz` (see `coerceDateProperties()`), computed once per class.
|
|
952
|
+
*/
|
|
953
|
+
static getDateColumns(clazz) {
|
|
954
|
+
let columns = _dateColumnCache.get(clazz);
|
|
955
|
+
if (!columns) {
|
|
956
|
+
columns = [];
|
|
957
|
+
for (const column of getColumnMetadata(clazz)) {
|
|
958
|
+
const type = column.options.type ?? column.designType;
|
|
959
|
+
const typeName = typeof type === "string" ? type.toLowerCase() : undefined;
|
|
960
|
+
if (type === Date || (typeName && DATE_COLUMN_TYPES.has(typeName))) {
|
|
961
|
+
columns.push({ propertyName: column.propertyName, dateOnly: typeName === "date" });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
_dateColumnCache.set(clazz, columns);
|
|
965
|
+
}
|
|
966
|
+
return columns;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Parses a client-supplied date value: an ISO 8601 string or a number of epoch milliseconds (see
|
|
970
|
+
* `coerceDateProperties()` for the exact rules). Returns `undefined` for anything else.
|
|
971
|
+
*/
|
|
972
|
+
static parseDateInput(value) {
|
|
973
|
+
if (typeof value === "number") {
|
|
974
|
+
const ok = Number.isFinite(value) &&
|
|
975
|
+
value >= MIN_EPOCH_MS &&
|
|
976
|
+
value <= MAX_EPOCH_MS &&
|
|
977
|
+
Math.abs(value) >= MIN_EPOCH_MS_MAGNITUDE;
|
|
978
|
+
return ok ? new Date(value) : undefined;
|
|
979
|
+
}
|
|
980
|
+
return typeof value === "string" ? RepoUtils.parseISODate(value, false) : undefined;
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Strictly parses an ISO 8601 date (`YYYY-MM-DD`) or, unless `dateOnly`, date-time string, rejecting impossible
|
|
984
|
+
* calendar values (e.g. `2026-02-30`) that `new Date()` would silently roll over. A date-time without a zone is
|
|
985
|
+
* read as UTC.
|
|
986
|
+
*/
|
|
987
|
+
static parseISODate(value, dateOnly) {
|
|
988
|
+
const dateMatch = value.match(REGEX_ISO_DATE);
|
|
989
|
+
const match = dateMatch ?? (dateOnly ? null : value.match(REGEX_ISO_DATE_TIME));
|
|
990
|
+
if (!match) {
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
994
|
+
const [hour, minute, second] = [Number(match[4] ?? 0), Number(match[5] ?? 0), Number(match[6] ?? 0)];
|
|
995
|
+
if (hour > 23 || minute > 59 || second > 59) {
|
|
996
|
+
return undefined;
|
|
997
|
+
}
|
|
998
|
+
const calendar = new Date(Date.UTC(year, month - 1, day));
|
|
999
|
+
calendar.setUTCFullYear(year); // Date.UTC() maps years 0-99 to 1900-1999
|
|
1000
|
+
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) {
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|
|
1003
|
+
if (dateMatch) {
|
|
1004
|
+
return calendar;
|
|
1005
|
+
}
|
|
1006
|
+
// Normalize the zone (none means UTC; `±HH` and `±HHmm` become `±HH:mm`) so `Date` parses it unambiguously.
|
|
1007
|
+
let zone = (match[8] ?? "Z").toUpperCase();
|
|
1008
|
+
if (zone !== "Z") {
|
|
1009
|
+
// The pattern only admits 2 or 4 digits here.
|
|
1010
|
+
const digits = zone.slice(1).replace(":", "");
|
|
1011
|
+
zone = `${zone[0]}${digits.slice(0, 2)}:${digits.length === 4 ? digits.slice(2) : "00"}`;
|
|
1012
|
+
}
|
|
1013
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
1014
|
+
const fraction = match[7] ? match[7].slice(0, 4).padEnd(4, "0") : "";
|
|
1015
|
+
const date = new Date(`${pad(year, 4)}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(second)}${fraction}${zone}`);
|
|
1016
|
+
return isNaN(date.getTime()) ? undefined : date;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Rejects update input with a top-level key that MongoDB would interpret as something other than a plain field
|
|
1020
|
+
* name: a dotted path (`"aliases.3"`, which writes a nested element) or an operator (`"$inc"`). Such a key
|
|
1021
|
+
* bypasses route/model validation (which only knows the model's real property names), so it is refused on
|
|
1022
|
+
* every backend rather than passed through.
|
|
1023
|
+
*
|
|
1024
|
+
* @throws ApiError `INVALID_REQUEST` (400) naming the first offending key.
|
|
1025
|
+
*/
|
|
1026
|
+
assertPlainPropertyNames(obj) {
|
|
1027
|
+
for (const key of Object.keys(obj ?? {})) {
|
|
1028
|
+
if (key.includes(".") || key.startsWith("$")) {
|
|
1029
|
+
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `Property ${key} is invalid. Property names cannot contain '.' or start with '$'.`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Determines whether `existing` is under optimistic locking: a `BaseEntity` instance or - for a `BaseEntity`
|
|
1035
|
+
* model - any object carrying a numeric `version`, such as a plain document read straight from a
|
|
1036
|
+
* `MongoRepository` (whose `find()`/`findOne()` return plain documents, not model instances).
|
|
1037
|
+
*/
|
|
1038
|
+
isVersioned(existing) {
|
|
1039
|
+
return (existing instanceof BaseEntity ||
|
|
1040
|
+
(typeof existing?.version === "number" && this.modelClass?.prototype instanceof BaseEntity));
|
|
1041
|
+
}
|
|
710
1042
|
/**
|
|
711
1043
|
* Returns the default access control list governing the model type. Returning a value of `undefined` will grant
|
|
712
1044
|
* full acccess to any user (including unauthenticated anonymous users).
|
|
@@ -831,7 +1163,10 @@ export class RepoUtils {
|
|
|
831
1163
|
}
|
|
832
1164
|
try {
|
|
833
1165
|
const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
|
|
834
|
-
|
|
1166
|
+
// When every matched record costs a per-record ACL check (a recordACL model, for any caller including an
|
|
1167
|
+
// anonymous one), the work is bounded by the query's page size, so one request removes at most one page.
|
|
1168
|
+
const checksRecordACLs = !!(this.aclUtils?.enabled && this.modelClass.recordACL && !options.ignoreACL);
|
|
1169
|
+
const uids = await this.findAllUids(searchQuery, options, checksRecordACLs ? this.recordACLCap(searchQuery, query) : undefined);
|
|
835
1170
|
if (uids.length > 0) {
|
|
836
1171
|
let finalUids = uids;
|
|
837
1172
|
// Check if this class uses record level ACLs. If so, we need to check the perms of
|
|
@@ -848,6 +1183,7 @@ export class RepoUtils {
|
|
|
848
1183
|
}
|
|
849
1184
|
}
|
|
850
1185
|
const cleansUpRecordACLs = !!(this.aclUtils?.enabled && this.modelClass.recordACL);
|
|
1186
|
+
const versionKeys = await this.versionCacheKeys(finalUids, options);
|
|
851
1187
|
// Now delete all records that were found
|
|
852
1188
|
if (this.repo instanceof MongoRepository) {
|
|
853
1189
|
await this.repo.deleteMany({ uid: { $in: finalUids } }, {
|
|
@@ -872,7 +1208,10 @@ export class RepoUtils {
|
|
|
872
1208
|
// restores the snapshot in that case. `saveACLs()` restores each ACL's exact prior version
|
|
873
1209
|
// (see `saveACL()`'s `preserveVersion` option) and refuses — rather than clobbers — any of
|
|
874
1210
|
// these uids that already has something at it again by the time the restore runs.
|
|
875
|
-
|
|
1211
|
+
// `unlessProtected`: a record that shares the uid of a class/route/default ACL must never remove it.
|
|
1212
|
+
const removedAcls = await this.aclUtils.removeACLs(finalUids, {
|
|
1213
|
+
unlessProtected: true,
|
|
1214
|
+
});
|
|
876
1215
|
if (removedAcls.length > 0) {
|
|
877
1216
|
registerRollbackHook(async () => {
|
|
878
1217
|
try {
|
|
@@ -885,6 +1224,10 @@ export class RepoUtils {
|
|
|
885
1224
|
});
|
|
886
1225
|
}
|
|
887
1226
|
}
|
|
1227
|
+
// Drop the removed records from the cache (cached lists read their records through these keys), and
|
|
1228
|
+
// the removed ACLs from ACLUtils' cache when this is the ACL model itself.
|
|
1229
|
+
this.uncacheRecords(finalUids, versionKeys);
|
|
1230
|
+
await this.invalidateACLCache(finalUids);
|
|
888
1231
|
if (!options?.skipPush) {
|
|
889
1232
|
let channels = options?.pushChannels || [];
|
|
890
1233
|
for (const uid of finalUids) {
|
|
@@ -915,8 +1258,14 @@ export class RepoUtils {
|
|
|
915
1258
|
throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
|
|
916
1259
|
}
|
|
917
1260
|
}
|
|
918
|
-
//
|
|
919
|
-
|
|
1261
|
+
// A dotted (`"aliases.3"`) or `$`-prefixed top-level key would write a nested path (or an operator) that
|
|
1262
|
+
// route/model validation never saw. `update()` itself never needs one, so it's refused outright.
|
|
1263
|
+
this.assertPlainPropertyNames(obj);
|
|
1264
|
+
// Enforce optimistic locking when applicable. Keyed on the record actually carrying a version, not on its
|
|
1265
|
+
// prototype: a plain document read straight from a `MongoRepository` is just as versioned as a model
|
|
1266
|
+
// instance, and silently skipping the check (and the version bump below) for it lost concurrent writes.
|
|
1267
|
+
const versioned = this.isVersioned(existing);
|
|
1268
|
+
if (versioned) {
|
|
920
1269
|
if (existing.version !== obj.version) {
|
|
921
1270
|
throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
|
|
922
1271
|
}
|
|
@@ -925,30 +1274,58 @@ export class RepoUtils {
|
|
|
925
1274
|
if (existing.uid !== obj.uid) {
|
|
926
1275
|
throw new ApiError(ApiErrors.OBJECT_ID_MISMATCH, 400, ApiErrorMessages.OBJECT_ID_MISMATCH);
|
|
927
1276
|
}
|
|
1277
|
+
// JSON has no date type - store Date-typed properties as real dates, not strings.
|
|
1278
|
+
this.coerceDateProperties(obj, this.getClassType(obj));
|
|
928
1279
|
// Force system-managed fields back to their persisted value, discarding whatever the client sent (or
|
|
929
|
-
// didn't send) for them. `dateCreated` is always protected
|
|
930
|
-
//
|
|
931
|
-
|
|
1280
|
+
// didn't send) for them. `dateCreated` is always protected, with no bypass - there is never a
|
|
1281
|
+
// legitimate reason to change it via update(). `@ReadOnly`-decorated properties are an app-level
|
|
1282
|
+
// opt-in for anything else (roles, ownership fields, etc.) that must never be client-settable, but
|
|
1283
|
+
// trusted server-side code may pass `allowReadOnly: true` to write them anyway - see that option's
|
|
1284
|
+
// own doc comment on `RepoUpdateOptions`.
|
|
1285
|
+
const keepPrevious = !!this.modelClass.trackChanges;
|
|
1286
|
+
if (versioned) {
|
|
932
1287
|
obj.dateCreated = existing.dateCreated;
|
|
933
1288
|
}
|
|
934
|
-
|
|
935
|
-
|
|
1289
|
+
if (!options?.allowReadOnly) {
|
|
1290
|
+
const readOnlyProps = ModelUtils.getReadOnlyPropertyNames(this.modelClass);
|
|
1291
|
+
const hasOwn = (target, prop) => Object.prototype.hasOwnProperty.call(target, prop);
|
|
1292
|
+
// `existing` usually comes from `findOne()`, which removes the @RequiresScope fields the requesting user
|
|
1293
|
+
// can't see. Copying such a field from it would erase the stored value, so a missing field is instead left
|
|
1294
|
+
// out of the write (an in-place update then keeps the stored value), or, for a trackChanges model whose
|
|
1295
|
+
// update writes a whole new version, taken from the stored record itself.
|
|
1296
|
+
let source = existing;
|
|
1297
|
+
if (keepPrevious && readOnlyProps.some((prop) => !hasOwn(existing, prop))) {
|
|
1298
|
+
source = (await this.loadStoredRecord(existing, options)) ?? existing;
|
|
1299
|
+
}
|
|
1300
|
+
for (const prop of readOnlyProps) {
|
|
1301
|
+
if (hasOwn(source, prop)) {
|
|
1302
|
+
obj[prop] = source[prop];
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
delete obj[prop];
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
936
1308
|
}
|
|
937
|
-
//
|
|
938
|
-
|
|
1309
|
+
// `_id` always comes from the stored record, never from the input. On MongoDB this also prevents duplicate
|
|
1310
|
+
// entries when saving; an input `_id` is never allowed to select (or be written over) some other document.
|
|
1311
|
+
if (existing._id !== undefined && existing._id !== null) {
|
|
939
1312
|
obj._id = existing._id;
|
|
940
1313
|
}
|
|
941
|
-
|
|
1314
|
+
else {
|
|
1315
|
+
delete obj._id;
|
|
1316
|
+
}
|
|
942
1317
|
let query = this.searchIdQuery(existing.uid, options?.version || obj.version);
|
|
943
1318
|
let result = null;
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1319
|
+
// A duplicate key raised by any of the writes below is mapped by `mapUpdateWriteError()`: same (uid, version)
|
|
1320
|
+
// unique index race as RepoUtils.create() - two concurrent updates of the same version can both pass the
|
|
1321
|
+
// optimistic-lock check above and both attempt to write version + 1, and the database rejects the loser - is
|
|
1322
|
+
// reported as a lost optimistic-lock race (409); a clash on any other unique column is a 400.
|
|
1323
|
+
try {
|
|
1324
|
+
if (this.repo instanceof MongoRepository) {
|
|
1325
|
+
// The fields to `$set` in place - never `_id`, which is immutable and already identifies the document.
|
|
1326
|
+
const { _id, ...fields } = obj;
|
|
1327
|
+
if (versioned) {
|
|
1328
|
+
if (keepPrevious) {
|
|
952
1329
|
result = this.instantiateObject(await this.repo.save({
|
|
953
1330
|
...obj,
|
|
954
1331
|
_id: undefined, // Ensure we save a new document
|
|
@@ -956,104 +1333,92 @@ export class RepoUtils {
|
|
|
956
1333
|
version: obj.version + 1,
|
|
957
1334
|
}, { session: txInfo?.session }));
|
|
958
1335
|
}
|
|
959
|
-
|
|
960
|
-
|
|
1336
|
+
else {
|
|
1337
|
+
// One atomic, version-conditioned find-and-modify that returns this call's own write. A separate
|
|
1338
|
+
// `updateOne()` followed by a `findOne(version + 1)` read-back could miss when a concurrent writer
|
|
1339
|
+
// bumped the version again in between, failing a write that had actually succeeded.
|
|
1340
|
+
result = await this.repo.findOneAndUpdate({ uid: obj.uid, version: obj.version }, {
|
|
1341
|
+
$set: {
|
|
1342
|
+
...fields,
|
|
1343
|
+
dateModified: new Date(),
|
|
1344
|
+
version: obj.version + 1,
|
|
1345
|
+
},
|
|
1346
|
+
}, { session: txInfo?.session, returnDocument: "after" });
|
|
1347
|
+
// No match means a concurrent writer already advanced this record past `obj.version` (or removed
|
|
1348
|
+
// it): a lost optimistic-lock race, reported as such rather than silently overwriting.
|
|
1349
|
+
if (!result) {
|
|
961
1350
|
throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
|
|
962
1351
|
}
|
|
963
|
-
throw err;
|
|
964
1352
|
}
|
|
965
1353
|
}
|
|
966
|
-
else {
|
|
967
|
-
|
|
968
|
-
$set: {
|
|
969
|
-
...obj,
|
|
970
|
-
dateModified: new Date(),
|
|
971
|
-
version: obj.version + 1,
|
|
972
|
-
},
|
|
973
|
-
}, {
|
|
974
|
-
session: txInfo?.session,
|
|
975
|
-
});
|
|
976
|
-
// `updateOne()` doesn't throw when its filter (including `version`) matches nothing - a
|
|
977
|
-
// concurrent writer that already advanced this row past `obj.version` leaves this call
|
|
978
|
-
// matching zero documents. Without this check, the fallback `findOne(version + 1)` below
|
|
979
|
-
// would silently find THAT concurrent writer's row and return it as if it were this call's
|
|
980
|
-
// own successful update - a genuine version conflict lost instead of reported.
|
|
981
|
-
if (updateResult?.matchedCount === 0) {
|
|
982
|
-
throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
else if (obj.uid) {
|
|
987
|
-
if (keepPrevious) {
|
|
988
|
-
try {
|
|
1354
|
+
else if (obj.uid) {
|
|
1355
|
+
if (keepPrevious) {
|
|
989
1356
|
result = this.instantiateObject(await this.repo.save({
|
|
990
1357
|
...obj,
|
|
1358
|
+
_id: undefined, // Ensure we save a new document
|
|
991
1359
|
version: obj.version + 1,
|
|
992
1360
|
}, { session: txInfo?.session }));
|
|
993
1361
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1362
|
+
else {
|
|
1363
|
+
result = await this.repo.findOneAndUpdate({ uid: obj.uid }, { $set: fields }, { session: txInfo?.session, returnDocument: "after" });
|
|
1364
|
+
// The record was removed after `existing` was read.
|
|
1365
|
+
if (!result) {
|
|
1366
|
+
throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
|
|
997
1367
|
}
|
|
998
|
-
throw err;
|
|
999
1368
|
}
|
|
1000
1369
|
}
|
|
1001
1370
|
else {
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
},
|
|
1006
|
-
}, { session: txInfo?.session });
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
else {
|
|
1010
|
-
const toSave = obj;
|
|
1011
|
-
if (keepPrevious) {
|
|
1012
|
-
toSave.version += 1;
|
|
1013
|
-
}
|
|
1014
|
-
result = await this.repo.save(toSave, { session: txInfo?.session });
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
else {
|
|
1018
|
-
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
1019
|
-
if (existing instanceof BaseEntity) {
|
|
1020
|
-
if (keepPrevious) {
|
|
1021
|
-
await repo.insert({
|
|
1022
|
-
...obj,
|
|
1023
|
-
dateModified: new Date(),
|
|
1024
|
-
version: obj.version + 1,
|
|
1025
|
-
});
|
|
1026
|
-
}
|
|
1027
|
-
else {
|
|
1028
|
-
const updateResult = await repo.update(query.where, {
|
|
1029
|
-
...obj,
|
|
1030
|
-
dateModified: new Date(),
|
|
1031
|
-
version: obj.version + 1,
|
|
1032
|
-
});
|
|
1033
|
-
// Same silent-conflict hazard as the Mongo branch above: `repo.update()` doesn't throw when
|
|
1034
|
-
// its WHERE clause (including `version`) matches nothing, it just reports 0 affected rows.
|
|
1035
|
-
// Only checked when the driver actually reports a number (some don't, e.g. `affected` stays
|
|
1036
|
-
// `undefined`) - never throw on ambiguous ignorance of the true row count.
|
|
1037
|
-
if (updateResult.affected === 0) {
|
|
1038
|
-
throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
|
|
1371
|
+
const toSave = obj;
|
|
1372
|
+
if (keepPrevious) {
|
|
1373
|
+
toSave.version += 1;
|
|
1039
1374
|
}
|
|
1375
|
+
result = await this.repo.save(toSave, { session: txInfo?.session });
|
|
1040
1376
|
}
|
|
1041
1377
|
}
|
|
1042
1378
|
else {
|
|
1043
|
-
const
|
|
1044
|
-
if (
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1379
|
+
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
1380
|
+
if (versioned) {
|
|
1381
|
+
if (keepPrevious) {
|
|
1382
|
+
await repo.insert({
|
|
1383
|
+
...obj,
|
|
1384
|
+
dateModified: new Date(),
|
|
1385
|
+
version: obj.version + 1,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
else {
|
|
1389
|
+
const updateResult = await repo.update(query.where, {
|
|
1390
|
+
...obj,
|
|
1391
|
+
dateModified: new Date(),
|
|
1392
|
+
version: obj.version + 1,
|
|
1393
|
+
});
|
|
1394
|
+
// Same silent-conflict hazard as the Mongo branch above: `repo.update()` doesn't throw when
|
|
1395
|
+
// its WHERE clause (including `version`) matches nothing, it just reports 0 affected rows.
|
|
1396
|
+
// Only checked when the driver actually reports a number (some don't, e.g. `affected` stays
|
|
1397
|
+
// `undefined`) - never throw on ambiguous ignorance of the true row count.
|
|
1398
|
+
if (updateResult.affected === 0) {
|
|
1399
|
+
throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1050
1402
|
}
|
|
1051
1403
|
else {
|
|
1052
|
-
|
|
1404
|
+
const toSave = obj;
|
|
1405
|
+
if (keepPrevious) {
|
|
1406
|
+
toSave.version += 1;
|
|
1407
|
+
// TypeORM's overloaded Repository.save() can't be resolved against `repo`'s inferred
|
|
1408
|
+
// `EntityManager | Repository<T>` union type — same class of friction as the "HAX" cast
|
|
1409
|
+
// above.
|
|
1410
|
+
result = await repo.save(toSave);
|
|
1411
|
+
}
|
|
1412
|
+
else {
|
|
1413
|
+
await repo.update(query.where, toSave);
|
|
1414
|
+
}
|
|
1053
1415
|
}
|
|
1054
1416
|
}
|
|
1055
1417
|
}
|
|
1056
|
-
|
|
1418
|
+
catch (err) {
|
|
1419
|
+
throw this.mapUpdateWriteError(err, keepPrevious);
|
|
1420
|
+
}
|
|
1421
|
+
query = this.searchIdQuery(existing.uid, versioned ? existing.version + 1 : undefined);
|
|
1057
1422
|
if (!result) {
|
|
1058
1423
|
if (this.repo instanceof MongoRepository) {
|
|
1059
1424
|
result = await this.repo.findOne(query["$match"] ? query["$match"] : query, {
|
|
@@ -1069,23 +1434,253 @@ export class RepoUtils {
|
|
|
1069
1434
|
}
|
|
1070
1435
|
}
|
|
1071
1436
|
result = this.instantiateObject(result);
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
// does not have access to. Done after caching (the cache must retain the full object for other
|
|
1081
|
-
// requests) but before the result is returned or broadcast to push subscribers.
|
|
1437
|
+
// Cache the object for faster retrieval: its latest-version entry, which list results read their records
|
|
1438
|
+
// through too, so a cached list reflects the update (a copy, so stripping scoped fields below can't alter it).
|
|
1439
|
+
this.cacheRecord(result);
|
|
1440
|
+
// An ACL document updated through the ACL routes must take effect right away.
|
|
1441
|
+
await this.invalidateACLCache([result.uid]);
|
|
1442
|
+
// Broadcast to push subscribers, then remove the properties scoped with @RequiresScope that the user does not
|
|
1443
|
+
// have access to from what is returned.
|
|
1444
|
+
this.publish([result.uid], "update", result, options);
|
|
1082
1445
|
ObjectUtils.deleteScopedProps(result, options?.user, this.modelClass);
|
|
1083
|
-
if (!options?.skipPush) {
|
|
1084
|
-
let channels = [result.uid].concat(options?.pushChannels || []);
|
|
1085
|
-
this.notificationUtils?.sendMessage(channels, this.modelClass.name, "update", result);
|
|
1086
|
-
}
|
|
1087
1446
|
return result;
|
|
1088
1447
|
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Maps an error thrown by one of `update()`'s writes: a duplicate key on the record's identity (`_id`/primary key
|
|
1450
|
+
* or `(uid, version)`) is a lost optimistic-lock race (409 `INVALID_OBJECT_VERSION`), and a duplicate value of any
|
|
1451
|
+
* other unique column is a 400 `IDENTIFIER_EXISTS`. Any other error is returned as is.
|
|
1452
|
+
*
|
|
1453
|
+
* @param err The error thrown by the write.
|
|
1454
|
+
* @param versionInsert Whether the write inserted a new version (`trackChanges`), which makes an unidentified
|
|
1455
|
+
* duplicate key most likely a version clash.
|
|
1456
|
+
*/
|
|
1457
|
+
mapUpdateWriteError(err, versionInsert) {
|
|
1458
|
+
if (err instanceof ApiError || !isDuplicateKeyError(err)) {
|
|
1459
|
+
return err;
|
|
1460
|
+
}
|
|
1461
|
+
return isIdentityDuplicate(err, versionInsert)
|
|
1462
|
+
? new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION)
|
|
1463
|
+
: new ApiError(ApiErrors.IDENTIFIER_EXISTS, 400, ApiErrorMessages.IDENTIFIER_EXISTS);
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Counts the stored rows/documents (every version, deleted or not) whose identifier matches `id`, ignoring ACLs.
|
|
1467
|
+
*
|
|
1468
|
+
* @param id The identifier, or identifiers, to match.
|
|
1469
|
+
* @param txInfo The active transaction, if any.
|
|
1470
|
+
* @param clazz The model class to build the identifier query for.
|
|
1471
|
+
*/
|
|
1472
|
+
async countById(id, txInfo, clazz = this.modelClass) {
|
|
1473
|
+
const query = ModelUtils.buildIdSearchQuery(this.repo, clazz, id, undefined);
|
|
1474
|
+
if (this.repo instanceof MongoRepository) {
|
|
1475
|
+
return await this.repo.count(query, { session: txInfo?.session });
|
|
1476
|
+
}
|
|
1477
|
+
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
1478
|
+
return await repo.count(query);
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Reads the stored version of `existing` straight from the database, without ACL checks, caching or scoped
|
|
1482
|
+
* property removal. Used by `update()` when `existing` lacks fields it must carry over.
|
|
1483
|
+
*/
|
|
1484
|
+
async loadStoredRecord(existing, options) {
|
|
1485
|
+
const txInfo = this.getTransaction(options);
|
|
1486
|
+
const query = this.searchIdQuery(existing.uid, typeof existing.version === "number" ? existing.version : undefined);
|
|
1487
|
+
if (this.repo instanceof MongoRepository) {
|
|
1488
|
+
return await this.repo.find(query, { session: txInfo?.session, sort: { version: -1 }, limit: 1 }).next();
|
|
1489
|
+
}
|
|
1490
|
+
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
1491
|
+
return await repo.findOne({ ...query, order: { version: "DESC" } });
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Returns whether this model keeps every version of a record as its own immutable row/document (`trackChanges`),
|
|
1495
|
+
* which is what makes a version-specific cache entry safe to keep until it expires.
|
|
1496
|
+
*/
|
|
1497
|
+
get versionsAreImmutable() {
|
|
1498
|
+
return !!this.modelClass?.trackChanges;
|
|
1499
|
+
}
|
|
1500
|
+
/** Parses a `version` option the way `searchIdQuery()` does. */
|
|
1501
|
+
parseVersion(version) {
|
|
1502
|
+
if (version === undefined || version === null || version === "") {
|
|
1503
|
+
return undefined;
|
|
1504
|
+
}
|
|
1505
|
+
return typeof version === "string" ? parseInt(version, 10) : version;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Returns the cache key of a single record: its latest version, or (only for a model whose versions are immutable)
|
|
1509
|
+
* a specific version. Returns `undefined` for a specific version of any other model, which is never cached: its
|
|
1510
|
+
* entry would outlive the in-place update that replaces that version.
|
|
1511
|
+
*
|
|
1512
|
+
* Record keys (`rec:`) and query result keys (`q:`) are distinct namespaces, so no client-chosen uid can ever name
|
|
1513
|
+
* a query result entry (or the other way around).
|
|
1514
|
+
*/
|
|
1515
|
+
recordCacheKey(uid, version) {
|
|
1516
|
+
if (version === undefined) {
|
|
1517
|
+
return `rec:latest:${uid}`;
|
|
1518
|
+
}
|
|
1519
|
+
return this.versionsAreImmutable ? `rec:v${version}:${uid}` : undefined;
|
|
1520
|
+
}
|
|
1521
|
+
/** Returns the cache key of a `find()` result for the given (JSON-serializable) key material. */
|
|
1522
|
+
queryCacheKey(material) {
|
|
1523
|
+
return `q:${this.hashQuery(material)}`;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Determines whether a cached record really is the one identified by `id` (and `version`, when given): one of the
|
|
1527
|
+
* model's identifier properties must hold `id`. Guards against a cache entry written under a colliding key.
|
|
1528
|
+
*/
|
|
1529
|
+
matchesId(record, id, version) {
|
|
1530
|
+
if (!record || typeof record !== "object") {
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
if (version !== undefined && record.version !== version) {
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
return ModelUtils.getIdPropertyNames(this.modelClass).some((prop) => record[prop] === id);
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Returns a shallow copy of `record` with the same prototype, for handing out (or stripping) without altering the
|
|
1540
|
+
* object a cache holds.
|
|
1541
|
+
*/
|
|
1542
|
+
copyRecord(record) {
|
|
1543
|
+
if (!record || typeof record !== "object") {
|
|
1544
|
+
return record;
|
|
1545
|
+
}
|
|
1546
|
+
return Object.assign(Object.create(Object.getPrototypeOf(record)), record);
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Stores a copy of `record` in the cache under its latest-version key, and under its version key when versions are
|
|
1550
|
+
* immutable. Fire-and-forget: a cache failure never fails the write.
|
|
1551
|
+
*/
|
|
1552
|
+
cacheRecord(record) {
|
|
1553
|
+
if (!this.cache || !record?.uid) {
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
const copy = this.copyRecord(record);
|
|
1557
|
+
const keys = [this.recordCacheKey(record.uid)];
|
|
1558
|
+
const version = record.version;
|
|
1559
|
+
if (typeof version === "number" && this.versionsAreImmutable) {
|
|
1560
|
+
keys.push(this.recordCacheKey(record.uid, version));
|
|
1561
|
+
}
|
|
1562
|
+
for (const key of keys) {
|
|
1563
|
+
this.cache.save(key, copy).catch((err) => this.logCacheError("save", err));
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Caches a page of `find()` results: each record under its own record key, and the query key as the list of those
|
|
1568
|
+
* records' `[uid, version]` references. A record of a model whose versions are immutable is referenced by its
|
|
1569
|
+
* version (a list may hold past versions); any other record by its latest-version key, which `update()` refreshes
|
|
1570
|
+
* and `delete()` removes, so a cached list never serves a stale or deleted record.
|
|
1571
|
+
*/
|
|
1572
|
+
cacheResults(queryKey, results) {
|
|
1573
|
+
const refs = [];
|
|
1574
|
+
const keys = [];
|
|
1575
|
+
const records = [];
|
|
1576
|
+
for (const record of results) {
|
|
1577
|
+
if (!record?.uid) {
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
const version = record.version;
|
|
1581
|
+
const ref = [
|
|
1582
|
+
record.uid,
|
|
1583
|
+
this.versionsAreImmutable && typeof version === "number" ? version : null,
|
|
1584
|
+
];
|
|
1585
|
+
refs.push(ref);
|
|
1586
|
+
keys.push(this.recordCacheKey(ref[0], ref[1] ?? undefined));
|
|
1587
|
+
records.push(record);
|
|
1588
|
+
}
|
|
1589
|
+
this.cache.saveMany(keys, records).catch((err) => this.logCacheError("saveMany", err));
|
|
1590
|
+
this.cache.save(queryKey, refs).catch((err) => this.logCacheError("save", err));
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Loads a cached page of `find()` results (see `cacheResults()`). Records that have since expired, been deleted, or
|
|
1594
|
+
* whose entry isn't the referenced record are left out; an empty array means nothing usable was cached.
|
|
1595
|
+
*/
|
|
1596
|
+
async loadCachedResults(queryKey) {
|
|
1597
|
+
const refs = await this.cache.load(queryKey);
|
|
1598
|
+
if (!Array.isArray(refs) || refs.length === 0) {
|
|
1599
|
+
return [];
|
|
1600
|
+
}
|
|
1601
|
+
const valid = refs.filter((ref) => Array.isArray(ref) && typeof ref[0] === "string");
|
|
1602
|
+
const loaded = await this.cache.loadMany(valid.map((ref) => this.recordCacheKey(ref[0], ref[1] ?? undefined)));
|
|
1603
|
+
return loaded.filter((record, i) => !!record && record.uid === valid[i][0] && (valid[i][1] === null || record.version === valid[i][1]));
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Returns the version-specific cache keys of every stored version of the given records (only for a model whose
|
|
1607
|
+
* versions are cached individually; otherwise none). Must be called before the records are removed.
|
|
1608
|
+
*/
|
|
1609
|
+
async versionCacheKeys(uids, options) {
|
|
1610
|
+
if (!this.cache || !this.versionsAreImmutable || uids.length === 0) {
|
|
1611
|
+
return [];
|
|
1612
|
+
}
|
|
1613
|
+
const txInfo = this.getTransaction(options);
|
|
1614
|
+
let rows;
|
|
1615
|
+
if (this.repo instanceof MongoRepository) {
|
|
1616
|
+
rows = await this.repo
|
|
1617
|
+
.find({ uid: { $in: uids } }, { session: txInfo?.session, projection: { uid: 1, version: 1 } })
|
|
1618
|
+
.toArray();
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
const { In } = ModelUtils.orm;
|
|
1622
|
+
const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
|
|
1623
|
+
rows = await repo.find({ where: { uid: In(uids) }, select: { uid: true, version: true } });
|
|
1624
|
+
}
|
|
1625
|
+
return rows
|
|
1626
|
+
.filter((row) => typeof row?.version === "number")
|
|
1627
|
+
.map((row) => this.recordCacheKey(row.uid, row.version));
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Removes the cached entries of the given records: their latest-version keys plus `versionKeys` (see
|
|
1631
|
+
* `versionCacheKeys()`). Fire-and-forget: a cache failure never fails the write.
|
|
1632
|
+
*/
|
|
1633
|
+
uncacheRecords(uids, versionKeys = []) {
|
|
1634
|
+
if (!this.cache || uids.length === 0) {
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const keys = uids.map((uid) => this.recordCacheKey(uid)).concat(versionKeys);
|
|
1638
|
+
this.cache.deleteMany(keys).catch((err) => this.logCacheError("deleteMany", err));
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Returns whether this is the ACL model itself (served by e.g. a `BaseACLRoute`), whose documents `ACLUtils`
|
|
1642
|
+
* caches separately under their uids.
|
|
1643
|
+
*/
|
|
1644
|
+
get isACLModel() {
|
|
1645
|
+
const clazz = this.modelClass;
|
|
1646
|
+
return (!!clazz &&
|
|
1647
|
+
(clazz === AccessControlListMongo ||
|
|
1648
|
+
clazz === AccessControlListSQL ||
|
|
1649
|
+
clazz.prototype instanceof AccessControlListMongo ||
|
|
1650
|
+
clazz.prototype instanceof AccessControlListSQL));
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* After a write to the ACL model, drops `ACLUtils`' cached copies of the written ACLs so permission checks see the
|
|
1654
|
+
* change right away (they would otherwise keep using the old ACL until its cache entry expired). A failure is
|
|
1655
|
+
* logged rather than failing the write, which has already happened.
|
|
1656
|
+
*/
|
|
1657
|
+
async invalidateACLCache(uids) {
|
|
1658
|
+
if (!this.isACLModel || !this.aclUtils || uids.length === 0) {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
await this.aclUtils.invalidateACLs(uids);
|
|
1663
|
+
}
|
|
1664
|
+
catch (err) {
|
|
1665
|
+
this.logger?.warn(`RepoUtils: Failed to invalidate cached ACL(s) ${uids.join(", ")}.`);
|
|
1666
|
+
this.logger?.debug(err);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Sends a push notification about a write, unless `skipPush` is set. The payload is a copy with every
|
|
1671
|
+
* `@RequiresScope` property removed: subscribers only need READ on the record's channel, and the writer's scopes
|
|
1672
|
+
* say nothing about theirs, so no scoped value is ever published. Consumers that need a scoped value must fetch
|
|
1673
|
+
* the record, which applies their own scopes.
|
|
1674
|
+
*/
|
|
1675
|
+
publish(uids, action, payload, options) {
|
|
1676
|
+
if (options?.skipPush || !this.notificationUtils) {
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const message = this.copyRecord(payload);
|
|
1680
|
+
ObjectUtils.deleteScopedProps(message, undefined, this.modelClass);
|
|
1681
|
+
const channels = uids.concat(options?.pushChannels || []);
|
|
1682
|
+
this.notificationUtils.sendMessage(channels, this.modelClass.name, action, message);
|
|
1683
|
+
}
|
|
1089
1684
|
/**
|
|
1090
1685
|
* Performs validation on the object(s) provided. This function first calls `ObjectUtils.validate()` to check
|
|
1091
1686
|
* any class level defined validation functions. Second, it scans for any properties with the `@Reference`
|
|
@@ -1143,6 +1738,8 @@ export class RepoUtils {
|
|
|
1143
1738
|
}
|
|
1144
1739
|
}
|
|
1145
1740
|
}
|
|
1741
|
+
/** Matches a whole-word `me` anywhere in a serialized query (see `find()`'s cache key). */
|
|
1742
|
+
RepoUtils.REGEX_ME = /\bme\b/;
|
|
1146
1743
|
__decorate([
|
|
1147
1744
|
Inject("ACLUtils"),
|
|
1148
1745
|
__metadata("design:type", Function)
|