@web-ts-toolkit/access-router 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  // src/index.ts
2
- import { isPlainObject as isPlainObject20, isString as isString10, isUndefined as isUndefined4 } from "@web-ts-toolkit/utils";
2
+ import { isPlainObject as isPlainObject21, isString as isString10, isUndefined as isUndefined4 } from "@web-ts-toolkit/utils";
3
3
 
4
4
  // src/middleware.ts
5
5
  import JsonRouter2 from "@web-ts-toolkit/express-json-router";
6
- import { isArray as isArray12, isFunction as isFunction6, isPlainObject as isPlainObject16, isString as isString7 } from "@web-ts-toolkit/utils";
6
+ import { isArray as isArray12, isFunction as isFunction6, isPlainObject as isPlainObject17, isString as isString7 } from "@web-ts-toolkit/utils";
7
7
 
8
8
  // src/core.ts
9
9
  import {
@@ -31,7 +31,8 @@ import {
31
31
  isNil as isNil2,
32
32
  isPlainObject as isPlainObject10,
33
33
  isString as isString4,
34
- keys
34
+ keys,
35
+ normalizeUrlPath
35
36
  } from "@web-ts-toolkit/utils";
36
37
 
37
38
  // src/helpers/collection.ts
@@ -65,8 +66,12 @@ var mapWithConcurrencyLimit = async (items, limit, iteratee) => {
65
66
  };
66
67
  var RequestConcurrencyScheduler = class {
67
68
  constructor(limit) {
69
+ this.activeWork = 0;
70
+ this.waiters = [];
68
71
  this.limit = Math.max(1, limit);
69
72
  }
73
+ // Each map bounds its own orchestration workers. Recursive maps never hold
74
+ // shared work permits while awaiting descendants (including at limit 1).
70
75
  async map(items, iteratee) {
71
76
  return mapWithConcurrencyLimit(items, this.limit, (item, index) => iteratee(item, index, true));
72
77
  }
@@ -74,6 +79,23 @@ var RequestConcurrencyScheduler = class {
74
79
  const [result] = await this.map([void 0], () => iteratee(true));
75
80
  return result;
76
81
  }
82
+ // Only leaf persistence operations belong here, not service/hook callbacks.
83
+ // All work calls on this scheduler share a FIFO admission ceiling.
84
+ async work(operation) {
85
+ if (this.activeWork >= this.limit) {
86
+ await new Promise((resolve) => this.waiters.push(resolve));
87
+ } else {
88
+ this.activeWork += 1;
89
+ }
90
+ try {
91
+ return await operation();
92
+ } finally {
93
+ const next = this.waiters.shift();
94
+ if (next)
95
+ next();
96
+ else this.activeWork -= 1;
97
+ }
98
+ }
77
99
  };
78
100
 
79
101
  // src/helpers/document.ts
@@ -298,6 +320,41 @@ function normalizeSortForOrderBy(sort) {
298
320
  orders: normalized.fields.map(({ direction }) => direction)
299
321
  };
300
322
  }
323
+ function sanitizeSortFields(sort, allowedFields) {
324
+ const { fields, errors } = normalizeSort(sort);
325
+ if (errors.length > 0) return { sort: void 0, stripped: [], errors };
326
+ const allowedSet = new Set(allowedFields.concat(["id", "_id"]));
327
+ const kept = fields.filter(({ field }) => allowedSet.has(field));
328
+ const stripped = fields.filter(({ field }) => !allowedSet.has(field)).map(({ field }) => field);
329
+ if (stripped.length === 0) return { sort, stripped, errors: [] };
330
+ if (kept.length === 0) return { sort: void 0, stripped, errors: [] };
331
+ if (typeof sort === "string") {
332
+ return {
333
+ sort: kept.map(({ field, direction }) => direction === "desc" ? `-${field}` : field).join(" "),
334
+ stripped,
335
+ errors: []
336
+ };
337
+ }
338
+ if (sort instanceof Map) {
339
+ return {
340
+ sort: new Map(kept.map(({ field, direction }) => [field, direction])),
341
+ stripped,
342
+ errors: []
343
+ };
344
+ }
345
+ if (Array.isArray(sort)) {
346
+ return {
347
+ sort: kept.map(({ field, direction }) => [field, direction]),
348
+ stripped,
349
+ errors: []
350
+ };
351
+ }
352
+ return {
353
+ sort: Object.fromEntries(kept.map(({ field, direction }) => [field, direction])),
354
+ stripped,
355
+ errors: []
356
+ };
357
+ }
301
358
 
302
359
  // src/helpers/index.ts
303
360
  import { forEach, isArray as isArray2, isEmpty, isObject, isPlainObject as isPlainObject4, isString as isString3 } from "@web-ts-toolkit/utils";
@@ -1165,6 +1222,7 @@ var AccessRuntime = class {
1165
1222
  this.modelAtts = {};
1166
1223
  this.modelInstances = {};
1167
1224
  this.openApiRegistry = new OpenApiRegistry();
1225
+ this.openApiPathPrefix = "";
1168
1226
  this.allowGlobalModelLookup = options.allowGlobalModelLookup ?? false;
1169
1227
  ensureMongooseJsonSchemaInitialized();
1170
1228
  }
@@ -1193,7 +1251,24 @@ var AccessRuntime = class {
1193
1251
  return global;
1194
1252
  }
1195
1253
  registerOpenApiRoute(route) {
1196
- this.openApiRegistry.register(route);
1254
+ this.openApiRegistry.register(
1255
+ this.openApiPathPrefix ? { ...route, path: normalizeUrlPath(`${this.openApiPathPrefix}/${route.path}`) } : route
1256
+ );
1257
+ }
1258
+ /**
1259
+ * Compose OpenAPI paths while synchronously constructing mounted routers.
1260
+ * Prefixes apply before collision checks; prior descriptors and Express paths
1261
+ * are unchanged. Nested calls compose and the prior scope is restored even on
1262
+ * failure. The callback must finish registration synchronously (no async work).
1263
+ */
1264
+ withOpenApiPathPrefix(prefix, build) {
1265
+ const previous = this.openApiPathPrefix;
1266
+ this.openApiPathPrefix = normalizeUrlPath(`${previous}/${prefix}`);
1267
+ try {
1268
+ return build();
1269
+ } finally {
1270
+ this.openApiPathPrefix = previous;
1271
+ }
1197
1272
  }
1198
1273
  /**
1199
1274
  * Enable strict OpenAPI collision detection for the remainder of this
@@ -1567,6 +1642,7 @@ import {
1567
1642
  get as get4,
1568
1643
  isArray as isArray7,
1569
1644
  isPlainObject as isPlainObject12,
1645
+ set as set3,
1570
1646
  uniq
1571
1647
  } from "@web-ts-toolkit/utils";
1572
1648
 
@@ -1878,7 +1954,8 @@ function resolveValueNode(template, parent, bare) {
1878
1954
  if (!isParentRefMarker(inner) || typeof inner.$parent !== "string" || (inner.$parent?.length ?? 0) === 0) {
1879
1955
  throw new CorrelatedReferenceError('Invalid $escape payload: expected { "$escape": { "$parent": "<field>" } }');
1880
1956
  }
1881
- return literalParentRefObject(inner.$parent);
1957
+ const literal = literalParentRefObject(inner.$parent);
1958
+ return bare ? { $eq: literal } : literal;
1882
1959
  }
1883
1960
  if (isParentRefMarker(template)) {
1884
1961
  const path = template.$parent;
@@ -2007,8 +2084,28 @@ function isCorrelatedInclude(value) {
2007
2084
  return isPlainObject11(value) && value.mode === "correlated";
2008
2085
  }
2009
2086
 
2087
+ // src/helpers/request-work.ts
2088
+ var states = /* @__PURE__ */ new WeakMap();
2089
+ function getRequestWorkState(req, runtime) {
2090
+ let owners = states.get(req);
2091
+ if (!owners) {
2092
+ owners = /* @__PURE__ */ new WeakMap();
2093
+ states.set(req, owners);
2094
+ }
2095
+ let state = owners.get(runtime);
2096
+ if (!state) {
2097
+ state = {
2098
+ totalQueries: 0,
2099
+ scheduler: new RequestConcurrencyScheduler(
2100
+ resolveRequestComplexity(runtime.getGlobalOption("requestComplexity")).maxBulkConcurrency
2101
+ )
2102
+ };
2103
+ owners.set(runtime, state);
2104
+ }
2105
+ return state;
2106
+ }
2107
+
2010
2108
  // src/services/base.ts
2011
- var correlatedExecStates = /* @__PURE__ */ new WeakMap();
2012
2109
  function getCorrelatedTemplateDepth(includes) {
2013
2110
  let max = 0;
2014
2111
  const visit = (entries, depth) => {
@@ -2058,6 +2155,7 @@ function validateClientFilter(filter2) {
2058
2155
  }
2059
2156
  var Base = class {
2060
2157
  constructor(req, modelName) {
2158
+ this.requestRuntime = getActiveRuntime() ?? defaultRuntime;
2061
2159
  this.req = req;
2062
2160
  this.modelName = modelName;
2063
2161
  }
@@ -2195,6 +2293,7 @@ var Base = class {
2195
2293
  }
2196
2294
  seenCorrelatedPaths.add(entry.path);
2197
2295
  }
2296
+ this.assertIncludeOutputPaths([...legacy, ...correlated]);
2198
2297
  let includeLocalFields = [];
2199
2298
  let includePaths = [];
2200
2299
  forEach3(legacy, (inc) => {
@@ -2211,6 +2310,39 @@ var Base = class {
2211
2310
  correlatedReferenceFields: uniq(compact(correlatedReferenceFields))
2212
2311
  };
2213
2312
  }
2313
+ validateIncludeOutputPath(path, modelName) {
2314
+ const permissionField = getModelOption(modelName, "documentPermissionField");
2315
+ if (get4(set3({}, path, true), permissionField) !== void 0 || get4(set3({}, permissionField, true), path) !== void 0) {
2316
+ this.throwClientRequestError(
2317
+ "bad_request" /* BadRequest */,
2318
+ `Include output path ${path} overlaps document permission field ${permissionField} on model ${modelName}`
2319
+ );
2320
+ }
2321
+ }
2322
+ assertIncludeOutputPaths(include) {
2323
+ const pending = [{ entries: include, modelName: this.modelName }];
2324
+ const visited = /* @__PURE__ */ new WeakMap();
2325
+ while (pending.length > 0) {
2326
+ const { entries, modelName } = pending.pop();
2327
+ if (modelName !== this.modelName && !getModelNames().includes(modelName)) {
2328
+ this.throwClientRequestError("bad_request" /* BadRequest */, `Model ${modelName} not found`);
2329
+ }
2330
+ for (const entry of entries) {
2331
+ if (!isPlainObject12(entry)) continue;
2332
+ const models = visited.get(entry) ?? /* @__PURE__ */ new Set();
2333
+ if (models.has(modelName)) continue;
2334
+ models.add(modelName);
2335
+ visited.set(entry, models);
2336
+ if (typeof entry.path === "string" && entry.path) {
2337
+ this.validateIncludeOutputPath(entry.path, modelName);
2338
+ }
2339
+ if ((entry.op === "read" || entry.op === "list") && typeof entry.model === "string") {
2340
+ const nested = entry.args?.include;
2341
+ if (nested) pending.push({ entries: compact(castArray(nested)), modelName: entry.model });
2342
+ }
2343
+ }
2344
+ }
2345
+ }
2214
2346
  getForeignKeyValues(value) {
2215
2347
  return flatten(castArray(value));
2216
2348
  }
@@ -2345,6 +2477,7 @@ var Base = class {
2345
2477
  const svc = await this.getAuthorizedTargetService(model, "count");
2346
2478
  const localValues = docs.flatMap((doc) => this.getForeignKeyValues(get4(doc, localField)));
2347
2479
  const result = await svc.countByFieldValues(foreignField, localValues, _filters ?? {}, "count");
2480
+ if (!result.success && result.code === "bad_request" /* BadRequest */) throw new ClientRequestError(result);
2348
2481
  if (!result.success) return docs;
2349
2482
  for (let y = 0; y < docs.length; y++) {
2350
2483
  const doc = docs[y];
@@ -2353,16 +2486,7 @@ var Base = class {
2353
2486
  return docs;
2354
2487
  }
2355
2488
  getCorrelatedExecState() {
2356
- const key = this.req;
2357
- let state = correlatedExecStates.get(key);
2358
- if (!state) {
2359
- state = {
2360
- totalQueries: 0,
2361
- scheduler: new RequestConcurrencyScheduler(this.getRequestComplexity().maxBulkConcurrency)
2362
- };
2363
- correlatedExecStates.set(key, state);
2364
- }
2365
- return state;
2489
+ return getRequestWorkState(this.req, this.requestRuntime);
2366
2490
  }
2367
2491
  claimCorrelatedQuerySlot() {
2368
2492
  const limits = this.getRequestComplexity();
@@ -2761,7 +2885,7 @@ var Base = class {
2761
2885
  // src/services/service.ts
2762
2886
  import {
2763
2887
  castArray as castArray2,
2764
- cloneDeep as cloneDeep3,
2888
+ cloneDeep as cloneDeep4,
2765
2889
  compact as compact2,
2766
2890
  isArray as isArray8,
2767
2891
  isBoolean as isBoolean3,
@@ -2774,6 +2898,26 @@ import {
2774
2898
  import { diff } from "just-diff";
2775
2899
 
2776
2900
  // src/model.ts
2901
+ import { cloneDeep as cloneDeep3 } from "@web-ts-toolkit/utils";
2902
+ function admitModelPersistence(adapter, scheduler) {
2903
+ return {
2904
+ modelName: adapter.modelName,
2905
+ mongooseModel: adapter.mongooseModel,
2906
+ new: () => adapter.new(),
2907
+ // Mongoose create(array) otherwise starts all document saves at once. Keep
2908
+ // the adapter's array input/output contract, with one permit per item; all
2909
+ // items are submitted even if one rejects, as with unordered create(array).
2910
+ create: (data) => Array.isArray(data) ? Promise.all(data.map((item) => scheduler.work(async () => (await adapter.create([item]))[0]))) : scheduler.work(() => adapter.create(data)),
2911
+ find: (props) => scheduler.work(() => adapter.find(props)),
2912
+ findOne: (props) => scheduler.work(() => adapter.findOne(props)),
2913
+ exists: (filter2) => scheduler.work(() => adapter.exists(filter2)),
2914
+ countDocuments: (filter2) => scheduler.work(() => adapter.countDocuments(filter2)),
2915
+ distinct: (field, conditions) => scheduler.work(() => adapter.distinct(field, conditions)),
2916
+ aggregate: (pipeline) => scheduler.work(() => adapter.aggregate(pipeline)),
2917
+ // Casting builds no persistence work and must not acquire a recursive permit.
2918
+ ...adapter.castFilter ? { castFilter: (filter2) => adapter.castFilter(filter2) } : {}
2919
+ };
2920
+ }
2777
2921
  var Model = class {
2778
2922
  constructor(modelName, runtime) {
2779
2923
  this.modelName = modelName;
@@ -2826,6 +2970,11 @@ var Model = class {
2826
2970
  countDocuments(filter2 = {}) {
2827
2971
  return this.mongooseModel.countDocuments(filter2);
2828
2972
  }
2973
+ /** Cast a copy using this model's query schema/setters, without executing query middleware or persistence. */
2974
+ castFilter(filter2) {
2975
+ const query = this.mongooseModel.countDocuments(cloneDeep3(filter2));
2976
+ return query.cast(this.mongooseModel);
2977
+ }
2829
2978
  // see https://mongoosejs.com/docs/api.html#model_Model.distinct
2830
2979
  distinct(field, conditions = {}) {
2831
2980
  return this.mongooseModel.distinct(field, conditions);
@@ -2836,6 +2985,39 @@ var Model = class {
2836
2985
  };
2837
2986
  var model_default = Model;
2838
2987
 
2988
+ // src/helpers/apply-update.ts
2989
+ import { isPlainObject as isPlainObject13, set as set4 } from "@web-ts-toolkit/utils";
2990
+ function applyUpdate(target, data, allowedFields) {
2991
+ const wholeFields = /* @__PURE__ */ new Set();
2992
+ const partialContainers = /* @__PURE__ */ new Set();
2993
+ const collectBoundary = (shape, prefix = "") => {
2994
+ for (const [key, value] of Object.entries(shape)) {
2995
+ const path = prefix ? `${prefix}.${key}` : key;
2996
+ if (value === true) wholeFields.add(path);
2997
+ else {
2998
+ partialContainers.add(path);
2999
+ collectBoundary(value, path);
3000
+ }
3001
+ }
3002
+ };
3003
+ for (const field of allowedFields) {
3004
+ collectBoundary(set4({}, field, true));
3005
+ }
3006
+ const apply = (values, prefix = "") => {
3007
+ for (const [key, value] of Object.entries(values)) {
3008
+ const path = prefix ? `${prefix}.${key}` : key;
3009
+ if (partialContainers.has(path) && !wholeFields.has(path) && (isPlainObject13(value) || Array.isArray(value))) {
3010
+ apply(value, path);
3011
+ } else if (isDocument2(target)) {
3012
+ target.set(path, value);
3013
+ } else {
3014
+ set4(target, path, value);
3015
+ }
3016
+ }
3017
+ };
3018
+ apply(data ?? {});
3019
+ }
3020
+
2839
3021
  // src/logger-helpers.ts
2840
3022
  var SENSITIVE_FILTER_KEYS = /* @__PURE__ */ new Set([
2841
3023
  "password",
@@ -3015,6 +3197,30 @@ var error = (error2) => {
3015
3197
 
3016
3198
  // src/services/model-subdocument-service.ts
3017
3199
  import { forEach as forEach4, get as get5, pick as pick2 } from "@web-ts-toolkit/utils";
3200
+ async function visibleMutationRows(service, isAllowed, parentId, sub, rows, response) {
3201
+ const accesses = ["read", `subs.${sub}.read`];
3202
+ if (response === "create") accesses.push(`subs.${sub}.list`);
3203
+ const allowed = await Promise.all(accesses.map(isAllowed));
3204
+ if (allowed.some((value) => !value)) return [];
3205
+ const parentFilter = await service.genFilter("read", { _id: parentId });
3206
+ if (parentFilter === false) return [];
3207
+ const parent = await service.findRawParentDoc({
3208
+ filter: { $and: [{ _id: parentId }, parentFilter] },
3209
+ select: "_id",
3210
+ populate: [],
3211
+ lean: true
3212
+ });
3213
+ if (!parent) return [];
3214
+ const targetFilter = response === "create" ? {} : response === "single" ? { _id: rows[0]?._id } : { _id: { $in: rows.map((row) => row._id) } };
3215
+ const [readFilter, listFilter, select] = await Promise.all([
3216
+ service.genFilter(`subs.${sub}.read`, targetFilter),
3217
+ response === "create" ? service.genFilter(`subs.${sub}.list`) : Promise.resolve({}),
3218
+ service.genQuerySelect("read", null, false, [sub, "sub"])
3219
+ ]);
3220
+ if (readFilter === false || listFilter === false) return [];
3221
+ const visible = filterCollection(filterCollection(rows, listFilter), readFilter);
3222
+ return select ? visible.map((row) => pick2(toObject(row), select.concat("_id"))) : visible;
3223
+ }
3018
3224
  async function listSub(service, id, sub, options) {
3019
3225
  const { filter: ft, select } = options ?? {};
3020
3226
  const filterErrors = validateClientFilter(ft);
@@ -3046,25 +3252,24 @@ async function readSub(service, id, sub, subId, options) {
3046
3252
  if (subSelect) subdoc = pick2(toObject(subdoc), subSelect.concat(["_id"]));
3047
3253
  return { success: true, kind: "single", code: "success" /* Success */, data: subdoc };
3048
3254
  }
3049
- async function updateSub(service, id, sub, subId, data) {
3255
+ async function updateSub(service, persist, isAllowed, id, sub, subId, data) {
3050
3256
  const parentDoc = await getParentDoc(service, id, sub, null, { access: "update" });
3051
3257
  if (!parentDoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
3052
3258
  const result = get5(parentDoc, sub);
3053
- const [subFilter, subReadSelect, subUpdateSelect] = await Promise.all([
3259
+ const [subFilter, subUpdateSelect] = await Promise.all([
3054
3260
  service.genFilter(`subs.${sub}.update`, { _id: subId }),
3055
- service.genQuerySelect("read", null, false, [sub, "sub"]),
3056
3261
  service.genQuerySelect("update", null, false, [sub, "sub"])
3057
3262
  ]);
3058
3263
  if (subFilter === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */ };
3059
- let subdoc = findElement(result, subFilter);
3264
+ const subdoc = findElement(result, subFilter);
3060
3265
  if (!subdoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
3061
3266
  const allowedData = pick2(data, subUpdateSelect);
3062
- Object.assign(subdoc, allowedData);
3063
- await parentDoc.save();
3064
- if (subReadSelect) subdoc = pick2(toObject(subdoc), subReadSelect.concat(["_id"]));
3065
- return { success: true, kind: "single", code: "success" /* Success */, data: subdoc };
3267
+ applyUpdate(subdoc, allowedData, subUpdateSelect);
3268
+ await persist(() => parentDoc.save());
3269
+ const visible = await visibleMutationRows(service, isAllowed, parentDoc._id, sub, [subdoc], "single");
3270
+ return { success: true, kind: "single", code: "success" /* Success */, data: visible[0] ?? null };
3066
3271
  }
3067
- async function bulkUpdateSub(service, id, sub, data) {
3272
+ async function bulkUpdateSub(service, persist, isAllowed, id, sub, data) {
3068
3273
  const { maxBulkItems } = service.getRequestComplexity();
3069
3274
  if (data.length > maxBulkItems) {
3070
3275
  return {
@@ -3077,24 +3282,23 @@ async function bulkUpdateSub(service, id, sub, data) {
3077
3282
  const parentDoc = await getParentDoc(service, id, sub, null, { access: "update" });
3078
3283
  if (!parentDoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
3079
3284
  let result = get5(parentDoc, sub);
3080
- const [subFilter, subReadSelect, subUpdateSelect] = await Promise.all([
3285
+ const [subFilter, subUpdateSelect] = await Promise.all([
3081
3286
  service.genFilter(`subs.${sub}.update`, { _id: { $in: data.map((v) => v._id) } }),
3082
- service.genQuerySelect("read", null, false, [sub, "sub"]),
3083
3287
  service.genQuerySelect("update", null, false, [sub, "sub"])
3084
3288
  ]);
3085
3289
  if (subFilter === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */ };
3086
- result = filterCollection(result, subFilter);
3290
+ result = filterCollection(result, subFilter).filter((subdoc) => findElementById(data, subdoc._id));
3087
3291
  forEach4(result, (subdoc) => {
3088
3292
  const tdata = findElementById(data, subdoc._id);
3089
3293
  if (!tdata) return;
3090
3294
  const allowedData = pick2(tdata, subUpdateSelect);
3091
- Object.assign(subdoc, allowedData);
3295
+ applyUpdate(subdoc, allowedData, subUpdateSelect);
3092
3296
  });
3093
- await parentDoc.save();
3094
- if (subReadSelect) result = result.map((v) => pick2(toObject(v), subReadSelect.concat(["_id"])));
3297
+ await persist(() => parentDoc.save());
3298
+ result = await visibleMutationRows(service, isAllowed, parentDoc._id, sub, result, "bulk");
3095
3299
  return { success: true, kind: "list", code: "success" /* Success */, data: result, count: result.length };
3096
3300
  }
3097
- async function createSub(service, id, sub, data, options) {
3301
+ async function createSub(service, persist, isAllowed, id, sub, data, options) {
3098
3302
  const { addFirst } = options ?? {};
3099
3303
  const { maxBulkItems } = service.getRequestComplexity();
3100
3304
  if (Array.isArray(data) && data.length > maxBulkItems) {
@@ -3108,21 +3312,18 @@ async function createSub(service, id, sub, data, options) {
3108
3312
  const parentDoc = await getParentDoc(service, id, sub, null, { access: "update" });
3109
3313
  if (!parentDoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
3110
3314
  let result = get5(parentDoc, sub);
3111
- const [subCreateSelect, subReadSelect] = await Promise.all([
3112
- service.genQuerySelect("create", null, false, [sub, "sub"]),
3113
- service.genQuerySelect("read", null, false, [sub, "sub"])
3114
- ]);
3315
+ const subCreateSelect = await service.genQuerySelect("create", null, false, [sub, "sub"]);
3115
3316
  const allowedData = Array.isArray(data) ? data.map((row) => pick2(row, subCreateSelect)) : pick2(data, subCreateSelect);
3116
3317
  if (Array.isArray(allowedData)) {
3117
3318
  addFirst === true ? result.unshift(...allowedData) : result.push(...allowedData);
3118
3319
  } else {
3119
3320
  addFirst === true ? result.unshift(allowedData) : result.push(allowedData);
3120
3321
  }
3121
- await parentDoc.save();
3122
- if (subReadSelect) result = result.map((v) => pick2(toObject(v), subReadSelect.concat(["_id"])));
3322
+ await persist(() => parentDoc.save());
3323
+ result = await visibleMutationRows(service, isAllowed, parentDoc._id, sub, result, "create");
3123
3324
  return { success: true, kind: "list", code: "created" /* Created */, data: result, count: result.length };
3124
3325
  }
3125
- async function deleteSub(service, id, sub, subId) {
3326
+ async function deleteSub(service, persist, id, sub, subId) {
3126
3327
  const parentDoc = await getParentDoc(service, id, sub, null, { access: "update" });
3127
3328
  if (!parentDoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
3128
3329
  const result = get5(parentDoc, sub);
@@ -3131,7 +3332,7 @@ async function deleteSub(service, id, sub, subId) {
3131
3332
  const subdoc = findElement(result, subFilter);
3132
3333
  if (!subdoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
3133
3334
  await ("deleteOne" in subdoc ? subdoc.deleteOne?.() : subdoc.remove?.());
3134
- await parentDoc.save();
3335
+ await persist(() => parentDoc.save());
3135
3336
  return { success: true, kind: "single", code: "success" /* Success */, data: subdoc._id };
3136
3337
  }
3137
3338
  async function getParentDoc(service, id, sub, args, options) {
@@ -3290,6 +3491,15 @@ var shouldKeepCorrelatedRef = (ref, keep) => {
3290
3491
  return false;
3291
3492
  };
3292
3493
  var Service = class extends Base {
3494
+ constructor(req, modelName) {
3495
+ super(req, modelName);
3496
+ this.persist = (operation) => this.getCorrelatedExecState().scheduler.work(operation);
3497
+ this.model = admitModelPersistence(this.createModelAdapter(modelName), this.getCorrelatedExecState().scheduler);
3498
+ this.options = this.getModelRouterOptions(modelName);
3499
+ this.defaults = this.options.defaults || {};
3500
+ this.baseFields = ["_id"];
3501
+ this.baseFieldsExt = this.baseFields.concat(this.options.documentPermissionField);
3502
+ }
3293
3503
  findRawParentDoc(args) {
3294
3504
  return this.model.findOne({
3295
3505
  ...args,
@@ -3306,6 +3516,10 @@ var Service = class extends Base {
3306
3516
  asServiceHookContext(context) {
3307
3517
  return context;
3308
3518
  }
3519
+ getEffectiveAllowedSortFields(allowedSortFields) {
3520
+ const extra = this.options?.sortableFields ?? [];
3521
+ return extra.length > 0 ? [...allowedSortFields, ...extra] : allowedSortFields;
3522
+ }
3309
3523
  beginOp(op, filter2, extra) {
3310
3524
  const startedAt = Date.now();
3311
3525
  debug({
@@ -3327,14 +3541,6 @@ var Service = class extends Base {
3327
3541
  ...extra ?? {}
3328
3542
  });
3329
3543
  }
3330
- constructor(req, modelName) {
3331
- super(req, modelName);
3332
- this.model = this.createModelAdapter(modelName);
3333
- this.options = this.getModelRouterOptions(modelName);
3334
- this.defaults = this.options.defaults || {};
3335
- this.baseFields = ["_id"];
3336
- this.baseFieldsExt = this.baseFields.concat(this.options.documentPermissionField);
3337
- }
3338
3544
  async findOne(filter2, args, options) {
3339
3545
  const filterErrors = this.validateClientFilter(filter2);
3340
3546
  if (filterErrors.length > 0) return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: filterErrors };
@@ -3342,30 +3548,36 @@ var Service = class extends Base {
3342
3548
  const { skim, includePermissions, access, populateAccess, lean } = this.resolveFindOneOptions(options);
3343
3549
  const { filter: overrideFilter, select: overrideSelect, populate: overridePopulate } = overrides ?? {};
3344
3550
  let parsedFilter;
3551
+ let processedInclude;
3345
3552
  try {
3346
- parsedFilter = await this.parseClientData(filter2);
3553
+ processedInclude = this.processInclude(include);
3554
+ parsedFilter = overrideFilter === false ? false : await this.parseClientData(filter2);
3347
3555
  } catch (error2) {
3348
3556
  const result = this.getClientRequestErrorResult(error2);
3349
3557
  if (result) return result;
3350
3558
  throw error2;
3351
3559
  }
3352
3560
  let [_filter, _select, _populate, allowedSortFields] = await Promise.all([
3353
- overrideFilter || this.genFilter(access, parsedFilter),
3561
+ overrideFilter ?? this.genFilter(access, parsedFilter),
3354
3562
  overrideSelect || this.genQuerySelect(access, select),
3355
3563
  overridePopulate || this.genPopulate(populateAccess || access, populate),
3356
3564
  this.genAllowedFields({}, access, this.baseFieldsExt)
3357
3565
  ]);
3358
- const { includes, correlatedIncludes, includeLocalFields, includePaths, correlatedReferenceFields } = this.processInclude(include);
3566
+ const { includes, correlatedIncludes, includeLocalFields, includePaths, correlatedReferenceFields } = processedInclude;
3359
3567
  const correlatedOutputPaths = correlatedIncludes.map((entry) => entry.path);
3360
3568
  const finalSelect = normalizeSelect(_select).concat(includeLocalFields, correlatedReferenceFields);
3569
+ const effectiveAllowedSortFields = this.getEffectiveAllowedSortFields(allowedSortFields);
3570
+ const stripDisallowedSort = this.options?.stripDisallowedSort ?? false;
3571
+ const sanitizedSort = stripDisallowedSort ? sanitizeSortFields(sort, effectiveAllowedSortFields) : null;
3572
+ const effectiveSort = stripDisallowedSort ? sanitizedSort?.sort : sort;
3361
3573
  const query = {
3362
3574
  filter: _filter,
3363
3575
  select: finalSelect,
3364
- sort,
3576
+ sort: effectiveSort,
3365
3577
  populate: _populate
3366
3578
  };
3367
3579
  const startedAt = this.beginOp("findOne", _filter, {
3368
- sort,
3580
+ sort: effectiveSort,
3369
3581
  selectCount: finalSelect.length,
3370
3582
  populateCount: Array.isArray(_populate) ? _populate.length : _populate ? 1 : 0
3371
3583
  });
@@ -3373,10 +3585,17 @@ var Service = class extends Base {
3373
3585
  this.completeOp("findOne", startedAt, "forbidden" /* Forbidden */, _filter);
3374
3586
  return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
3375
3587
  }
3376
- const sortErrors = validateSortFields(sort, allowedSortFields);
3377
- if (sortErrors.length > 0) {
3378
- this.completeOp("findOne", startedAt, "bad_request" /* BadRequest */, _filter);
3379
- return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortErrors, query };
3588
+ if (stripDisallowedSort) {
3589
+ if (sanitizedSort && sanitizedSort.errors.length > 0) {
3590
+ this.completeOp("findOne", startedAt, "bad_request" /* BadRequest */, _filter);
3591
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sanitizedSort.errors, query };
3592
+ }
3593
+ } else {
3594
+ const sortErrors = validateSortFields(sort, effectiveAllowedSortFields);
3595
+ if (sortErrors.length > 0) {
3596
+ this.completeOp("findOne", startedAt, "bad_request" /* BadRequest */, _filter);
3597
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortErrors, query };
3598
+ }
3380
3599
  }
3381
3600
  let doc = await this.model.findOne({ ...query, lean });
3382
3601
  if (!doc) {
@@ -3390,7 +3609,7 @@ var Service = class extends Base {
3390
3609
  originalDocumentSnapshot: toObject(doc),
3391
3610
  resolvedQuery: query
3392
3611
  };
3393
- const correlatedSnapshots = [cloneDeep3(toObject(doc))];
3612
+ const correlatedSnapshots = [cloneDeep4(toObject(doc))];
3394
3613
  try {
3395
3614
  doc = await this.includeDocs(doc, includes);
3396
3615
  if (correlatedIncludes.length > 0) {
@@ -3437,7 +3656,8 @@ var Service = class extends Base {
3437
3656
  const { select, populate, include, overrides } = this.resolveFindByIdArgs(args);
3438
3657
  const { skim, includePermissions, access, populateAccess, lean } = this.resolveFindByIdOptions(options);
3439
3658
  const { select: overrideSelect, populate: overridePopulate, idFilter: overrideIdFilter } = overrides ?? {};
3440
- const filter2 = overrideIdFilter || await this.genIDFilter(id);
3659
+ const filter2 = overrideIdFilter ?? await this.genIDFilter(id);
3660
+ if (filter2 === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query: { filter: filter2 } };
3441
3661
  return this.findOne(
3442
3662
  filter2,
3443
3663
  {
@@ -3459,15 +3679,17 @@ var Service = class extends Base {
3459
3679
  const { skim, includePermissions, includeCount, populateAccess, lean } = this.resolveFindOptions(options);
3460
3680
  const { filter: overrideFilter, select: overrideSelect, populate: overridePopulate } = overrides ?? {};
3461
3681
  let parsedFilter;
3682
+ let processedInclude;
3462
3683
  try {
3463
- parsedFilter = await this.parseClientData(filter2);
3684
+ processedInclude = this.processInclude(include);
3685
+ parsedFilter = overrideFilter === false ? false : await this.parseClientData(filter2);
3464
3686
  } catch (error2) {
3465
3687
  const result = this.getClientRequestErrorResult(error2);
3466
3688
  if (result) return result;
3467
3689
  throw error2;
3468
3690
  }
3469
3691
  const [_filter, _select, _populate, pagination, allowedSortFields] = await Promise.all([
3470
- overrideFilter || this.genFilter("list", parsedFilter),
3692
+ overrideFilter ?? this.genFilter("list", parsedFilter),
3471
3693
  overrideSelect || this.genQuerySelect("list", select),
3472
3694
  overridePopulate || this.genPopulate(populateAccess, populate),
3473
3695
  genPagination({ skip, limit, page, pageSize }, this.options.listHardLimit),
@@ -3475,17 +3697,21 @@ var Service = class extends Base {
3475
3697
  ]);
3476
3698
  const finalSelect = normalizeSelect(_select);
3477
3699
  const filteredPopulate = isArray8(finalSelect) && isArray8(_populate) ? _populate.filter((p) => finalSelect.includes(p.path.split(".")[0])) : _populate;
3478
- const { includes, correlatedIncludes, includeLocalFields, includePaths, correlatedReferenceFields } = this.processInclude(include);
3700
+ const { includes, correlatedIncludes, includeLocalFields, includePaths, correlatedReferenceFields } = processedInclude;
3479
3701
  const correlatedOutputPaths = correlatedIncludes.map((entry) => entry.path);
3702
+ const effectiveAllowedSortFields = this.getEffectiveAllowedSortFields(allowedSortFields);
3703
+ const stripDisallowedSort = this.options?.stripDisallowedSort ?? false;
3704
+ const sanitizedSort = stripDisallowedSort ? sanitizeSortFields(sort, effectiveAllowedSortFields) : null;
3705
+ const effectiveSort = stripDisallowedSort ? sanitizedSort?.sort : sort;
3480
3706
  const query = {
3481
3707
  filter: _filter,
3482
3708
  select: finalSelect.concat(includeLocalFields, correlatedReferenceFields),
3483
3709
  populate: filteredPopulate,
3484
- sort,
3710
+ sort: effectiveSort,
3485
3711
  ...pagination
3486
3712
  };
3487
3713
  const startedAt = this.beginOp("find", _filter, {
3488
- sort,
3714
+ sort: effectiveSort,
3489
3715
  skip: pagination.skip,
3490
3716
  limit: pagination.limit,
3491
3717
  selectCount: finalSelect.concat(includeLocalFields, correlatedReferenceFields).length,
@@ -3495,10 +3721,17 @@ var Service = class extends Base {
3495
3721
  this.completeOp("find", startedAt, "forbidden" /* Forbidden */, _filter);
3496
3722
  return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
3497
3723
  }
3498
- const sortErrors = validateSortFields(sort, allowedSortFields);
3499
- if (sortErrors.length > 0) {
3500
- this.completeOp("find", startedAt, "bad_request" /* BadRequest */, _filter);
3501
- return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortErrors, query };
3724
+ if (stripDisallowedSort) {
3725
+ if (sanitizedSort && sanitizedSort.errors.length > 0) {
3726
+ this.completeOp("find", startedAt, "bad_request" /* BadRequest */, _filter);
3727
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sanitizedSort.errors, query };
3728
+ }
3729
+ } else {
3730
+ const sortErrors = validateSortFields(sort, effectiveAllowedSortFields);
3731
+ if (sortErrors.length > 0) {
3732
+ this.completeOp("find", startedAt, "bad_request" /* BadRequest */, _filter);
3733
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortErrors, query };
3734
+ }
3502
3735
  }
3503
3736
  let docs = await this.model.find({
3504
3737
  ...query,
@@ -3513,7 +3746,7 @@ var Service = class extends Base {
3513
3746
  resolvedQuery: query
3514
3747
  }));
3515
3748
  const _decorate = isFunction3(decorate) ? decorate : (v) => v;
3516
- const correlatedSnapshots = docs.map((doc) => cloneDeep3(toObject(doc)));
3749
+ const correlatedSnapshots = docs.map((doc) => cloneDeep4(toObject(doc)));
3517
3750
  try {
3518
3751
  docs = await this.includeDocs(docs, includes);
3519
3752
  if (correlatedIncludes.length > 0) {
@@ -3685,7 +3918,7 @@ var Service = class extends Base {
3685
3918
  }
3686
3919
  if (includeDocPermissions) doc = await this.addDocPermissions(doc, "create", contexts[index]);
3687
3920
  if (includePermissions) doc = await this.addFieldPermissions(doc, "read", contexts[index]);
3688
- if (resolvedPopulate.length > 0) await populateDoc(doc, resolvedPopulate);
3921
+ if (resolvedPopulate.length > 0) await this.persist(() => populateDoc(doc, resolvedPopulate));
3689
3922
  doc = await this.trimOutputFields(doc, "read", this.baseFieldsExt);
3690
3923
  let outputDoc = await _decorate(doc, contexts[index]);
3691
3924
  if (!includePermissions) outputDoc = this.addEmptyPermissions(outputDoc);
@@ -3741,6 +3974,13 @@ var Service = class extends Base {
3741
3974
  data: doc
3742
3975
  };
3743
3976
  }
3977
+ /**
3978
+ * Update at authorized policy paths: admitted leaves preserve omitted siblings;
3979
+ * whole-authorized objects/arrays replace. Shared by updateById and existing-row
3980
+ * upsert. Validation precedes trusted prepare output (not re-filtered); partial
3981
+ * prepare containers preserve omissions. Transform retains explicit document-setter
3982
+ * replacement authority. Send nested client JSON, not literal dotted update keys.
3983
+ */
3744
3984
  async updateOne(filter2, data, args, options, decorate) {
3745
3985
  const filterErrors = this.validateClientFilter(filter2);
3746
3986
  if (filterErrors.length > 0) return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: filterErrors };
@@ -3748,7 +3988,7 @@ var Service = class extends Base {
3748
3988
  const { skim, includePermissions, populateAccess } = this.resolveUpdateOneOptions(options);
3749
3989
  const { filter: overrideFilter, populate: overridePopulate } = overrides ?? {};
3750
3990
  const [_filter, _populate] = await Promise.all([
3751
- overrideFilter || this.genFilter("update", filter2),
3991
+ overrideFilter ?? this.genFilter("update", filter2),
3752
3992
  overridePopulate || this.genPopulate(populateAccess, populate)
3753
3993
  ]);
3754
3994
  const query = { filter: _filter, populate: _populate };
@@ -3803,11 +4043,11 @@ var Service = class extends Base {
3803
4043
  }
3804
4044
  const prepared = await this.prepare(allowedData, "update", context);
3805
4045
  context.preparedData = prepared;
3806
- Object.assign(doc, prepared);
4046
+ applyUpdate(doc, prepared, allowedFields);
3807
4047
  context.modifiedPaths = doc.modifiedPaths();
3808
4048
  doc = assertModelDocument(await this.transform(doc, "update", context), this.modelName, "transform");
3809
4049
  context.currentDocument = doc;
3810
- doc = await doc.save();
4050
+ doc = await this.persist(() => doc.save());
3811
4051
  const diffExcludeFields = [this.options.documentPermissionField, "__v"];
3812
4052
  this.asServiceHookContext(context).diff = (d) => {
3813
4053
  context.changes = diff(
@@ -3827,7 +4067,7 @@ var Service = class extends Base {
3827
4067
  }
3828
4068
  if (includeDocPermissions) doc = await this.addDocPermissions(doc, "update", context);
3829
4069
  if (includePermissions) doc = await this.addFieldPermissions(doc, "update", context);
3830
- if (_populate) await populateDoc(doc, _populate);
4070
+ if (_populate) await this.persist(() => populateDoc(doc, _populate));
3831
4071
  doc = await this.trimOutputFields(doc, "read", this.baseFieldsExt);
3832
4072
  let outputDoc = doc;
3833
4073
  if (isFunction3(decorate)) outputDoc = await decorate(outputDoc, context);
@@ -3839,7 +4079,8 @@ var Service = class extends Base {
3839
4079
  const { populate, overrides } = this.resolveUpdateByIdArgs(args);
3840
4080
  const { skim, includePermissions, populateAccess } = this.resolveUpdateByIdOptions(options);
3841
4081
  const { populate: overridePopulate, idFilter: overrideIdFilter } = overrides;
3842
- const filter2 = overrideIdFilter || await this.genIDFilter(id);
4082
+ const filter2 = overrideIdFilter ?? await this.genIDFilter(id);
4083
+ if (filter2 === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query: { filter: filter2 } };
3843
4084
  return this.updateOne(
3844
4085
  filter2,
3845
4086
  data,
@@ -3859,7 +4100,7 @@ var Service = class extends Base {
3859
4100
  const { populate, overrides } = this.resolveUpsertArgs(args);
3860
4101
  const { skim, includePermissions, populateAccess } = this.resolveUpsertOptions(options);
3861
4102
  const { filter: overrideFilter, populate: overridePopulate } = overrides ?? {};
3862
- const _filter = await (overrideFilter || this.genFilter("update", filter2));
4103
+ const _filter = await (overrideFilter ?? this.genFilter("update", filter2));
3863
4104
  const query = { filter: _filter };
3864
4105
  const startedAt = this.beginOp("upsert", _filter);
3865
4106
  if (_filter === false) {
@@ -3919,7 +4160,9 @@ var Service = class extends Base {
3919
4160
  resolvedQuery: query
3920
4161
  };
3921
4162
  await this.beforeDelete(doc, context);
3922
- await ("deleteOne" in doc ? doc.deleteOne() : doc.remove());
4163
+ await this.persist(
4164
+ () => "deleteOne" in doc ? doc.deleteOne() : doc.remove()
4165
+ );
3923
4166
  context.finalDocumentSnapshot = toObject(doc);
3924
4167
  await this.afterDelete(doc, context);
3925
4168
  this.completeOp("delete", startedAt, "success" /* Success */, filter2);
@@ -3930,6 +4173,7 @@ var Service = class extends Base {
3930
4173
  if (filterErrors.length > 0) return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: filterErrors };
3931
4174
  const { access, includeId } = this.resolveExistsOptions(options);
3932
4175
  filter2 = await this.genFilter(access, filter2);
4176
+ if (filter2 === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query: { filter: filter2 } };
3933
4177
  const result = await this.model.exists(filter2);
3934
4178
  return {
3935
4179
  success: true,
@@ -3999,6 +4243,13 @@ var Service = class extends Base {
3999
4243
  query
4000
4244
  };
4001
4245
  }
4246
+ /**
4247
+ * Group target document IDs by original parent-key aliases using one aggregate.
4248
+ * Resolves the requested row policy (legacy count includes pass `count`), casts
4249
+ * authorized filters/foreign operands with the target schema, and deduplicates IDs
4250
+ * independently of pagination. Cast failures return BadRequest; false policy returns
4251
+ * Forbidden. Casting does not execute countDocuments middleware/plugins.
4252
+ */
4002
4253
  async countByFieldValues(foreignField, values, filter2 = {}, access = "list") {
4003
4254
  if (!isValidFieldPath(foreignField)) {
4004
4255
  return {
@@ -4017,7 +4268,25 @@ var Service = class extends Base {
4017
4268
  });
4018
4269
  const query = { filter: filter2 };
4019
4270
  if (filter2 === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
4020
- const matchFilter = filter2;
4271
+ let matchFilter = filter2;
4272
+ let castValues = uniqueValues;
4273
+ try {
4274
+ if (this.model.castFilter) {
4275
+ matchFilter = this.model.castFilter(matchFilter);
4276
+ const foreignFilter = this.model.castFilter({ [foreignField]: { $in: uniqueValues } });
4277
+ castValues = foreignFilter[foreignField]?.$in;
4278
+ if (!Array.isArray(castValues) || castValues.length !== uniqueValues.length) {
4279
+ throw new Error("Foreign field query casting must retain the join operands");
4280
+ }
4281
+ }
4282
+ } catch {
4283
+ return {
4284
+ success: false,
4285
+ kind: "error",
4286
+ code: "bad_request" /* BadRequest */,
4287
+ errors: [{ detail: "Invalid grouped include count filter or foreign-key value" }]
4288
+ };
4289
+ }
4021
4290
  const rows = await this.model.aggregate([
4022
4291
  { $match: matchFilter },
4023
4292
  {
@@ -4028,12 +4297,20 @@ var Service = class extends Base {
4028
4297
  }
4029
4298
  },
4030
4299
  { $unwind: "$foreignValues" },
4031
- { $match: { foreignValues: { $in: uniqueValues } } },
4300
+ { $match: { foreignValues: { $in: castValues } } },
4032
4301
  { $group: { _id: "$foreignValues", documentIds: { $addToSet: "$_id" } } }
4033
4302
  ]);
4034
- const counts = /* @__PURE__ */ new Map();
4303
+ const grouped = /* @__PURE__ */ new Map();
4035
4304
  for (const row of rows) {
4036
- counts.set(String(row._id), new Set(row.documentIds.map((id) => String(id))));
4305
+ const key = String(row._id);
4306
+ const ids = grouped.get(key) ?? /* @__PURE__ */ new Set();
4307
+ for (const id of row.documentIds) ids.add(String(id));
4308
+ grouped.set(key, ids);
4309
+ }
4310
+ const counts = /* @__PURE__ */ new Map();
4311
+ for (let i = 0; i < uniqueValues.length; i++) {
4312
+ const ids = grouped.get(String(castValues[i]));
4313
+ if (ids) counts.set(String(uniqueValues[i]), ids);
4037
4314
  }
4038
4315
  return { success: true, kind: "single", code: "success" /* Success */, data: counts, query };
4039
4316
  }
@@ -4112,17 +4389,60 @@ var Service = class extends Base {
4112
4389
  async readSub(id, sub, subId, options) {
4113
4390
  return readSub(this, id, sub, subId, options);
4114
4391
  }
4392
+ /**
4393
+ * Apply authorized policy-path updates, preserving protected siblings, then save.
4394
+ * Output requires post-save parent read and subdocument read operation/row access;
4395
+ * hidden output is successful `data: null` (direct/root-entry 200), not a failed write.
4396
+ * Visible output uses read fields plus `_id`. Do not retry solely for null output.
4397
+ */
4115
4398
  async updateSub(id, sub, subId, data) {
4116
- return updateSub(this, id, sub, subId, data);
4399
+ return updateSub(
4400
+ this,
4401
+ this.persist,
4402
+ (access) => this.req.macl.isAllowed(this.modelName, access),
4403
+ id,
4404
+ sub,
4405
+ subId,
4406
+ data
4407
+ );
4117
4408
  }
4409
+ /**
4410
+ * Save authorized targeted updates, then return readable targets in stored order.
4411
+ * Requires parent/subdocument read access for output, not subdocument list access;
4412
+ * uses read fields plus `_id`. Hidden output succeeds with `data: [], count: 0`
4413
+ * (direct/root-entry 200); count measures visible rows, not all updated rows.
4414
+ */
4118
4415
  async bulkUpdateSub(id, sub, data) {
4119
- return bulkUpdateSub(this, id, sub, castArray2(data));
4416
+ return bulkUpdateSub(
4417
+ this,
4418
+ this.persist,
4419
+ (access) => this.req.macl.isAllowed(this.modelName, access),
4420
+ id,
4421
+ sub,
4422
+ castArray2(data)
4423
+ );
4120
4424
  }
4425
+ /**
4426
+ * Append/prepend admitted rows, then return the visible full array (including when
4427
+ * input is empty). Output requires post-save parent read and subdocument list AND
4428
+ * read guards/row filters; fields use read projection plus `_id`.
4429
+ * Hidden output succeeds with code `created`, `data: [], count: 0`
4430
+ * (direct/root-entry 201). Visible count/order follow filtered stored rows.
4431
+ * Empty output does not mean the write failed; addFirst is a service option.
4432
+ */
4121
4433
  async createSub(id, sub, data, options) {
4122
- return createSub(this, id, sub, data, options);
4434
+ return createSub(
4435
+ this,
4436
+ this.persist,
4437
+ (access) => this.req.macl.isAllowed(this.modelName, access),
4438
+ id,
4439
+ sub,
4440
+ data,
4441
+ options
4442
+ );
4123
4443
  }
4124
4444
  async deleteSub(id, sub, subId) {
4125
- return deleteSub(this, id, sub, subId);
4445
+ return deleteSub(this, this.persist, id, sub, subId);
4126
4446
  }
4127
4447
  async getParentDoc(id, sub, args, options) {
4128
4448
  return getParentDoc(this, id, sub, args, options);
@@ -4196,6 +4516,7 @@ var PublicService = class extends Service {
4196
4516
  },
4197
4517
  { skim, includePermissions, access, populateAccess, lean }
4198
4518
  );
4519
+ if (!result.success && (result.code === "bad_request" /* BadRequest */ || result.code === "forbidden" /* Forbidden */)) return result;
4199
4520
  if (tryList && (!result.success || !result.data)) {
4200
4521
  const listAllowed = await this.req.macl.isAllowed(this.modelName, "list");
4201
4522
  if (!listAllowed) {
@@ -4236,6 +4557,7 @@ var PublicService = class extends Service {
4236
4557
  },
4237
4558
  { skim, includePermissions, access, populateAccess, lean }
4238
4559
  );
4560
+ if (!result.success && (result.code === "bad_request" /* BadRequest */ || result.code === "forbidden" /* Forbidden */)) return result;
4239
4561
  if (tryList && (!result.success || !result.data)) {
4240
4562
  const listAllowed = await this.req.macl.isAllowed(this.modelName, "list");
4241
4563
  if (!listAllowed) {
@@ -4284,6 +4606,11 @@ var PublicService = class extends Service {
4284
4606
  }
4285
4607
  return result;
4286
4608
  }
4609
+ /**
4610
+ * With an _id, check update-policy existence then update: terminal denial returns
4611
+ * Forbidden, an allowed miss remains Unauthorized, and neither creates a row.
4612
+ * Without an _id, use the create path.
4613
+ */
4287
4614
  async _upsert(data, args, options) {
4288
4615
  const idKey = this.getIdentifier();
4289
4616
  if (idKey !== "_id") {
@@ -4514,7 +4841,7 @@ var DataService = class {
4514
4841
  };
4515
4842
 
4516
4843
  // src/processors.ts
4517
- import { cloneDeep as cloneDeep4, forEach as forEach6, get as get7, hasOwn, isArray as isArray9, isPlainObject as isPlainObject14, map, set as set5 } from "@web-ts-toolkit/utils";
4844
+ import { cloneDeep as cloneDeep5, forEach as forEach6, get as get7, hasOwn, isArray as isArray9, isPlainObject as isPlainObject15, map, set as set6 } from "@web-ts-toolkit/utils";
4518
4845
  var UNSAFE_PATH_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
4519
4846
  var DEFAULT_MAX_SLICE = 1e3;
4520
4847
  var DEFAULT_MASK_REPLACEMENT = "***";
@@ -4549,7 +4876,7 @@ function resolveLeafParents(obj, src) {
4549
4876
  for (let ind = 0; ind < segs.length - 1; ind += 1) {
4550
4877
  const seg = segs[ind];
4551
4878
  targets = targets.reduce((ret, target) => {
4552
- if (!isPlainObject14(target)) return ret;
4879
+ if (!isPlainObject15(target)) return ret;
4553
4880
  const targetObject = target;
4554
4881
  const next = targetObject[seg];
4555
4882
  if (isArray9(next)) {
@@ -4563,7 +4890,7 @@ function resolveLeafParents(obj, src) {
4563
4890
  const leaf = segs[segs.length - 1];
4564
4891
  const pairs = [];
4565
4892
  forEach6(targets, (target) => {
4566
- if (!isPlainObject14(target)) return;
4893
+ if (!isPlainObject15(target)) return;
4567
4894
  pairs.push({ parent: target, leaf });
4568
4895
  });
4569
4896
  return pairs;
@@ -4571,7 +4898,7 @@ function resolveLeafParents(obj, src) {
4571
4898
  var copyAndDepopulate = (docObject, operations, options = { mutable: true, idField: "_id" }) => {
4572
4899
  const mutable = options.mutable !== false;
4573
4900
  const idField = options.idField ?? "_id";
4574
- const obj = mutable ? docObject : cloneDeep4(docObject);
4901
+ const obj = mutable ? docObject : cloneDeep5(docObject);
4575
4902
  forEach6(Array.isArray(operations) ? operations : [], (op) => {
4576
4903
  if (!op || typeof op.src !== "string" || typeof op.dest !== "string") return;
4577
4904
  const src = op.src;
@@ -4584,7 +4911,7 @@ var copyAndDepopulate = (docObject, operations, options = { mutable: true, idFie
4584
4911
  forEach6(segs, (seg, ind) => {
4585
4912
  if (segs.length === ind + 1) {
4586
4913
  forEach6(targets, (target) => {
4587
- if (!isPlainObject14(target)) return;
4914
+ if (!isPlainObject15(target)) return;
4588
4915
  const targetObject = target;
4589
4916
  const leaf = targetObject[seg];
4590
4917
  const sourceForDest = leaf;
@@ -4592,7 +4919,7 @@ var copyAndDepopulate = (docObject, operations, options = { mutable: true, idFie
4592
4919
  if (isArray9(leaf)) {
4593
4920
  if (leaf.length === 0) {
4594
4921
  depopulated = [];
4595
- } else if (leaf.every((item) => isPlainObject14(item))) {
4922
+ } else if (leaf.every((item) => isPlainObject15(item))) {
4596
4923
  depopulated = map(leaf, idField).map((id, i) => {
4597
4924
  if (id === void 0) {
4598
4925
  failMissingId(`${src}[${i}]`);
@@ -4602,7 +4929,7 @@ var copyAndDepopulate = (docObject, operations, options = { mutable: true, idFie
4602
4929
  } else {
4603
4930
  return;
4604
4931
  }
4605
- } else if (isPlainObject14(leaf)) {
4932
+ } else if (isPlainObject15(leaf)) {
4606
4933
  const id = get7(leaf, idField);
4607
4934
  if (id === void 0) {
4608
4935
  failMissingId(src);
@@ -4611,12 +4938,12 @@ var copyAndDepopulate = (docObject, operations, options = { mutable: true, idFie
4611
4938
  } else {
4612
4939
  return;
4613
4940
  }
4614
- set5(targetObject, dest, sourceForDest);
4615
- set5(targetObject, seg, depopulated);
4941
+ set6(targetObject, dest, sourceForDest);
4942
+ set6(targetObject, seg, depopulated);
4616
4943
  });
4617
4944
  } else {
4618
4945
  targets = targets.reduce((ret, target) => {
4619
- if (!isPlainObject14(target)) return ret;
4946
+ if (!isPlainObject15(target)) return ret;
4620
4947
  const targetObject = target;
4621
4948
  const next = targetObject[seg];
4622
4949
  if (isArray9(next)) {
@@ -4633,7 +4960,7 @@ var copyAndDepopulate = (docObject, operations, options = { mutable: true, idFie
4633
4960
  };
4634
4961
  var copyPaths = (docObject, operations, options = { mutable: true }) => {
4635
4962
  const mutable = options.mutable !== false;
4636
- const obj = mutable ? docObject : cloneDeep4(docObject);
4963
+ const obj = mutable ? docObject : cloneDeep5(docObject);
4637
4964
  forEach6(Array.isArray(operations) ? operations : [], (op) => {
4638
4965
  if (!op || typeof op.src !== "string" || typeof op.dest !== "string") return;
4639
4966
  const src = op.src;
@@ -4644,14 +4971,14 @@ var copyPaths = (docObject, operations, options = { mutable: true }) => {
4644
4971
  if (src === dest) return;
4645
4972
  forEach6(resolveLeafParents(obj, src), ({ parent, leaf }) => {
4646
4973
  if (!hasOwn(parent, leaf)) return;
4647
- set5(parent, dest, cloneDeep4(parent[leaf]));
4974
+ set6(parent, dest, cloneDeep5(parent[leaf]));
4648
4975
  });
4649
4976
  });
4650
4977
  return obj;
4651
4978
  };
4652
4979
  var movePaths = (docObject, operations, options = { mutable: true }) => {
4653
4980
  const mutable = options.mutable !== false;
4654
- const obj = mutable ? docObject : cloneDeep4(docObject);
4981
+ const obj = mutable ? docObject : cloneDeep5(docObject);
4655
4982
  forEach6(Array.isArray(operations) ? operations : [], (op) => {
4656
4983
  if (!op || typeof op.src !== "string" || typeof op.dest !== "string") return;
4657
4984
  const src = op.src;
@@ -4662,8 +4989,8 @@ var movePaths = (docObject, operations, options = { mutable: true }) => {
4662
4989
  if (src === dest) return;
4663
4990
  forEach6(resolveLeafParents(obj, src), ({ parent, leaf }) => {
4664
4991
  if (!hasOwn(parent, leaf)) return;
4665
- const value = cloneDeep4(parent[leaf]);
4666
- set5(parent, dest, value);
4992
+ const value = cloneDeep5(parent[leaf]);
4993
+ set6(parent, dest, value);
4667
4994
  if (dest === leaf || dest.startsWith(`${leaf}.`)) return;
4668
4995
  delete parent[leaf];
4669
4996
  });
@@ -4674,7 +5001,7 @@ var sliceArrays = (docObject, operations, options = { mutable: true, maxSlice: D
4674
5001
  const mutable = options.mutable !== false;
4675
5002
  const maxSlice = options.maxSlice ?? DEFAULT_MAX_SLICE;
4676
5003
  assertNonNegativeSafeInteger(maxSlice, "maxSlice", "sliceArrays");
4677
- const obj = mutable ? docObject : cloneDeep4(docObject);
5004
+ const obj = mutable ? docObject : cloneDeep5(docObject);
4678
5005
  forEach6(Array.isArray(operations) ? operations : [], (op) => {
4679
5006
  if (!op || typeof op.src !== "string") return;
4680
5007
  const src = op.src;
@@ -4698,9 +5025,9 @@ var sliceArrays = (docObject, operations, options = { mutable: true, maxSlice: D
4698
5025
  const end = typeof limit === "undefined" ? void 0 : skip + limit;
4699
5026
  const window = leafValue.slice(skip, end);
4700
5027
  if (typeof rawDest === "undefined") {
4701
- set5(parent, leaf, window);
5028
+ set6(parent, leaf, window);
4702
5029
  } else {
4703
- set5(parent, rawDest, window);
5030
+ set6(parent, rawDest, window);
4704
5031
  }
4705
5032
  });
4706
5033
  });
@@ -4708,7 +5035,7 @@ var sliceArrays = (docObject, operations, options = { mutable: true, maxSlice: D
4708
5035
  };
4709
5036
  var countPaths = (docObject, operations, options = { mutable: true }) => {
4710
5037
  const mutable = options.mutable !== false;
4711
- const obj = mutable ? docObject : cloneDeep4(docObject);
5038
+ const obj = mutable ? docObject : cloneDeep5(docObject);
4712
5039
  forEach6(Array.isArray(operations) ? operations : [], (op) => {
4713
5040
  if (!op || typeof op.src !== "string" || typeof op.dest !== "string") return;
4714
5041
  const src = op.src;
@@ -4722,21 +5049,21 @@ var countPaths = (docObject, operations, options = { mutable: true }) => {
4722
5049
  let count;
4723
5050
  if (isArray9(leafValue)) {
4724
5051
  count = leafValue.length;
4725
- } else if (isPlainObject14(leafValue)) {
5052
+ } else if (isPlainObject15(leafValue)) {
4726
5053
  count = Object.keys(leafValue).length;
4727
5054
  } else if (leafValue === null || typeof leafValue === "undefined") {
4728
5055
  count = 0;
4729
5056
  } else {
4730
5057
  count = 1;
4731
5058
  }
4732
- set5(parent, dest, count);
5059
+ set6(parent, dest, count);
4733
5060
  });
4734
5061
  });
4735
5062
  return obj;
4736
5063
  };
4737
5064
  var maskPaths = (docObject, operations, options = { mutable: true }) => {
4738
5065
  const mutable = options.mutable !== false;
4739
- const obj = mutable ? docObject : cloneDeep4(docObject);
5066
+ const obj = mutable ? docObject : cloneDeep5(docObject);
4740
5067
  forEach6(Array.isArray(operations) ? operations : [], (op) => {
4741
5068
  if (!op || typeof op.src !== "string") return;
4742
5069
  const src = op.src;
@@ -4745,7 +5072,7 @@ var maskPaths = (docObject, operations, options = { mutable: true }) => {
4745
5072
  const replacement = Object.prototype.hasOwnProperty.call(op, "replacement") ? op.replacement : DEFAULT_MASK_REPLACEMENT;
4746
5073
  forEach6(resolveLeafParents(obj, src), ({ parent, leaf }) => {
4747
5074
  if (!hasOwn(parent, leaf)) return;
4748
- set5(parent, leaf, replacement);
5075
+ set6(parent, leaf, replacement);
4749
5076
  });
4750
5077
  });
4751
5078
  return obj;
@@ -4798,7 +5125,7 @@ import {
4798
5125
  isEmpty as isEmpty2,
4799
5126
  isEqual,
4800
5127
  isFunction as isFunction4,
4801
- isPlainObject as isPlainObject15,
5128
+ isPlainObject as isPlainObject16,
4802
5129
  isString as isString5
4803
5130
  } from "@web-ts-toolkit/utils";
4804
5131
 
@@ -4839,10 +5166,10 @@ var permission_default = Permission;
4839
5166
 
4840
5167
  // src/core-shared.ts
4841
5168
  function isAndFilter(filter2) {
4842
- return !!filter2 && isPlainObject15(filter2) && Object.keys(filter2).length === 1 && isArray10(filter2.$and);
5169
+ return !!filter2 && isPlainObject16(filter2) && Object.keys(filter2).length === 1 && isArray10(filter2.$and);
4843
5170
  }
4844
5171
  function isMergeableClause(filter2) {
4845
- return !!filter2 && isPlainObject15(filter2) && Object.keys(filter2).every((key) => !key.startsWith("$"));
5172
+ return !!filter2 && isPlainObject16(filter2) && Object.keys(filter2).every((key) => !key.startsWith("$"));
4846
5173
  }
4847
5174
  function optimizeAndFilter(clausesInput) {
4848
5175
  const clauses = [];
@@ -4860,7 +5187,7 @@ function optimizeAndFilter(clausesInput) {
4860
5187
  if (isArray10(value)) {
4861
5188
  return `[${value.map(dedupeKey).join(",")}]`;
4862
5189
  }
4863
- if (isPlainObject15(value)) {
5190
+ if (isPlainObject16(value)) {
4864
5191
  return `{${Object.keys(value).sort().map((key) => `${key}:${dedupeKey(value[key])}`).join(",")}}`;
4865
5192
  }
4866
5193
  return JSON.stringify(value);
@@ -4902,7 +5229,7 @@ function optimizeAndFilter(clausesInput) {
4902
5229
  }
4903
5230
  function normalizeFilter(filter2) {
4904
5231
  if (filter2 === false) return false;
4905
- if (!filter2 || !isPlainObject15(filter2) || isEmpty2(filter2)) return null;
5232
+ if (!filter2 || !isPlainObject16(filter2) || isEmpty2(filter2)) return null;
4906
5233
  if (!isAndFilter(filter2)) {
4907
5234
  return filter2;
4908
5235
  }
@@ -4969,7 +5296,7 @@ async function setRequestPermissions(req) {
4969
5296
  const globalPermissions = getGlobalOption("globalPermissions");
4970
5297
  if (!isFunction4(globalPermissions)) return;
4971
5298
  const permissions = await globalPermissions.call(req, req);
4972
- if (isPlainObject15(permissions)) req[requestPermissionField] = permissions;
5299
+ if (isPlainObject16(permissions)) req[requestPermissionField] = permissions;
4973
5300
  else if (isArray10(permissions)) req[requestPermissionField] = arrayToRecord(permissions);
4974
5301
  else if (isString5(permissions)) req[requestPermissionField] = { [permissions]: true };
4975
5302
  owners.set(requestPermissionField, activeRuntime);
@@ -5189,6 +5516,7 @@ async function resolveSelectForRequest({
5189
5516
  }
5190
5517
 
5191
5518
  // src/core.ts
5519
+ var emptyPermissionLeaf = () => ({ $: "_" });
5192
5520
  var Core = class {
5193
5521
  constructor(req) {
5194
5522
  this.cachedPermissions = null;
@@ -5392,7 +5720,7 @@ var Core = class {
5392
5720
  }
5393
5721
  addEmptyPermissions(modelName, doc) {
5394
5722
  const docPermissionField = getModelOption(modelName, "documentPermissionField");
5395
- setDocValue(doc, docPermissionField, { _view: { $: "_" }, _edit: { $: "_" } });
5723
+ setDocValue(doc, docPermissionField, { _view: emptyPermissionLeaf(), _edit: emptyPermissionLeaf() });
5396
5724
  return doc;
5397
5725
  }
5398
5726
  async addDocPermissions(modelName, doc, access, context) {
@@ -5442,8 +5770,8 @@ var Core = class {
5442
5770
  },
5443
5771
  {}
5444
5772
  );
5445
- setDocValue(doc, `${docPermissionField}._view`, viewObj);
5446
- setDocValue(doc, `${docPermissionField}._edit`, editObj);
5773
+ setDocValue(doc, `${docPermissionField}._view`, Object.keys(viewObj).length > 0 ? viewObj : emptyPermissionLeaf());
5774
+ setDocValue(doc, `${docPermissionField}._edit`, Object.keys(editObj).length > 0 ? editObj : emptyPermissionLeaf());
5447
5775
  return doc;
5448
5776
  }
5449
5777
  async decorate(modelName, doc, access, context) {
@@ -5582,12 +5910,12 @@ function guard(condition) {
5582
5910
  const permissions = req[PERMISSIONS];
5583
5911
  let cond = condition;
5584
5912
  let phas = (key) => permissions.has(key);
5585
- if (isPlainObject16(condition)) {
5913
+ if (isPlainObject17(condition)) {
5586
5914
  const { modelName, id, condition: _cond } = condition;
5587
5915
  const svc = req.macl.getPublicService(modelName);
5588
5916
  const select = getModelOption(modelName, `alwaysSelectFields.read`, void 0);
5589
5917
  let _id = isString7(id) ? id : null;
5590
- if (isPlainObject16(id)) {
5918
+ if (isPlainObject17(id)) {
5591
5919
  const { type = "param", key } = id;
5592
5920
  if (type === "param") {
5593
5921
  const paramValue = req.params[key];
@@ -5629,7 +5957,7 @@ import express2 from "express";
5629
5957
 
5630
5958
  // src/routers/model-router.ts
5631
5959
  import JsonRouter5 from "@web-ts-toolkit/express-json-router";
5632
- import { forEach as forEach8, isPlainObject as isPlainObject18, isString as isString8, isUndefined as isUndefined2, normalizeUrlPath as normalizeUrlPath2, padEnd } from "@web-ts-toolkit/utils";
5960
+ import { forEach as forEach8, isPlainObject as isPlainObject19, isString as isString8, isUndefined as isUndefined2, normalizeUrlPath as normalizeUrlPath3, padEnd } from "@web-ts-toolkit/utils";
5633
5961
 
5634
5962
  // src/routers/router-mutation.ts
5635
5963
  var MODEL_BUILD_TIME_OPTION_KEYS = /* @__PURE__ */ new Set([
@@ -5758,7 +6086,7 @@ import JsonRouter4 from "@web-ts-toolkit/express-json-router";
5758
6086
 
5759
6087
  // src/validation/common.ts
5760
6088
  import { z as z2 } from "zod";
5761
- import { isPlainObject as isPlainObject17 } from "@web-ts-toolkit/utils";
6089
+ import { isPlainObject as isPlainObject18 } from "@web-ts-toolkit/utils";
5762
6090
  var stringOrStringArray = z2.union([z2.string(), z2.array(z2.string())]);
5763
6091
  var safeIntegerString = (min, message) => z2.string().regex(/^\d+$/, message).refine((value) => Number.isSafeInteger(Number(value)), "Expected a safe integer").refine((value) => Number(value) >= min, message);
5764
6092
  var queryBooleanString = z2.enum(["true", "false"]);
@@ -5858,7 +6186,7 @@ var correlatedFilterSchema = z2.record(z2.string(), z2.unknown()).superRefine((f
5858
6186
  }
5859
6187
  });
5860
6188
  var correlatedEmptyOptionsSchema = z2.unknown().refine(
5861
- (value) => value === void 0 || isPlainObject17(value) && Object.keys(value).length === 0,
6189
+ (value) => value === void 0 || isPlainObject18(value) && Object.keys(value).length === 0,
5862
6190
  "Correlated include options must be absent or empty"
5863
6191
  ).optional();
5864
6192
  var correlatedPathSchema = z2.string().min(1).superRefine((path, ctx) => {
@@ -5895,7 +6223,7 @@ var correlatedListArgsSchema = z2.object({
5895
6223
  include: z2.lazy(() => correlatedNestedIncludeSchema).optional()
5896
6224
  }).strict().superRefine((args, ctx) => checkArgsMarkers(args.select, args.sort, ctx));
5897
6225
  var correlatedCountArgsSchema = z2.unknown().refine(
5898
- (value) => value === void 0 || isPlainObject17(value) && Object.keys(value).length === 0,
6226
+ (value) => value === void 0 || isPlainObject18(value) && Object.keys(value).length === 0,
5899
6227
  "Count includes accept no args"
5900
6228
  ).optional();
5901
6229
  var correlatedReadByIdSchema = z2.object({
@@ -5945,7 +6273,7 @@ var correlatedNestedIncludeSchema = z2.union([includeItemSchema, z2.array(includ
5945
6273
  var rejectDuplicateCorrelatedPaths = (items, ctx) => {
5946
6274
  const seen = /* @__PURE__ */ new Set();
5947
6275
  items.forEach((item, index) => {
5948
- if (isPlainObject17(item) && item.mode === "correlated" && typeof item.path === "string") {
6276
+ if (isPlainObject18(item) && item.mode === "correlated" && typeof item.path === "string") {
5949
6277
  if (seen.has(item.path)) {
5950
6278
  ctx.addIssue({
5951
6279
  code: z2.ZodIssueCode.custom,
@@ -6144,19 +6472,12 @@ function fromJoi(schema) {
6144
6472
  }
6145
6473
  function fromAjv(validator) {
6146
6474
  return async (value) => {
6147
- const outcome = validator(value);
6148
- if (isThenable(outcome)) {
6475
+ if (validator.$async === true) {
6149
6476
  try {
6150
- const data = await outcome;
6151
- if (data === false) {
6152
- return {
6153
- success: false,
6154
- issues: normalizeAjvIssues(validator.errors ?? [])
6155
- };
6156
- }
6477
+ const data = await validator(value);
6157
6478
  return {
6158
6479
  success: true,
6159
- data: data === true ? value : data
6480
+ data
6160
6481
  };
6161
6482
  } catch (error2) {
6162
6483
  if (!isAjvValidationError(error2)) {
@@ -6164,11 +6485,21 @@ function fromAjv(validator) {
6164
6485
  }
6165
6486
  return {
6166
6487
  success: false,
6167
- issues: normalizeAjvIssues(error2.errors ?? [])
6488
+ issues: normalizeAjvIssues(error2.errors)
6168
6489
  };
6169
6490
  }
6170
6491
  }
6171
- const errorsSnapshot = validator.errors ? [...validator.errors] : [];
6492
+ const outcome = validator(value);
6493
+ if (isThenable(outcome)) {
6494
+ void Promise.resolve(outcome).catch(() => {
6495
+ });
6496
+ throw new TypeError(
6497
+ "fromAjv: promise/thenable validators must declare $async: true and resolve with validated data"
6498
+ );
6499
+ }
6500
+ if (typeof outcome !== "boolean") {
6501
+ throw new TypeError("fromAjv: synchronous validators must return a boolean verdict");
6502
+ }
6172
6503
  if (outcome) {
6173
6504
  return {
6174
6505
  success: true,
@@ -6177,7 +6508,9 @@ function fromAjv(validator) {
6177
6508
  }
6178
6509
  return {
6179
6510
  success: false,
6180
- issues: normalizeAjvIssues(errorsSnapshot)
6511
+ // Normalize immediately, copying message/path before another call can
6512
+ // replace the errors array or mutate its entries.
6513
+ issues: normalizeAjvIssues(validator.errors ?? [])
6181
6514
  };
6182
6515
  };
6183
6516
  }
@@ -6407,14 +6740,14 @@ function isArkTypeErrors(value) {
6407
6740
  return record.arkKind === "errors" || record[" arkKind"] === "errors";
6408
6741
  }
6409
6742
  function isThenable(value) {
6410
- return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
6743
+ return (typeof value === "object" || typeof value === "function") && value !== null && "then" in value && typeof value.then === "function";
6411
6744
  }
6412
6745
  function isAjvValidationError(error2) {
6413
6746
  if (typeof error2 !== "object" || error2 === null || !("errors" in error2)) {
6414
6747
  return false;
6415
6748
  }
6416
- const errors = error2.errors;
6417
- return errors == null || Array.isArray(errors);
6749
+ const candidate = error2;
6750
+ return candidate.ajv === true && candidate.validation === true && Array.isArray(candidate.errors);
6418
6751
  }
6419
6752
  function isVineValidationError(error2) {
6420
6753
  if (typeof error2 !== "object" || error2 === null || !("messages" in error2)) {
@@ -7507,11 +7840,11 @@ function setModelSubDocumentRoutes(context) {
7507
7840
  }
7508
7841
 
7509
7842
  // src/openapi/route-registration.ts
7510
- import { normalizeUrlPath } from "@web-ts-toolkit/utils";
7843
+ import { normalizeUrlPath as normalizeUrlPath2 } from "@web-ts-toolkit/utils";
7511
7844
  function registerOpenApiRoute(runtime, basePath, defaultTag, route) {
7512
7845
  runtime.registerOpenApiRoute({
7513
7846
  ...route,
7514
- path: normalizeUrlPath(basePath + route.path),
7847
+ path: normalizeUrlPath2(basePath + route.path),
7515
7848
  tags: route.tags ?? [defaultTag]
7516
7849
  });
7517
7850
  }
@@ -7682,7 +8015,7 @@ var ModelRouter = class _ModelRouter {
7682
8015
  this.runtime = runtime;
7683
8016
  this.runtime.setModelOptions(modelName, initialOptions);
7684
8017
  this.options = this.runtime.getModelOptions(modelName);
7685
- this.fullBasePath = normalizeUrlPath2(this.options.parentPath + this.options.basePath);
8018
+ this.fullBasePath = normalizeUrlPath3(this.options.parentPath + this.options.basePath);
7686
8019
  this.modelName = modelName;
7687
8020
  this.router = new JsonRouter5(this.options.basePath, createSetCore(this.runtime), accessRouterResponseHandler);
7688
8021
  this.model = new model_default(modelName, this.runtime);
@@ -7763,7 +8096,7 @@ var ModelRouter = class _ModelRouter {
7763
8096
  logEndpoints() {
7764
8097
  runWithRuntime(this.runtime, () => {
7765
8098
  forEach8(this.router.getEndpoints(), ({ method, path }) => {
7766
- logInfoMessage(`${padEnd(method, 6)} ${normalizeUrlPath2(this.options.parentPath + path)}`);
8099
+ logInfoMessage(`${padEnd(method, 6)} ${normalizeUrlPath3(this.options.parentPath + path)}`);
7767
8100
  });
7768
8101
  });
7769
8102
  }
@@ -7776,7 +8109,7 @@ var ModelRouter = class _ModelRouter {
7776
8109
  value
7777
8110
  );
7778
8111
  }
7779
- if (arguments.length === 1 && isPlainObject18(keyOrOptions)) {
8112
+ if (arguments.length === 1 && isPlainObject19(keyOrOptions)) {
7780
8113
  assertMutableRouterOptions("model", keyOrOptions);
7781
8114
  this.runtime.setModelOptions(this.modelName, keyOrOptions);
7782
8115
  }
@@ -7799,7 +8132,7 @@ var ModelRouter = class _ModelRouter {
7799
8132
 
7800
8133
  // src/routers/root-router.ts
7801
8134
  import JsonRouter6 from "@web-ts-toolkit/express-json-router";
7802
- import { isNumber as _isNumber, normalizeUrlPath as normalizeUrlPath3, orderBy as _orderBy } from "@web-ts-toolkit/utils";
8135
+ import { isNumber as _isNumber, normalizeUrlPath as normalizeUrlPath4, orderBy as _orderBy } from "@web-ts-toolkit/utils";
7803
8136
 
7804
8137
  // src/core-data.ts
7805
8138
  var DataCore = class {
@@ -8065,7 +8398,7 @@ var RootRouter = class {
8065
8398
  this.setRoutes();
8066
8399
  }
8067
8400
  registerOpenApiRoute(route) {
8068
- registerOpenApiRoute(this.runtime, normalizeUrlPath3(this.basename), "root", route);
8401
+ registerOpenApiRoute(this.runtime, normalizeUrlPath4(this.basename), "root", route);
8069
8402
  }
8070
8403
  wrapResult(index, item, result) {
8071
8404
  return {
@@ -8215,7 +8548,7 @@ var RootRouter = class {
8215
8548
 
8216
8549
  // src/routers/data-router.ts
8217
8550
  import JsonRouter7 from "@web-ts-toolkit/express-json-router";
8218
- import { isPlainObject as isPlainObject19, isString as isString9, isUndefined as isUndefined3, normalizeUrlPath as normalizeUrlPath4 } from "@web-ts-toolkit/utils";
8551
+ import { isPlainObject as isPlainObject20, isString as isString9, isUndefined as isUndefined3, normalizeUrlPath as normalizeUrlPath5 } from "@web-ts-toolkit/utils";
8219
8552
  var clientErrors4 = JsonRouter7.clientErrors;
8220
8553
  function setOption2(parentKey, optionKey, option) {
8221
8554
  const key = isUndefined3(option) ? parentKey : `${parentKey}.${optionKey}`;
@@ -8260,7 +8593,7 @@ var DataRouter = class {
8260
8593
  this.runtime = runtime;
8261
8594
  this.runtime.setDataOptions(dataName, initialOptions);
8262
8595
  this.options = this.runtime.getDataOptions(dataName);
8263
- this.fullBasePath = normalizeUrlPath4(this.options.parentPath + this.options.basePath);
8596
+ this.fullBasePath = normalizeUrlPath5(this.options.parentPath + this.options.basePath);
8264
8597
  this.dataName = dataName;
8265
8598
  this.router = new JsonRouter7(this.options.basePath, createSetDataCore(this.runtime), accessRouterResponseHandler);
8266
8599
  this.setCollectionRoutes();
@@ -8416,7 +8749,7 @@ var DataRouter = class {
8416
8749
  assertMutableRouterOption("data", keyOrOptions);
8417
8750
  this.runtime.setDataOption(this.dataName, keyOrOptions, value);
8418
8751
  }
8419
- if (arguments.length === 1 && isPlainObject19(keyOrOptions)) {
8752
+ if (arguments.length === 1 && isPlainObject20(keyOrOptions)) {
8420
8753
  assertMutableRouterOptions("data", keyOrOptions);
8421
8754
  this.runtime.setDataOptions(this.dataName, keyOrOptions);
8422
8755
  }
@@ -8563,7 +8896,7 @@ function createRuntimeApi(runtime) {
8563
8896
  if (arguments.length === 2 && isString10(keyOrOptions)) {
8564
8897
  return runtime.setGlobalOption(keyOrOptions, value);
8565
8898
  }
8566
- if (arguments.length === 1 && isPlainObject20(keyOrOptions)) {
8899
+ if (arguments.length === 1 && isPlainObject21(keyOrOptions)) {
8567
8900
  return runtime.setGlobalOptions(keyOrOptions);
8568
8901
  }
8569
8902
  };