@rapidrest/service-core 1.8.0 → 2.1.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.
Files changed (87) hide show
  1. package/dist/lib/ApiErrors.js +10 -0
  2. package/dist/lib/ApiErrors.js.map +1 -1
  3. package/dist/lib/BackgroundServiceManager.js +51 -9
  4. package/dist/lib/BackgroundServiceManager.js.map +1 -1
  5. package/dist/lib/EventListenerManager.js +35 -2
  6. package/dist/lib/EventListenerManager.js.map +1 -1
  7. package/dist/lib/NetUtils.js +215 -21
  8. package/dist/lib/NetUtils.js.map +1 -1
  9. package/dist/lib/ObjectFactory.js +7 -2
  10. package/dist/lib/ObjectFactory.js.map +1 -1
  11. package/dist/lib/RateLimiter.js.map +1 -1
  12. package/dist/lib/Server.js +121 -80
  13. package/dist/lib/Server.js.map +1 -1
  14. package/dist/lib/auth/AuthMiddleware.js +160 -100
  15. package/dist/lib/auth/AuthMiddleware.js.map +1 -1
  16. package/dist/lib/auth/JWTStrategy.js +6 -2
  17. package/dist/lib/auth/JWTStrategy.js.map +1 -1
  18. package/dist/lib/database/ConnectionManager.js +47 -1
  19. package/dist/lib/database/ConnectionManager.js.map +1 -1
  20. package/dist/lib/database/DatabaseErrors.js +88 -0
  21. package/dist/lib/database/DatabaseErrors.js.map +1 -0
  22. package/dist/lib/database/MongoRepository.js +31 -3
  23. package/dist/lib/database/MongoRepository.js.map +1 -1
  24. package/dist/lib/database/MongoSchemaSync.js +7 -1
  25. package/dist/lib/database/MongoSchemaSync.js.map +1 -1
  26. package/dist/lib/database/TypeOrmSupport.js +56 -6
  27. package/dist/lib/database/TypeOrmSupport.js.map +1 -1
  28. package/dist/lib/database/index.js +1 -0
  29. package/dist/lib/database/index.js.map +1 -1
  30. package/dist/lib/decorators/PersistenceDecorators.js +23 -0
  31. package/dist/lib/decorators/PersistenceDecorators.js.map +1 -1
  32. package/dist/lib/decorators/RouteDecorators.js +4 -2
  33. package/dist/lib/decorators/RouteDecorators.js.map +1 -1
  34. package/dist/lib/http/bun/BunRouter.js +119 -9
  35. package/dist/lib/http/bun/BunRouter.js.map +1 -1
  36. package/dist/lib/http/index.js +1 -0
  37. package/dist/lib/http/index.js.map +1 -1
  38. package/dist/lib/http/session/sessionMiddleware.js +62 -14
  39. package/dist/lib/http/session/sessionMiddleware.js.map +1 -1
  40. package/dist/lib/http/types.js +10 -1
  41. package/dist/lib/http/types.js.map +1 -1
  42. package/dist/lib/http/uWS/Adapters.js +31 -13
  43. package/dist/lib/http/uWS/Adapters.js.map +1 -1
  44. package/dist/lib/http/uWS/Router.js +80 -16
  45. package/dist/lib/http/uWS/Router.js.map +1 -1
  46. package/dist/lib/http/uWS/WebSocket.js +4 -2
  47. package/dist/lib/http/uWS/WebSocket.js.map +1 -1
  48. package/dist/lib/models/ModelUtils.js +910 -227
  49. package/dist/lib/models/ModelUtils.js.map +1 -1
  50. package/dist/lib/models/RepoUtils.js +863 -266
  51. package/dist/lib/models/RepoUtils.js.map +1 -1
  52. package/dist/lib/routes/BaseAdminRoute.js +5 -4
  53. package/dist/lib/routes/BaseAdminRoute.js.map +1 -1
  54. package/dist/lib/routes/BasePushRoute.js +104 -40
  55. package/dist/lib/routes/BasePushRoute.js.map +1 -1
  56. package/dist/lib/routes/CRUDRoute.js +31 -17
  57. package/dist/lib/routes/CRUDRoute.js.map +1 -1
  58. package/dist/lib/routes/RouteUtils.js +120 -29
  59. package/dist/lib/routes/RouteUtils.js.map +1 -1
  60. package/dist/lib/security/ACLUtils.js +170 -34
  61. package/dist/lib/security/ACLUtils.js.map +1 -1
  62. package/dist/types/ApiErrors.d.ts +10 -0
  63. package/dist/types/BackgroundServiceManager.d.ts +6 -0
  64. package/dist/types/EventListenerManager.d.ts +4 -0
  65. package/dist/types/NetUtils.d.ts +65 -6
  66. package/dist/types/RateLimiter.d.ts +4 -3
  67. package/dist/types/Server.d.ts +33 -2
  68. package/dist/types/auth/AuthMiddleware.d.ts +35 -4
  69. package/dist/types/database/ConnectionManager.d.ts +18 -0
  70. package/dist/types/database/DatabaseErrors.d.ts +26 -0
  71. package/dist/types/database/MongoRepository.d.ts +21 -2
  72. package/dist/types/database/index.d.ts +1 -0
  73. package/dist/types/decorators/PersistenceDecorators.d.ts +31 -0
  74. package/dist/types/decorators/RouteDecorators.d.ts +4 -2
  75. package/dist/types/http/bun/BunRouter.d.ts +23 -2
  76. package/dist/types/http/index.d.ts +2 -1
  77. package/dist/types/http/session/sessionMiddleware.d.ts +14 -4
  78. package/dist/types/http/types.d.ts +40 -0
  79. package/dist/types/http/uWS/Adapters.d.ts +10 -1
  80. package/dist/types/http/uWS/Router.d.ts +15 -2
  81. package/dist/types/models/ModelUtils.d.ts +301 -47
  82. package/dist/types/models/RepoUtils.d.ts +240 -4
  83. package/dist/types/routes/BasePushRoute.d.ts +5 -0
  84. package/dist/types/routes/CRUDRoute.d.ts +10 -0
  85. package/dist/types/routes/RouteUtils.d.ts +37 -1
  86. package/dist/types/security/ACLUtils.d.ts +68 -7
  87. 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 every uid matching the given (already-built) search query, ignoring any pagination `take`/`page`
103
- * baked into it by `ModelUtils.buildSearchQuery`. Used by `count`/`exists`/`truncate`, which must narrow by
104
- * record-level ACLs against the *entire* matching set rather than a single page of it — applying the default
105
- * `take` here would silently undercount, under-check existence for, or under-delete a large result set.
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
- if (Array.isArray(searchQuery)) {
111
- return await this.repo.distinct("uid", searchQuery[0].$match, { session: txInfo?.session });
112
- }
113
- return await this.repo.distinct("uid", searchQuery["$match"] ? searchQuery["$match"] : searchQuery, {
114
- session: txInfo?.session,
115
- });
116
- }
117
- // Only the uid column is needed, and pagination must not clip the result set here.
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
- return rows.map((obj) => obj.uid);
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
- // A client-supplied `?deleted=true` filter overrides `buildSearchQuery()`'s default exclusion of
160
- // soft-deleted rows. Counting a matched soft-deleted row requires the DELETE+UPDATE permissions.
161
- const clientRequestsDeleted = query?.deleted === true || query?.deleted === "true";
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
- effectiveQuery = { ...query };
167
- delete effectiveQuery.deleted;
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
- const uids = await this.findAllUids(searchQuery, options);
190
- let permitted;
191
- if (clientRequestsDeleted) {
192
- // Every uid this query matched is, by construction, a soft-deleted record - check the restore
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 query = ModelUtils.buildIdSearchQuery(repo, clazz, ids, undefined);
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
- if (this.repo instanceof MongoRepository) {
364
- saved = await this.repo.save(newObj, { session: txInfo?.session });
365
- }
366
- else {
367
- const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
368
- saved = await repo.save(newObj);
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
- const result = this.instantiateObject(saved);
371
- if (this.aclUtils?.enabled && this.modelClass.recordACL) {
372
- // Reuse the existing ACL if this is a legitimate trackChanges "new version" rather than building a fresh,
373
- // version-less object from scratch. Building fresh here would let saveACL()'s optimistic-lock version
374
- // check pass by coincidence (a never-updated ACL is also version 0), silently discarding the real records.
375
- const existingAcl = await this.aclUtils.findACL(result.uid);
376
- const isFreshAcl = !existingAcl;
377
- const acl = existingAcl ?? {
378
- uid: result.uid,
379
- parentUid: options?.acl?.parentUid || this.defaultACLUid,
380
- records: options?.acl?.records || [],
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
- else if (modifiedExistingAcl) {
421
- // Reverting a change to an already-existing ACL isn't well-defined in general (it may carry
422
- // other, unrelated state) — log loudly instead so this is visible for manual reconciliation.
423
- const modifiedAclUid = acl.uid;
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
- if (this.cache) {
430
- // Cache the object for faster retrieval.
431
- const query = this.searchIdQuery(newObj.uid);
432
- const cacheKey = this.hashQuery(query);
433
- this.cache.save(cacheKey, result).catch((err) => this.logCacheError("save", err));
434
- this.cache.save(result.uid, result).catch((err) => this.logCacheError("save", err));
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
- // Process the result to remove any properties that have been scoped with @RequiresScope that the user
437
- // does not have access to. Done after caching (the cache must retain the full object for other
438
- // requests) but before the result is returned or broadcast to push subscribers.
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
- if (this.aclUtils?.enabled && this.modelClass.recordACL) {
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
- const removedAcl = await this.aclUtils.removeACL(uid);
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
- if (this.cache) {
509
- // Delete the object from cache.
510
- this.cache.delete(uid).catch((err) => this.logCacheError("delete", err));
511
- this.cache.delete(this.hashQuery(query)).catch((err) => this.logCacheError("delete", err));
512
- this.cache
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
- // When we hash the seach query we need to ensure we're including the pagination information to preserve
549
- // like queries and results.
550
- const searchQueryHash = this.hashQuery({
551
- ...query,
552
- limit,
553
- page,
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
- const cached = await this.cache.loadSet(searchQueryHash);
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.cache.saveSet(searchQueryHash, results).catch((err) => this.logCacheError("saveSet", err));
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 use the default shape,
641
- // so changing it here alone would desync this read from what those write. Soft-deleted records are
642
- // filtered out below instead, after the cache/DB read, regardless of which one produced the result.
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
- if (!options?.skipCache && this.cache) {
645
- existing = await this.cache.load(this.hashQuery(query));
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 (this.cache) {
814
+ if (cacheKey) {
680
815
  // Cache the object for faster retrieval
681
- this.cache.save(this.hashQuery(query), existing).catch((err) => this.logCacheError("save", err));
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
- const uids = await this.findAllUids(searchQuery, options);
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
- const removedAcls = await this.aclUtils.removeACLs(finalUids);
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
- // Enforce optimistic locking when applicable
919
- if (existing instanceof BaseEntity) {
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; `@ReadOnly`-decorated properties are an
930
- // app-level opt-in for anything else (roles, ownership fields, etc.) that must never be client-settable.
931
- if (existing instanceof BaseEntity) {
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
- for (const prop of ModelUtils.getReadOnlyPropertyNames(this.modelClass)) {
935
- obj[prop] = existing[prop];
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
- // When using MongoDB we need to copy the _id property in order to prevent duplicate entries
938
- if (existing instanceof BaseMongoEntity) {
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
- const keepPrevious = !!this.modelClass.trackChanges;
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
- if (this.repo instanceof MongoRepository) {
945
- if (existing instanceof BaseEntity) {
946
- if (keepPrevious) {
947
- // Same (uid, version) unique index race as RepoUtils.create(): two concurrent updates of
948
- // the same version can both pass the optimistic-lock check above and both attempt to
949
- // insert (uid, version + 1). The database rejects the loser; treat that the same as a
950
- // lost optimistic-lock race rather than letting the raw duplicate-key error escape.
951
- try {
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
- catch (err) {
960
- if (err?.code === 11000) {
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
- const updateResult = await this.repo.updateOne({ uid: obj.uid, version: obj.version }, {
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
- catch (err) {
995
- if (err?.code === 11000) {
996
- throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
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
- await this.repo.updateOne({ uid: obj.uid }, {
1003
- $set: {
1004
- ...obj,
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 toSave = obj;
1044
- if (keepPrevious) {
1045
- toSave.version += 1;
1046
- // TypeORM's overloaded Repository.save() can't be resolved against `repo`'s inferred
1047
- // `EntityManager | Repository<T>` union type — same class of friction as the "HAX" cast
1048
- // above.
1049
- result = await repo.save(toSave);
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
- await repo.update(query.where, toSave);
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
- query = this.searchIdQuery(existing.uid, existing instanceof BaseEntity ? existing.version + 1 : undefined);
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
- if (result && this.cache) {
1073
- // Cache the object for faster retrieval.
1074
- this.cache.save(this.hashQuery(query), result).catch((err) => this.logCacheError("save", err));
1075
- this.cache
1076
- .save(this.hashQuery(this.searchIdQuery(result.uid)), result)
1077
- .catch((err) => this.logCacheError("save", err));
1078
- }
1079
- // Process the result to remove any properties that have been scoped with @RequiresScope that the user
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)