@atscript/moost-db 0.1.147 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { i as resolveDbSpace, n as clearDbSpaces, r as provideDbSpace, t as DEFAULT_DB_SPACE } from "./db-space-registry-C_wft7kl.mjs";
1
+ import { a as resolveDbSpace, i as provideDbSpace, n as clearDbSpaces, r as closeDbSpaces, t as DEFAULT_DB_SPACE } from "./db-space-registry-Cdb0FGCn.mjs";
2
2
  import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
3
3
  import { Body, Delete, Get, HttpError, MoostHttp, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
4
4
  import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Pipe, Provide, Resolve, TInterceptorPriority, TPipePriority, defineBeforeInterceptor, defineInterceptor, definePipeFn, getMoostMate, useControllerContext, withControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
7
- import { cached, current, defineWook, key, useRouteParams } from "@wooksjs/event-core";
6
+ import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, groupSourceVerdict, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, numericOperandProblem, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
7
+ import { cached, current, defineWook, key, routeParamsKey, useRouteParams } from "@wooksjs/event-core";
8
8
  import { useBody } from "@wooksjs/http-body";
9
9
  import { BUCKET_UNITS } from "@uniqu/core";
10
10
  import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
@@ -70,16 +70,25 @@ function insightError(insights, message) {
70
70
  const entry = insightPaths.get(insights);
71
71
  return entry?.message === message ? badRequest(entry.path, message) : new HttpError(400, message);
72
72
  }
73
+ /**
74
+ * The 400 envelope of a `ValidatorError`; `undefined` for any other error.
75
+ * @internal Not part of the public API (not re-exported from the barrel).
76
+ */
77
+ function validatorErrorToHttp(error) {
78
+ return error instanceof ValidatorError ? errorEnvelope(400, error.message, error.errors) : void 0;
79
+ }
73
80
  //#endregion
74
81
  //#region src/validation-interceptor.ts
75
82
  const dbErrorCodeToStatus = {
76
83
  CONFLICT: 409,
77
84
  CAS_MISMATCH: 409,
78
85
  TX_WAIT_TIMEOUT: 503,
86
+ SPACE_CLOSED: 503,
79
87
  BUCKET_TZ_UNAVAILABLE: 501
80
88
  };
81
89
  function transformValidationError(error, reply) {
82
- if (error instanceof ValidatorError) reply(errorEnvelope(400, error.message, error.errors));
90
+ const validation = validatorErrorToHttp(error);
91
+ if (validation) reply(validation);
83
92
  else if (error instanceof DbError) reply(errorEnvelope(dbErrorCodeToStatus[error.code] ?? 400, error.message, error.errors));
84
93
  }
85
94
  const validationErrorTransform = () => defineInterceptor({ error: transformValidationError }, TInterceptorPriority.BEFORE_ALL);
@@ -514,6 +523,38 @@ function identityKey(id) {
514
523
  return idKey(id, Object.keys(id).toSorted());
515
524
  }
516
525
  /**
526
+ * `resolved` (index-aligned with `requested`) with duplicate identities
527
+ * collapsed to the first, plus the per-request list ({@link TAppliedIds}).
528
+ */
529
+ function applyResolvedIds(requested, resolved) {
530
+ const ids = [];
531
+ const requests = [];
532
+ const seen = /* @__PURE__ */ new Set();
533
+ let changed = false;
534
+ for (let i = 0; i < resolved.length; i++) {
535
+ const k = identityKey(resolved[i]);
536
+ if (k === void 0) {
537
+ ids.push(resolved[i]);
538
+ continue;
539
+ }
540
+ if (k !== identityKey(requested[i])) changed = true;
541
+ requests.push({
542
+ id: requested[i],
543
+ key: k
544
+ });
545
+ if (seen.has(k)) {
546
+ changed = true;
547
+ continue;
548
+ }
549
+ seen.add(k);
550
+ ids.push(resolved[i]);
551
+ }
552
+ return changed ? {
553
+ ids,
554
+ requests
555
+ } : { ids };
556
+ }
557
+ /**
517
558
  * The deduped identities of `rows` over `fields` — a value read by `read`
518
559
  * (default: the row's own field) — and, per row, its identity's index in
519
560
  * `ids` (`-1`: the row is absent, or a value is missing / null).
@@ -711,6 +752,10 @@ const ACTION_OVERLAY = Symbol.for("atscript-db.actionOverlay");
711
752
  const ACTION_SCOPE = Symbol.for("atscript-db.actionScope");
712
753
  /** `true` when the controller overrides `actionRowScope` (since 0.1.147). */
713
754
  const ACTION_SCOPED = Symbol.for("atscript-db.actionScoped");
755
+ /** The controller's internal `resolveRowIds` call for an action's ids (since 0.1.148). */
756
+ const ROW_RESOLVE_IDS = Symbol.for("atscript-db.resolveRowIds");
757
+ /** `true` when the controller overrides `resolveRowIds` (since 0.1.148). */
758
+ const ROW_RESOLVES = Symbol.for("atscript-db.rowResolves");
714
759
  /**
715
760
  * The controller whose hooks govern this action's rows: only when the action
716
761
  * runs against the controller's OWN readable — an `opts.table` binding on a
@@ -743,10 +788,6 @@ const dbActionOverlaySlot = cached(async (ctx) => {
743
788
  await awaitActionPrepared(ctx);
744
789
  return await overlayOf.call(ctrl) ?? null;
745
790
  });
746
- /** `true` when this action's controller overrides `actionRowScope`. */
747
- function isActionScoped(ctx) {
748
- return ctx.get(scopedControllerSlot)?.[ACTION_SCOPED] === true;
749
- }
750
791
  /**
751
792
  * The loaded `rows` (already inside the row overlay) with every row outside
752
793
  * the action's `actionRowScope` replaced by `undefined` (since 0.1.147). The
@@ -1179,6 +1220,26 @@ function isNonEmptyStringArray(value) {
1179
1220
  }
1180
1221
  //#endregion
1181
1222
  //#region src/meta/field-capabilities.ts
1223
+ /** The display-only refusal clause per position. */
1224
+ const DISPLAY_ONLY_POSITION = {
1225
+ filter: "a filter",
1226
+ sort: "$sort",
1227
+ groupedSelect: "a grouped $select",
1228
+ groupBy: "$groupBy",
1229
+ having: "$having",
1230
+ aggregate: "an aggregate",
1231
+ bucket: "a calendar bucket"
1232
+ };
1233
+ /** The capability of a declared decoration: selectable, nothing else. */
1234
+ const DECORATION_CAP = {
1235
+ filterable: false,
1236
+ sortable: false,
1237
+ selectable: true,
1238
+ indexed: false,
1239
+ bucketable: false,
1240
+ groupable: false,
1241
+ numeric: false
1242
+ };
1182
1243
  const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER_REASON}.`;
1183
1244
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1184
1245
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
@@ -1283,6 +1344,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1283
1344
  bucketUnits;
1284
1345
  /** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
1285
1346
  aggregateFns;
1347
+ /** Whether the adapter renders aggregate arithmetic (`/meta.aggregateExpressions`). */
1348
+ aggregateExpressions;
1286
1349
  /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1287
1350
  signature;
1288
1351
  /**
@@ -1292,9 +1355,24 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1292
1355
  * the index reads must be added here.
1293
1356
  */
1294
1357
  static adapterSignature(source) {
1295
- return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}`;
1358
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}|${source.supportsAggregateExpressions()}`;
1359
+ }
1360
+ /**
1361
+ * The navigation paths of a readable: its `navFields`, else its relation
1362
+ * names (partial readables list only the latter).
1363
+ */
1364
+ static navPathsOf(source) {
1365
+ const nav = new Set(source.navFields);
1366
+ if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1367
+ return nav;
1296
1368
  }
1297
1369
  _entries = /* @__PURE__ */ new Map();
1370
+ /**
1371
+ * Declared display-only decorations (`@DbDecorations`, since 0.1.148) —
1372
+ * virtual entries: key → the readable paths it `requires`. Selectable only;
1373
+ * visible while every required path is.
1374
+ */
1375
+ _decorations;
1298
1376
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1299
1377
  _objectParents = /* @__PURE__ */ new Map();
1300
1378
  /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
@@ -1307,7 +1385,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1307
1385
  get objectParents() {
1308
1386
  return this._objectParents;
1309
1387
  }
1310
- constructor(source, writeOnly) {
1388
+ constructor(source, writeOnly, decorations = /* @__PURE__ */ new Map()) {
1389
+ this._decorations = decorations;
1311
1390
  const tableMeta = source.type.metadata;
1312
1391
  this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
1313
1392
  this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
@@ -1317,6 +1396,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1317
1396
  this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
1318
1397
  const fns = source.aggregateFns();
1319
1398
  this.aggregateFns = [...ALL_AGGREGATE_FNS].filter((fn) => fns.has(fn));
1399
+ this.aggregateExpressions = source.supportsAggregateExpressions();
1320
1400
  const physicalNames = /* @__PURE__ */ new Set();
1321
1401
  const jsonValueParents = /* @__PURE__ */ new Set();
1322
1402
  for (const fd of source.fieldDescriptors) {
@@ -1329,8 +1409,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1329
1409
  dimensions: source.dimensions,
1330
1410
  measures: source.measures
1331
1411
  };
1332
- const nav = new Set(source.navFields);
1333
- if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1412
+ const nav = FieldCapabilityIndex.navPathsOf(source);
1334
1413
  this.navFields = nav;
1335
1414
  const isNavOrDescendant = (path) => selfOrAncestor(path, nav) !== void 0;
1336
1415
  const flatMap = source.flatMap;
@@ -1394,13 +1473,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1394
1473
  if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1395
1474
  const bucket = isWriteOnly ? void 0 : bucketSourceVerdict(fd, this._bucketTable, source);
1396
1475
  const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
1476
+ const group = isWriteOnly ? void 0 : groupSourceVerdict(fd, this._bucketTable, source);
1477
+ const groupReason = !group ? REASON_WRITE_ONLY : group.ok ? void 0 : `${group.reason}.`;
1397
1478
  const cap = {
1398
1479
  filterable: filterBy.compare === void 0,
1399
1480
  sortable: sortReason === void 0,
1400
1481
  selectable: true,
1401
1482
  indexed: fd.isIndexed === true,
1402
- bucketable: bucketReason === void 0
1483
+ bucketable: bucketReason === void 0,
1484
+ groupable: groupReason === void 0,
1485
+ numeric: this.aggregateExpressions && physicalReason === void 0 && numericOperandProblem(fd) === void 0
1403
1486
  };
1487
+ if (groupReason) cap.groupReason = groupReason;
1404
1488
  if (filterOps.length > 0) cap.filterOps = filterOps;
1405
1489
  if (filterBy.compare) cap.filterReason = filterBy.compare;
1406
1490
  if (sortReason) cap.sortReason = sortReason;
@@ -1421,6 +1505,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1421
1505
  entry.fd
1422
1506
  ];
1423
1507
  }
1508
+ /** The declared decoration keys, in declaration order. */
1509
+ get decorationKeys() {
1510
+ return this._decorations.keys();
1511
+ }
1512
+ /** The capability of the declared decoration `key` (selectable only), `undefined` when `key` is none. */
1513
+ decorationCap(key) {
1514
+ return this._decorations.has(key) ? DECORATION_CAP : void 0;
1515
+ }
1516
+ /** The decoration `key` is visible: every path it `requires` passes `exists` (the hidden-field hook). */
1517
+ decorationVisible(key, exists) {
1518
+ return this._decorations.get(key)?.every(exists) === true;
1519
+ }
1424
1520
  /** Physical filter capability (adapter ∧ ¬writeOnly ∧ ¬encrypted) — ignores the manual-mode policy. */
1425
1521
  isPhysicallyFilterable(path) {
1426
1522
  return this._entries.get(path)?.physicalFilterable === true;
@@ -1445,6 +1541,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1445
1541
  * JSON-stored column" — clients pin that wording, so do not "align" it
1446
1542
  * with the core backstop's text.
1447
1543
  *
1544
+ * A declared decoration (`@DbDecorations`) is a virtual entry: `select` while
1545
+ * every path it requires passes `exists`, any other position a display-only
1546
+ * refusal (`groupedSelect` is a `$select` of an aggregate query), and hidden
1547
+ * sources answer `Unknown field` like a nonexistent path.
1548
+ *
1448
1549
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1449
1550
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1450
1551
  *
@@ -1454,8 +1555,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1454
1555
  * name the prefixed one. A filter on a navigation path names the predicate
1455
1556
  * alternative (`ticket=$some(status=…)`).
1456
1557
  */
1457
- check(local, op, exists, predicate = "compare", prefix = "") {
1558
+ check(local, gateOp, exists, predicate = "compare", prefix = "") {
1458
1559
  const path = prefix + local;
1560
+ const requires = prefix === "" ? this._decorations.get(local) : void 0;
1561
+ if (requires) {
1562
+ if (!requires.every(exists)) return unknownField(path);
1563
+ if (gateOp === "select") return void 0;
1564
+ return {
1565
+ path,
1566
+ message: `Field "${path}" is display-only and cannot be used in ${DISPLAY_ONLY_POSITION[gateOp]}`
1567
+ };
1568
+ }
1569
+ const op = gateOp === "groupedSelect" ? "select" : gateOp;
1459
1570
  if (!exists(local)) return unknownField(path);
1460
1571
  const { kind, parent: localParent } = classifyQueryPath(this, local);
1461
1572
  const parent = localParent === void 0 ? void 0 : prefix + localParent;
@@ -1485,6 +1596,10 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1485
1596
  path,
1486
1597
  message: `Bucketing field "${path}" is not permitted — ${entry.cap.bucketReason}`
1487
1598
  };
1599
+ case "groupBy": return entry.cap.groupable ? void 0 : {
1600
+ path,
1601
+ message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.cap.groupReason}`
1602
+ };
1488
1603
  default: return entry.physicalFilterable ? void 0 : {
1489
1604
  path,
1490
1605
  message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
@@ -1851,6 +1966,10 @@ function hasClientPredicate(filter) {
1851
1966
  * without copying).
1852
1967
  */
1853
1968
  function readRequestContext(endpoint, controls, filter) {
1969
+ if (endpoint === "insert") return controls.$onConflict === "ignore" ? {
1970
+ endpoint,
1971
+ onConflict: "ignore"
1972
+ } : { endpoint };
1854
1973
  if (!filter || Object.keys(filter).length === 0) return {
1855
1974
  endpoint,
1856
1975
  controls,
@@ -2139,7 +2258,7 @@ let AsReadableController = class AsReadableController {
2139
2258
  async parseRequest(endpoint, url) {
2140
2259
  let request;
2141
2260
  if (url !== void 0) {
2142
- const { parsed, hasNonControl } = endpoint === "one" ? this.parseControlsOnlyFromUrl(url) : {
2261
+ const { parsed, hasNonControl } = endpoint === "one" || endpoint === "insert" ? this.parseControlsOnlyFromUrl(url) : {
2143
2262
  parsed: this.parseQueryString(url),
2144
2263
  hasNonControl: false
2145
2264
  };
@@ -2559,8 +2678,8 @@ function isIdValidationSource(value) {
2559
2678
  const v = value;
2560
2679
  return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
2561
2680
  }
2562
- function validateSingleId(body, source, path = "") {
2563
- const errors = collectIdErrors(body, source, path);
2681
+ function validateSingleId(body, source, opts = {}) {
2682
+ const errors = collectIdErrors(body, source, opts);
2564
2683
  if (errors.length > 0) throw new ValidatorError(errors);
2565
2684
  return body;
2566
2685
  }
@@ -2577,11 +2696,12 @@ function validateMultiId(body, source, maxIds = Infinity) {
2577
2696
  details: []
2578
2697
  }]);
2579
2698
  const errors = [];
2580
- for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
2699
+ for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, { path: `[${i}]` }));
2581
2700
  if (errors.length > 0) throw new ValidatorError(errors);
2582
2701
  return body;
2583
2702
  }
2584
- function collectIdErrors(value, source, pathPrefix) {
2703
+ function collectIdErrors(value, source, opts) {
2704
+ const pathPrefix = opts.path ?? "";
2585
2705
  if (!isPlainObject$1(value)) return [{
2586
2706
  path: pathPrefix,
2587
2707
  message: "Expected JSON object for row identifier",
@@ -2602,14 +2722,21 @@ function collectIdErrors(value, source, pathPrefix) {
2602
2722
  const errors = [];
2603
2723
  for (const fieldName of match.fields) {
2604
2724
  const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
2605
- const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
2725
+ const err = checkScalar(value[fieldName], sub) ?? (opts.strictTypes === false ? void 0 : checkType(value[fieldName], cache.fieldByName.get(fieldName), sub));
2606
2726
  if (err) errors.push(err);
2607
2727
  }
2608
2728
  return errors;
2609
2729
  }
2610
- function checkScalar(value, fd, path) {
2730
+ /**
2731
+ * An identifier value is always a scalar: an object would reach the filter
2732
+ * as an operator expression (`{ $ne: null }`) whatever the field's type.
2733
+ */
2734
+ function checkScalar(value, path) {
2735
+ return typeof value === "object" && value !== null ? scalarMismatch(path, "a scalar", value) : void 0;
2736
+ }
2737
+ /** The value's type against the field's declared design type (default `string`). */
2738
+ function checkType(value, fd, path) {
2611
2739
  const expected = fd?.designType ?? "string";
2612
- if (typeof value === "object" && value !== null) return scalarMismatch(path, "a scalar", value);
2613
2740
  if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
2614
2741
  if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
2615
2742
  if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
@@ -2632,14 +2759,70 @@ function isPlainObject$1(value) {
2632
2759
  //#endregion
2633
2760
  //#region src/actions/id-cache.ts
2634
2761
  /**
2762
+ * EVERY id the client sent, in request order, with the identity of the id it
2763
+ * resolved to (since 0.1.148) — set only when the controller's
2764
+ * `resolveRowIds` changed or collapsed an id. The single model: refusals,
2765
+ * `reasons`, summaries and counts are judged and reported per request id
2766
+ * through {@link echoRequests}; the resolved (deduped) ids serve only the
2767
+ * row load and the handler.
2768
+ */
2769
+ const dbActionRequestIdsKey = key("atscript_db_action_request_ids");
2770
+ /** Number of request ids (`fallback` when no id was resolved to another). */
2771
+ function requestCount(ctx, fallback) {
2772
+ return ctx.has(dbActionRequestIdsKey) ? ctx.get(dbActionRequestIdsKey).length : fallback;
2773
+ }
2774
+ /** Number of request ids that resolved to one of `ids` (`ids.length` when none was rewritten). */
2775
+ function requestCountOf(ctx, ids) {
2776
+ if (!ctx.has(dbActionRequestIdsKey)) return ids.length;
2777
+ const keys = new Set(ids.map((id) => identityKey(id)));
2778
+ return ctx.get(dbActionRequestIdsKey).filter((r) => keys.has(r.key)).length;
2779
+ }
2780
+ /**
2781
+ * Entries keyed by a resolved id, reported for every REQUEST id that resolved
2782
+ * to it — in request order, each as the client sent it (`id` replaced). Two
2783
+ * aliases of one row come back exactly like two distinct rows (same order,
2784
+ * same count). Entries whose id no request resolved to stay as they are,
2785
+ * after the request ones.
2786
+ */
2787
+ function echoRequests(ctx, entries) {
2788
+ if (!ctx.has(dbActionRequestIdsKey)) return [...entries];
2789
+ const byKey = /* @__PURE__ */ new Map();
2790
+ const rest = [];
2791
+ for (const e of entries) {
2792
+ const k = identityKey(e.id);
2793
+ if (k === void 0) rest.push(e);
2794
+ else if (!byKey.has(k)) byKey.set(k, e);
2795
+ }
2796
+ const out = [];
2797
+ const matched = /* @__PURE__ */ new Set();
2798
+ for (const r of ctx.get(dbActionRequestIdsKey)) {
2799
+ const e = byKey.get(r.key);
2800
+ if (!e) continue;
2801
+ matched.add(r.key);
2802
+ out.push({
2803
+ ...e,
2804
+ id: r.id
2805
+ });
2806
+ }
2807
+ for (const [k, e] of byKey) if (!matched.has(k)) rest.push(e);
2808
+ return [...out, ...rest];
2809
+ }
2810
+ /** The id as the client sent it (the first, for an id several requests resolved to). */
2811
+ function requestIdOf(ctx, id) {
2812
+ return echoRequests(ctx, [{ id }])[0].id;
2813
+ }
2814
+ /**
2635
2815
  * Validates the body's `ids` against the action table's identifications. For
2636
2816
  * the controller's own table that is its `idSource` (since 0.1.134): a unique
2637
2817
  * index over a field `hasField` hides neither addresses a row nor appears in
2638
2818
  * the "must exactly match one of" message. An `opts.table` binding has no
2639
2819
  * visibility hook. The controller's `prepareRequest` (since 0.1.143) runs
2640
2820
  * first, so the visibility the ids are validated against is the request's.
2821
+ * Then, when the controller overrides `resolveRowIds` (since 0.1.148), the
2822
+ * validated ids go through it — a one-element array for a `'row'` action —
2823
+ * and the resolved ids (duplicates collapsed) are what every consumer sees.
2641
2824
  */
2642
- async function resolveValidatedId(ctx, validate) {
2825
+ async function resolveValidatedId(ctx, level, validate) {
2643
2826
  await awaitActionPrepared(ctx);
2644
2827
  let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
2645
2828
  if (!source) {
@@ -2649,9 +2832,21 @@ async function resolveValidatedId(ctx, validate) {
2649
2832
  if (!isIdValidationSource(source)) throw noTableError(ctx);
2650
2833
  const env = await ctx.get(dbActionBodySlot);
2651
2834
  validate(env.ids, source);
2652
- return env.ids;
2835
+ const scoped = ctx.get(scopedControllerSlot);
2836
+ const resolve = scoped?.[ROW_RESOLVES] ? scoped[ROW_RESOLVE_IDS] : void 0;
2837
+ if (!resolve) return env.ids;
2838
+ const requested = level === "row" ? [env.ids] : env.ids;
2839
+ const overlay = await ctx.get(dbActionOverlaySlot);
2840
+ const { ids, requests } = await resolve.call(scoped, requested, {
2841
+ purpose: "action",
2842
+ action: readCurrentActionMeta(ctx)?.name,
2843
+ level,
2844
+ overlay: overlay ?? void 0
2845
+ });
2846
+ if (requests) ctx.set(dbActionRequestIdsKey, requests);
2847
+ return level === "row" ? ids[0] : ids;
2653
2848
  }
2654
- const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
2849
+ const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, "row", validateSingleId));
2655
2850
  /**
2656
2851
  * The `'rows'` action's identifiers: the body's validated `ids`, or — for a
2657
2852
  * query target (`query`, since 0.1.147) — the identities of the rows it
@@ -2661,7 +2856,7 @@ const dbActionIdsSlot = cached(async (ctx) => {
2661
2856
  await awaitActionPrepared(ctx);
2662
2857
  const target = await ctx.get(dbActionQueryTargetSlot);
2663
2858
  if (target) return target.ids;
2664
- return await resolveValidatedId(ctx, (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
2859
+ return await resolveValidatedId(ctx, "rows", (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
2665
2860
  });
2666
2861
  const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
2667
2862
  const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
@@ -2681,29 +2876,70 @@ function asFetchTable(value) {
2681
2876
  function seedActionFields(ctx, table) {
2682
2877
  return actionRowFields(table, requiredFieldsOf(readCurrentActionMeta(ctx)?.opts), actionFieldVisibility(ctx));
2683
2878
  }
2879
+ const UNSCOPED = {
2880
+ kind: "resolved",
2881
+ scope: null
2882
+ };
2883
+ const DEFERRED = { kind: "deferred" };
2884
+ function inPreferredShape(ids, preferred) {
2885
+ return ids.every((id) => {
2886
+ return Object.keys(id).length === preferred.length && preferred.every((f) => f in id);
2887
+ });
2888
+ }
2889
+ async function resolvePreScope(ctx, level) {
2890
+ const ctrl = ctx.get(scopedControllerSlot);
2891
+ const action = readCurrentActionMeta(ctx)?.name;
2892
+ const scopeOf = ctrl?.[ACTION_SCOPED] ? ctrl[ACTION_SCOPE] : void 0;
2893
+ if (!ctrl || !scopeOf || action === void 0) return UNSCOPED;
2894
+ if (await ctx.get(dbActionOverlaySlot)) return DEFERRED;
2895
+ const table = asFetchTable(getActionTable(ctx));
2896
+ const preferred = table?.preferredId;
2897
+ if (!table || !preferred?.length) return DEFERRED;
2898
+ const [target, requested] = await Promise.all([level === "rows" ? ctx.get(dbActionQueryTargetSlot) : void 0, level === "row" ? ctx.get(dbActionIdSlot).then((id) => [id]) : ctx.get(dbActionIdsSlot)]);
2899
+ if (target || !inPreferredShape(requested, preferred)) return DEFERRED;
2900
+ const ids = level === "row" ? requested : dedupeIdentities(requested, preferred).ids;
2901
+ if (ids.length === 0) return UNSCOPED;
2902
+ return {
2903
+ kind: "resolved",
2904
+ scope: nonEmptyFilter(await scopeOf.call(ctrl, action, createScopeContext("execute", ids, table))) ?? null
2905
+ };
2906
+ }
2907
+ /**
2908
+ * {@link TPreScope} of the current action — once per event. An event runs one
2909
+ * action, so the first caller's `level` (`'row'` / `'rows'`) is the action's.
2910
+ */
2911
+ const dbActionPreScopeSlot = cached((ctx) => {
2912
+ let pending;
2913
+ return (level) => pending ??= resolvePreScope(ctx, level);
2914
+ });
2684
2915
  /**
2685
2916
  * Loaded row / rows are ANDed with the controller's row overlay (see
2686
2917
  * `dbActionOverlaySlot`, since 0.1.143) and, since 0.1.147, checked against
2687
- * the action's `actionRowScope` for the loaded candidates: an out-of-scope id
2688
- * loads nothing — exactly like a missing one (the same 404 on `'row'`
2689
- * actions, so the two can't be told apart).
2918
+ * the action's `actionRowScope`: an out-of-scope id loads nothing — exactly
2919
+ * like a missing one (the same 404 on `'row'` actions, so the two can't be
2920
+ * told apart).
2690
2921
  *
2691
2922
  * Order: `prepareRequest` → the row overlay (BEFORE the request body is
2692
- * read: fail-fast authorization) → the ids (body) → the row load →
2693
- * `actionRowScope` (it needs the candidates).
2923
+ * read: fail-fast authorization) → the ids (body) → `resolveRowIds` (since
2924
+ * 0.1.148) → `actionRowScope` (pre-load, when there is no overlay and the
2925
+ * ids are in `preferredId` shape — its restriction joins the one row load;
2926
+ * since 0.1.148) → the row load → otherwise `actionRowScope` on the loaded
2927
+ * candidates (it needs them).
2694
2928
  */
2695
2929
  async function loadRow(ctx) {
2696
2930
  const overlay = await ctx.get(dbActionOverlaySlot);
2697
2931
  const id = await ctx.get(dbActionIdSlot);
2698
2932
  const table = asFetchTable(getActionTable(ctx));
2699
2933
  if (!table) throw noTableError(ctx);
2934
+ const pre = await ctx.get(dbActionPreScopeSlot)("row");
2700
2935
  const fields = seedActionFields(ctx, table);
2701
2936
  for (const k of Object.keys(id)) fields.add(k);
2702
- const loaded = await table.findOne({
2703
- filter: withOverlay(id, overlay),
2937
+ const idFilter = withOverlay(withOverlay(id, overlay), pre.kind === "resolved" ? pre.scope : null);
2938
+ let row = await table.findOne({
2939
+ filter: idFilter,
2704
2940
  controls: { $select: [...fields] }
2705
- });
2706
- const [row] = loaded == null ? [void 0] : await applyActionScope(ctx, table, [loaded]);
2941
+ }) ?? void 0;
2942
+ if (row !== void 0 && pre.kind === "deferred") [row] = await applyActionScope(ctx, table, [row]);
2707
2943
  if (row === void 0) throw new HttpError(404, "Row not found for action identifier");
2708
2944
  return row;
2709
2945
  }
@@ -2714,7 +2950,11 @@ async function loadRows(ctx) {
2714
2950
  if (!table) throw noTableError(ctx);
2715
2951
  const fields = seedActionFields(ctx, table);
2716
2952
  const target = await ctx.get(dbActionQueryTargetSlot);
2717
- if (!target) return applyActionScope(ctx, table, await findRowsByIds(table, ids, overlay, fields));
2953
+ if (!target) {
2954
+ const pre = await ctx.get(dbActionPreScopeSlot)("rows");
2955
+ if (pre.kind === "resolved") return findRowsByIds(table, ids, pre.scope ? withOverlay(pre.scope, overlay) : overlay, fields);
2956
+ return applyActionScope(ctx, table, await findRowsByIds(table, ids, overlay, fields));
2957
+ }
2718
2958
  const rows = await target.load(ids, fields);
2719
2959
  const stale = /* @__PURE__ */ new Set();
2720
2960
  for (let i = 0; i < rows.length; i++) if (rows[i] === void 0) stale.add(i);
@@ -2771,25 +3011,52 @@ function DbActionTarget() {
2771
3011
  var TargetBase = class {
2772
3012
  kind;
2773
3013
  matched;
3014
+ ctx;
2774
3015
  skipped = [];
2775
3016
  failed = [];
2776
3017
  processed = 0;
2777
- constructor(kind, matched) {
3018
+ /** Identities of every id handed to the handler so far. */
3019
+ handedKeys = /* @__PURE__ */ new Set();
3020
+ failedKeys = /* @__PURE__ */ new Set();
3021
+ constructor(kind, matched, ctx) {
2778
3022
  this.kind = kind;
2779
3023
  this.matched = matched;
3024
+ this.ctx = ctx;
2780
3025
  }
3026
+ /**
3027
+ * One failure per id, whether or not `resolveRowIds` rewrote any id: a
3028
+ * second `fail()` of the same identity is ignored (the first reason stands),
3029
+ * so `failed` and `processed` agree in both modes.
3030
+ */
2781
3031
  fail(id, reason) {
3032
+ const k = identityKey(id);
3033
+ if (k !== void 0) {
3034
+ if (this.failedKeys.has(k)) return;
3035
+ this.failedKeys.add(k);
3036
+ }
2782
3037
  this.failed.push({
2783
3038
  id,
2784
3039
  reason
2785
3040
  });
2786
3041
  }
3042
+ /**
3043
+ * A batch handed to the handler, counted per request id (two aliases of one
3044
+ * row count twice, like two distinct rows).
3045
+ */
3046
+ countProcessed(ids) {
3047
+ for (const id of ids) {
3048
+ const k = identityKey(id);
3049
+ if (k !== void 0) this.handedKeys.add(k);
3050
+ }
3051
+ this.processed += requestCountOf(this.ctx, ids);
3052
+ }
2787
3053
  summary() {
3054
+ const failed = echoRequests(this.ctx, this.failed);
2788
3055
  return {
2789
3056
  matched: this.matched,
2790
- processed: Math.max(0, this.processed - this.failed.length),
2791
- skipped: [...this.skipped],
2792
- failed: [...this.failed]
3057
+ processed: Math.max(0, this.processed - failed.length),
3058
+ skipped: echoRequests(this.ctx, this.skipped),
3059
+ failed
2793
3060
  };
2794
3061
  }
2795
3062
  };
@@ -2801,12 +3068,12 @@ var MaterializedTarget = class extends TargetBase {
2801
3068
  ids;
2802
3069
  rows;
2803
3070
  iterated = false;
2804
- constructor(kind, matched, ids, rows, skipped) {
2805
- super(kind, matched);
3071
+ constructor(kind, matched, ids, rows, skipped, ctx) {
3072
+ super(kind, matched, ctx);
2806
3073
  this.ids = ids;
2807
3074
  this.rows = rows;
2808
3075
  this.skipped.push(...skipped);
2809
- this.processed = ids.length;
3076
+ this.countProcessed(ids);
2810
3077
  }
2811
3078
  async *batches() {
2812
3079
  if (this.iterated) throw new Error("[moost-db actions] target.batches() is single-pass");
@@ -2822,21 +3089,19 @@ async function setMaterializedTarget(ctx) {
2822
3089
  const target = await resolveActionQueryTarget(ctx, false);
2823
3090
  const ids = await ctx.get(dbActionIdsSlot);
2824
3091
  const skipped = ctx.has(dbActionSkippedKey) ? ctx.get(dbActionSkippedKey) : [];
2825
- ctx.set(dbActionTargetKey, new MaterializedTarget(target ? "query" : "ids", target ? target.matched : ids.length + skipped.length, ids, async () => {
3092
+ ctx.set(dbActionTargetKey, new MaterializedTarget(target ? "query" : "ids", target ? target.matched : requestCount(ctx, ids.length + skipped.length), ids, async () => {
2826
3093
  return (await ctx.get(dbActionRowsSlot)).filter((r) => r !== void 0);
2827
- }, skipped));
3094
+ }, skipped, ctx));
2828
3095
  }
2829
3096
  /** The `@DbActionTarget` surface: the target in batches, each gated when it is reached. */
2830
3097
  var StreamedTarget = class extends TargetBase {
2831
- ctx;
2832
3098
  source;
2833
3099
  batchSize;
2834
3100
  action;
2835
3101
  disabled;
2836
3102
  iterated = false;
2837
3103
  constructor(matched, ctx, source, batchSize, action, disabled) {
2838
- super(source.kind, matched);
2839
- this.ctx = ctx;
3104
+ super(source.kind, matched, ctx);
2840
3105
  this.source = source;
2841
3106
  this.batchSize = batchSize;
2842
3107
  this.action = action;
@@ -2854,7 +3119,7 @@ var StreamedTarget = class extends TargetBase {
2854
3119
  const batch = await this.gate(ids.slice(start, start + this.batchSize));
2855
3120
  this.next = start + this.batchSize;
2856
3121
  if (batch.ids.length === 0) continue;
2857
- this.processed += batch.ids.length;
3122
+ this.countProcessed(batch.ids);
2858
3123
  this.current = batch.ids;
2859
3124
  yield batch;
2860
3125
  this.current = void 0;
@@ -2862,30 +3127,66 @@ var StreamedTarget = class extends TargetBase {
2862
3127
  this.next = ids.length;
2863
3128
  }
2864
3129
  /**
2865
- * The summary of a run the handler failed after it received a batch: the
2866
- * batch it held is uncertain (moved to `failed` with the error's message),
2867
- * the rows not reached are `failed` as `"not run"`, `aborted` set.
3130
+ * The summary of a run the handler failed after it received a batch, judged
3131
+ * per REQUEST id by its own request position — an alias after the abort
3132
+ * point is "not run" even when its row was judged (run, skipped) earlier,
3133
+ * exactly as the same request of distinct rows would be:
3134
+ * - at or after the position the run stopped at: `failed` as `"not run"`;
3135
+ * - before it, a row the handler failed itself: its reason;
3136
+ * - before it, the batch the handler held (uncertain): the error's message;
3137
+ * - before it, a row the gate skipped: `skipped`; a row that ran: `processed`.
3138
+ * `aborted` is set.
2868
3139
  */
2869
3140
  abort(error) {
2870
3141
  const message = errorMessage(error);
2871
- const base = this.summary();
2872
- const holding = this.current ?? [];
2873
- const reported = new Set(this.failed.map((f) => identityKey(f.id)));
2874
- const uncertain = holding.filter((id) => !reported.has(identityKey(id)));
3142
+ const ids = this.source.ids;
3143
+ const requests = this.ctx.has(dbActionRequestIdsKey) ? this.ctx.get(dbActionRequestIdsKey) : ids.map((id, i) => ({
3144
+ id,
3145
+ key: identityKey(id) ?? `#${i}`
3146
+ }));
3147
+ const held = new Set((this.current ?? []).map((id) => identityKey(id)));
3148
+ let cutoff = requests.length;
3149
+ if (this.ctx.has(dbActionRequestIdsKey)) {
3150
+ const firstOf = (key) => requests.findIndex((r) => r.key === key);
3151
+ if (held.size > 0) cutoff = 1 + Math.max(...[...held].map(firstOf));
3152
+ else if (this.next < ids.length) cutoff = firstOf(identityKey(ids[this.next]));
3153
+ if (cutoff < 0) cutoff = requests.length;
3154
+ } else if (this.next < ids.length) cutoff = this.next;
3155
+ const failedBy = new Map(this.failed.map((f) => [identityKey(f.id), f]));
3156
+ const skippedBy = /* @__PURE__ */ new Map();
3157
+ for (const row of this.skipped) {
3158
+ const k = identityKey(row.id);
3159
+ if (!skippedBy.has(k)) skippedBy.set(k, row);
3160
+ }
3161
+ const failed = [];
3162
+ const skipped = [];
3163
+ let processed = 0;
3164
+ requests.forEach((r, i) => {
3165
+ const own = failedBy.get(r.key);
3166
+ const skip = skippedBy.get(r.key);
3167
+ if (i >= cutoff) failed.push({
3168
+ id: r.id,
3169
+ reason: "not run"
3170
+ });
3171
+ else if (own) failed.push({
3172
+ id: r.id,
3173
+ reason: own.reason
3174
+ });
3175
+ else if (held.has(r.key)) failed.push({
3176
+ id: r.id,
3177
+ reason: message
3178
+ });
3179
+ else if (skip) skipped.push({
3180
+ ...skip,
3181
+ id: r.id
3182
+ });
3183
+ else if (this.handedKeys.has(r.key)) processed++;
3184
+ });
2875
3185
  return {
2876
- ...base,
2877
- processed: Math.max(0, base.processed - uncertain.length),
2878
- failed: [
2879
- ...base.failed,
2880
- ...uncertain.map((id) => ({
2881
- id,
2882
- reason: message
2883
- })),
2884
- ...this.source.ids.slice(this.next).map((id) => ({
2885
- id,
2886
- reason: "not run"
2887
- }))
2888
- ],
3186
+ matched: this.matched,
3187
+ processed,
3188
+ skipped,
3189
+ failed,
2889
3190
  aborted: {
2890
3191
  status: errorStatus(error),
2891
3192
  message
@@ -2980,7 +3281,7 @@ async function setStreamedTarget(ctx, action, disabled) {
2980
3281
  return findRowsByIds(table, batch, overlay, select);
2981
3282
  }
2982
3283
  };
2983
- const target = new StreamedTarget(query ? query.matched : source.ids.length, ctx, source, batchSize, action, disabled);
3284
+ const target = new StreamedTarget(query ? query.matched : requestCount(ctx, source.ids.length), ctx, source, batchSize, action, disabled);
2984
3285
  ctx.set(dbActionTargetKey, target);
2985
3286
  return {
2986
3287
  target,
@@ -3009,6 +3310,8 @@ const ACTION_SLOTS = [
3009
3310
  dbActionInputSlot,
3010
3311
  dbActionIdSlot,
3011
3312
  dbActionIdsSlot,
3313
+ dbActionRequestIdsKey,
3314
+ dbActionPreScopeSlot,
3012
3315
  dbActionRowSlot,
3013
3316
  dbActionRowsSlot,
3014
3317
  dbActionOverlaySlot,
@@ -3316,6 +3619,398 @@ function getDbEndpoint(target, method) {
3316
3619
  }
3317
3620
  }
3318
3621
  //#endregion
3622
+ //#region src/decorations/decoration-index.ts
3623
+ /** A decoration key is a valid `$select` URL name: top-level, no `$` prefix, no dots. */
3624
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3625
+ /**
3626
+ * The own readable paths a `requires` path stands for: itself when it is an
3627
+ * own field, else — a parent object of a flattened (SQL) readable — every own
3628
+ * field below it. Empty when the path is neither.
3629
+ */
3630
+ function ownLeaves(path, ownPaths) {
3631
+ if (ownPaths.has(path)) return [path];
3632
+ const prefix = `${path}.`;
3633
+ return [...ownPaths].filter((own) => own.startsWith(prefix));
3634
+ }
3635
+ /**
3636
+ * Validates `@DbDecorations` metadata against the bound readable and indexes
3637
+ * it. Throws `[moost-db]` errors (once per class — the caller memoizes).
3638
+ */
3639
+ function buildDecorationIndex(controller, meta, source) {
3640
+ const fail = (message) => {
3641
+ throw new Error(`[moost-db] ${controller}: @DbDecorations ${message}`);
3642
+ };
3643
+ const { type } = meta;
3644
+ if (type.type.kind !== "object") fail("expects an object interface");
3645
+ if (type.metadata.has("db.table") || type.metadata.has("db.view")) fail("expects a plain interface — it must not carry @db.table or @db.view");
3646
+ const keys = [...type.type.props.keys()];
3647
+ const fieldPaths = /* @__PURE__ */ new Set();
3648
+ for (const path of source.flatMap?.keys() ?? []) for (let at = path.indexOf(".");; at = path.indexOf(".", at + 1)) {
3649
+ fieldPaths.add(at < 0 ? path : path.slice(0, at));
3650
+ if (at < 0) break;
3651
+ }
3652
+ const isField = (path) => fieldPaths.has(path);
3653
+ for (const key of keys) {
3654
+ if (!KEY_RE.test(key)) fail(`key "${key}" must be a plain top-level identifier (no "$" prefix, no dots)`);
3655
+ if (isField(key) || source.relations?.has(key) || source.navFields.has(key)) fail(`key "${key}" collides with a field or relation of the bound readable`);
3656
+ }
3657
+ const requires = /* @__PURE__ */ new Map();
3658
+ const leavesOf = /* @__PURE__ */ new Map();
3659
+ const nested = (path) => {
3660
+ const prefix = `${path}.`;
3661
+ const below = [...source.flatMap?.keys() ?? []].filter((p) => p.startsWith(prefix));
3662
+ return below.filter((p) => selfOrAncestor(p, source.navFields) === void 0 && !below.some((other) => other.startsWith(`${p}.`)));
3663
+ };
3664
+ for (const key of keys) requires.set(key, []);
3665
+ for (const [key, paths] of Object.entries(meta.requires)) {
3666
+ if (!requires.has(key)) fail(`\`requires\` names "${key}", which is not a declared decoration`);
3667
+ const resolved = [];
3668
+ for (const path of paths) {
3669
+ const leaves = ownLeaves(path, source.ownPaths);
3670
+ if (leaves.length === 0) fail(`"${key}" requires "${path}", which is not an own field of the bound readable`);
3671
+ for (const leaf of leaves) if ([...source.writeOnly].some((wo) => wo.startsWith(`${leaf}.`)) || selfOrAncestor(leaf, source.writeOnly) !== void 0) fail(`"${key}" requires "${path}", which is @db.writeOnly (a sealed value cannot be read)`);
3672
+ for (const leaf of leaves) {
3673
+ if (leavesOf.has(leaf)) continue;
3674
+ const below = nested(leaf);
3675
+ if (below.length > 0) leavesOf.set(leaf, below);
3676
+ }
3677
+ resolved.push(...leaves);
3678
+ }
3679
+ requires.set(key, [...new Set(resolved)]);
3680
+ }
3681
+ const visibleOn = /* @__PURE__ */ new Map();
3682
+ for (const [key, paths] of requires) {
3683
+ const all = new Set(paths);
3684
+ for (const path of paths) for (const leaf of leavesOf.get(path) ?? []) all.add(leaf);
3685
+ visibleOn.set(key, [...all]);
3686
+ }
3687
+ return {
3688
+ type,
3689
+ keys,
3690
+ keySet: new Set(keys),
3691
+ requires,
3692
+ visibleOn,
3693
+ leavesOf,
3694
+ memo: { meta: /* @__PURE__ */ new WeakMap() }
3695
+ };
3696
+ }
3697
+ //#endregion
3698
+ //#region src/select-shape.ts
3699
+ /** The included (`1` / `true`) and excluded (`0` / `false`) keys of a `$select` map. */
3700
+ function mapShape(map) {
3701
+ const included = [];
3702
+ const excluded = [];
3703
+ for (const [key, value] of Object.entries(map)) if (value === 1 || value === true) included.push(key);
3704
+ else if (value === 0 || value === false) excluded.push(key);
3705
+ return {
3706
+ included,
3707
+ excluded
3708
+ };
3709
+ }
3710
+ /** The one splitter every `$select` consumer in the controller reads. */
3711
+ function selectShape(raw) {
3712
+ if (raw === void 0 || raw === null) return { kind: "all" };
3713
+ if (Array.isArray(raw)) return {
3714
+ kind: "list",
3715
+ items: raw
3716
+ };
3717
+ const map = raw;
3718
+ return {
3719
+ kind: "map",
3720
+ map,
3721
+ ...mapShape(map)
3722
+ };
3723
+ }
3724
+ //#endregion
3725
+ //#region src/decorations/decoration-planner.ts
3726
+ const NO_DECORATIONS = /* @__PURE__ */ new Set();
3727
+ /**
3728
+ * The decoration plumbing of one controller (`@DbDecorations`, since 0.1.148):
3729
+ * rewrites a read's `$select` ({@link plan}), decides what the response
3730
+ * carries once the final projection is known ({@link serve}), and builds the
3731
+ * `/meta` view ({@link meta}). The request gate is not here — decorations are
3732
+ * virtual entries of the {@link FieldCapabilityIndex}.
3733
+ */
3734
+ var DecorationPlanner = class {
3735
+ index;
3736
+ host;
3737
+ constructor(index, host) {
3738
+ this.index = index;
3739
+ this.host = host;
3740
+ }
3741
+ visible(key) {
3742
+ return this.host.capabilities().decorationVisible(key, this.host.isVisible);
3743
+ }
3744
+ requiresOf(keys) {
3745
+ return [...new Set(keys.flatMap((key) => this.index.requires.get(key)))];
3746
+ }
3747
+ /**
3748
+ * Splits the wire `$select` of a read (non-grouped: the gate already
3749
+ * refused a decoration in a grouped one): `requested` is every declared
3750
+ * decoration key whose sources are visible and that the client named — or,
3751
+ * without a `$select`, all of them (an exclusion map: all minus the excluded
3752
+ * ones). The real `$select` is widened by the requested keys' `requires`;
3753
+ * the paths added only for the hook (`requiresOnly`) are stripped again.
3754
+ * The result keeps the representation of `raw` (array or map).
3755
+ */
3756
+ plan(raw) {
3757
+ const { keySet, keys } = this.index;
3758
+ const shape = selectShape(raw);
3759
+ if (shape.kind === "all") return {
3760
+ select: void 0,
3761
+ requested: keys.filter((k) => this.visible(k)),
3762
+ requiresOnly: []
3763
+ };
3764
+ const passthrough = {
3765
+ select: raw,
3766
+ requested: [],
3767
+ requiresOnly: []
3768
+ };
3769
+ if (shape.kind === "list") {
3770
+ const named = shape.items.filter((item) => typeof item === "string" && keySet.has(item));
3771
+ if (named.length === 0) return passthrough;
3772
+ const real = shape.items.filter((item) => !(typeof item === "string" && keySet.has(item)));
3773
+ const have = new Set(real.filter((item) => typeof item === "string"));
3774
+ return this.inclusion([...new Set(named)], real, have, (list) => list);
3775
+ }
3776
+ const { map, included, excluded } = shape;
3777
+ if (included.length === 0 && excluded.length > 0) {
3778
+ const skip = new Set(excluded);
3779
+ const requested = keys.filter((key) => !skip.has(key) && this.visible(key));
3780
+ const real = Object.fromEntries(Object.entries(map).filter(([k]) => !keySet.has(k)));
3781
+ const requiresOnly = /* @__PURE__ */ new Set();
3782
+ const remaining = new Set(Object.keys(real));
3783
+ const unexclude = (key) => {
3784
+ requiresOnly.add(key);
3785
+ remaining.delete(key);
3786
+ delete real[key];
3787
+ };
3788
+ for (const path of this.requiresOf(requested)) {
3789
+ const hit = selfOrAncestor(path, remaining);
3790
+ if (hit !== void 0) {
3791
+ unexclude(hit);
3792
+ continue;
3793
+ }
3794
+ const prefix = `${path}.`;
3795
+ for (const key of remaining) if (key.startsWith(prefix)) unexclude(key);
3796
+ }
3797
+ return {
3798
+ select: Object.keys(real).length > 0 ? real : void 0,
3799
+ requested,
3800
+ requiresOnly: [...requiresOnly]
3801
+ };
3802
+ }
3803
+ const named = included.filter((k) => keySet.has(k));
3804
+ if (named.length === 0) return passthrough;
3805
+ const real = included.filter((k) => !keySet.has(k));
3806
+ return this.inclusion(named, real, new Set(real), (list) => Object.fromEntries(list.map((k) => [k, 1])));
3807
+ }
3808
+ /**
3809
+ * The inclusion forms: `real` (the client's own selection) plus the
3810
+ * requested decorations' `requires` that it lacks, re-emitted by `emit`.
3811
+ */
3812
+ inclusion(named, real, have, emit) {
3813
+ const requested = named.filter((key) => this.visible(key));
3814
+ const extra = this.requiresOf(requested).filter((path) => selfOrAncestor(path, have) === void 0);
3815
+ const { list, added } = this.nonEmptyInclusion([...real, ...extra]);
3816
+ return {
3817
+ select: emit(list),
3818
+ requested,
3819
+ requiresOnly: [...extra.filter((path) => !this.host.preferred.has(path)), ...added],
3820
+ selected: [...have]
3821
+ };
3822
+ }
3823
+ /**
3824
+ * An inclusion list is never empty (an empty one selects everything): a
3825
+ * request naming only decorations with nothing to read selects the
3826
+ * `preferredId` fields (which the response carries anyway) or, without one,
3827
+ * a single visible field that is stripped again (`added`).
3828
+ */
3829
+ nonEmptyInclusion(list) {
3830
+ if (list.length > 0) return {
3831
+ list,
3832
+ added: []
3833
+ };
3834
+ const { preferred } = this.host;
3835
+ if (preferred.size > 0) return {
3836
+ list: [...preferred],
3837
+ added: []
3838
+ };
3839
+ const field = this.host.firstVisibleField();
3840
+ return field === void 0 ? {
3841
+ list,
3842
+ added: []
3843
+ } : {
3844
+ list: [field],
3845
+ added: [field]
3846
+ };
3847
+ }
3848
+ /**
3849
+ * The decoration step once the final projection is known: a requested key
3850
+ * is served only if every `requires` path survived `transformProjection`,
3851
+ * the seal and the preferred-id widening (a policy that strips a source
3852
+ * silently drops the decoration, as it drops a field). `kept` is the final
3853
+ * projection's paths (`null` = every field).
3854
+ */
3855
+ serve(plan, kept) {
3856
+ const { keys, requires, leavesOf } = this.index;
3857
+ const carried = (path, set) => selfOrAncestor(path, set) !== void 0 || (leavesOf.get(path)?.every((leaf) => selfOrAncestor(leaf, set) !== void 0) ?? false);
3858
+ const dropPaths = plan.requiresOnly.map((path) => path.split("."));
3859
+ const keepPaths = plan.requiresOnly.map((path) => (plan.selected ?? []).filter((sel) => sel.startsWith(`${path}.`)).map((sel) => sel.split(".")));
3860
+ const selectedPaths = plan.selected?.map((sel) => sel.split("."));
3861
+ if (plan.requested.length === 0) return {
3862
+ served: NO_DECORATIONS,
3863
+ dropKeys: keys,
3864
+ dropPaths,
3865
+ keepPaths,
3866
+ selectedPaths
3867
+ };
3868
+ const keptSet = kept === null ? void 0 : new Set(kept);
3869
+ const served = new Set(plan.requested.filter((key) => keptSet === void 0 || requires.get(key).every((path) => carried(path, keptSet))));
3870
+ return {
3871
+ served,
3872
+ dropKeys: keys.filter((key) => !served.has(key)),
3873
+ dropPaths,
3874
+ keepPaths,
3875
+ selectedPaths
3876
+ };
3877
+ }
3878
+ /**
3879
+ * The `/meta` envelope with the declared decorations: each one still
3880
+ * present in `decorations` (an overlay may delete props to hide a
3881
+ * decoration per principal) whose sources are visible is kept and gets its
3882
+ * `fields[key]` entry (from its virtual capability-index entry); the others
3883
+ * are pruned, and `decorations` is dropped when none is left. Runs after
3884
+ * `applyMetaOverlay` — an overlay never sees the decoration `fields`
3885
+ * entries. Memoized per input while visibility is not request-scoped.
3886
+ */
3887
+ meta(meta) {
3888
+ if (!meta.decorations) return meta;
3889
+ const { memo, keySet } = this.index;
3890
+ const memoize = !this.host.scoped;
3891
+ const hit = memoize ? memo.meta.get(meta) : void 0;
3892
+ if (hit) return hit;
3893
+ const capabilities = this.host.capabilities();
3894
+ const props = meta.decorations.type.props ?? {};
3895
+ const kept = {};
3896
+ const fields = { ...meta.fields };
3897
+ for (const [key, prop] of Object.entries(props)) {
3898
+ const cap = keySet.has(key) && this.visible(key) ? capabilities.decorationCap(key) : void 0;
3899
+ if (!cap) continue;
3900
+ kept[key] = prop;
3901
+ fields[key] = {
3902
+ sortable: cap.sortable,
3903
+ filterable: cap.filterable,
3904
+ decoration: true
3905
+ };
3906
+ }
3907
+ let out;
3908
+ if (Object.keys(kept).length === 0) {
3909
+ const { decorations: _dropped, ...rest } = meta;
3910
+ out = rest;
3911
+ } else out = {
3912
+ ...meta,
3913
+ fields,
3914
+ decorations: {
3915
+ ...meta.decorations,
3916
+ type: {
3917
+ ...meta.decorations.type,
3918
+ props: kept
3919
+ }
3920
+ }
3921
+ };
3922
+ if (memoize) memo.meta.set(meta, out);
3923
+ return out;
3924
+ }
3925
+ /** The declared interface serialized for `/meta.decorations`, once per class. */
3926
+ serialized(serialize) {
3927
+ return this.index.memo.serialized ??= serialize();
3928
+ }
3929
+ };
3930
+ /** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
3931
+ function stripDecorations(rows, read) {
3932
+ const { dropKeys, dropPaths, keepPaths, selectedPaths } = read;
3933
+ if (dropKeys.length === 0 && dropPaths.length === 0) return;
3934
+ const owned = (prefix) => selectedPaths !== void 0 && selectedPaths.some((sel) => prefix.every((part, i) => sel[i] === part));
3935
+ for (const row of rows) {
3936
+ for (const key of dropKeys) delete row[key];
3937
+ dropPaths.forEach((parts, i) => {
3938
+ const keep = keepPaths[i] ?? [];
3939
+ if (keep.length === 0) deleteDescending(row, parts, 0, selectedPaths !== void 0, owned);
3940
+ else pruneExcept(row, parts, 0, keep);
3941
+ });
3942
+ }
3943
+ }
3944
+ /** An object without keys, or an array (of any nesting) holding nothing but such values (what a strip left of a parent). */
3945
+ function isHollow(value) {
3946
+ if (Array.isArray(value)) return value.every((el) => isHollow(el));
3947
+ return isPlainObject(value) && Object.keys(value).length === 0;
3948
+ }
3949
+ /**
3950
+ * Deletes the path `parts` below `value`, descending through arrays of objects
3951
+ * (`items.qty` strips every element's `qty`). With `clean` (an inclusion read),
3952
+ * a parent the strip emptied — or a `null` one — goes too, unless the client
3953
+ * selected something at or below it.
3954
+ */
3955
+ function deleteDescending(value, parts, depth, clean, owned) {
3956
+ if (Array.isArray(value)) {
3957
+ const dropEmptied = clean && depth > 0 && !owned(parts.slice(0, depth));
3958
+ for (let i = value.length - 1; i >= 0; i--) {
3959
+ const el = value[i];
3960
+ const wasHollow = isHollow(el);
3961
+ deleteDescending(el, parts, depth, clean, owned);
3962
+ if (dropEmptied && !wasHollow && isHollow(el)) value.splice(i, 1);
3963
+ }
3964
+ return;
3965
+ }
3966
+ if (!isPlainObject(value)) return;
3967
+ const key = parts[depth];
3968
+ if (depth === parts.length - 1) {
3969
+ delete value[key];
3970
+ return;
3971
+ }
3972
+ const child = value[key];
3973
+ const prefix = parts.slice(0, depth + 1);
3974
+ if (child === null && clean && !owned(prefix)) {
3975
+ delete value[key];
3976
+ return;
3977
+ }
3978
+ deleteDescending(child, parts, depth + 1, clean, owned);
3979
+ if (clean && isHollow(child) && !owned(prefix)) delete value[key];
3980
+ }
3981
+ /**
3982
+ * Below `parts`, deletes everything except the branches leading to `keep`
3983
+ * (client-selected descendants), through arrays of objects as well.
3984
+ */
3985
+ function pruneExcept(value, parts, depth, keep) {
3986
+ if (Array.isArray(value)) {
3987
+ for (const el of value) pruneExcept(el, parts, depth, keep);
3988
+ return;
3989
+ }
3990
+ if (!isPlainObject(value)) return;
3991
+ if (depth < parts.length) {
3992
+ pruneExcept(value[parts[depth]], parts, depth + 1, keep);
3993
+ return;
3994
+ }
3995
+ prune(value, parts.length, keep.filter((k) => k.length > parts.length));
3996
+ }
3997
+ function prune(value, depth, branches) {
3998
+ if (Array.isArray(value)) {
3999
+ for (const el of value) prune(el, depth, branches);
4000
+ return;
4001
+ }
4002
+ if (!isPlainObject(value)) return;
4003
+ for (const key of Object.keys(value)) {
4004
+ const next = branches.filter((b) => b[depth] === key);
4005
+ if (next.length === 0) delete value[key];
4006
+ else if (next.some((b) => b.length > depth + 1)) prune(value[key], depth + 1, next);
4007
+ }
4008
+ }
4009
+ /** `true` when stripping `read` changes nothing — skip the post-hook step. */
4010
+ function stripsNothing(read) {
4011
+ return read === void 0 || read.dropKeys.length === 0 && read.dropPaths.length === 0;
4012
+ }
4013
+ //#endregion
3319
4014
  //#region src/decorators.ts
3320
4015
  /**
3321
4016
  * DI token under which the {@link AtscriptDbReadable} instance
@@ -3475,7 +4170,8 @@ const QUERY_CONTROLS = [
3475
4170
  "filter",
3476
4171
  "insights",
3477
4172
  ...dtoControls(QueryControlsDto),
3478
- "groupBy"
4173
+ "groupBy",
4174
+ "rowOrder"
3479
4175
  ];
3480
4176
  const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
3481
4177
  const ONE_CONTROLS = dtoControls(GetOneControlsDto);
@@ -3501,11 +4197,28 @@ const PATH_OPS = [
3501
4197
  "aggregate",
3502
4198
  "bucket"
3503
4199
  ];
4200
+ /** The controller of the event that owns the route params (nearest ancestor that set them). */
4201
+ function routedController(ctx) {
4202
+ for (let c = ctx; c; c = c.parent) {
4203
+ try {
4204
+ c.getOwn(routeParamsKey);
4205
+ } catch {
4206
+ continue;
4207
+ }
4208
+ return controllerOf(c);
4209
+ }
4210
+ }
3504
4211
  /** The 400 of a filter / sort on a `@db.writeOnly` field. */
3505
4212
  function writeOnlyError(path, op) {
3506
4213
  const verdict = writeOnlyVerdict(path, op);
3507
4214
  return badRequest(verdict.path, verdict.message);
3508
4215
  }
4216
+ /**
4217
+ * Validated `@DbDecorations` per readable and controller class — a
4218
+ * `FOR_EVENT` controller is constructed per event, and must not re-validate
4219
+ * (nor re-serialize) its declaration each time.
4220
+ */
4221
+ const decorationIndexes = /* @__PURE__ */ new WeakMap();
3509
4222
  /** Controller classes already warned that `actionRowScope` has no row identity to match by. */
3510
4223
  const warnedNoIdentity = /* @__PURE__ */ new WeakSet();
3511
4224
  let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
@@ -3537,7 +4250,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3537
4250
  get capabilities() {
3538
4251
  const current = this._capabilities;
3539
4252
  if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
3540
- const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
4253
+ const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet, this._decorations?.visibleOn);
3541
4254
  this._capabilities = index;
3542
4255
  return index;
3543
4256
  }
@@ -3574,6 +4287,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3574
4287
  _derivedSource;
3575
4288
  /** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
3576
4289
  _targetWriteOnly = /* @__PURE__ */ new WeakMap();
4290
+ /** Own leaf paths per readable (bound + `$with` targets), see {@link _leavesOf}. */
4291
+ _targetLeaves = /* @__PURE__ */ new WeakMap();
3577
4292
  _indexFieldPathsCache;
3578
4293
  /** {@link _nativeSearch} per request, keyed by the request's parsed controls. */
3579
4294
  _nativeSearchByRequest = /* @__PURE__ */ new WeakMap();
@@ -3597,8 +4312,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3597
4312
  _gateFieldsMemo = /* @__PURE__ */ new WeakMap();
3598
4313
  /** `true` when a subclass overrides {@link allowedActions}. */
3599
4314
  _hasAllowedActions;
4315
+ /** The class's validated `@DbDecorations` (since 0.1.148), `undefined` when none is declared. */
4316
+ _decorations;
4317
+ /** The decoration plumbing of {@link _decorations} — see `DecorationPlanner`. */
4318
+ _planner;
3600
4319
  /** `true` when a subclass overrides {@link actionRowScope} (the gate, `$actions` and `/meta/actions` apply it). */
3601
4320
  _hasActionRowScope;
4321
+ /** @internal `true` when a subclass overrides {@link resolveRowIds} (every id-addressed endpoint calls it; since 0.1.148). */
4322
+ [ROW_RESOLVES];
3602
4323
  /** `true` when the class declares `@DbActionsFrom` (since 0.1.147). */
3603
4324
  _hasDelegations;
3604
4325
  /** `transformProjection` is overridden (a delegation's id paths are checked against it). */
@@ -3622,15 +4343,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3622
4343
  this._writeOnlySet = this._writeOnlyOf(resolved);
3623
4344
  this._derivedSource = this._derivedSourcesOf(resolved);
3624
4345
  this._invertibleFields = this._collectInvertibleFields();
4346
+ this._decorates = typeof this.decorateRows === "function";
4347
+ this._decorations = this._resolveDecorations(new.target);
3625
4348
  this._searchFallbackFields = this._collectSearchFallbackFields();
3626
4349
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
3627
4350
  this._quantityRefByPath = this._collectQuantityRefs();
3628
4351
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
3629
4352
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
3630
- this._decorates = typeof this.decorateRows === "function";
3631
4353
  const proto = _AsDbReadableController.prototype;
3632
4354
  this._hasRowOverlay = this.transformOne !== proto.transformOne || this.transformFilter !== proto.transformFilter;
3633
4355
  this._hasActionRowScope = this.actionRowScope !== proto.actionRowScope;
4356
+ this[ROW_RESOLVES] = this.resolveRowIds !== proto.resolveRowIds;
3634
4357
  this._hasProjectionHook = this.transformProjection !== proto.transformProjection;
3635
4358
  this._hasAllowedActions = this.allowedActions !== proto.allowedActions;
3636
4359
  this._hasDelegations = hasActionDelegations(new.target);
@@ -3647,6 +4370,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3647
4370
  sealedFor: (readable, prefix = "") => this._sealedFor(readable, prefix)
3648
4371
  };
3649
4372
  this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
4373
+ this._planner = this._decorations && new DecorationPlanner(this._decorations, {
4374
+ isVisible,
4375
+ scoped,
4376
+ preferred: this._preferredIdSet,
4377
+ capabilities: () => this.capabilities,
4378
+ firstVisibleField: () => this._invertibleFields.find((path) => isVisible(path))
4379
+ });
4380
+ }
4381
+ /**
4382
+ * The class's `@DbDecorations`, validated against the bound readable once
4383
+ * per class and readable (a `[moost-db]` error when invalid); warns once
4384
+ * when `decorateRows` is not implemented.
4385
+ */
4386
+ _resolveDecorations(ctor) {
4387
+ const meta = getAtscriptDbMate().read(ctor)?.atscript_db_decorations;
4388
+ if (!meta) return void 0;
4389
+ let perCtor = decorationIndexes.get(this.readable);
4390
+ if (!perCtor) decorationIndexes.set(this.readable, perCtor = /* @__PURE__ */ new Map());
4391
+ let index = perCtor.get(ctor);
4392
+ if (!index) {
4393
+ index = buildDecorationIndex(ctor.name, meta, {
4394
+ flatMap: this.readable.flatMap,
4395
+ relations: this.readable.relations,
4396
+ navFields: FieldCapabilityIndex.navPathsOf(this.readable),
4397
+ ownPaths: new Set(this._invertibleFields),
4398
+ writeOnly: this._writeOnlySet
4399
+ });
4400
+ perCtor.set(ctor, index);
4401
+ if (!this._decorates) this.logger.warn(`@DbDecorations declares ${index.keys.join(", ")} but ${ctor.name} does not implement decorateRows() — the keys are never filled`);
4402
+ }
4403
+ return index;
3650
4404
  }
3651
4405
  /**
3652
4406
  * The identifications this request may address rows through (since
@@ -3672,7 +4426,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3672
4426
  }
3673
4427
  _collectInvertibleFields() {
3674
4428
  const out = [];
3675
- const nav = this.capabilities.navFields;
4429
+ const nav = FieldCapabilityIndex.navPathsOf(this.readable);
3676
4430
  for (const fd of this.readable.fieldDescriptors) {
3677
4431
  if (fd.ignored) continue;
3678
4432
  if (selfOrAncestor(fd.path, nav) !== void 0) continue;
@@ -3729,7 +4483,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3729
4483
  * (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
3730
4484
  * answers exactly like a nonexistent index (400); a hidden DEFAULT text
3731
4485
  * index falls back to the `@db.column.searchable` substring search over
3732
- * visible fields (or ignores the term when there are none). A
4486
+ * visible fields (or ignores the term when there are none — on list
4487
+ * endpoints; query targets, delegated targets and {@link resolveQuery}
4488
+ * answer 400 `TARGET_INVALID` instead). A
3733
4489
  * `@db.column.derived` field is visible only while its source path is,
3734
4490
  * and one whose source is hidden is sealed out of every read projection
3735
4491
  * for the request, like a `@db.writeOnly` field. The same holds for a
@@ -3762,7 +4518,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3762
4518
  checkCapabilities(parsed) {
3763
4519
  const capabilities = this.capabilities;
3764
4520
  const isVisible = this.fieldVisibility.isVisible;
3765
- const refs = collectQueryPaths$1(parsed);
4521
+ const select = parsed.controls?.$select;
4522
+ const refs = collectQueryPaths$1(parsed, Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0);
3766
4523
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
3767
4524
  const relState = { nodes: 0 };
3768
4525
  for (const ref of refs.filter) {
@@ -3778,9 +4535,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3778
4535
  const recorded = parsed.controls ? this._clientWith.get(parsed.controls) : void 0;
3779
4536
  const withRelError = this._relationGate().checkWith(recorded === void 0 ? liveWith : recorded?.tree, relState);
3780
4537
  if (withRelError) return withRelError;
3781
- for (const op of PATH_OPS) for (const path of refs[op]) {
3782
- const verdict = capabilities.check(path, op, isVisible);
3783
- if (verdict) return badRequest(verdict.path, verdict.message);
4538
+ for (const op of PATH_OPS) {
4539
+ const gateOp = op === "select" && refs.aggregateMode ? "groupedSelect" : op;
4540
+ for (const path of refs[op]) {
4541
+ const verdict = capabilities.check(path, gateOp, isVisible);
4542
+ if (verdict) return badRequest(verdict.path, verdict.message);
4543
+ }
3784
4544
  }
3785
4545
  const having = checkHavingKeys(refs);
3786
4546
  if (having) return badRequest(having.path, having.message);
@@ -3806,7 +4566,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3806
4566
  navFields: capabilities.navFields
3807
4567
  });
3808
4568
  } catch (error) {
3809
- if (!(error instanceof DbError)) throw error;
4569
+ if (!(error instanceof DbError) || error.code !== "INVALID_QUERY") throw error;
3810
4570
  const [issue] = error.errors;
3811
4571
  return badRequest(issue.path, issue.message);
3812
4572
  }
@@ -3984,12 +4744,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3984
4744
  const sub = nested.$select ?? rel.$select;
3985
4745
  return {
3986
4746
  ...nested,
3987
- $select: this._sealSelect(sub, sealed)
4747
+ $select: this._sealSelect(sub, sealed, target)
3988
4748
  };
3989
4749
  });
3990
4750
  const out = {
3991
4751
  ...controls,
3992
- $select: this._sealSelect(select, vis.sealedFor(this.readable))
4752
+ $select: this._sealSelect(select, vis.sealedFor(this.readable), this.readable)
3993
4753
  };
3994
4754
  if ($with !== controls.$with) out.$with = $with;
3995
4755
  return out;
@@ -4258,34 +5018,24 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4258
5018
  * The rows the row-level action `actionName` may run on (since 0.1.145),
4259
5019
  * as an extra row filter; `undefined` or `{}` = no restriction (the
4260
5020
  * default). Enforced by the action gate — the action's ids / rows are
4261
- * loaded under {@link rowOverlay}, then checked against this filter, so an
4262
- * id outside it gets the same 404 "Row not found for action identifier"
5021
+ * loaded under {@link rowOverlay}, then checked against this filter (or —
5022
+ * without an overlay — the filter joins the load), so an id outside it gets
5023
+ * the same 404 "Row not found for action identifier"
4263
5024
  * as a missing one — and reflected in `$actions` and
4264
5025
  * `GET /meta/actions`, which list the action only on rows inside it.
4265
5026
  *
4266
- * Since 0.1.147 the hook receives the candidate rows (`ctx`), so a scope
4267
- * can depend on them — e.g. derive `{ ticketKey: { $in: … } }` from a
4268
- * related table read for exactly these rows:
4269
- *
4270
- * | `ctx.purpose` | asked by | `ctx.ids` |
4271
- * | --- | --- | --- |
4272
- * | `"execute"` | the action gate | the loaded ids / rows (≤ `maxIds`; one batch of a query target) |
4273
- * | `"rows"` | `$actions` on a read (and a view's delegated verdicts) | the read's rows |
4274
- * | `"available"` | `GET /meta/actions/:id` | the one row |
4275
- *
4276
- * - Called only with at least one candidate, at most once per action per
4277
- * evaluation; `ctx.ids` is the same array object for every action of
4278
- * one evaluation (memoize on it with a `WeakMap`).
4279
- * - Candidates are already inside the row overlay — ids that do not exist
4280
- * or fall outside it never reach the hook.
4281
- * - The result only restricts (`ids ∧ rowOverlay ∧ scope`); a throw fails
4282
- * the request — never a silent "allow".
4283
- * - The filter runs straight against the bound readable: it may use
4284
- * fields {@link hasField} hides, and nothing of it (nor of
4285
- * `ctx.loadRows`) reaches the response. Equal filters — the same object,
4286
- * or structurally equal ones — share one id-only query.
4287
- * - Runs after {@link prepareRequest} and, on the action route, after the
4288
- * request body is read (it needs the ids).
5027
+ * The hook receives the candidate rows (`ctx`), so a scope can depend on
5028
+ * them — what `ctx.purpose`, `ctx.ids` and `ctx.loadRows` hold, and when the
5029
+ * hook is asked before any load, is documented on {@link TDbActionScopeContext}.
5030
+ * Called only with at least one candidate, at most once per action per
5031
+ * evaluation. An answer restricting nothing (`undefined`, `null`, `{}`)
5032
+ * makes the gate load no row at all; a restriction is folded into the one
5033
+ * row load. The result only restricts (`ids ∧ rowOverlay ∧ scope`); a throw
5034
+ * fails the request — never a silent "allow". The filter runs straight
5035
+ * against the bound readable: it may use fields {@link hasField} hides, and
5036
+ * nothing of it reaches the response. Runs after {@link prepareRequest} and,
5037
+ * on the action route, after the request body is read and
5038
+ * {@link resolveRowIds}.
4289
5039
  *
4290
5040
  * Not overriding it costs nothing; a one-parameter override keeps working.
4291
5041
  * moost-db always passes `ctx` — it is optional in the signature only so
@@ -4338,6 +5088,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4338
5088
  * {@link checkCapabilities}, {@link hasField}), where the client
4339
5089
  * predicates' {@link transformRelationFilter} also runs: a query target
4340
5090
  * never filters on, nor counts by, a field the caller can't read.
5091
+ * {@link resolveQuery} does not call it (there is no action of this
5092
+ * controller to scope).
4341
5093
  *
4342
5094
  * @since 0.1.147
4343
5095
  */
@@ -4351,6 +5103,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4351
5103
  transformProjection(projection) {
4352
5104
  return projection;
4353
5105
  }
5106
+ /**
5107
+ * The shared projection step of `/query`, `/pages`, `/geo` and `/one`:
5108
+ * splits the declared decoration keys out of the wire `$select`
5109
+ * ({@link DecorationPlanner.plan}), runs {@link transformProjection} on the
5110
+ * rest (decoration keys never reach it — a permission layer needs no
5111
+ * change; their `requires` paths are added so the hook's inputs are read),
5112
+ * then seals every projection level. `finish` completes it once the
5113
+ * endpoint is past its own checks: the preferred-id widening (an
5114
+ * `HttpError` for a mixed `$select`) and the decoration step — what every
5115
+ * read endpoint then passes to {@link _runReadWithActions}.
5116
+ */
5117
+ async _projectRead(controls) {
5118
+ const plan = this._planner?.plan(controls.$select);
5119
+ const transformed = await this.transformProjection(plan ? plan.select : controls.$select);
5120
+ const sealed = this._sealControls(controls, transformed);
5121
+ const finish = () => {
5122
+ const select = this.widenPreferredIdProjection(sealed.$select);
5123
+ if (select instanceof HttpError) return select;
5124
+ let kept;
5125
+ const keptPaths = () => kept === void 0 ? kept = this._resolveProjectionForAugmenter(select) : kept;
5126
+ return {
5127
+ select,
5128
+ kept: keptPaths,
5129
+ read: plan && this._planner.serve(plan, keptPaths())
5130
+ };
5131
+ };
5132
+ return {
5133
+ sealed,
5134
+ finish
5135
+ };
5136
+ }
4354
5137
  widenPreferredIdProjection(projection) {
4355
5138
  const widened = this.widenQuantityRefProjection(projection);
4356
5139
  if (widened instanceof HttpError) return widened;
@@ -4372,12 +5155,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4372
5155
  return out;
4373
5156
  }
4374
5157
  _widenMapProjection(projection) {
4375
- const entries = Object.entries(projection);
4376
- if (entries.length === 0) return projection;
4377
- const included = /* @__PURE__ */ new Set();
4378
- const excluded = /* @__PURE__ */ new Set();
4379
- for (const [k, v] of entries) if (v === 1 || v === true) included.add(k);
4380
- else if (v === 0 || v === false) excluded.add(k);
5158
+ if (Object.keys(projection).length === 0) return projection;
5159
+ const shape = mapShape(projection);
5160
+ const included = new Set(shape.included);
5161
+ const excluded = new Set(shape.excluded);
4381
5162
  if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
4382
5163
  if (excluded.size === 0) {
4383
5164
  let allPresent = true;
@@ -4433,12 +5214,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4433
5214
  return [...projection, ...toAdd];
4434
5215
  }
4435
5216
  _widenQuantityMapProjection(projection) {
4436
- const entries = Object.entries(projection);
4437
- if (entries.length === 0) return projection;
4438
- const included = /* @__PURE__ */ new Set();
4439
- const excluded = /* @__PURE__ */ new Set();
4440
- for (const [k, v] of entries) if (v === 1 || v === true) included.add(k);
4441
- else if (v === 0 || v === false) excluded.add(k);
5217
+ if (Object.keys(projection).length === 0) return projection;
5218
+ const shape = mapShape(projection);
5219
+ const included = new Set(shape.included);
5220
+ const excluded = new Set(shape.excluded);
4442
5221
  if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
4443
5222
  if (excluded.size === 0) {
4444
5223
  const toAdd = [];
@@ -4453,22 +5232,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4453
5232
  }
4454
5233
  /** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
4455
5234
  _resolveProjectionForAugmenter(select) {
4456
- if (select === void 0) return null;
4457
- if (Array.isArray(select)) {
4458
- const out = [];
4459
- const seen = /* @__PURE__ */ new Set();
4460
- for (const item of select) if (typeof item === "string" && !seen.has(item)) {
4461
- seen.add(item);
4462
- out.push(item);
4463
- }
4464
- return out;
4465
- }
4466
- const obj = select;
4467
- const included = [];
4468
- const excluded = [];
4469
- for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
4470
- else if (v === 0 || v === false) excluded.push(k);
4471
- if (included.length > 0 && excluded.length === 0) return included;
5235
+ const shape = selectShape(select);
5236
+ if (shape.kind === "all") return null;
5237
+ if (shape.kind === "list") return [...new Set(shape.items.filter((item) => typeof item === "string"))];
5238
+ const { included, excluded } = shape;
5239
+ if (included.length > 0 && excluded.length === 0) return [...included];
4472
5240
  if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
4473
5241
  throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
4474
5242
  }
@@ -4514,7 +5282,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4514
5282
  }
4515
5283
  return result;
4516
5284
  }
4517
- async _prepareAugmentation(controls, select) {
5285
+ async _prepareAugmentation(controls, projected) {
4518
5286
  if (!controls.$actions) return null;
4519
5287
  const [own, delegations] = await Promise.all([this._resolveAugmentEnvelopes(), this._activeDelegations()]);
4520
5288
  if (own === null && delegations.length === 0) return null;
@@ -4524,7 +5292,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4524
5292
  scopeOverlay = this.rowOverlay();
4525
5293
  scopeOverlay.catch(() => {});
4526
5294
  }
4527
- let resolvedProjection = this._resolveProjectionForAugmenter(select);
5295
+ let resolvedProjection = projected.kept();
4528
5296
  let widenedSelect = resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection);
4529
5297
  if (resolvedProjection !== null && delegations.length > 0) {
4530
5298
  const base = widenedSelect ?? resolvedProjection;
@@ -4629,6 +5397,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4629
5397
  [ACTION_OVERLAY]() {
4630
5398
  return this.rowOverlay();
4631
5399
  }
5400
+ /**
5401
+ * @internal {@link resolveRowIds} for an action's validated ids (since
5402
+ * 0.1.148): the output validated, duplicate identities collapsed, and the
5403
+ * ids as the client sent them kept for the error bodies and summaries.
5404
+ */
5405
+ async [ROW_RESOLVE_IDS](ids, ctx) {
5406
+ return applyResolvedIds(ids, await this._runResolveRowIds(ids, ctx));
5407
+ }
4632
5408
  /** @internal {@link actionRowScope} for the gate's loaded candidates (since 0.1.147). */
4633
5409
  [ACTION_SCOPE](action, ctx) {
4634
5410
  return this._actionScope(action, ctx);
@@ -4650,18 +5426,76 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4650
5426
  async [RESOLVE_TARGET](req) {
4651
5427
  const { action } = req;
4652
5428
  const body = parseQueryTargetBody(action, req.query);
5429
+ const { rows, filter, findMany, exclude, visibleOf, cap } = await this._resolveMatching({
5430
+ label: action,
5431
+ body,
5432
+ cap: req.cap,
5433
+ maxExclude: req.maxExclude,
5434
+ overlay: req.overlay,
5435
+ scopeByAction: true,
5436
+ select: [...new Set(req.select)],
5437
+ sortBy: req.select,
5438
+ excludeShapes: req.excludeShapes,
5439
+ visibleOf: req.visibleOf
5440
+ });
5441
+ const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
5442
+ ...q,
5443
+ controls: {
5444
+ ...q.controls,
5445
+ $limit: Math.max(ids.length, cap + 1)
5446
+ }
5447
+ }) }, ids, scope, select);
5448
+ const snapshotFields = new Set(req.select);
5449
+ let first = true;
5450
+ return {
5451
+ matched: rows.length,
5452
+ rows,
5453
+ dryRun: body.dryRun === true,
5454
+ exclude,
5455
+ visibleOf,
5456
+ load: (ids, select) => {
5457
+ if (!first) return byIds(findMany, ids, filter, select);
5458
+ first = false;
5459
+ const fields = [...select];
5460
+ if (!fields.every((f) => snapshotFields.has(f))) {
5461
+ const plain = (q) => this.readable.findMany(q);
5462
+ return byIds(plain, ids, void 0, fields);
5463
+ }
5464
+ return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
5465
+ }
5466
+ };
5467
+ }
5468
+ /**
5469
+ * The shared resolver behind query targets and {@link resolveQuery}: the
5470
+ * query body validated, checked and run as a READ of this controller, then
5471
+ * ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
5472
+ * scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
5473
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
5474
+ * `TARGET_CHANGED`.
5475
+ *
5476
+ * Everything that depends on the read's visibility — the `$search`
5477
+ * fallback, the native-search memo, the un-appliable-term refusal and the
5478
+ * delegated identity — is computed INSIDE the read child, where the
5479
+ * permission layer's per-request state is the read's.
5480
+ */
5481
+ async _resolveMatching(spec) {
5482
+ const { label, body } = spec;
5483
+ const cap = Math.min(spec.cap, body.maxRows ?? Infinity);
4653
5484
  const parsed = this.parseUrlOr400(body.q.startsWith("?") ? body.q.slice(1) : body.q);
4654
5485
  const controls = {};
4655
5486
  for (const [k, v] of Object.entries(parsed.controls ?? {})) {
4656
5487
  if (v === void 0) continue;
4657
- if (k !== "$search" && k !== "$index") throw targetInvalid(action, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
4658
- if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(action, "$search must be a search term");
5488
+ if (k !== "$search" && k !== "$index") throw targetInvalid(label, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
5489
+ if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(label, "$search must be a search term");
4659
5490
  controls[k] = k === "$search" ? `${v}` : v;
4660
5491
  }
4661
- if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(action, "$index must be an index name");
5492
+ if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(label, "$index must be an index name");
4662
5493
  const exclude = body.exclude ?? [];
4663
- const shapes = req.excludeShapes ?? [];
5494
+ const shapes = spec.excludeShapes ?? [];
5495
+ const sealedSet = () => new Set([...this.fieldVisibility.sealedFor(this.readable), ...this._leavesOf(this.readable).filter((leaf) => !this.fieldVisibility.isVisible(leaf))]);
4664
5496
  const check = () => {
5497
+ let sealed;
5498
+ const readableLeaf = (leaf) => !(sealed ??= sealedSet()).has(leaf);
4665
5499
  const controlsError = this.validateControls(controls, "query");
4666
5500
  if (controlsError) throw new HttpError(400, controlsError);
4667
5501
  const gateError = this.checkCapabilities({
@@ -4669,71 +5503,160 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4669
5503
  controls
4670
5504
  });
4671
5505
  if (gateError) throw gateError;
4672
- if (exclude.length > 0) validateMultiId(exclude, shapes.length === 0 ? this.idSource : {
4673
- identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
4674
- fields,
4675
- source: "target"
4676
- }))],
4677
- fieldDescriptors: this.readable.fieldDescriptors
4678
- }, req.maxExclude);
5506
+ if (exclude.length > 0) {
5507
+ const source = shapes.length === 0 ? this.idSource : {
5508
+ identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
5509
+ fields,
5510
+ source: "target"
5511
+ }))],
5512
+ fieldDescriptors: this.readable.fieldDescriptors
5513
+ };
5514
+ try {
5515
+ validateMultiId(exclude, source, spec.maxExclude);
5516
+ } catch (error) {
5517
+ throw validatorErrorToHttp(error) ?? error;
5518
+ }
5519
+ }
5520
+ const nav = FieldCapabilityIndex.navPathsOf(this.readable);
5521
+ for (const path of spec.selectGate ?? []) {
5522
+ if (this.capabilities.decorationCap(path) || selfOrAncestor(path, nav) !== void 0) throw badRequest(path, `Unknown field "${path}"`);
5523
+ const verdict = this.capabilities.check(path, "select", this.fieldVisibility.isVisible);
5524
+ if (verdict) throw badRequest(verdict.path, verdict.message);
5525
+ if (this._writeOnlySet.has(path)) throw badRequest(path, `Field "${path}" is @db.writeOnly`);
5526
+ const prefix = `${path}.`;
5527
+ const leaves = this._leavesOf(this.readable).filter((leaf) => leaf.startsWith(prefix));
5528
+ if (leaves.length > 0 && !leaves.some((leaf) => readableLeaf(leaf))) throw badRequest(path, `Unknown field "${path}"`);
5529
+ }
5530
+ for (const path of spec.sortGate ?? []) {
5531
+ const verdict = this.capabilities.check(path, "sort", this.fieldVisibility.isVisible);
5532
+ if (verdict) throw targetInvalid(label, verdict.message);
5533
+ }
4679
5534
  };
4680
- const ownScope = req.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope;
4681
- const [[base, scope], overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
5535
+ const ownScope = spec.scopeByAction === true && (spec.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope);
5536
+ const [read, overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
4682
5537
  check();
4683
- const [clientFilter, readScope] = await Promise.all([this._relationOverlay(parsed), ownScope ? this.queryTargetScope(action) : void 0]);
4684
- return [req.overlay === "read" ? await this.transformFilter(clientFilter ?? {}) : clientFilter, readScope];
4685
- }), req.overlay === "action" ? this.rowOverlay() : void 0]);
4686
- const filter = andFilters(this.applySearchFallback(base, controls), overlay, scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
4687
- const strategy = await this._resolveReadStrategy(controls);
5538
+ const [clientFilter, readScope, strategy] = await Promise.all([
5539
+ this._relationOverlay(parsed),
5540
+ ownScope ? this.queryTargetScope(label) : void 0,
5541
+ this._resolveReadStrategy(controls)
5542
+ ]);
5543
+ const base = spec.overlay === "action" ? clientFilter : await this.transformFilter(clientFilter ?? {});
5544
+ const searched = this.applySearchFallback(base, controls);
5545
+ if (controls.$search && strategy.kind !== "search" && searched === base) throw targetInvalid(label, "$search is not available here");
5546
+ const visibleOf = spec.visibleOf?.filter((f) => this.fieldVisibility.isVisible(f));
5547
+ let select = spec.select;
5548
+ if (spec.selectGate) {
5549
+ const sealed = sealedSet();
5550
+ for (const id of spec.identity ?? []) sealed.delete(id);
5551
+ select = this._sealSelect([...spec.select], sealed, this.readable);
5552
+ }
5553
+ return {
5554
+ searched,
5555
+ readScope,
5556
+ strategy,
5557
+ visibleOf,
5558
+ select
5559
+ };
5560
+ }, spec.routeParams), spec.overlay === "action" ? this.rowOverlay() : void 0]);
5561
+ const { strategy } = read;
5562
+ const filter = andFilters(read.searched, overlay, read.readScope, spec.scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
4688
5563
  const findMany = (q) => strategy.kind === "search" ? this.readable.search(strategy.term, q, strategy.index) : this.readable.findMany(q);
4689
- const cap = Math.min(req.cap, body.maxRows ?? Infinity);
4690
- const sort = {};
4691
- for (const f of req.select) sort[f] = 1;
4692
5564
  const rows = await findMany({
4693
5565
  filter,
4694
5566
  controls: {
4695
- $select: [...new Set(req.select)],
4696
- $sort: sort,
5567
+ $select: [...read.select],
5568
+ $sort: Object.fromEntries(spec.sortBy.map((f) => [f, 1])),
4697
5569
  $limit: cap + 1
4698
5570
  }
4699
5571
  });
4700
- if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", action, `The query matches more than ${cap} rows`, { cap });
4701
- if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", action, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
4702
- const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
4703
- ...q,
4704
- controls: {
4705
- ...q.controls,
4706
- $limit: Math.max(ids.length, cap + 1)
4707
- }
4708
- }) }, ids, scope, select);
4709
- const snapshotFields = new Set(req.select);
4710
- let first = true;
5572
+ if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", label, `The query matches more than ${cap} rows`, { cap });
5573
+ if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", label, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
4711
5574
  return {
4712
- matched: rows.length,
4713
5575
  rows,
4714
- dryRun: body.dryRun === true,
5576
+ filter,
5577
+ findMany,
4715
5578
  exclude,
4716
- load: (ids, select) => {
4717
- if (!first) return byIds(findMany, ids, filter, select);
4718
- first = false;
4719
- const fields = [...select];
4720
- if (!fields.every((f) => snapshotFields.has(f))) {
4721
- const plain = (q) => this.readable.findMany(q);
4722
- return byIds(plain, ids, void 0, fields);
4723
- }
4724
- return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
4725
- }
5579
+ visibleOf: read.visibleOf,
5580
+ cap,
5581
+ select: read.select
4726
5582
  };
4727
5583
  }
4728
5584
  /**
5585
+ * Rows of THIS controller matching `q`, resolved as a READ of it for the
5586
+ * current event's caller (since 0.1.149) — from your own command, e.g. to
5587
+ * act on "every issue matching this search". `q` is a `GET /query` string
5588
+ * (`$search` / `$index` and a filter only) or a query-target envelope
5589
+ * `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
5590
+ *
5591
+ * The read runs under this controller's full read policy
5592
+ * ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
5593
+ * {@link validateControls}, the capability / index gate and the
5594
+ * {@link transformFilter} overlay) with the current event's identity.
5595
+ * Route interceptors and guards of the `query` route do not run; read
5596
+ * authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
5597
+ * is not called. Hooks see no route params of the caller (only a call from
5598
+ * the routed event's own controller instance keeps its params); pass route-derived
5599
+ * restrictions as `opts.scope`. Joins the caller's open transaction.
5600
+ *
5601
+ * Rows are ordered by identity (`preferredId`, else the primary key) and
5602
+ * carry the identity fields plus `opts.select` (gated like `/query`
5603
+ * `$select`; `transformProjection` is not applied — hide fields with
5604
+ * {@link hasField}; decoration keys and navigation paths are refused). An
5605
+ * identity-less readable is ordered by `select` (each path sortable, else
5606
+ * `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
5607
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
5608
+ * `TARGET_CHANGED`; a `$search` that can't be applied → 400
5609
+ * `TARGET_INVALID`. Must be awaited inside a running event handler.
5610
+ */
5611
+ async resolveQuery(q, opts = {}) {
5612
+ let caller;
5613
+ try {
5614
+ caller = current();
5615
+ } catch {
5616
+ throw new Error("[moost-db] resolveQuery must be awaited inside an event handler");
5617
+ }
5618
+ const cap = opts.cap ?? 1e3;
5619
+ if (!Number.isInteger(cap) || cap < 1) throw new Error("[moost-db] resolveQuery: `cap` must be a positive integer");
5620
+ const select = opts.select ?? [];
5621
+ if (!Array.isArray(select) || select.some((p) => typeof p !== "string")) throw new Error("[moost-db] resolveQuery: `select` must be an array of field paths");
5622
+ const ids = this.readable.preferredId?.length ? this.readable.preferredId : this.readable.primaryKeys;
5623
+ const order = ids.length > 0 ? ids : select;
5624
+ if (order.length === 0) throw new Error("[moost-db] resolveQuery: this readable has no identity — pass `select` (rows are ordered by it)");
5625
+ const label = readCurrentActionMeta(caller)?.name ?? "";
5626
+ const body = parseQueryTargetBody(label, typeof q === "string" ? { q } : q);
5627
+ if (body.dryRun !== void 0) throw targetInvalid(label, "resolveQuery takes no `dryRun` — read `query.dryRun` yourself");
5628
+ let sameRoute = false;
5629
+ try {
5630
+ sameRoute = controllerOf(caller) === this && routedController(caller) === this;
5631
+ } catch {}
5632
+ const fields = [...new Set([...ids, ...select])];
5633
+ const { rows, select: sealedSelect } = await this._resolveMatching({
5634
+ label,
5635
+ body,
5636
+ cap,
5637
+ maxExclude: DEFAULT_MAX_ACTION_IDS,
5638
+ overlay: "read",
5639
+ scope: opts.scope,
5640
+ select: fields,
5641
+ sortBy: order,
5642
+ selectGate: select,
5643
+ identity: ids,
5644
+ sortGate: ids.length > 0 ? void 0 : order,
5645
+ routeParams: sameRoute ? void 0 : {}
5646
+ });
5647
+ return rows.map((row) => projectRow$1(row, sealedSelect));
5648
+ }
5649
+ /**
4729
5650
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
4730
5651
  * the current event whose controller context is this controller's `query`
4731
5652
  * handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
4732
5653
  * request-scoped state a permission layer builds there (read grant, field
4733
- * visibility) is the read's and stays in the child.
5654
+ * visibility) is the read's and stays in the child. `routeParams` (since
5655
+ * 0.1.149) replaces the route params the child's hooks read.
4734
5656
  */
4735
- _asRead(controls, filter, fn) {
5657
+ _asRead(controls, filter, fn, routeParams) {
4736
5658
  return runAsController(this, "query", async () => {
5659
+ if (routeParams) current().set(routeParamsKey, routeParams);
4737
5660
  if (typeof this.prepareRequest === "function") await this.prepareRequest(readRequestContext("query", controls, filter));
4738
5661
  return fn();
4739
5662
  });
@@ -4757,9 +5680,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4757
5680
  /**
4758
5681
  * `select` without the `sealed` paths (see {@link _sealControls}); an
4759
5682
  * exclusion of them is forced when there is no projection, or when every
4760
- * requested path was sealed.
5683
+ * requested path was sealed. An inclusion naming a PARENT of a sealed path
5684
+ * (`secret` over a write-only `secret.hash`) is replaced by the parent's
5685
+ * unsealed leaves, so a sealed descendant never rides along with it.
4761
5686
  */
4762
- _sealSelect(select, writeOnly) {
5687
+ _sealSelect(select, writeOnly, readable) {
4763
5688
  if (writeOnly.size === 0) return select;
4764
5689
  const exclusion = () => {
4765
5690
  const out = {};
@@ -4767,29 +5692,47 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4767
5692
  return out;
4768
5693
  };
4769
5694
  if (select === void 0) return exclusion();
5695
+ const expand = (path) => {
5696
+ if (writeOnly.has(path)) return [];
5697
+ const prefix = `${path}.`;
5698
+ let parent = false;
5699
+ for (const sealed of writeOnly) if (sealed.startsWith(prefix)) {
5700
+ parent = true;
5701
+ break;
5702
+ }
5703
+ if (!parent) return [path];
5704
+ return this._leavesOf(readable).filter((leaf) => leaf.startsWith(prefix) && selfOrAncestor(leaf, writeOnly) === void 0);
5705
+ };
4770
5706
  if (Array.isArray(select)) {
4771
- const kept = select.filter((item) => typeof item === "string" ? !writeOnly.has(item) : !writeOnly.has(item.$field ?? ""));
5707
+ const kept = [];
5708
+ for (const item of select) if (typeof item === "string") kept.push(...expand(item));
5709
+ else if (!writeOnly.has(item.$field ?? "")) kept.push(item);
4772
5710
  return kept.length > 0 ? kept : exclusion();
4773
5711
  }
4774
5712
  const entries = Object.entries(select);
4775
5713
  if (entries.length > 0 && (entries[0][1] === 1 || entries[0][1] === true)) {
4776
5714
  const out = {};
4777
- for (const [k, v] of entries) if (!writeOnly.has(k)) out[k] = v;
5715
+ for (const [k, v] of entries) for (const path of expand(k)) out[path] = v;
4778
5716
  return Object.keys(out).length > 0 ? out : exclusion();
4779
5717
  }
4780
5718
  const out = { ...select };
4781
5719
  for (const f of writeOnly) out[f] = 0;
4782
5720
  return out;
4783
5721
  }
4784
- /** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
4785
- _findWriteOnlyInAggregate(groupBy, select) {
4786
- if (this._writeOnlySet.size === 0) return void 0;
4787
- const sealed = (f) => this._writeOnlySet.has(f) && this.fieldVisibility.isVisible(f);
4788
- for (const f of groupBy) if (sealed(f)) return f;
4789
- if (Array.isArray(select)) for (const item of select) {
4790
- const field = typeof item === "string" ? item : item.$field;
4791
- if (field && sealed(field)) return field;
5722
+ /** Own leaf field paths (no navigation, no ignored field) of `readable`, once per readable. */
5723
+ _leavesOf(readable) {
5724
+ let leaves = this._targetLeaves.get(readable);
5725
+ if (!leaves) {
5726
+ const paths = [...readable.flatMap?.keys() ?? []].filter((path) => path !== "");
5727
+ const parents = /* @__PURE__ */ new Set();
5728
+ for (const path of paths) for (let at = path.indexOf("."); at >= 0; at = path.indexOf(".", at + 1)) parents.add(path.slice(0, at));
5729
+ const nav = readable.navFields ?? /* @__PURE__ */ new Set();
5730
+ const ignored = /* @__PURE__ */ new Set();
5731
+ for (const fd of readable.fieldDescriptors) if (fd.ignored) ignored.add(fd.path);
5732
+ leaves = paths.filter((path) => !parents.has(path) && selfOrAncestor(path, nav) === void 0 && selfOrAncestor(path, ignored) === void 0);
5733
+ this._targetLeaves.set(readable, leaves);
4792
5734
  }
5735
+ return leaves;
4793
5736
  }
4794
5737
  /**
4795
5738
  * Merges the `$search` fallback into the filter: a case-insensitive literal
@@ -4797,7 +5740,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4797
5740
  * with the existing filter. Applies only when native search does not serve
4798
5741
  * the request (no native search, or — since 0.1.143 — its default index
4799
5742
  * reads a field {@link hasField} hides) and the request isn't a vector
4800
- * search (`$vector` consumes the term).
5743
+ * search (`$vector` consumes the term). Lenient on list endpoints: a term
5744
+ * nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
5745
+ * refuse it — a subclass override that applies the term must return a new
5746
+ * filter object.
4801
5747
  */
4802
5748
  applySearchFallback(filter, controls) {
4803
5749
  const term = controls.$search;
@@ -4859,13 +5805,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4859
5805
  * subclass implements it. Returns the hook's result — `undefined`, with no
4860
5806
  * promise or microtask, when there is no hook or it is synchronous.
4861
5807
  */
4862
- _finishRows(rows, prep, ctx) {
5808
+ _finishRows(rows, prep, ctx, read) {
4863
5809
  const overlay = prep?.scopeOverlay;
4864
- if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx);
5810
+ if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx, read);
4865
5811
  return (async () => {
4866
5812
  const names = prep.envelopes.map((e) => e.info.name);
4867
5813
  const [masks, delegated] = await Promise.all([overlay ? this._scopeMasks(rows, names, "rows", overlay) : void 0, Promise.all(prep.delegations.map((d) => this._delegatedRowVerdicts(rows, d)))]);
4868
- await this._augmentAndDecorate(rows, prep, ctx, masks, delegated);
5814
+ await this._augmentAndDecorate(rows, prep, ctx, read, masks, delegated);
4869
5815
  })();
4870
5816
  }
4871
5817
  /**
@@ -4873,7 +5819,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4873
5819
  * ones (`delegated`: per delegation, per row) — then {@link decorateRows}
4874
5820
  * when implemented.
4875
5821
  */
4876
- _augmentAndDecorate(rows, prep, ctx, outOfScope, delegated) {
5822
+ _augmentAndDecorate(rows, prep, ctx, read, outOfScope, delegated) {
4877
5823
  if (prep) {
4878
5824
  augmentRowsWithActions({
4879
5825
  envelopes: prep.envelopes,
@@ -4884,7 +5830,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4884
5830
  });
4885
5831
  if (prep.delegations.length > 0) mergeDelegatedActions(rows, delegated ?? []);
4886
5832
  }
4887
- return this._decorates ? this.decorateRows(rows, ctx) : void 0;
5833
+ const decorated = this._decorates ? this.decorateRows(rows, ctx) : void 0;
5834
+ if (!read || stripsNothing(read)) return decorated;
5835
+ const strip = () => stripDecorations(rows, read);
5836
+ if (decorated === void 0) return strip();
5837
+ return Promise.resolve(decorated).then(strip);
4888
5838
  }
4889
5839
  /**
4890
5840
  * The app of the current event, through DI — never the one this
@@ -4960,9 +5910,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4960
5910
  */
4961
5911
  resolveMeta() {
4962
5912
  const own = super.resolveMeta();
4963
- if (!this._hasDelegations && !this._hasFieldOverridden) return own;
5913
+ if (!this._hasDelegations && !this._hasFieldOverridden && !this._planner) return own;
4964
5914
  return (async () => {
4965
- const [meta, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
5915
+ const [overlaid, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
5916
+ const meta = this._planner ? this._planner.meta(overlaid) : overlaid;
4966
5917
  const visible = this._applyIndexVisibility(meta);
4967
5918
  return delegated.length > 0 ? {
4968
5919
  ...visible,
@@ -5016,7 +5967,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5016
5967
  /** @internal Source side of a delegation: `GET /meta/actions` for one id, `names` only. */
5017
5968
  async [AVAILABLE_ACTIONS](id, names) {
5018
5969
  await this.parseRequest("availableActions");
5019
- return this._availableActions(id, names);
5970
+ return (await this._availableResolved(id, names)).own;
5020
5971
  }
5021
5972
  /** @internal Source side of a delegation: the `names` the caller may run (`allowedActions`). */
5022
5973
  async [ALLOWED_ACTIONS](names) {
@@ -5035,8 +5986,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5035
5986
  * `$actions=true`, then run {@link decorateRows}. Caller dispatches the
5036
5987
  * strategy to its read-method family (count vs no-count).
5037
5988
  */
5038
- async _runReadWithActions(endpoint, queryObj, controls, select, exec) {
5039
- const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, select), this._resolveReadStrategy(controls)]);
5989
+ async _runReadWithActions(endpoint, queryObj, controls, projected, exec) {
5990
+ const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveReadStrategy(controls)]);
5040
5991
  const result = await exec(prep?.widenedSelect ? {
5041
5992
  ...queryObj,
5042
5993
  controls: {
@@ -5046,19 +5997,120 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5046
5997
  } : queryObj, strategy);
5047
5998
  const pending = this._finishRows(result.data, prep, {
5048
5999
  endpoint,
5049
- projection: select,
5050
- controls
5051
- });
6000
+ projection: projected.select,
6001
+ controls,
6002
+ decorations: projected.read?.served ?? NO_DECORATIONS
6003
+ }, projected.read);
5052
6004
  if (pending) await pending;
5053
6005
  return result;
5054
6006
  }
5055
6007
  /**
6008
+ * Maps the ids an id-addressed endpoint received to the rows' current ids
6009
+ * (since 0.1.148) — the seam for stale or alias ids, e.g. a natural key that
6010
+ * was renamed. Called once per request, after {@link prepareRequest} and
6011
+ * after the request's own validation, before anything reads the row, by:
6012
+ *
6013
+ * | `ctx.purpose` | endpoint | `ids` |
6014
+ * | --- | --- | --- |
6015
+ * | `"one"` | `GET /one/:id`, `GET /one?…` | one id: the path string, or the `?`-form identification object |
6016
+ * | `"available"` | `GET /meta/actions/:id`, `?…` (and a view asking its source) | one id, as above |
6017
+ * | `"remove"` | `DELETE /:id`, `DELETE /?…` (`AsDbController`) | one id, as above |
6018
+ * | `"action"` | an action route | the body's validated ids (one for a `'row'` action) |
6019
+ *
6020
+ * Contract:
6021
+ *
6022
+ * - Return one id per input id, index-aligned (anything else is a 500). An
6023
+ * id that already names a row must come back UNCHANGED — the current
6024
+ * holder of a key wins over an alias; so must an id you cannot resolve
6025
+ * (never throw for an unknown alias: a custom error is an oracle — the
6026
+ * endpoint then answers its normal miss).
6027
+ * - The output is validated (a server bug is a 500, never a client 400): a
6028
+ * scalar is resolved like a path scalar (primary key first, then the
6029
+ * visible unique keys, inside the row overlay); an object must be one of
6030
+ * the visible identifications ({@link idSource}); an `"action"` id must
6031
+ * be such an object.
6032
+ * - The resolved id is never trusted for access: the endpoint still reads
6033
+ * or deletes it under {@link rowOverlay} and the visible identifications.
6034
+ * `ctx.overlay` is that overlay — resolve INSIDE it when an alias could
6035
+ * name several rows, so a row the caller can't reach never shadows one
6036
+ * they can. Don't log or return the canonical id in errors.
6037
+ * - Handlers (`@DbActionID()`, `useDbActionId()`), {@link rowOverlay} reads,
6038
+ * {@link actionRowScope} and `onRemove` / `guardRemove` receive the
6039
+ * resolved ids; error bodies and `summary()` echo the ids the client sent.
6040
+ * - Write bodies (`POST` / `PUT` / `PATCH`) are not resolved — use
6041
+ * `onWrite`. Not called by value-help controllers, `$actions` on a read
6042
+ * or a query target. Not overriding it costs nothing.
6043
+ *
6044
+ * It is NOT overridden by {@link resolveRowFilter}, which does not take part
6045
+ * in `/one` for real tables and views.
6046
+ *
6047
+ * ```ts
6048
+ * protected async resolveRowIds(ids: readonly TDbRowIdInput[], ctx: TDbRowIdsContext) {
6049
+ * return Promise.all(ids.map(async (id) => {
6050
+ * const key = typeof id === "object" ? id.code : id
6051
+ * if (typeof key !== "string") return id
6052
+ * // the current holder of the key wins; consult the alias table on a miss
6053
+ * // resolve INSIDE the overlay: a row the caller cannot reach never wins
6054
+ * const inScope = (code: string) =>
6055
+ * this.readable.count({ filter: ctx.overlay ? { $and: [{ code }, ctx.overlay] } : { code } })
6056
+ * if (await inScope(key)) return id
6057
+ * const alias = await aliases.findOne({ filter: { oldCode: key } })
6058
+ * if (!alias || !(await inScope(alias.newCode))) return id
6059
+ * return typeof id === "object" ? { code: alias.newCode } : alias.newCode
6060
+ * }))
6061
+ * }
6062
+ * ```
6063
+ *
6064
+ * @since 0.1.148
6065
+ */
6066
+ resolveRowIds(ids, _ctx) {
6067
+ return ids;
6068
+ }
6069
+ /** {@link resolveRowIds} with its output validated (a server bug is a 500). */
6070
+ async _runResolveRowIds(ids, ctx) {
6071
+ const out = await this.resolveRowIds(ids, ctx);
6072
+ if (!Array.isArray(out) || out.length !== ids.length) throw new HttpError(500, "resolveRowIds must return one id per request id");
6073
+ const source = this.idSource;
6074
+ for (const id of out) {
6075
+ if ((typeof id === "string" || typeof id === "number" || typeof id === "boolean") && ctx.purpose !== "action") continue;
6076
+ try {
6077
+ validateSingleId(id, source, { strictTypes: ctx.purpose === "action" });
6078
+ } catch (error) {
6079
+ if (error instanceof ValidatorError) throw new HttpError(500, "resolveRowIds returned an invalid id");
6080
+ throw error;
6081
+ }
6082
+ }
6083
+ return out;
6084
+ }
6085
+ /**
6086
+ * @internal The one id of an id-addressed endpoint through
6087
+ * {@link resolveRowIds} (identity, at no cost, when it is not overridden)
6088
+ * and the row overlay it was resolved inside — computed once, for the
6089
+ * endpoint's read or delete under that overlay.
6090
+ */
6091
+ async _resolveWithOverlay(id, purpose) {
6092
+ const overlay = await this.rowOverlay();
6093
+ if (!this[ROW_RESOLVES]) return {
6094
+ id,
6095
+ overlay
6096
+ };
6097
+ const [resolved] = await this._runResolveRowIds([id], {
6098
+ purpose,
6099
+ overlay
6100
+ });
6101
+ return {
6102
+ id: resolved,
6103
+ overlay
6104
+ };
6105
+ }
6106
+ /**
5056
6107
  * The filter addressing exactly the ONE row `id` means — the readable's
5057
6108
  * PK-first `resolveRowFilter` (since 0.1.143) under this request's
5058
6109
  * identifications (`_idOpts`). `scope` (the row overlay) restricts which
5059
6110
  * rows count while the id is pinned, so a row outside it never shadows one
5060
6111
  * inside it. Readables without it (partial mocks) fall back to
5061
- * `resolveIdFilter`.
6112
+ * `resolveIdFilter`. Not an alias seam: `/one` reads through `findOneByRow`
6113
+ * on every real table or view — map stale ids in {@link resolveRowIds}.
5062
6114
  */
5063
6115
  resolveRowFilter(id, scope) {
5064
6116
  const readable = this.readable;
@@ -5132,8 +6184,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5132
6184
  if (groupBy?.length && controls.$vector !== void 0) return new HttpError(400, "Cannot combine $vector and $groupBy in the same query");
5133
6185
  const error = this.validateParsed(parsed, "query");
5134
6186
  if (error) return error;
5135
- if (groupBy?.length) {
5136
- const sealed = this._findWriteOnlyInAggregate(groupBy, controls.$select);
6187
+ if (groupBy?.length && this._writeOnlySet.size > 0) {
6188
+ const refs = collectQueryPaths$1(parsed, true);
6189
+ const sealed = [
6190
+ ...refs.groupBy,
6191
+ ...refs.aggregate,
6192
+ ...refs.bucket,
6193
+ ...refs.select,
6194
+ ...refs.sort
6195
+ ].find((path) => this._writeOnlySet.has(path) && this.fieldVisibility.isVisible(path));
5137
6196
  if (sealed) return new HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
5138
6197
  }
5139
6198
  const gateError = this.checkCapabilities(parsed);
@@ -5147,9 +6206,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5147
6206
  insights: parsed.insights
5148
6207
  });
5149
6208
  }
5150
- const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(clientFilter), this.transformProjection(controls.$select)]);
6209
+ const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
5151
6210
  const filter = this.applySearchFallback(transformedFilter, controls);
5152
- const sealed = this._sealControls(controls, transformedSelect);
5153
6211
  if (controls.$count) return this.readable.count({
5154
6212
  filter,
5155
6213
  controls: {
@@ -5157,19 +6215,19 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5157
6215
  $select: sealed.$select
5158
6216
  }
5159
6217
  });
5160
- const select = this.widenPreferredIdProjection(sealed.$select);
5161
- if (select instanceof HttpError) return select;
6218
+ const projected = finish();
6219
+ if (projected instanceof HttpError) return projected;
5162
6220
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
5163
6221
  const queryObj = {
5164
6222
  filter,
5165
6223
  controls: {
5166
6224
  ...sealed,
5167
- $select: select,
6225
+ $select: projected.select,
5168
6226
  $limit: controls.$limit || 1e3,
5169
6227
  $threshold: threshold
5170
6228
  }
5171
6229
  };
5172
- return (await this._runReadWithActions("query", queryObj, controls, select, async (q, strategy) => {
6230
+ return (await this._runReadWithActions("query", queryObj, controls, projected, async (q, strategy) => {
5173
6231
  switch (strategy.kind) {
5174
6232
  case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
5175
6233
  case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
@@ -5190,23 +6248,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5190
6248
  const page = Math.max(Number(controls.$page || 1), 1);
5191
6249
  const size = Math.max(Number(controls.$size || 10), 1);
5192
6250
  const skip = (page - 1) * size;
5193
- const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(clientFilter), this.transformProjection(controls.$select)]);
6251
+ const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
5194
6252
  const filter = this.applySearchFallback(transformedFilter, controls);
5195
- const sealed = this._sealControls(controls, transformedSelect);
5196
- const select = this.widenPreferredIdProjection(sealed.$select);
5197
- if (select instanceof HttpError) return select;
6253
+ const projected = finish();
6254
+ if (projected instanceof HttpError) return projected;
5198
6255
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
5199
6256
  const query = {
5200
6257
  filter,
5201
6258
  controls: {
5202
6259
  ...sealed,
5203
- $select: select,
6260
+ $select: projected.select,
5204
6261
  $skip: skip,
5205
6262
  $limit: size,
5206
6263
  $threshold: threshold
5207
6264
  }
5208
6265
  };
5209
- const result = await this._runReadWithActions("pages", query, controls, select, async (q, strategy) => {
6266
+ const result = await this._runReadWithActions("pages", query, controls, projected, async (q, strategy) => {
5210
6267
  switch (strategy.kind) {
5211
6268
  case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
5212
6269
  case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
@@ -5248,10 +6305,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5248
6305
  const gateError = this.checkCapabilities(parsed);
5249
6306
  if (gateError) return gateError;
5250
6307
  const clientFilter = await this._relationOverlay(parsed);
5251
- const [filter, transformedSelect] = await Promise.all([this.transformFilter(clientFilter), this.transformProjection(controls.$select)]);
5252
- const sealed = this._sealControls(controls, transformedSelect);
5253
- const select = this.widenPreferredIdProjection(sealed.$select);
5254
- if (select instanceof HttpError) return select;
6308
+ const [filter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
6309
+ const projected = finish();
6310
+ if (projected instanceof HttpError) return projected;
5255
6311
  const paginated = controls.$page !== void 0 || controls.$size !== void 0;
5256
6312
  const page = Math.max(Number(controls.$page || 1), 1);
5257
6313
  const size = Math.max(Number(controls.$size || 10), 1);
@@ -5261,7 +6317,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5261
6317
  ...sealed,
5262
6318
  $center: void 0,
5263
6319
  $index: void 0,
5264
- $select: select,
6320
+ $select: projected.select,
5265
6321
  ...paginated ? {
5266
6322
  $skip: (page - 1) * size,
5267
6323
  $limit: size
@@ -5269,7 +6325,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5269
6325
  }
5270
6326
  };
5271
6327
  if (paginated) {
5272
- const result = await this._runReadWithActions("geo", queryObj, controls, select, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
6328
+ const result = await this._runReadWithActions("geo", queryObj, controls, projected, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
5273
6329
  return {
5274
6330
  data: result.data,
5275
6331
  page,
@@ -5278,7 +6334,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5278
6334
  count: result.count
5279
6335
  };
5280
6336
  }
5281
- return (await this._runReadWithActions("geo", queryObj, controls, select, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
6337
+ return (await this._runReadWithActions("geo", queryObj, controls, projected, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
5282
6338
  }
5283
6339
  /** Parses the `$center` control: `"lng,lat"` string (or tuple) → `[number, number]`. */
5284
6340
  _parseGeoCenter(raw) {
@@ -5327,21 +6383,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5327
6383
  const error = this.validateParsed(parsed, "getOne") ?? this.checkCapabilities(parsed);
5328
6384
  if (error) return error;
5329
6385
  await this._relationOverlay(parsed);
5330
- const sealed = this._sealControls(controls, await this.transformProjection(controls.$select));
5331
- const select = this.widenPreferredIdProjection(sealed.$select);
5332
- if (select instanceof HttpError) return select;
5333
- const [prep, overlay] = await Promise.all([this._prepareAugmentation(controls, select), this.rowOverlay()]);
6386
+ const { sealed, finish } = await this._projectRead(controls);
6387
+ const projected = finish();
6388
+ if (projected instanceof HttpError) return projected;
6389
+ const [prep, { id: resolvedId, overlay }] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveWithOverlay(id, "one")]);
5334
6390
  const readControls = {
5335
6391
  ...sealed,
5336
- $select: prep?.widenedSelect ?? select
6392
+ $select: prep?.widenedSelect ?? projected.select
5337
6393
  };
5338
- const item = await this.returnOne(this._findRow(id, overlay, readControls));
6394
+ const item = await this.returnOne(this._findRow(resolvedId, overlay, readControls));
5339
6395
  if (item instanceof HttpError) return item;
5340
6396
  const pending = this._finishRows([item], prep, {
5341
6397
  endpoint: "one",
5342
- projection: select,
5343
- controls
5344
- });
6398
+ projection: projected.select,
6399
+ controls,
6400
+ decorations: projected.read?.served ?? NO_DECORATIONS
6401
+ }, projected.read);
5345
6402
  if (pending) await pending;
5346
6403
  return item;
5347
6404
  }
@@ -5358,10 +6415,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5358
6415
  */
5359
6416
  async availableActionsById(id) {
5360
6417
  await this.parseRequest("availableActions");
5361
- const own = await this._availableActions(id);
6418
+ const { own, id: resolved } = await this._availableResolved(id);
5362
6419
  if (!this._hasDelegations) return own;
5363
- const preferred = this.readable.preferredId;
5364
- return this._delegatedAvailable(own, (d) => preferred.length === 1 && d.paths.every((p) => p === preferred[0]) ? Object.fromEntries(Object.keys(d.idMap).map((f) => [f, id])) : void 0);
6420
+ return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved));
5365
6421
  }
5366
6422
  /**
5367
6423
  * **GET /meta/actions?field1=val1&…** — {@link availableActionsById} by
@@ -5371,13 +6427,50 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5371
6427
  async availableActions(query) {
5372
6428
  await this.parseRequest("availableActions");
5373
6429
  const idObj = this.extractIdShape(query);
5374
- const sourceIdOf = (d) => d.paths.every((p) => query[p] !== void 0) ? Object.fromEntries(Object.entries(d.idMap).map(([f, p]) => [f, query[p]])) : void 0;
5375
6430
  if (idObj instanceof HttpError) {
5376
- if (!(await this._activeDelegations()).some((d) => sourceIdOf(d) !== void 0)) return idObj;
5377
- return this._delegatedAvailable({ actions: [] }, sourceIdOf);
6431
+ if (!(await this._activeDelegations()).some((d) => this._sourceIdOf(d, void 0, query) !== void 0)) return idObj;
6432
+ return this._delegatedAvailable({ actions: [] }, (d) => this._sourceIdOf(d, void 0, query));
5378
6433
  }
5379
- const own = await this._availableActions(idObj);
5380
- return this._hasDelegations ? this._delegatedAvailable(own, sourceIdOf) : own;
6434
+ const { own, id: resolved } = await this._availableResolved(idObj);
6435
+ if (!this._hasDelegations) return own;
6436
+ return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved, query, this._identificationFields()));
6437
+ }
6438
+ /** Every field of every identification (primary key and unique indexes) the view addresses a row by. */
6439
+ _identificationFields() {
6440
+ return [...new Set(this.idSource.identifications.flatMap((i) => i.fields))];
6441
+ }
6442
+ /**
6443
+ * A delegation's source id: each source id field from the row's mapped path
6444
+ * of the RESOLVED `id`. A path of ANY of the view's identifications
6445
+ * (`consumed` — not just the one the request matched: `?id=1&code=T-OLD` names
6446
+ * `code` too) is NEVER taken from the raw `?` query: that value may be an
6447
+ * alias `resolveRowIds` rewrote, and the source would see (and answer for)
6448
+ * it. Paths outside the identification fall back to `fallback`'s (the raw
6449
+ * query's) value; `undefined` when a path has none.
6450
+ */
6451
+ _sourceIdOf(d, id, fallback, consumed = []) {
6452
+ const value = (path) => id?.[path] ?? (consumed.includes(path) ? void 0 : fallback?.[path]);
6453
+ return d.paths.every((path) => value(path) !== void 0) ? Object.fromEntries(Object.entries(d.idMap).map(([field, path]) => [field, value(path)])) : void 0;
6454
+ }
6455
+ /**
6456
+ * {@link _availableActions} for a request id (`names`: only those actions):
6457
+ * through {@link resolveRowIds} (`"available"`) first, the row overlay
6458
+ * computed once for both — and the resolved id returned as an object (a
6459
+ * scalar is the single-field `preferredId` value) for the delegated part to
6460
+ * derive its source id from.
6461
+ */
6462
+ async _availableResolved(id, names) {
6463
+ const { id: resolved, overlay } = await this._resolveWithOverlay(id, "available");
6464
+ const own = await this._availableActions(resolved, overlay, names);
6465
+ if (typeof resolved === "object") return {
6466
+ own,
6467
+ id: resolved
6468
+ };
6469
+ const preferred = this.readable.preferredId;
6470
+ return {
6471
+ own,
6472
+ id: preferred.length === 1 ? { [preferred[0]]: resolved } : void 0
6473
+ };
5381
6474
  }
5382
6475
  /**
5383
6476
  * **POST /delegated-actions/:name** — a query target for a `@DbActionsFrom`
@@ -5440,7 +6533,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5440
6533
  maxExclude: limits.maxIds,
5441
6534
  overlay: "read",
5442
6535
  select: [...new Set([...identity, ...delegation.paths])],
5443
- excludeShapes: [delegation.paths]
6536
+ excludeShapes: [delegation.paths],
6537
+ visibleOf: identity
5444
6538
  });
5445
6539
  if (resolved.dryRun) return { matched: resolved.matched };
5446
6540
  const summary = {
@@ -5450,7 +6544,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5450
6544
  failed: []
5451
6545
  };
5452
6546
  const { ids, index } = mapToSourceIds(resolved.rows, delegation.idMap);
5453
- const visibleIdentity = identity.filter((f) => this.fieldVisibility.isVisible(f));
6547
+ const visibleIdentity = resolved.visibleOf ?? [];
5454
6548
  for (let i = 0; i < index.length; i++) {
5455
6549
  if (index[i] >= 0) continue;
5456
6550
  const row = resolved.rows[i];
@@ -5549,8 +6643,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5549
6643
  * actions are then checked on it (`purpose: "available"`) exactly like
5550
6644
  * `$actions` rows.
5551
6645
  */
5552
- async _availableActions(id, names) {
5553
- const [envelopes, overlay] = await Promise.all([names ? this._envelopesNamed(names) : this._resolveAugmentEnvelopes(), this.rowOverlay()]);
6646
+ async _availableActions(id, overlay, names) {
6647
+ const envelopes = await (names ? this._envelopesNamed(names) : this._resolveAugmentEnvelopes());
5554
6648
  if (!envelopes?.length) return { actions: [] };
5555
6649
  const idKeys = id !== null && typeof id === "object" ? Object.keys(id) : [];
5556
6650
  const fieldsOf = envelopes.map((e) => {
@@ -5631,7 +6725,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5631
6725
  if (fd.encrypted) entry.encrypted = true;
5632
6726
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
5633
6727
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
6728
+ if (cap.groupable) entry.groupable = true;
5634
6729
  if (cap.bucketable) entry.bucketable = true;
6730
+ if (cap.numeric) entry.numeric = true;
5635
6731
  if (fd.derived) entry.derived = true;
5636
6732
  if (fd.computed) entry.computed = true;
5637
6733
  fields[path] = entry;
@@ -5646,11 +6742,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5646
6742
  relations,
5647
6743
  fields,
5648
6744
  type: this.getSerializedType(),
6745
+ ...this._decorations && { decorations: this._planner.serialized(() => this.serializeForMeta(this._decorations.type)) },
5649
6746
  actions: this.buildActions(),
5650
6747
  crud: this.buildCrud(),
5651
6748
  versionColumn: this.readable.versionColumn,
5652
6749
  ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] },
5653
- aggregateFns: [...capabilities.aggregateFns]
6750
+ aggregateFns: [...capabilities.aggregateFns],
6751
+ aggregateExpressions: capabilities.aggregateExpressions
5654
6752
  };
5655
6753
  }
5656
6754
  buildCrud() {
@@ -5781,7 +6879,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5781
6879
  buildCrud() {
5782
6880
  return {
5783
6881
  ...super.buildCrud(),
5784
- insert: [],
6882
+ insert: this.readable.dbAdapter?.supportsInsertIgnore() ? ["onConflict"] : [],
5785
6883
  update: [],
5786
6884
  replace: [],
5787
6885
  remove: []
@@ -5801,7 +6899,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5801
6899
  return data;
5802
6900
  }
5803
6901
  /**
5804
- * Intercepts delete operations. Return `undefined` to abort (500 "Not
6902
+ * Intercepts delete operations. Receives the id {@link resolveRowIds}
6903
+ * resolved (the one the request carried when it is not overridden; since
6904
+ * 0.1.148). Return `undefined` to abort (500 "Not
5805
6905
  * deleted"); return an `Error` instance to respond with that error.
5806
6906
  * Runs outside any transaction. May be async (e.g. to resolve composite
5807
6907
  * ids from external state).
@@ -5938,8 +7038,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5938
7038
  * table's transaction and an out-of-scope row is not deleted — a 404,
5939
7039
  * exactly like a missing one.
5940
7040
  */
5941
- async _deleteOrThrow(id) {
5942
- const scope = await this.rowOverlay();
7041
+ async _deleteOrThrow(id, scope) {
5943
7042
  const args = scope ? [{
5944
7043
  ...this._removeArgs[0],
5945
7044
  scope
@@ -5950,16 +7049,34 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5950
7049
  }
5951
7050
  /**
5952
7051
  * **POST /** — inserts one or many records.
7052
+ *
7053
+ * `?$onConflict=ignore` (since 0.1.148) skips rows colliding on the primary
7054
+ * key or a unique index instead of answering 409. The response then is
7055
+ * `{ insertedId?, conflict }` for an object body and
7056
+ * `{ insertedCount, insertedIds, inserted, conflicts }` for an array body.
7057
+ * Any other `$` control on POST answers 400.
5953
7058
  */
5954
- async insert(payload) {
5955
- await this.parseRequest("insert");
7059
+ async insert(payload, url) {
7060
+ const { controls } = await this.parseRequest("insert", url ?? "");
7061
+ const onConflict = this._readOnConflict(controls);
5956
7062
  assertWriteShape(payload);
7063
+ const args = onConflict ? [{
7064
+ ...this._writeArgs[0],
7065
+ onConflict
7066
+ }] : this._writeArgs;
5957
7067
  if (Array.isArray(payload)) {
5958
7068
  const rows = await this._writeBody("insertMany", payload, true);
5959
- return this.table.insertMany(rows, ...this._writeArgs);
7069
+ return this.table.insertMany(rows, ...args);
5960
7070
  }
5961
7071
  const row = await this._writeBody("insert", payload, false);
5962
- return this.table.insertOne(row, ...this._writeArgs);
7072
+ return this.table.insertOne(row, ...args);
7073
+ }
7074
+ /** The only POST control: `$onConflict` (`error` | `ignore`). Anything else `$…` → 400. */
7075
+ _readOnConflict(controls) {
7076
+ for (const key of Object.keys(controls)) if (key !== "$onConflict") throw badRequest("", `Unsupported control "${key}" on insert`);
7077
+ const mode = controls.$onConflict;
7078
+ if (mode !== void 0 && mode !== "error" && mode !== "ignore") throw badRequest("", `$onConflict must be "error" or "ignore", got ${JSON.stringify(mode)}`);
7079
+ return mode === "ignore" ? "ignore" : void 0;
5963
7080
  }
5964
7081
  /**
5965
7082
  * **PUT /** — fully replaces one or many records matched by primary key.
@@ -6025,7 +7142,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
6025
7142
  if (typeof table.recordFilter === "function") try {
6026
7143
  filter = table.recordFilter(data, this._idOpts);
6027
7144
  } catch (error) {
6028
- if (!(error instanceof DbError)) throw error;
7145
+ if (!(error instanceof DbError) || error.code === "SPACE_CLOSED") throw error;
6029
7146
  filter = null;
6030
7147
  }
6031
7148
  else filter = await this.resolveRowFilter(data);
@@ -6046,8 +7163,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
6046
7163
  */
6047
7164
  async remove(id) {
6048
7165
  await this.parseRequest("remove");
6049
- const resolvedId = await this._checkHook(this.onRemove(id), "Not deleted");
6050
- return this._deleteOrThrow(resolvedId);
7166
+ const { id: resolved, overlay } = await this._resolveWithOverlay(id, "remove");
7167
+ const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
7168
+ return this._deleteOrThrow(resolvedId, overlay);
6051
7169
  }
6052
7170
  /**
6053
7171
  * **DELETE /?field1=val1&field2=val2** — removes a record by composite key
@@ -6057,15 +7175,17 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
6057
7175
  await this.parseRequest("remove");
6058
7176
  const idObj = this.extractIdShape(query);
6059
7177
  if (idObj instanceof HttpError) throw idObj;
6060
- const resolvedId = await this._checkHook(this.onRemove(idObj), "Not deleted");
6061
- return this._deleteOrThrow(resolvedId);
7178
+ const { id: resolved, overlay } = await this._resolveWithOverlay(idObj, "remove");
7179
+ const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
7180
+ return this._deleteOrThrow(resolvedId, overlay);
6062
7181
  }
6063
7182
  };
6064
7183
  __decorate([
6065
7184
  Post(""),
6066
7185
  __decorateParam(0, Body()),
7186
+ __decorateParam(1, Url()),
6067
7187
  __decorateMetadata("design:type", Function),
6068
- __decorateMetadata("design:paramtypes", [Object]),
7188
+ __decorateMetadata("design:paramtypes", [Object, String]),
6069
7189
  __decorateMetadata("design:returntype", Promise)
6070
7190
  ], AsDbController.prototype, "insert", null);
6071
7191
  __decorate([
@@ -6562,7 +7682,7 @@ function buildGateInterceptor(opts) {
6562
7682
  await ctx.get(dbActionOverlaySlot);
6563
7683
  if (level === "row") {
6564
7684
  const verdict = judgeRow(action, disabled, await ctx.get(dbActionRowSlot));
6565
- if (verdict) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdict)]);
7685
+ if (verdict) throw rowDisabledError(ctx, action, await ctx.get(dbActionIdSlot), verdictReason(verdict));
6566
7686
  return;
6567
7687
  }
6568
7688
  if (await queryTargetReply(ctx, reply)) return;
@@ -6586,8 +7706,7 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
6586
7706
  const existingRows = [];
6587
7707
  for (const row of rows) if (row !== void 0) existingRows.push(row);
6588
7708
  const verdicts = disabled ? judgeRows(action, disabled, existingRows) : void 0;
6589
- const failingIds = [];
6590
- const failingReasons = [];
7709
+ const failing = [];
6591
7710
  const passingRows = [];
6592
7711
  const passingIds = [];
6593
7712
  const skipped = [];
@@ -6598,8 +7717,10 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
6598
7717
  const verdict = row === void 0 ? void 0 : verdicts?.[verdictIndex++];
6599
7718
  if (row === void 0 || verdict) {
6600
7719
  const reason = verdictReason(verdict);
6601
- failingIds.push(ids[i]);
6602
- failingReasons.push(reason);
7720
+ failing.push({
7721
+ id: ids[i],
7722
+ reason
7723
+ });
6603
7724
  const skipReason = reason ?? (stale?.has(i) ? "stale" : void 0);
6604
7725
  skipped.push(skipReason === void 0 ? { id: ids[i] } : {
6605
7726
  id: ids[i],
@@ -6611,27 +7732,47 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
6611
7732
  }
6612
7733
  }
6613
7734
  if (onDisabledRows === "skip") {
6614
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
6615
- if (failingIds.length > 0) {
7735
+ if (passingRows.length === 0) throw disabledError(ctx, action, failing);
7736
+ if (failing.length > 0) {
6616
7737
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
6617
7738
  ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
6618
7739
  ctx.set(dbActionSkippedKey, skipped);
6619
7740
  }
6620
7741
  return;
6621
7742
  }
6622
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
7743
+ if (failing.length > 0) throw disabledError(ctx, action, failing);
7744
+ }
7745
+ /**
7746
+ * The 409 of a `'rows'` gate: every failing REQUEST id in request order, each
7747
+ * as the client sent it, with its own reason (`resolveRowIds`, since 0.1.148)
7748
+ * — {@link echoRequests} is the one place that maps resolved ids back.
7749
+ */
7750
+ function disabledError(ctx, action, failing) {
7751
+ const echoed = echoRequests(ctx, failing);
7752
+ return new ActionDisabledError(action, void 0, echoed.map((f) => f.id), echoed.map((f) => f.reason));
7753
+ }
7754
+ /** The 409 of a `'row'` gate: the id as the client sent it. */
7755
+ function rowDisabledError(ctx, action, id, reason) {
7756
+ return new ActionDisabledError(action, requestIdOf(ctx, id), void 0, [reason]);
7757
+ }
7758
+ /** The action's scope restricts (or needs the loaded rows to decide) — see `TPreScope`. */
7759
+ async function needsScopeLoad(ctx, level) {
7760
+ const pre = await ctx.get(dbActionPreScopeSlot)(level);
7761
+ return pre.kind === "deferred" || pre.scope !== null;
6623
7762
  }
6624
7763
  /**
6625
7764
  * Interceptor for `'row'` / `'rows'` actions without `disabled` (and for a
6626
7765
  * `@DbActionRow*` handler of any other level: bound-table injection only):
6627
7766
  * runs the controller's `prepareRequest` (when defined, since 0.1.143),
6628
- * injects the bound table and — only when the controller has a row overlay
6629
- * (`transformOne` / `transformFilter` overridden, non-empty), overrides
6630
- * `actionRowScope` (since 0.1.145) or the request is a query target (since
6631
- * 0.1.147) — verifies the requested ids before the handler runs by loading
6632
- * the row(s) the handler would get: `'row'` → the 404 of a missing row;
6633
- * `'rows'` → out-of-scope and missing ids fail like disabled rows with no
6634
- * reason (`onDisabledRows`). Nothing to verify → no query.
7767
+ * injects the bound table and — only when there is something to verify —
7768
+ * checks the requested ids before the handler runs by loading the row(s) the
7769
+ * handler would get: `'row'` → the 404 of a missing row; `'rows'` →
7770
+ * out-of-scope and missing ids fail like disabled rows with no reason
7771
+ * (`onDisabledRows`). Something to verify: a row overlay (`transformOne` /
7772
+ * `transformFilter` overridden, non-empty), a non-empty `actionRowScope` for
7773
+ * the action (the hook is asked first, with the request's ids — an override
7774
+ * that restricts nothing verifies nothing; since 0.1.148), or a query target
7775
+ * (since 0.1.147). Nothing to verify → no query.
6635
7776
  */
6636
7777
  function buildThinInterceptor(opts) {
6637
7778
  const { table, scope } = opts;
@@ -6642,12 +7783,12 @@ function buildThinInterceptor(opts) {
6642
7783
  if (!scope) return;
6643
7784
  const overlay = await ctx.get(dbActionOverlaySlot);
6644
7785
  if (scope.level === "row") {
6645
- if (overlay || isActionScoped(ctx)) await ctx.get(dbActionRowSlot);
7786
+ if (overlay || await needsScopeLoad(ctx, "row")) await ctx.get(dbActionRowSlot);
6646
7787
  return;
6647
7788
  }
6648
7789
  if (await queryTargetReply(ctx, reply)) return;
6649
7790
  const target = await ctx.get(dbActionQueryTargetSlot);
6650
- if (overlay || target || isActionScoped(ctx)) await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
7791
+ if (overlay || target || await needsScopeLoad(ctx, "rows")) await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
6651
7792
  await setMaterializedTarget(ctx);
6652
7793
  }, ACTION_GATE_PRIORITY);
6653
7794
  }
@@ -7070,6 +8211,52 @@ function DbActionsFrom(source, opts = {}) {
7070
8211
  */
7071
8212
  const perRow = (fn) => (rows) => rows.map(fn);
7072
8213
  //#endregion
8214
+ //#region src/decorations/db-decorations.decorator.ts
8215
+ /**
8216
+ * Declares display-only (decoration) fields of a table or view controller —
8217
+ * values `decorateRows` computes and attaches to rows (since 0.1.148).
8218
+ *
8219
+ * `type` is a plain atscript interface (no `@db.table` / `@db.view`) whose
8220
+ * top-level props are the decorations: their `@meta.label`, `@expect.*` and
8221
+ * `@ui.*` annotations travel in `/meta.decorations`, each key is listed in
8222
+ * `/meta.fields` with `decoration: true`, and a client may name it in
8223
+ * `$select`. A decoration is never filterable, sortable or groupable, and it
8224
+ * is not part of `/meta.type` (forms and write validation never see it).
8225
+ *
8226
+ * ```ts
8227
+ * @TableController(TicketTable)
8228
+ * @DbDecorations(TicketDecorations, { requires: { ownerName: ["ownerId"] } })
8229
+ * export class TicketsController extends AsDbController<typeof TicketTable> {
8230
+ * protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
8231
+ * if (ctx.decorations.has("ownerName")) {
8232
+ * // read rows[i].ownerId, set rows[i].ownerName
8233
+ * }
8234
+ * }
8235
+ * }
8236
+ * ```
8237
+ *
8238
+ * Validated once per class at first use (a `[moost-db]` error): the type is an
8239
+ * object interface; keys are top-level identifiers that collide with no field
8240
+ * or relation of the readable; every `requires` path is an own, readable
8241
+ * (not `@db.writeOnly`) field or a parent object of own fields (on SQL a nested
8242
+ * object is flattened to leaf columns; the hook still gets the whole object). Inherited under `@Inherit()`. Not supported on
8243
+ * value-help controllers.
8244
+ *
8245
+ * @since 0.1.148
8246
+ */
8247
+ function DbDecorations(type, opts = {}) {
8248
+ if (!isAnnotatedType(type)) throw new Error("[moost-db] @DbDecorations: expects a compiled atscript interface");
8249
+ const meta = {
8250
+ type,
8251
+ requires: Object.fromEntries(Object.entries(opts.requires ?? {}).map(([key, paths]) => [key, [...paths ?? []]]))
8252
+ };
8253
+ const decorate = getAtscriptDbMate().decorate("atscript_db_decorations", meta);
8254
+ return (target) => {
8255
+ if (isAsValueHelpControllerSubclass(target)) throw new Error(`[moost-db] ${target.name} is a value-help controller — @DbDecorations is not supported there.`);
8256
+ return decorate(target);
8257
+ };
8258
+ }
8259
+ //#endregion
7073
8260
  //#region src/permissions/crud-handlers.ts
7074
8261
  /**
7075
8262
  * The handler method(s) serving each CRUD op on `AsDbReadableController` /
@@ -7103,4 +8290,4 @@ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
7103
8290
  one: ["runGetOne", "runGetOneComposite"]
7104
8291
  });
7105
8292
  //#endregion
7106
- export { ActionDisabledError, ActionTargetError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
8293
+ export { ActionDisabledError, ActionTargetError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };