@web-ts-toolkit/access-router 0.38.0 → 0.39.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
@@ -47,12 +47,42 @@ var findElementById = (collection, id) => {
47
47
  return findElement(collection, { _id: id });
48
48
  };
49
49
 
50
+ // src/helpers/concurrency.ts
51
+ var mapWithConcurrencyLimit = async (items, limit, iteratee) => {
52
+ const results = new Array(items.length);
53
+ let cursor = 0;
54
+ const workerCount = Math.min(Math.max(limit, 1), items.length || 1);
55
+ await Promise.all(
56
+ Array.from({ length: workerCount }, async () => {
57
+ while (cursor < items.length) {
58
+ const current = cursor;
59
+ cursor += 1;
60
+ results[current] = await iteratee(items[current], current);
61
+ }
62
+ })
63
+ );
64
+ return results;
65
+ };
66
+ var RequestConcurrencyScheduler = class {
67
+ constructor(limit) {
68
+ this.limit = Math.max(1, limit);
69
+ }
70
+ async map(items, iteratee) {
71
+ return mapWithConcurrencyLimit(items, this.limit, (item, index) => iteratee(item, index, true));
72
+ }
73
+ async run(iteratee) {
74
+ const [result] = await this.map([void 0], () => iteratee(true));
75
+ return result;
76
+ }
77
+ };
78
+
50
79
  // src/helpers/document.ts
51
80
  import { Document } from "mongoose";
52
81
  import { get, isPlainObject as isPlainObject2, pick, set } from "@web-ts-toolkit/utils";
53
82
 
54
83
  // src/helpers/query.ts
55
84
  import { flattenDeep, isNil, isPlainObject, isString, reduce } from "@web-ts-toolkit/utils";
85
+ var DEFAULT_LIST_HARD_LIMIT = 1e3;
56
86
  var normalizeSafeInteger = (value, min) => {
57
87
  if (isNil(value)) return null;
58
88
  const parsed = Number(value);
@@ -65,9 +95,10 @@ function genPagination({
65
95
  page,
66
96
  pageSize
67
97
  }, hardLimit) {
98
+ const resolvedHardLimit = normalizeSafeInteger(hardLimit, 1) ?? DEFAULT_LIST_HARD_LIMIT;
68
99
  let _skip = 0;
69
- let _limit = normalizeSafeInteger(limit ?? pageSize, 1) ?? hardLimit;
70
- if (!Number.isSafeInteger(_limit) || _limit > hardLimit) _limit = hardLimit;
100
+ let _limit = normalizeSafeInteger(limit ?? pageSize, 1) ?? resolvedHardLimit;
101
+ if (!Number.isSafeInteger(_limit) || _limit > resolvedHardLimit) _limit = resolvedHardLimit;
71
102
  const normalizedSkip = normalizeSafeInteger(skip, 0);
72
103
  const normalizedPage = normalizeSafeInteger(page, 0);
73
104
  if (normalizedSkip !== null) {
@@ -84,14 +115,6 @@ function genPagination({
84
115
  }
85
116
  return { skip: _skip, limit: _limit };
86
117
  }
87
- function parseSortString(sortString) {
88
- if (!sortString) return { sortKey: "", sortOrder: "asc" };
89
- if (sortString.startsWith("-")) {
90
- return { sortKey: sortString.substring(1), sortOrder: "desc" };
91
- } else {
92
- return { sortKey: sortString, sortOrder: "asc" };
93
- }
94
- }
95
118
  function normalizeSelect(select) {
96
119
  if (Array.isArray(select)) return flattenDeep(select.map(normalizeSelect));
97
120
  if (isPlainObject(select)) {
@@ -221,8 +244,63 @@ function handleResultError(result) {
221
244
  }
222
245
  }
223
246
 
247
+ // src/helpers/sort-policy.ts
248
+ var validSortOrders = [1, -1, "asc", "ascending", "desc", "descending"];
249
+ var fieldPathPattern = /^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)*$/;
250
+ var sortDirection = (order) => order === -1 || order === "desc" || order === "descending" ? "desc" : "asc";
251
+ var sortError = (detail) => ({ detail, pointer: "#/sort" });
252
+ function isValidFieldPath(field) {
253
+ return typeof field === "string" && fieldPathPattern.test(field);
254
+ }
255
+ function isFieldAllowed(field, allowedFields) {
256
+ return new Set(allowedFields.concat(["id", "_id"])).has(field);
257
+ }
258
+ function normalizeSort(sort) {
259
+ if (sort === null || sort === void 0 || sort === "") return { fields: [], errors: [] };
260
+ if (typeof sort === "string") {
261
+ const fields2 = sort.trim().split(/\s+/).filter(Boolean).map((raw) => {
262
+ const desc = raw.startsWith("-");
263
+ return { raw, field: desc ? raw.slice(1) : raw, direction: desc ? "desc" : "asc" };
264
+ });
265
+ const errors2 = fields2.filter(({ field }) => !isValidFieldPath(field)).map(({ raw }) => sortError(`Invalid sort field: ${raw}`));
266
+ return { fields: errors2.length > 0 ? [] : fields2.map(({ field, direction }) => ({ field, direction })), errors: errors2 };
267
+ }
268
+ const entries = sort instanceof Map ? Array.from(sort.entries()) : Array.isArray(sort) ? sort : Object.entries(sort);
269
+ const errors = [];
270
+ const fields = [];
271
+ for (const entry of entries) {
272
+ if (!Array.isArray(entry) || entry.length !== 2) {
273
+ errors.push(sortError("Invalid sort entry: expected [field, order]"));
274
+ continue;
275
+ }
276
+ const [field, order] = entry;
277
+ if (!isValidFieldPath(field)) {
278
+ errors.push(sortError(`Invalid sort field: ${String(field)}`));
279
+ continue;
280
+ }
281
+ if (!validSortOrders.includes(order)) {
282
+ errors.push(sortError(`Invalid sort order for field: ${field}`));
283
+ continue;
284
+ }
285
+ fields.push({ field, direction: sortDirection(order) });
286
+ }
287
+ return { fields: errors.length > 0 ? [] : fields, errors };
288
+ }
289
+ function validateSortFields(sort, allowedFields) {
290
+ const { fields, errors } = normalizeSort(sort);
291
+ if (errors.length > 0) return errors;
292
+ return fields.filter(({ field }) => !isFieldAllowed(field, allowedFields)).map(({ field }) => sortError(`Sort field is not allowed: ${field}`));
293
+ }
294
+ function normalizeSortForOrderBy(sort) {
295
+ const normalized = normalizeSort(sort);
296
+ return {
297
+ fields: normalized.fields.map(({ field }) => field),
298
+ orders: normalized.fields.map(({ direction }) => direction)
299
+ };
300
+ }
301
+
224
302
  // src/helpers/index.ts
225
- import { forEach, isArray as isArray2, isEmpty, isObject, isPlainObject as isPlainObject4, isString as isString3, mapValuesAsync } from "@web-ts-toolkit/utils";
303
+ import { forEach, isArray as isArray2, isEmpty, isObject, isPlainObject as isPlainObject4, isString as isString3 } from "@web-ts-toolkit/utils";
226
304
 
227
305
  // src/lib.ts
228
306
  import { Document as Document2, Schema } from "mongoose";
@@ -282,26 +360,33 @@ function buildSubPaths(schema) {
282
360
  });
283
361
  return subPaths;
284
362
  }
285
- async function iterateQuery(query, handler) {
363
+ async function iterateQuery(query, handler, scheduler, scheduled = false) {
286
364
  if (!isPlainObject4(query)) return query;
287
365
  if (!handler) return query;
288
366
  const queryObject = query;
289
- return mapValuesAsync(queryObject, async (val, key) => {
367
+ const entries = Object.entries(queryObject);
368
+ const mapEntry = async ([key, val]) => {
290
369
  if (isPlainObject4(val)) {
291
370
  const plainValue = val;
292
371
  if (plainValue.$$sq) {
293
- return handler(0 /* SubQuery */, plainValue.$$sq, key);
372
+ return scheduled || !scheduler ? handler(0 /* SubQuery */, plainValue.$$sq, key) : scheduler.run(() => Promise.resolve(handler(0 /* SubQuery */, plainValue.$$sq, key)));
294
373
  } else if (plainValue.$$date) {
295
374
  return handler(1 /* Date */, plainValue.$$date, key);
296
375
  } else {
297
- return iterateQuery(val, handler);
376
+ return iterateQuery(val, handler, scheduler, scheduled);
298
377
  }
299
378
  }
300
379
  if (isArray2(val)) {
301
- return Promise.all(val.map((v) => iterateQuery(v, handler)));
380
+ const items = val;
381
+ if (scheduler && !scheduled) {
382
+ return scheduler.map(items, (v) => iterateQuery(v, handler, scheduler, true));
383
+ }
384
+ return Promise.all(items.map((v) => iterateQuery(v, handler, scheduler, scheduled)));
302
385
  }
303
386
  return val;
304
- });
387
+ };
388
+ const values = scheduler && !scheduled ? await scheduler.map(entries, mapEntry) : await Promise.all(entries.map(mapEntry));
389
+ return Object.fromEntries(entries.map(([key], index) => [key, values[index]]));
305
390
  }
306
391
  var createValidator = (fn) => {
307
392
  const stringHandler = (key) => key.trim().split(" ").every((v) => fn(v));
@@ -667,6 +752,42 @@ var defaultLogger = createLogger({
667
752
 
668
753
  // src/options/manager.ts
669
754
  import { assign, get as get2, set as set2 } from "@web-ts-toolkit/utils";
755
+ var toPath = (key) => {
756
+ if (Array.isArray(key)) return key;
757
+ return typeof key === "string" ? key.split(".") : [key];
758
+ };
759
+ var cloneConfigValue = (value) => {
760
+ if (Array.isArray(value)) {
761
+ return value.map((item) => cloneConfigValue(item));
762
+ }
763
+ if (!value || typeof value !== "object") {
764
+ return value;
765
+ }
766
+ if (Object.getPrototypeOf(value) !== Object.prototype) {
767
+ return value;
768
+ }
769
+ return Object.fromEntries(
770
+ Object.entries(value).map(([entryKey, entryValue]) => [
771
+ entryKey,
772
+ cloneConfigValue(entryValue)
773
+ ])
774
+ );
775
+ };
776
+ var freezeConfigValue = (value) => {
777
+ if (!value || typeof value !== "object") {
778
+ return value;
779
+ }
780
+ if (Object.isFrozen(value)) {
781
+ return value;
782
+ }
783
+ if (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype) {
784
+ for (const nestedValue of Object.values(value)) {
785
+ freezeConfigValue(nestedValue);
786
+ }
787
+ return Object.freeze(value);
788
+ }
789
+ return value;
790
+ };
670
791
  var getNestedOption = (manager, key, defaultValue) => {
671
792
  const keys2 = String(key).split(".");
672
793
  if (keys2.length === 1) {
@@ -684,8 +805,9 @@ var getNestedOption = (manager, key, defaultValue) => {
684
805
  return option;
685
806
  };
686
807
  var OptionsManager = class {
687
- constructor(defaultOptions) {
808
+ constructor(defaultOptions, options = {}) {
688
809
  this.defaultOptions = defaultOptions;
810
+ this.preserveKeys = new Set(options.preserveKeys ?? []);
689
811
  this.listeners = {};
690
812
  const _this = this;
691
813
  this.currentOptions = new Proxy({}, {
@@ -706,13 +828,34 @@ var OptionsManager = class {
706
828
  return get2(this.currentOptions, key, defaultValue);
707
829
  }
708
830
  set(key, value) {
709
- set2(this.currentOptions, key, value);
831
+ const path = toPath(key);
832
+ if (path.length <= 1) {
833
+ set2(this.currentOptions, path, this.preserveKeys.has(String(path[0])) ? value : cloneConfigValue(value));
834
+ return;
835
+ }
836
+ const [rootKey, ...nestedPath] = path;
837
+ const currentRoot = get2(this.currentOptions, [rootKey], void 0);
838
+ const nextRoot = cloneConfigValue(currentRoot ?? {});
839
+ set2(nextRoot, nestedPath, cloneConfigValue(value));
840
+ set2(this.currentOptions, [rootKey], nextRoot);
710
841
  }
711
842
  fetch() {
712
- return { ...this.currentOptions };
843
+ const cloned = cloneConfigValue(this.currentOptions);
844
+ for (const key of this.preserveKeys) {
845
+ if (key in this.currentOptions) {
846
+ cloned[key] = this.currentOptions[key];
847
+ }
848
+ }
849
+ return freezeConfigValue(cloned);
713
850
  }
714
851
  assign(options) {
715
- assign(this.currentOptions, options);
852
+ const cloned = cloneConfigValue(options);
853
+ for (const key of this.preserveKeys) {
854
+ if (key in options) {
855
+ cloned[key] = options[key];
856
+ }
857
+ }
858
+ assign(this.currentOptions, cloned);
716
859
  }
717
860
  onchange(key, func) {
718
861
  set2(this.listeners, key, func);
@@ -730,7 +873,8 @@ var defaultRequestComplexity = {
730
873
  maxBulkItems: 100,
731
874
  maxIncludeCount: 10,
732
875
  maxSubQueryCount: 10,
733
- maxBulkConcurrency: 10
876
+ maxBulkConcurrency: 10,
877
+ maxHookConcurrency: 10
734
878
  };
735
879
  var normalizePositiveInteger = (value, fallback) => {
736
880
  return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : fallback;
@@ -746,6 +890,10 @@ var resolveRequestComplexity = (options) => ({
746
890
  maxBulkConcurrency: normalizePositiveInteger(
747
891
  options?.maxBulkConcurrency,
748
892
  defaultRequestComplexity.maxBulkConcurrency
893
+ ),
894
+ maxHookConcurrency: normalizePositiveInteger(
895
+ options?.maxHookConcurrency,
896
+ defaultRequestComplexity.maxHookConcurrency
749
897
  )
750
898
  });
751
899
  function validateRequestComplexity(value, options, scope = "request") {
@@ -839,7 +987,8 @@ var defaultModelOptions = {
839
987
  var defaultDataOptions = {
840
988
  basePath: null,
841
989
  parentPath: "/",
842
- queryRouteSegment: "__query"
990
+ queryRouteSegment: "__query",
991
+ listHardLimit: DEFAULT_LIST_HARD_LIMIT
843
992
  };
844
993
  var normalizeBasePath = (name, value) => {
845
994
  if (isNil2(value)) {
@@ -896,17 +1045,30 @@ var classifyPermissionSchema = (permissionSchema, modelPermissionPrefix) => {
896
1045
  modelPermissionKeys
897
1046
  };
898
1047
  };
1048
+ var refreshPermissionMetadata = (target) => {
1049
+ const permissionSchema = target.permissionSchema ?? {};
1050
+ const { schemaKeys, globalPermissionKeys, modelPermissionKeys } = classifyPermissionSchema(
1051
+ permissionSchema,
1052
+ target.modelPermissionPrefix ?? ""
1053
+ );
1054
+ target._permissionSchemaKeys = schemaKeys;
1055
+ target._globalPermissionKeys = globalPermissionKeys;
1056
+ target._modelPermissionKeys = modelPermissionKeys;
1057
+ };
899
1058
  var AccessRuntime = class {
900
- constructor() {
901
- this.globalOptions = new OptionsManager({
902
- requestPermissionField: "_permissions",
903
- globalPermissions: () => ({}),
904
- requireRegisteredPopulateModels: true,
905
- logger: defaultLogger,
906
- requestComplexity: defaultRequestComplexity
907
- }).build();
1059
+ constructor(options = {}) {
1060
+ this.globalOptions = new OptionsManager(
1061
+ {
1062
+ requestPermissionField: "_permissions",
1063
+ globalPermissions: () => ({}),
1064
+ requireRegisteredPopulateModels: true,
1065
+ logger: defaultLogger,
1066
+ requestComplexity: defaultRequestComplexity
1067
+ },
1068
+ { preserveKeys: ["logger"] }
1069
+ ).build();
908
1070
  this.defaultModelOptions = new OptionsManager({
909
- listHardLimit: 1e3,
1071
+ listHardLimit: DEFAULT_LIST_HARD_LIMIT,
910
1072
  documentPermissionField: "_permissions",
911
1073
  idParam: "id",
912
1074
  idField: "_id",
@@ -924,6 +1086,7 @@ var AccessRuntime = class {
924
1086
  this.modelAtts = {};
925
1087
  this.modelInstances = {};
926
1088
  this.openApiRegistry = new OpenApiRegistry();
1089
+ this.allowGlobalModelLookup = options.allowGlobalModelLookup ?? false;
927
1090
  ensureMongooseJsonSchemaInitialized();
928
1091
  }
929
1092
  registerModelInstance(modelName, model) {
@@ -939,13 +1102,16 @@ var AccessRuntime = class {
939
1102
  this.modelInstances[modelName] = model;
940
1103
  }
941
1104
  hasModelInstance(modelName) {
942
- return modelName in this.modelInstances;
1105
+ return modelName in this.modelInstances || this.allowGlobalModelLookup && modelName in mongoose.models;
943
1106
  }
944
1107
  getModelInstance(modelName) {
945
1108
  const registered = this.modelInstances[modelName];
946
1109
  if (registered) return registered;
1110
+ if (!this.allowGlobalModelLookup) return null;
947
1111
  const global = mongoose.models[modelName];
948
- return global ?? null;
1112
+ if (!global) return null;
1113
+ this.registerModelInstance(modelName, global);
1114
+ return global;
949
1115
  }
950
1116
  registerOpenApiRoute(route) {
951
1117
  this.openApiRegistry.register(route);
@@ -994,20 +1160,20 @@ var AccessRuntime = class {
994
1160
  return this.defaultModelOptions.get(key, defaultValue);
995
1161
  }
996
1162
  createModelOptions(modelName) {
997
- const model = this.getModelInstance(modelName) ?? mongoose.model(modelName);
1163
+ const model = this.getModelInstance(modelName);
1164
+ if (!model) {
1165
+ throw new Error(
1166
+ `Runtime model registry missing model "${modelName}". Pass a mongoose.Model instance to createRouter() or register it with registerModelInstance() before using this isolated runtime.`
1167
+ );
1168
+ }
998
1169
  const manager = new OptionsManager({
999
1170
  ...defaultModelOptions,
1000
1171
  modelName
1001
1172
  });
1002
- manager.onchange("permissionSchema", function(newval, key, target) {
1003
- const permissionSchema = newval ?? {};
1004
- const { schemaKeys, globalPermissionKeys, modelPermissionKeys } = classifyPermissionSchema(
1005
- permissionSchema,
1006
- target.modelPermissionPrefix ?? ""
1007
- );
1008
- target._permissionSchemaKeys = schemaKeys;
1009
- target._globalPermissionKeys = globalPermissionKeys;
1010
- target._modelPermissionKeys = modelPermissionKeys;
1173
+ manager.onchange("permissionSchema", function(_newval, _key, target) {
1174
+ refreshPermissionMetadata(target);
1175
+ }).onchange("modelPermissionPrefix", function(_newval, _key, target) {
1176
+ refreshPermissionMetadata(target);
1011
1177
  }).onchange("basePath", function(newval, key, target) {
1012
1178
  target[key] = normalizeBasePath(
1013
1179
  modelName,
@@ -1136,7 +1302,7 @@ var AccessRuntime = class {
1136
1302
  return get3(this.modelAtts, modelName, []);
1137
1303
  }
1138
1304
  };
1139
- var defaultRuntime = new AccessRuntime();
1305
+ var defaultRuntime = new AccessRuntime({ allowGlobalModelLookup: true });
1140
1306
 
1141
1307
  // src/runtime-context.ts
1142
1308
  import { AsyncLocalStorage } from "async_hooks";
@@ -1236,7 +1402,6 @@ import {
1236
1402
  flatten,
1237
1403
  forEach as forEach3,
1238
1404
  get as get4,
1239
- intersectionBy,
1240
1405
  isArray as isArray6,
1241
1406
  isPlainObject as isPlainObject10,
1242
1407
  uniq
@@ -1298,8 +1463,8 @@ var Base = class {
1298
1463
  genIDFilter(id) {
1299
1464
  return this.req.macl.genIDFilter(this.modelName, id);
1300
1465
  }
1301
- genPopulate(access, populate) {
1302
- return this.req.macl.genPopulate(this.modelName, access, populate);
1466
+ genPopulate(access, populate, subPaths) {
1467
+ return this.req.macl.genPopulate(this.modelName, access, populate, subPaths);
1303
1468
  }
1304
1469
  genSelect(access, targetFields, skipChecks, subPaths) {
1305
1470
  return this.req.macl.genSelect(this.modelName, access, targetFields, skipChecks, subPaths);
@@ -1396,6 +1561,55 @@ var Base = class {
1396
1561
  includePaths
1397
1562
  };
1398
1563
  }
1564
+ getForeignKeyValues(value) {
1565
+ return flatten(castArray(value));
1566
+ }
1567
+ getForeignKey(value) {
1568
+ return String(value);
1569
+ }
1570
+ buildForeignKeyIndex(rows, foreignField) {
1571
+ const index = /* @__PURE__ */ new Map();
1572
+ for (const row of rows) {
1573
+ for (const value of this.getForeignKeyValues(get4(row, foreignField))) {
1574
+ const key = this.getForeignKey(value);
1575
+ const values = index.get(key);
1576
+ if (values) values.push(row);
1577
+ else index.set(key, [row]);
1578
+ }
1579
+ }
1580
+ return index;
1581
+ }
1582
+ getIndexedMatches(index, localValue) {
1583
+ const seen = /* @__PURE__ */ new Set();
1584
+ const matches = [];
1585
+ for (const value of this.getForeignKeyValues(localValue)) {
1586
+ for (const row of index.get(this.getForeignKey(value)) ?? []) {
1587
+ if (seen.has(row)) continue;
1588
+ seen.add(row);
1589
+ matches.push(row);
1590
+ }
1591
+ }
1592
+ return matches;
1593
+ }
1594
+ getIndexedCount(index, localValue) {
1595
+ const ids = /* @__PURE__ */ new Set();
1596
+ for (const value of this.getForeignKeyValues(localValue)) {
1597
+ for (const id of index.get(this.getForeignKey(value)) ?? []) {
1598
+ ids.add(id);
1599
+ }
1600
+ }
1601
+ return ids.size;
1602
+ }
1603
+ sanitizeIncludeArgs(args) {
1604
+ if (!isPlainObject10(args)) return {};
1605
+ const { overrides: _clientOverrides, ...trustedArgs } = args;
1606
+ return trustedArgs;
1607
+ }
1608
+ assertIncludeForeignField(include) {
1609
+ if (!isValidFieldPath(include.foreignField)) {
1610
+ this.throwClientRequestError("bad_request" /* BadRequest */, `Invalid include foreignField: ${include.foreignField}`);
1611
+ }
1612
+ }
1399
1613
  async includeDocs(docs, include) {
1400
1614
  if (!include) return docs;
1401
1615
  const includes = compact(castArray(include));
@@ -1404,25 +1618,52 @@ var Base = class {
1404
1618
  let docList = isSingle ? [docs] : docs;
1405
1619
  for (let x = 0; x < includes.length; x++) {
1406
1620
  const include2 = includes[x];
1407
- if (include2.op === "count") {
1408
- docList = await this.includeDocsCount(docList, include2);
1409
- } else {
1410
- docList = await this.includeDocsList(docList, include2);
1621
+ this.assertIncludeForeignField(include2);
1622
+ switch (include2.op) {
1623
+ case "count":
1624
+ docList = await this.includeDocsCount(docList, include2);
1625
+ break;
1626
+ case "read":
1627
+ docList = await this.includeDocsRead(docList, include2);
1628
+ break;
1629
+ case "list":
1630
+ docList = await this.includeDocsList(docList, include2);
1631
+ break;
1411
1632
  }
1412
1633
  }
1413
1634
  return isSingle ? docList[0] : docList;
1414
1635
  }
1636
+ async includeDocsRead(docs, include) {
1637
+ const { model, path, localField, foreignField, filter: _filters, args = {}, options = {} } = include;
1638
+ const svc = await this.getAuthorizedTargetService(model, "read");
1639
+ for (let y = 0; y < docs.length; y++) {
1640
+ const doc = docs[y];
1641
+ const localValue = get4(doc, localField);
1642
+ const filter2 = { ..._filters ?? {}, [foreignField]: { $in: this.getForeignKeyValues(localValue) } };
1643
+ const trustedOptions = {
1644
+ ...options,
1645
+ access: "read",
1646
+ lean: true,
1647
+ includePermissions: false
1648
+ };
1649
+ const result = await svc.findOne(filter2, this.sanitizeIncludeArgs(args), trustedOptions);
1650
+ if (result.success) {
1651
+ setDocValue(doc, path, result.data);
1652
+ }
1653
+ }
1654
+ return docs;
1655
+ }
1415
1656
  async includeDocsList(docs, include) {
1416
1657
  const { model, op, path, localField, foreignField, filter: _filters, args = {}, options = {} } = include;
1417
1658
  const svc = await this.getAuthorizedTargetService(model, op);
1418
1659
  const includeLocalValues = [];
1419
- forEach3(docs, (doc, i) => {
1420
- includeLocalValues.push(get4(doc, localField));
1660
+ forEach3(docs, (doc) => {
1661
+ includeLocalValues.push(...this.getForeignKeyValues(get4(doc, localField)));
1421
1662
  });
1422
1663
  const filter2 = { ..._filters ?? {}, [foreignField]: { $in: flatten(includeLocalValues) } };
1423
1664
  const authorizedFilter = await svc.genFilter(op, filter2);
1424
1665
  const trustedArgs = {
1425
- ...args,
1666
+ ...this.sanitizeIncludeArgs(args),
1426
1667
  overrides: {
1427
1668
  filter: authorizedFilter
1428
1669
  }
@@ -1435,58 +1676,42 @@ var Base = class {
1435
1676
  };
1436
1677
  const trustedResult = await svc.find(filter2, trustedArgs, trustedOptions);
1437
1678
  if (!trustedResult.success) return docs;
1679
+ const index = this.buildForeignKeyIndex(trustedResult.data, foreignField);
1438
1680
  for (let y = 0; y < docs.length; y++) {
1439
1681
  const doc = docs[y];
1440
- const localValue = get4(doc, localField);
1441
- const filterFn = (row) => intersectionBy(castArray(localValue), castArray(get4(row, foreignField)), String).length > 0;
1442
- const matches = trustedResult.data.filter(filterFn);
1682
+ const matches = this.getIndexedMatches(index, get4(doc, localField));
1443
1683
  setDocValue(doc, path, op === "list" ? matches : matches[0]);
1444
1684
  }
1445
1685
  return docs;
1446
1686
  }
1447
1687
  async includeDocsCount(docs, include) {
1448
- const { model, path, localField, foreignField, filter: _filters, args = {}, options = {} } = include;
1688
+ const { model, path, localField, foreignField, filter: _filters } = include;
1449
1689
  const svc = await this.getAuthorizedTargetService(model, "count");
1450
- const includeLocalValues = [];
1451
- forEach3(docs, (doc) => {
1452
- includeLocalValues.push(get4(doc, localField));
1453
- });
1454
- const filter2 = { ..._filters ?? {}, [foreignField]: { $in: flatten(includeLocalValues) } };
1455
- const authorizedFilter = await svc.genFilter("list", filter2);
1456
- const trustedArgs = {
1457
- ...args,
1458
- select: [foreignField],
1459
- overrides: {
1460
- filter: authorizedFilter
1461
- }
1462
- };
1463
- const trustedOptions = {
1464
- ...options,
1465
- lean: true,
1466
- includePermissions: false,
1467
- includeCount: false
1468
- };
1469
- const result = await svc.find(filter2, trustedArgs, trustedOptions);
1690
+ const localValues = docs.flatMap((doc) => this.getForeignKeyValues(get4(doc, localField)));
1691
+ const result = await svc.countByFieldValues(foreignField, localValues, _filters ?? {}, "count");
1470
1692
  if (!result.success) return docs;
1471
1693
  for (let y = 0; y < docs.length; y++) {
1472
1694
  const doc = docs[y];
1473
- const localValue = get4(doc, localField);
1474
- const filterFn = (row) => intersectionBy(castArray(localValue), castArray(get4(row, foreignField)), String).length > 0;
1475
- setDocValue(doc, path, result.data.filter(filterFn).length);
1695
+ setDocValue(doc, path, this.getIndexedCount(result.data, get4(doc, localField)));
1476
1696
  }
1477
1697
  return docs;
1478
1698
  }
1479
- async parseClientData(filter2) {
1480
- const result = await iterateQuery(filter2, async (fo, val, key) => {
1481
- switch (fo) {
1482
- case 0 /* SubQuery */:
1483
- return this.handleSubQuery(val, key);
1484
- case 1 /* Date */:
1485
- return this.handleDate(val, key);
1486
- default:
1487
- return null;
1488
- }
1489
- });
1699
+ async parseClientData(filter2, scheduler = new RequestConcurrencyScheduler(this.getRequestComplexity().maxBulkConcurrency), scheduled = false) {
1700
+ const result = await iterateQuery(
1701
+ filter2,
1702
+ async (fo, val, key) => {
1703
+ switch (fo) {
1704
+ case 0 /* SubQuery */:
1705
+ return this.handleSubQuery(val, key);
1706
+ case 1 /* Date */:
1707
+ return this.handleDate(val, key);
1708
+ default:
1709
+ return null;
1710
+ }
1711
+ },
1712
+ scheduler,
1713
+ scheduled
1714
+ );
1490
1715
  return result;
1491
1716
  }
1492
1717
  async handleSubQuery(sq, key) {
@@ -1535,14 +1760,74 @@ import {
1535
1760
  isBoolean as isBoolean3,
1536
1761
  isFunction as isFunction3,
1537
1762
  omit,
1538
- pick as pick4,
1763
+ pick as pick3,
1539
1764
  uniq as uniq2,
1540
1765
  uniqBy
1541
1766
  } from "@web-ts-toolkit/utils";
1542
1767
  import { diff } from "just-diff";
1543
1768
 
1544
1769
  // src/model.ts
1545
- import mongoose2 from "mongoose";
1770
+ var Model = class {
1771
+ constructor(modelName, runtime) {
1772
+ this.modelName = modelName;
1773
+ this.runtime = runtime ?? null;
1774
+ const resolvedRuntime = runtime ?? getActiveRuntime() ?? defaultRuntime;
1775
+ const registered = resolvedRuntime.getModelInstance(modelName);
1776
+ if (!registered) {
1777
+ throw new Error(
1778
+ `Runtime model registry missing model "${modelName}". Pass a mongoose.Model instance to createRouter() or register it with registerModelInstance() before using this runtime.`
1779
+ );
1780
+ }
1781
+ this.mongooseModel = registered;
1782
+ if (!this.mongooseModel) return;
1783
+ }
1784
+ new() {
1785
+ const doc = new this.mongooseModel();
1786
+ return doc;
1787
+ }
1788
+ create(data) {
1789
+ return this.mongooseModel.create(data);
1790
+ }
1791
+ find({ filter: filter2, select, sort, populate, limit, hardLimit, skip, lean }) {
1792
+ const builder = this.mongooseModel.find(filter2);
1793
+ if (select) builder.select(select);
1794
+ if (skip) builder.skip(Number(skip));
1795
+ const normalizedLimit = Number(limit);
1796
+ if (Number.isSafeInteger(normalizedLimit) && normalizedLimit > 0) {
1797
+ builder.limit(normalizedLimit);
1798
+ } else if (Number.isSafeInteger(hardLimit) && Number(hardLimit) > 0) {
1799
+ builder.limit(Number(hardLimit));
1800
+ }
1801
+ if (sort) builder.sort(sort);
1802
+ if (populate) builder.populate(populate);
1803
+ if (lean) builder.lean();
1804
+ return builder;
1805
+ }
1806
+ findOne({ filter: filter2, select, sort, populate, lean }) {
1807
+ const builder = this.mongooseModel.findOne(filter2);
1808
+ if (select) builder.select(select);
1809
+ if (sort) builder.sort(sort);
1810
+ if (populate) builder.populate(populate);
1811
+ if (lean) builder.lean();
1812
+ return builder;
1813
+ }
1814
+ exists(filter2) {
1815
+ if (!filter2) return null;
1816
+ return this.findOne({ filter: filter2 }).select("_id").lean();
1817
+ }
1818
+ // see https://mongoosejs.com/docs/api.html#query_Query-countDocuments
1819
+ countDocuments(filter2 = {}) {
1820
+ return this.mongooseModel.countDocuments(filter2);
1821
+ }
1822
+ // see https://mongoosejs.com/docs/api.html#model_Model.distinct
1823
+ distinct(field, conditions = {}) {
1824
+ return this.mongooseModel.distinct(field, conditions);
1825
+ }
1826
+ aggregate(pipeline) {
1827
+ return this.mongooseModel.aggregate(pipeline);
1828
+ }
1829
+ };
1830
+ var model_default = Model;
1546
1831
 
1547
1832
  // src/logger-helpers.ts
1548
1833
  var SENSITIVE_FILTER_KEYS = /* @__PURE__ */ new Set([
@@ -1721,138 +2006,8 @@ var error = (error2) => {
1721
2006
  safeApply(fn, target ?? defaultLogger, [error2]);
1722
2007
  };
1723
2008
 
1724
- // src/model.ts
1725
- var safeSortLogError = (msg, ...args) => {
1726
- const message = args.length > 0 ? `${msg} ${safeStringify(args.length === 1 ? args[0] : args)}` : msg;
1727
- error(message);
1728
- };
1729
- var Model = class {
1730
- constructor(modelName, runtime) {
1731
- this.modelName = modelName;
1732
- this.runtime = runtime ?? null;
1733
- const resolvedRuntime = runtime ?? getActiveRuntime();
1734
- const registered = resolvedRuntime?.getModelInstance(modelName) ?? null;
1735
- const global = mongoose2.models[modelName];
1736
- this.model = registered ?? global ?? mongoose2.model(modelName);
1737
- if (!this.model) return;
1738
- }
1739
- new() {
1740
- const doc = new this.model();
1741
- return doc;
1742
- }
1743
- create(data) {
1744
- return this.model.create(data);
1745
- }
1746
- find({ filter: filter2, select, sort, populate, limit, hardLimit, skip, lean }) {
1747
- if (!this.validateSort(sort)) {
1748
- sort = null;
1749
- }
1750
- const builder = this.model.find(filter2);
1751
- if (select) builder.select(select);
1752
- if (skip) builder.skip(Number(skip));
1753
- const normalizedLimit = Number(limit);
1754
- if (Number.isSafeInteger(normalizedLimit) && normalizedLimit > 0) {
1755
- builder.limit(normalizedLimit);
1756
- } else if (Number.isSafeInteger(hardLimit) && Number(hardLimit) > 0) {
1757
- builder.limit(Number(hardLimit));
1758
- }
1759
- if (sort) builder.sort(sort);
1760
- if (populate) builder.populate(populate);
1761
- if (lean) builder.lean();
1762
- return builder;
1763
- }
1764
- validateSort(sort, logError = safeSortLogError) {
1765
- const validSortOrders = [1, -1, "asc", "ascending", "desc", "descending"];
1766
- const fieldPathPattern = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
1767
- const isValidFieldPath = (field) => fieldPathPattern.test(field);
1768
- if (sort === null || sort === void 0) return true;
1769
- if (typeof sort === "string") {
1770
- const fields = sort.trim().split(/\s+/).filter(Boolean);
1771
- const isValid = fields.every((field) => isValidFieldPath(field.startsWith("-") ? field.slice(1) : field));
1772
- if (!isValid) {
1773
- logError("Invalid sort string:", sort);
1774
- return false;
1775
- }
1776
- return true;
1777
- }
1778
- if (Array.isArray(sort)) {
1779
- const isValid = sort.every((pair) => {
1780
- if (!Array.isArray(pair) || pair.length !== 2) {
1781
- logError("Invalid sort array element: must be [key, order]", pair);
1782
- return false;
1783
- }
1784
- const [key, order] = pair;
1785
- if (typeof key !== "string" || !isValidFieldPath(key)) {
1786
- logError("Invalid sort array key: must be a field path string", key);
1787
- return false;
1788
- }
1789
- if (!validSortOrders.includes(order)) {
1790
- logError('Invalid sort array order: must be 1, -1, "asc", or "desc"', order);
1791
- return false;
1792
- }
1793
- return true;
1794
- });
1795
- return isValid;
1796
- }
1797
- if (typeof sort === "object" && !(sort instanceof Map)) {
1798
- const isValid = Object.entries(sort).every(([key, order]) => {
1799
- if (!isValidFieldPath(key)) {
1800
- logError("Invalid sort object key: must be a field path string", key);
1801
- return false;
1802
- }
1803
- if (!validSortOrders.includes(order)) {
1804
- logError('Invalid sort object value: must be 1, -1, "asc", or "desc"', order);
1805
- return false;
1806
- }
1807
- return true;
1808
- });
1809
- return isValid;
1810
- }
1811
- if (sort instanceof Map) {
1812
- const isValid = Array.from(sort.entries()).every(([key, order]) => {
1813
- if (!isValidFieldPath(key)) {
1814
- logError("Invalid sort Map key: must be a field path string", key);
1815
- return false;
1816
- }
1817
- if (!validSortOrders.includes(order)) {
1818
- logError('Invalid sort Map value: must be 1, -1, "asc", or "desc"', order);
1819
- return false;
1820
- }
1821
- return true;
1822
- });
1823
- return isValid;
1824
- }
1825
- logError("Invalid sort type: must be string, array, object, or Map", sort);
1826
- return false;
1827
- }
1828
- findOne({ filter: filter2, select, sort, populate, lean }) {
1829
- if (!this.validateSort(sort)) {
1830
- sort = null;
1831
- }
1832
- const builder = this.model.findOne(filter2);
1833
- if (select) builder.select(select);
1834
- if (sort) builder.sort(sort);
1835
- if (populate) builder.populate(populate);
1836
- if (lean) builder.lean();
1837
- return builder;
1838
- }
1839
- exists(filter2) {
1840
- if (!filter2) return null;
1841
- return this.findOne({ filter: filter2 }).select("_id").lean();
1842
- }
1843
- // see https://mongoosejs.com/docs/api.html#query_Query-countDocuments
1844
- countDocuments(filter2 = {}) {
1845
- return this.model.countDocuments(filter2);
1846
- }
1847
- // see https://mongoosejs.com/docs/api.html#model_Model.distinct
1848
- distinct(field, conditions = {}) {
1849
- return this.model.distinct(field, conditions);
1850
- }
1851
- };
1852
- var model_default = Model;
1853
-
1854
2009
  // src/services/model-subdocument-service.ts
1855
- import { forEach as forEach4, get as get5, pick as pick3 } from "@web-ts-toolkit/utils";
2010
+ import { forEach as forEach4, get as get5, pick as pick2 } from "@web-ts-toolkit/utils";
1856
2011
  async function listSub(service, id, sub, options) {
1857
2012
  const { filter: ft, select } = options ?? {};
1858
2013
  const filterErrors = validateClientFilter(ft);
@@ -1866,7 +2021,7 @@ async function listSub(service, id, sub, options) {
1866
2021
  ]);
1867
2022
  if (subFilter === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */ };
1868
2023
  result = filterCollection(result, subFilter);
1869
- if (subSelect) result = result.map((v) => pick3(toObject(v), subSelect.concat("_id")));
2024
+ if (subSelect) result = result.map((v) => pick2(toObject(v), subSelect.concat("_id")));
1870
2025
  return { success: true, kind: "list", code: "success" /* Success */, data: result, count: result.length };
1871
2026
  }
1872
2027
  async function readSub(service, id, sub, subId, options) {
@@ -1881,7 +2036,7 @@ async function readSub(service, id, sub, subId, options) {
1881
2036
  if (subFilter === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */ };
1882
2037
  let subdoc = findElement(result, subFilter);
1883
2038
  if (!subdoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
1884
- if (subSelect) subdoc = pick3(toObject(subdoc), subSelect.concat(["_id"]));
2039
+ if (subSelect) subdoc = pick2(toObject(subdoc), subSelect.concat(["_id"]));
1885
2040
  return { success: true, kind: "single", code: "success" /* Success */, data: subdoc };
1886
2041
  }
1887
2042
  async function updateSub(service, id, sub, subId, data) {
@@ -1896,10 +2051,10 @@ async function updateSub(service, id, sub, subId, data) {
1896
2051
  if (subFilter === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */ };
1897
2052
  let subdoc = findElement(result, subFilter);
1898
2053
  if (!subdoc) return { success: false, kind: "error", code: "not_found" /* NotFound */ };
1899
- const allowedData = pick3(data, subUpdateSelect);
2054
+ const allowedData = pick2(data, subUpdateSelect);
1900
2055
  Object.assign(subdoc, allowedData);
1901
2056
  await parentDoc.save();
1902
- if (subReadSelect) subdoc = pick3(toObject(subdoc), subReadSelect.concat(["_id"]));
2057
+ if (subReadSelect) subdoc = pick2(toObject(subdoc), subReadSelect.concat(["_id"]));
1903
2058
  return { success: true, kind: "single", code: "success" /* Success */, data: subdoc };
1904
2059
  }
1905
2060
  async function bulkUpdateSub(service, id, sub, data) {
@@ -1925,11 +2080,11 @@ async function bulkUpdateSub(service, id, sub, data) {
1925
2080
  forEach4(result, (subdoc) => {
1926
2081
  const tdata = findElementById(data, subdoc._id);
1927
2082
  if (!tdata) return;
1928
- const allowedData = pick3(tdata, subUpdateSelect);
2083
+ const allowedData = pick2(tdata, subUpdateSelect);
1929
2084
  Object.assign(subdoc, allowedData);
1930
2085
  });
1931
2086
  await parentDoc.save();
1932
- if (subReadSelect) result = result.map((v) => pick3(toObject(v), subReadSelect.concat(["_id"])));
2087
+ if (subReadSelect) result = result.map((v) => pick2(toObject(v), subReadSelect.concat(["_id"])));
1933
2088
  return { success: true, kind: "list", code: "success" /* Success */, data: result, count: result.length };
1934
2089
  }
1935
2090
  async function createSub(service, id, sub, data, options) {
@@ -1950,14 +2105,14 @@ async function createSub(service, id, sub, data, options) {
1950
2105
  service.genQuerySelect("create", null, false, [sub, "sub"]),
1951
2106
  service.genQuerySelect("read", null, false, [sub, "sub"])
1952
2107
  ]);
1953
- const allowedData = Array.isArray(data) ? data.map((row) => pick3(row, subCreateSelect)) : pick3(data, subCreateSelect);
2108
+ const allowedData = Array.isArray(data) ? data.map((row) => pick2(row, subCreateSelect)) : pick2(data, subCreateSelect);
1954
2109
  if (Array.isArray(allowedData)) {
1955
2110
  addFirst === true ? result.unshift(...allowedData) : result.push(...allowedData);
1956
2111
  } else {
1957
2112
  addFirst === true ? result.unshift(allowedData) : result.push(allowedData);
1958
2113
  }
1959
2114
  await parentDoc.save();
1960
- if (subReadSelect) result = result.map((v) => pick3(toObject(v), subReadSelect.concat(["_id"])));
2115
+ if (subReadSelect) result = result.map((v) => pick2(toObject(v), subReadSelect.concat(["_id"])));
1961
2116
  return { success: true, kind: "list", code: "created" /* Created */, data: result, count: result.length };
1962
2117
  }
1963
2118
  async function deleteSub(service, id, sub, subId) {
@@ -1977,7 +2132,8 @@ async function getParentDoc(service, id, sub, args, options) {
1977
2132
  const { access = "read", lean = false } = options ?? {};
1978
2133
  const parentFilter = await service.genFilter(access, await service.genIDFilter(id));
1979
2134
  if (parentFilter === false) return null;
1980
- return service.findRawParentDoc({ filter: parentFilter, select: sub, populate: genSubPopulate(sub, populate), lean });
2135
+ const subPopulate = await service.genPopulate("read", genSubPopulate(sub, populate), [sub, "sub"]);
2136
+ return service.findRawParentDoc({ filter: parentFilter, select: sub, populate: subPopulate, lean });
1981
2137
  }
1982
2138
 
1983
2139
  // src/services/model-service-defaults.ts
@@ -2103,21 +2259,6 @@ var assertModelDocument = (value, modelName, hookName) => {
2103
2259
  }
2104
2260
  throw new Error(`${hookName} hook for model=${modelName} must return a Mongoose document instance`);
2105
2261
  };
2106
- var mapWithConcurrencyLimit = async (items, limit, iteratee) => {
2107
- const results = new Array(items.length);
2108
- let cursor = 0;
2109
- const workerCount = Math.min(Math.max(limit, 1), items.length || 1);
2110
- await Promise.all(
2111
- Array.from({ length: workerCount }, async () => {
2112
- while (cursor < items.length) {
2113
- const current = cursor;
2114
- cursor += 1;
2115
- results[current] = await iteratee(items[current], current);
2116
- }
2117
- })
2118
- );
2119
- return results;
2120
- };
2121
2262
  var Service = class extends Base {
2122
2263
  findRawParentDoc(args) {
2123
2264
  return this.model.findOne({
@@ -2126,6 +2267,12 @@ var Service = class extends Base {
2126
2267
  populate: args.populate
2127
2268
  });
2128
2269
  }
2270
+ createModelAdapter(modelName) {
2271
+ return new model_default(modelName);
2272
+ }
2273
+ getModelRouterOptions(modelName) {
2274
+ return getModelOptions(modelName);
2275
+ }
2129
2276
  asServiceHookContext(context) {
2130
2277
  return context;
2131
2278
  }
@@ -2152,8 +2299,8 @@ var Service = class extends Base {
2152
2299
  }
2153
2300
  constructor(req, modelName) {
2154
2301
  super(req, modelName);
2155
- this.model = new model_default(modelName);
2156
- this.options = getModelOptions(modelName);
2302
+ this.model = this.createModelAdapter(modelName);
2303
+ this.options = this.getModelRouterOptions(modelName);
2157
2304
  this.defaults = this.options.defaults || {};
2158
2305
  this.baseFields = ["_id"];
2159
2306
  this.baseFieldsExt = this.baseFields.concat(this.options.documentPermissionField);
@@ -2172,10 +2319,11 @@ var Service = class extends Base {
2172
2319
  if (result) return result;
2173
2320
  throw error2;
2174
2321
  }
2175
- let [_filter, _select, _populate] = await Promise.all([
2322
+ let [_filter, _select, _populate, allowedSortFields] = await Promise.all([
2176
2323
  overrideFilter || this.genFilter(access, parsedFilter),
2177
2324
  overrideSelect || this.genQuerySelect(access, select),
2178
- overridePopulate || this.genPopulate(populateAccess || access, populate)
2325
+ overridePopulate || this.genPopulate(populateAccess || access, populate),
2326
+ this.genAllowedFields({}, access, this.baseFieldsExt)
2179
2327
  ]);
2180
2328
  const { includes, includeLocalFields, includePaths } = this.processInclude(include);
2181
2329
  const finalSelect = normalizeSelect(_select).concat(includeLocalFields);
@@ -2194,13 +2342,18 @@ var Service = class extends Base {
2194
2342
  this.completeOp("findOne", startedAt, "forbidden" /* Forbidden */, _filter);
2195
2343
  return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
2196
2344
  }
2345
+ const sortErrors = validateSortFields(sort, allowedSortFields);
2346
+ if (sortErrors.length > 0) {
2347
+ this.completeOp("findOne", startedAt, "bad_request" /* BadRequest */, _filter);
2348
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortErrors, query };
2349
+ }
2197
2350
  let doc = await this.model.findOne({ ...query, lean });
2198
2351
  if (!doc) {
2199
2352
  this.completeOp("findOne", startedAt, "not_found" /* NotFound */, _filter);
2200
2353
  return { success: false, kind: "error", code: "not_found" /* NotFound */, query };
2201
2354
  }
2202
2355
  const context = {
2203
- mongooseModel: this.model.model,
2356
+ mongooseModel: this.model.mongooseModel,
2204
2357
  modelName: this.modelName,
2205
2358
  operation: access,
2206
2359
  originalDocumentSnapshot: toObject(doc),
@@ -2264,11 +2417,12 @@ var Service = class extends Base {
2264
2417
  if (result) return result;
2265
2418
  throw error2;
2266
2419
  }
2267
- const [_filter, _select, _populate, pagination] = await Promise.all([
2420
+ const [_filter, _select, _populate, pagination, allowedSortFields] = await Promise.all([
2268
2421
  overrideFilter || this.genFilter("list", parsedFilter),
2269
2422
  overrideSelect || this.genQuerySelect("list", select),
2270
2423
  overridePopulate || this.genPopulate(populateAccess, populate),
2271
- genPagination({ skip, limit, page, pageSize }, this.options.listHardLimit)
2424
+ genPagination({ skip, limit, page, pageSize }, this.options.listHardLimit),
2425
+ this.genAllowedFields({}, "list", this.baseFieldsExt)
2272
2426
  ]);
2273
2427
  const finalSelect = normalizeSelect(_select);
2274
2428
  const filteredPopulate = isArray7(finalSelect) && isArray7(_populate) ? _populate.filter((p) => finalSelect.includes(p.path.split(".")[0])) : _populate;
@@ -2291,13 +2445,18 @@ var Service = class extends Base {
2291
2445
  this.completeOp("find", startedAt, "forbidden" /* Forbidden */, _filter);
2292
2446
  return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
2293
2447
  }
2448
+ const sortErrors = validateSortFields(sort, allowedSortFields);
2449
+ if (sortErrors.length > 0) {
2450
+ this.completeOp("find", startedAt, "bad_request" /* BadRequest */, _filter);
2451
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortErrors, query };
2452
+ }
2294
2453
  let docs = await this.model.find({
2295
2454
  ...query,
2296
2455
  hardLimit: this.options.listHardLimit,
2297
2456
  lean
2298
2457
  });
2299
2458
  const contexts = docs.map((doc) => ({
2300
- mongooseModel: this.model.model,
2459
+ mongooseModel: this.model.mongooseModel,
2301
2460
  modelName: this.modelName,
2302
2461
  operation: "list",
2303
2462
  originalDocumentSnapshot: toObject(doc),
@@ -2360,8 +2519,33 @@ var Service = class extends Base {
2360
2519
  errors: [{ detail: `Bulk create exceeds maximum item count of ${maxBulkItems}` }]
2361
2520
  };
2362
2521
  }
2522
+ const parseErrors = [];
2523
+ const parseScheduler = new RequestConcurrencyScheduler(maxBulkConcurrency);
2363
2524
  try {
2364
- dataArr = await Promise.all(dataArr.map((d) => this.parseClientData(d)));
2525
+ const parsedData = await parseScheduler.map(dataArr, async (d, index) => {
2526
+ try {
2527
+ return await this.parseClientData(d, parseScheduler, true);
2528
+ } catch (error2) {
2529
+ const result = this.getClientRequestErrorResult(error2);
2530
+ if (!result) throw error2;
2531
+ parseErrors.push({
2532
+ index,
2533
+ code: result.code,
2534
+ errors: (result.errors ?? []).map((issue) => this.formatBulkValidationIssue(issue, isArr ? index : null))
2535
+ });
2536
+ return void 0;
2537
+ }
2538
+ });
2539
+ if (parseErrors.length > 0) {
2540
+ const sortedErrors = parseErrors.slice().sort((a, b) => a.index - b.index);
2541
+ return {
2542
+ success: false,
2543
+ kind: "error",
2544
+ code: sortedErrors[0]?.code ?? "bad_request" /* BadRequest */,
2545
+ errors: sortedErrors.flatMap((entry) => entry.errors)
2546
+ };
2547
+ }
2548
+ dataArr = parsedData;
2365
2549
  } catch (error2) {
2366
2550
  const result = this.getClientRequestErrorResult(error2);
2367
2551
  if (result) return result;
@@ -2372,14 +2556,14 @@ var Service = class extends Base {
2372
2556
  const validationErrors = [];
2373
2557
  const validationItems = await mapWithConcurrencyLimit(dataArr, maxBulkConcurrency, async (item, index) => {
2374
2558
  const context = {
2375
- mongooseModel: this.model.model,
2559
+ mongooseModel: this.model.mongooseModel,
2376
2560
  modelName: this.modelName,
2377
2561
  operation: "create",
2378
2562
  originalData: item,
2379
2563
  resolvedQuery: resolvedPopulate.length > 0 ? { populate: resolvedPopulate } : {}
2380
2564
  };
2381
2565
  const allowedFields = await this.genAllowedFields(item, "create");
2382
- const allowedData = pick4(item, allowedFields);
2566
+ const allowedData = pick3(item, allowedFields);
2383
2567
  context.allowedFields = allowedFields;
2384
2568
  context.allowedData = allowedData;
2385
2569
  const validated = await this.validate(allowedData, "create", context);
@@ -2515,7 +2699,7 @@ var Service = class extends Base {
2515
2699
  return { success: false, kind: "error", code: "not_found" /* NotFound */, query };
2516
2700
  }
2517
2701
  const context = {
2518
- mongooseModel: this.model.model,
2702
+ mongooseModel: this.model.mongooseModel,
2519
2703
  modelName: this.modelName,
2520
2704
  operation: "update",
2521
2705
  resolvedQuery: query
@@ -2536,7 +2720,7 @@ var Service = class extends Base {
2536
2720
  context.docPermissions = this.getDocPermissions(doc);
2537
2721
  context.currentDocument = doc;
2538
2722
  const allowedFields = await this.genAllowedFields(doc, "update");
2539
- const allowedData = pick4(data, allowedFields);
2723
+ const allowedData = pick3(data, allowedFields);
2540
2724
  context.allowedFields = allowedFields;
2541
2725
  context.allowedData = allowedData;
2542
2726
  const validated = await this.validate(allowedData, "update", context);
@@ -2661,7 +2845,7 @@ var Service = class extends Base {
2661
2845
  return { success: false, kind: "error", code: "not_found" /* NotFound */, query };
2662
2846
  }
2663
2847
  const context = {
2664
- mongooseModel: this.model.model,
2848
+ mongooseModel: this.model.mongooseModel,
2665
2849
  modelName: this.modelName,
2666
2850
  operation: "delete",
2667
2851
  originalDocumentSnapshot: toObject(doc),
@@ -2690,22 +2874,11 @@ var Service = class extends Base {
2690
2874
  };
2691
2875
  }
2692
2876
  isValidDistinctFieldName(field) {
2693
- if (typeof field !== "string" || field.length === 0) return false;
2694
- if (field.includes("$")) return false;
2695
- if (field.includes("..")) return false;
2696
- if (field.startsWith(".") || field.endsWith(".")) return false;
2697
- if (/\s/.test(field)) return false;
2698
- return true;
2877
+ return isValidFieldPath(field);
2699
2878
  }
2700
2879
  async authorizeDistinctField(field) {
2701
2880
  const allowedFields = await this.genAllowedFields(null, "read");
2702
- const isAllowed = allowedFields.some((allowed) => {
2703
- if (allowed === field) return true;
2704
- if (allowed.startsWith(`${field}.`)) return true;
2705
- if (field.startsWith(`${allowed}.`)) return true;
2706
- return false;
2707
- });
2708
- if (!isAllowed) {
2881
+ if (!isFieldAllowed(field, allowedFields)) {
2709
2882
  return {
2710
2883
  success: false,
2711
2884
  kind: "error",
@@ -2743,6 +2916,44 @@ var Service = class extends Base {
2743
2916
  if (filter2 === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
2744
2917
  return { success: true, kind: "single", code: "success" /* Success */, data: await this.model.countDocuments(filter2), query };
2745
2918
  }
2919
+ async countByFieldValues(foreignField, values, filter2 = {}, access = "list") {
2920
+ if (!isValidFieldPath(foreignField)) {
2921
+ return {
2922
+ success: false,
2923
+ kind: "error",
2924
+ code: "bad_request" /* BadRequest */,
2925
+ errors: [{ detail: `Invalid include foreignField: ${foreignField}` }]
2926
+ };
2927
+ }
2928
+ const filterErrors = this.validateClientFilter(filter2);
2929
+ if (filterErrors.length > 0) return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: filterErrors };
2930
+ const uniqueValues = uniqBy(values, (value) => String(value));
2931
+ filter2 = await this.genFilter(access, {
2932
+ ...filter2,
2933
+ [foreignField]: { $in: uniqueValues }
2934
+ });
2935
+ const query = { filter: filter2 };
2936
+ if (filter2 === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
2937
+ const matchFilter = filter2;
2938
+ const rows = await this.model.aggregate([
2939
+ { $match: matchFilter },
2940
+ {
2941
+ $project: {
2942
+ foreignValues: {
2943
+ $cond: [{ $isArray: `$${foreignField}` }, `$${foreignField}`, [`$${foreignField}`]]
2944
+ }
2945
+ }
2946
+ },
2947
+ { $unwind: "$foreignValues" },
2948
+ { $match: { foreignValues: { $in: uniqueValues } } },
2949
+ { $group: { _id: "$foreignValues", documentIds: { $addToSet: "$_id" } } }
2950
+ ]);
2951
+ const counts = /* @__PURE__ */ new Map();
2952
+ for (const row of rows) {
2953
+ counts.set(String(row._id), new Set(row.documentIds.map((id) => String(id))));
2954
+ }
2955
+ return { success: true, kind: "single", code: "success" /* Success */, data: counts, query };
2956
+ }
2746
2957
  getDocPermissions(doc) {
2747
2958
  return getDocPermissions(this.modelName, doc);
2748
2959
  }
@@ -2836,7 +3047,7 @@ var Service = class extends Base {
2836
3047
  };
2837
3048
 
2838
3049
  // src/services/public-service.ts
2839
- import { pick as pick5 } from "@web-ts-toolkit/utils";
3050
+ import { pick as pick4 } from "@web-ts-toolkit/utils";
2840
3051
  var PublicService = class extends Service {
2841
3052
  async _list(filter2, args, options) {
2842
3053
  const { select, populate, include, sort, skip, limit, page, pageSize, tasks } = this.resolvePublicListArgs(args);
@@ -2854,7 +3065,7 @@ var PublicService = class extends Service {
2854
3065
  return result;
2855
3066
  }
2856
3067
  const docs = await this.decorateAll(result.data, "list", {
2857
- mongooseModel: this.model.model,
3068
+ mongooseModel: this.model.mongooseModel,
2858
3069
  modelName: this.modelName,
2859
3070
  operation: "list",
2860
3071
  resolvedQuery: result.query
@@ -2875,7 +3086,7 @@ var PublicService = class extends Service {
2875
3086
  let d = toObject(doc);
2876
3087
  d = await this.decorate(d, "create", context);
2877
3088
  d = this.runTasks(d, tasks);
2878
- if (select) d = pick5(d, [...normalizeSelect(select), ...this.baseFieldsExt]);
3089
+ if (select) d = pick4(d, [...normalizeSelect(select), ...this.baseFieldsExt]);
2879
3090
  return d;
2880
3091
  }
2881
3092
  );
@@ -2903,12 +3114,17 @@ var PublicService = class extends Service {
2903
3114
  { skim, includePermissions, access, populateAccess, lean }
2904
3115
  );
2905
3116
  if (tryList && (!result.success || !result.data)) {
3117
+ const listAllowed = await this.req.macl.isAllowed(this.modelName, "list");
3118
+ if (!listAllowed) {
3119
+ return { success: false, kind: "error", code: "unauthorized" /* Unauthorized */, errors: ["Unauthorized"] };
3120
+ }
2906
3121
  access = "list";
2907
3122
  result = await this.findById(
2908
3123
  id,
2909
3124
  {
2910
3125
  select,
2911
3126
  populate,
3127
+ include,
2912
3128
  overrides: { idFilter }
2913
3129
  },
2914
3130
  { skim, includePermissions, access, populateAccess, lean }
@@ -2938,6 +3154,10 @@ var PublicService = class extends Service {
2938
3154
  { skim, includePermissions, access, populateAccess, lean }
2939
3155
  );
2940
3156
  if (tryList && (!result.success || !result.data)) {
3157
+ const listAllowed = await this.req.macl.isAllowed(this.modelName, "list");
3158
+ if (!listAllowed) {
3159
+ return { success: false, kind: "error", code: "unauthorized" /* Unauthorized */, errors: ["Unauthorized"] };
3160
+ }
2941
3161
  access = "list";
2942
3162
  result = await this.findOne(
2943
3163
  filter2,
@@ -2945,6 +3165,7 @@ var PublicService = class extends Service {
2945
3165
  select,
2946
3166
  sort,
2947
3167
  populate,
3168
+ include,
2948
3169
  overrides: {}
2949
3170
  },
2950
3171
  { skim, includePermissions, access, populateAccess, lean }
@@ -2970,8 +3191,8 @@ var PublicService = class extends Service {
2970
3191
  let d = toObject(doc);
2971
3192
  d = await this.decorate(d, "update", context);
2972
3193
  d = this.runTasks(d, tasks);
2973
- if (select) d = pick5(d, [...normalizeSelect(select), ...this.baseFieldsExt]);
2974
- else if (!returningAll) d = pick5(d, [...Object.keys(data), "_id"]);
3194
+ if (select) d = pick4(d, [...normalizeSelect(select), ...this.baseFieldsExt]);
3195
+ else if (!returningAll) d = pick4(d, [...Object.keys(data), "_id"]);
2975
3196
  return d;
2976
3197
  }
2977
3198
  );
@@ -3093,7 +3314,7 @@ var PublicService = class extends Service {
3093
3314
  };
3094
3315
 
3095
3316
  // src/services/data-service.ts
3096
- import { orderBy, pick as pick6 } from "@web-ts-toolkit/utils";
3317
+ import { orderBy, pick as pick5 } from "@web-ts-toolkit/utils";
3097
3318
  var DataService = class {
3098
3319
  constructor(req, dataName) {
3099
3320
  this.req = req;
@@ -3115,7 +3336,7 @@ var DataService = class {
3115
3336
  let doc = await findElement(this.data, _filter);
3116
3337
  if (!doc) return { success: false, kind: "error", code: "not_found" /* NotFound */, query };
3117
3338
  doc = await this.trimOutputFields(doc, access);
3118
- if (_select.length > 0) doc = pick6(doc, _select);
3339
+ if (_select.length > 0) doc = pick5(doc, _select);
3119
3340
  return { success: true, kind: "single", code: "success" /* Success */, data: doc, query };
3120
3341
  }
3121
3342
  async findById(id, args, options) {
@@ -3134,6 +3355,7 @@ var DataService = class {
3134
3355
  const filterErrors = validateClientFilter(filter2);
3135
3356
  if (filterErrors.length > 0) return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: filterErrors };
3136
3357
  const { select, sort, skip, limit, page, pageSize } = args ?? {};
3358
+ const hasExplicitLimit = limit != null || pageSize != null;
3137
3359
  const [_filter, _select, pagination] = await Promise.all([
3138
3360
  this.genFilter("list", filter2),
3139
3361
  this.genQuerySelect("list", select),
@@ -3146,26 +3368,26 @@ var DataService = class {
3146
3368
  ...pagination
3147
3369
  };
3148
3370
  if (_filter === false) return { success: false, kind: "error", code: "forbidden" /* Forbidden */, query };
3149
- if (sort) {
3150
- const sortFieldErrors = await this.validateSortFields(sort, "list");
3151
- if (sortFieldErrors.length > 0) {
3152
- return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortFieldErrors, query };
3153
- }
3371
+ const sortFieldErrors = validateSortFields(sort, await this.genAllowedFields({}, "list"));
3372
+ if (sortFieldErrors.length > 0) {
3373
+ return { success: false, kind: "error", code: "bad_request" /* BadRequest */, errors: sortFieldErrors, query };
3154
3374
  }
3155
3375
  let docs = await filterCollection(this.data, _filter);
3156
3376
  const totalCount = docs.length;
3157
3377
  if (sort) {
3158
- const { sortKey, sortOrder } = parseSortString(sort);
3159
- docs = orderBy(docs, [sortKey], [sortOrder]);
3378
+ const normalizedSort = normalizeSortForOrderBy(sort);
3379
+ docs = orderBy(docs, normalizedSort.fields, normalizedSort.orders);
3160
3380
  }
3161
3381
  const pagedDocs = docs.slice(query.skip, query.limit && query.skip + query.limit);
3162
- const trimmed = await Promise.all(
3163
- pagedDocs.map(async (doc) => {
3164
- doc = await this.trimOutputFields(doc, "list");
3165
- if (_select.length > 0) doc = pick6(doc, _select);
3166
- return doc;
3167
- })
3168
- );
3382
+ const { maxHookConcurrency } = this.getRequestComplexity();
3383
+ const trimmed = await mapWithConcurrencyLimit(pagedDocs, maxHookConcurrency, async (doc) => {
3384
+ doc = await this.trimOutputFields(doc, "list");
3385
+ if (_select.length > 0) doc = pick5(doc, _select);
3386
+ return doc;
3387
+ });
3388
+ if (!hasExplicitLimit && trimmed.length === totalCount) {
3389
+ query.limit = trimmed.length;
3390
+ }
3169
3391
  return {
3170
3392
  success: true,
3171
3393
  kind: "list",
@@ -3182,6 +3404,9 @@ var DataService = class {
3182
3404
  decorateAll(docs, access, context) {
3183
3405
  return this.req.dacl.decorateAll(this.dataName, docs, access, context);
3184
3406
  }
3407
+ getRequestComplexity() {
3408
+ return resolveRequestComplexity(getGlobalOption("requestComplexity"));
3409
+ }
3185
3410
  genAllowedFields(doc, access, baseFields) {
3186
3411
  return this.req.dacl.genAllowedFields(this.dataName, doc, access, baseFields);
3187
3412
  }
@@ -3203,30 +3428,10 @@ var DataService = class {
3203
3428
  trimOutputFields(doc, access, baseFields) {
3204
3429
  return this.pickAllowedFields(doc, access, baseFields);
3205
3430
  }
3206
- async validateSortFields(sort, access) {
3207
- const fieldPathPattern = /^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)*$/;
3208
- const fields = sort.trim().split(/\s+/).filter(Boolean).map((field) => ({
3209
- raw: field,
3210
- name: field.startsWith("-") ? field.slice(1) : field
3211
- }));
3212
- const errors = [];
3213
- for (const { raw, name } of fields) {
3214
- if (!fieldPathPattern.test(name)) {
3215
- errors.push({ detail: `Invalid sort field: ${raw}`, pointer: `#/sort` });
3216
- continue;
3217
- }
3218
- if (name === "id" || name === "_id") continue;
3219
- const allowed = await this.genAllowedFields({}, access);
3220
- if (!allowed.includes(name)) {
3221
- errors.push({ detail: `Sort field is not allowed: ${name}`, pointer: `#/sort` });
3222
- }
3223
- }
3224
- return errors;
3225
- }
3226
3431
  };
3227
3432
 
3228
3433
  // src/processors.ts
3229
- import { cloneDeep, forEach as forEach6, get as get7, isArray as isArray8, isPlainObject as isPlainObject12, map as map2, set as set5 } from "@web-ts-toolkit/utils";
3434
+ import { cloneDeep, forEach as forEach6, get as get7, isArray as isArray8, isPlainObject as isPlainObject12, map, set as set5 } from "@web-ts-toolkit/utils";
3230
3435
  var UNSAFE_PATH_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3231
3436
  function failUnsafeSegment(path) {
3232
3437
  throw new Error(
@@ -3270,7 +3475,7 @@ var copyAndDepopulate = (docObject, operations, options = { mutable: true, idFie
3270
3475
  if (leaf.length === 0) {
3271
3476
  depopulated = [];
3272
3477
  } else if (leaf.every((item) => isPlainObject12(item))) {
3273
- depopulated = map2(leaf, idField).map((id, i) => {
3478
+ depopulated = map(leaf, idField).map((id, i) => {
3274
3479
  if (id === void 0) {
3275
3480
  failMissingId(`${src}[${i}]`);
3276
3481
  }
@@ -3819,7 +4024,7 @@ var Core = class {
3819
4024
  alwaysSelectFields
3820
4025
  });
3821
4026
  }
3822
- async genPopulate(modelName, access = "read", _populate = null) {
4027
+ async genPopulate(modelName, access = "read", _populate = null, subPaths = []) {
3823
4028
  if (!_populate) return [];
3824
4029
  let populate = Array.isArray(_populate) ? _populate : [_populate];
3825
4030
  populate = compact3(
@@ -3830,12 +4035,14 @@ var Core = class {
3830
4035
  path: p.path,
3831
4036
  select: normalizeSelect(p.select)
3832
4037
  };
3833
- const parentPath = ret.path.includes(".") ? ret.path.split(".")[0] : ret.path;
4038
+ const pathForSourcePermission = subPaths.length > 0 && ret.path.startsWith(`${subPaths[0]}.`) ? ret.path.slice(subPaths[0].length + 1) : ret.path;
4039
+ const parentPath = pathForSourcePermission.includes(".") ? pathForSourcePermission.split(".")[0] : pathForSourcePermission;
3834
4040
  const allowedParentPaths = await this.genSelect(
3835
4041
  modelName,
3836
4042
  populateAccess,
3837
4043
  [parentPath],
3838
- false
4044
+ false,
4045
+ subPaths
3839
4046
  );
3840
4047
  if (!allowedParentPaths.includes(parentPath)) return null;
3841
4048
  const refModelName = getModelRef(modelName, ret.path);
@@ -4325,7 +4532,7 @@ var includeItemSchema = z2.object({
4325
4532
  filter: z2.record(z2.string(), z2.unknown()).optional(),
4326
4533
  localField: z2.string().min(1),
4327
4534
  foreignField: z2.string().min(1),
4328
- args: z2.unknown().optional(),
4535
+ args: z2.unknown().optional().describe("Forwarded to list/read includes. Count includes ignore pagination fields so counts remain exact."),
4329
4536
  options: z2.unknown().optional()
4330
4537
  }).passthrough();
4331
4538
  var includeSchema = z2.union([includeItemSchema, z2.array(includeItemSchema)]);
@@ -4862,7 +5069,7 @@ import { z as z4 } from "zod";
4862
5069
  var dataListBodySchema = z4.object({
4863
5070
  filter: objectOrArraySchema.optional(),
4864
5071
  select: projectionSchema.optional(),
4865
- sort: z4.string().optional(),
5072
+ sort: sortSchema.optional(),
4866
5073
  skip: z4.union([nonNegativeIntegerSchema, nonNegativeIntegerString]).optional(),
4867
5074
  limit: z4.union([positiveIntegerSchema, positiveIntegerString]).optional(),
4868
5075
  page: z4.union([nonNegativeIntegerSchema, nonNegativeIntegerString]).optional(),
@@ -4954,7 +5161,7 @@ var rootModelSubReadArgsSchema = z5.object({
4954
5161
  var rootModelCountOptionsSchema = z5.object({}).strict().optional();
4955
5162
  var rootDataListArgsSchema = z5.object({
4956
5163
  select: projectionSchema.optional(),
4957
- sort: z5.string().optional(),
5164
+ sort: sortSchema.optional(),
4958
5165
  skip: z5.union([nonNegativeIntegerSchema, nonNegativeIntegerString]).optional(),
4959
5166
  limit: z5.union([positiveIntegerSchema, positiveIntegerString]).optional(),
4960
5167
  page: z5.union([nonNegativeIntegerSchema, nonNegativeIntegerString]).optional(),
@@ -6194,7 +6401,12 @@ async function decorateDataListResult(svc, result, context = {}) {
6194
6401
  operation: "list",
6195
6402
  resolvedQuery: result.query
6196
6403
  };
6197
- const decoratedDocs = await Promise.all(result.data.map((doc) => svc.decorate(doc, "list", decorateContext)));
6404
+ const { maxHookConcurrency } = svc.getRequestComplexity();
6405
+ const decoratedDocs = await mapWithConcurrencyLimit(
6406
+ result.data,
6407
+ maxHookConcurrency,
6408
+ (doc) => svc.decorate(doc, "list", decorateContext)
6409
+ );
6198
6410
  const data = await svc.decorateAll(decoratedDocs, "list", decorateContext);
6199
6411
  return {
6200
6412
  ...result,
@@ -6562,7 +6774,7 @@ var DataRouter = class {
6562
6774
  const svc = this.getService(req);
6563
6775
  const result = await svc.find(
6564
6776
  filter2 ?? {},
6565
- { select, sort: typeof sort === "string" ? sort : void 0, skip, limit, page, pageSize },
6777
+ { select, sort, skip, limit, page, pageSize },
6566
6778
  { includeCount }
6567
6779
  );
6568
6780
  handleResultError(result);