@atscript/moost-db 0.1.147 → 0.1.148

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,9 +1,9 @@
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";
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
7
  import { cached, current, defineWook, key, useRouteParams } from "@wooksjs/event-core";
8
8
  import { useBody } from "@wooksjs/http-body";
9
9
  import { BUCKET_UNITS } from "@uniqu/core";
@@ -76,6 +76,7 @@ const dbErrorCodeToStatus = {
76
76
  CONFLICT: 409,
77
77
  CAS_MISMATCH: 409,
78
78
  TX_WAIT_TIMEOUT: 503,
79
+ SPACE_CLOSED: 503,
79
80
  BUCKET_TZ_UNAVAILABLE: 501
80
81
  };
81
82
  function transformValidationError(error, reply) {
@@ -514,6 +515,38 @@ function identityKey(id) {
514
515
  return idKey(id, Object.keys(id).toSorted());
515
516
  }
516
517
  /**
518
+ * `resolved` (index-aligned with `requested`) with duplicate identities
519
+ * collapsed to the first, plus the per-request list ({@link TAppliedIds}).
520
+ */
521
+ function applyResolvedIds(requested, resolved) {
522
+ const ids = [];
523
+ const requests = [];
524
+ const seen = /* @__PURE__ */ new Set();
525
+ let changed = false;
526
+ for (let i = 0; i < resolved.length; i++) {
527
+ const k = identityKey(resolved[i]);
528
+ if (k === void 0) {
529
+ ids.push(resolved[i]);
530
+ continue;
531
+ }
532
+ if (k !== identityKey(requested[i])) changed = true;
533
+ requests.push({
534
+ id: requested[i],
535
+ key: k
536
+ });
537
+ if (seen.has(k)) {
538
+ changed = true;
539
+ continue;
540
+ }
541
+ seen.add(k);
542
+ ids.push(resolved[i]);
543
+ }
544
+ return changed ? {
545
+ ids,
546
+ requests
547
+ } : { ids };
548
+ }
549
+ /**
517
550
  * The deduped identities of `rows` over `fields` — a value read by `read`
518
551
  * (default: the row's own field) — and, per row, its identity's index in
519
552
  * `ids` (`-1`: the row is absent, or a value is missing / null).
@@ -711,6 +744,10 @@ const ACTION_OVERLAY = Symbol.for("atscript-db.actionOverlay");
711
744
  const ACTION_SCOPE = Symbol.for("atscript-db.actionScope");
712
745
  /** `true` when the controller overrides `actionRowScope` (since 0.1.147). */
713
746
  const ACTION_SCOPED = Symbol.for("atscript-db.actionScoped");
747
+ /** The controller's internal `resolveRowIds` call for an action's ids (since 0.1.148). */
748
+ const ROW_RESOLVE_IDS = Symbol.for("atscript-db.resolveRowIds");
749
+ /** `true` when the controller overrides `resolveRowIds` (since 0.1.148). */
750
+ const ROW_RESOLVES = Symbol.for("atscript-db.rowResolves");
714
751
  /**
715
752
  * The controller whose hooks govern this action's rows: only when the action
716
753
  * runs against the controller's OWN readable — an `opts.table` binding on a
@@ -743,10 +780,6 @@ const dbActionOverlaySlot = cached(async (ctx) => {
743
780
  await awaitActionPrepared(ctx);
744
781
  return await overlayOf.call(ctrl) ?? null;
745
782
  });
746
- /** `true` when this action's controller overrides `actionRowScope`. */
747
- function isActionScoped(ctx) {
748
- return ctx.get(scopedControllerSlot)?.[ACTION_SCOPED] === true;
749
- }
750
783
  /**
751
784
  * The loaded `rows` (already inside the row overlay) with every row outside
752
785
  * the action's `actionRowScope` replaced by `undefined` (since 0.1.147). The
@@ -1179,6 +1212,26 @@ function isNonEmptyStringArray(value) {
1179
1212
  }
1180
1213
  //#endregion
1181
1214
  //#region src/meta/field-capabilities.ts
1215
+ /** The display-only refusal clause per position. */
1216
+ const DISPLAY_ONLY_POSITION = {
1217
+ filter: "a filter",
1218
+ sort: "$sort",
1219
+ groupedSelect: "a grouped $select",
1220
+ groupBy: "$groupBy",
1221
+ having: "$having",
1222
+ aggregate: "an aggregate",
1223
+ bucket: "a calendar bucket"
1224
+ };
1225
+ /** The capability of a declared decoration: selectable, nothing else. */
1226
+ const DECORATION_CAP = {
1227
+ filterable: false,
1228
+ sortable: false,
1229
+ selectable: true,
1230
+ indexed: false,
1231
+ bucketable: false,
1232
+ groupable: false,
1233
+ numeric: false
1234
+ };
1182
1235
  const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER_REASON}.`;
1183
1236
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1184
1237
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
@@ -1283,6 +1336,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1283
1336
  bucketUnits;
1284
1337
  /** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
1285
1338
  aggregateFns;
1339
+ /** Whether the adapter renders aggregate arithmetic (`/meta.aggregateExpressions`). */
1340
+ aggregateExpressions;
1286
1341
  /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1287
1342
  signature;
1288
1343
  /**
@@ -1292,9 +1347,24 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1292
1347
  * the index reads must be added here.
1293
1348
  */
1294
1349
  static adapterSignature(source) {
1295
- return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}`;
1350
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}|${source.supportsAggregateExpressions()}`;
1351
+ }
1352
+ /**
1353
+ * The navigation paths of a readable: its `navFields`, else its relation
1354
+ * names (partial readables list only the latter).
1355
+ */
1356
+ static navPathsOf(source) {
1357
+ const nav = new Set(source.navFields);
1358
+ if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1359
+ return nav;
1296
1360
  }
1297
1361
  _entries = /* @__PURE__ */ new Map();
1362
+ /**
1363
+ * Declared display-only decorations (`@DbDecorations`, since 0.1.148) —
1364
+ * virtual entries: key → the readable paths it `requires`. Selectable only;
1365
+ * visible while every required path is.
1366
+ */
1367
+ _decorations;
1298
1368
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1299
1369
  _objectParents = /* @__PURE__ */ new Map();
1300
1370
  /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
@@ -1307,7 +1377,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1307
1377
  get objectParents() {
1308
1378
  return this._objectParents;
1309
1379
  }
1310
- constructor(source, writeOnly) {
1380
+ constructor(source, writeOnly, decorations = /* @__PURE__ */ new Map()) {
1381
+ this._decorations = decorations;
1311
1382
  const tableMeta = source.type.metadata;
1312
1383
  this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
1313
1384
  this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
@@ -1317,6 +1388,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1317
1388
  this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
1318
1389
  const fns = source.aggregateFns();
1319
1390
  this.aggregateFns = [...ALL_AGGREGATE_FNS].filter((fn) => fns.has(fn));
1391
+ this.aggregateExpressions = source.supportsAggregateExpressions();
1320
1392
  const physicalNames = /* @__PURE__ */ new Set();
1321
1393
  const jsonValueParents = /* @__PURE__ */ new Set();
1322
1394
  for (const fd of source.fieldDescriptors) {
@@ -1329,8 +1401,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1329
1401
  dimensions: source.dimensions,
1330
1402
  measures: source.measures
1331
1403
  };
1332
- const nav = new Set(source.navFields);
1333
- if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1404
+ const nav = FieldCapabilityIndex.navPathsOf(source);
1334
1405
  this.navFields = nav;
1335
1406
  const isNavOrDescendant = (path) => selfOrAncestor(path, nav) !== void 0;
1336
1407
  const flatMap = source.flatMap;
@@ -1394,13 +1465,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1394
1465
  if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1395
1466
  const bucket = isWriteOnly ? void 0 : bucketSourceVerdict(fd, this._bucketTable, source);
1396
1467
  const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
1468
+ const group = isWriteOnly ? void 0 : groupSourceVerdict(fd, this._bucketTable, source);
1469
+ const groupReason = !group ? REASON_WRITE_ONLY : group.ok ? void 0 : `${group.reason}.`;
1397
1470
  const cap = {
1398
1471
  filterable: filterBy.compare === void 0,
1399
1472
  sortable: sortReason === void 0,
1400
1473
  selectable: true,
1401
1474
  indexed: fd.isIndexed === true,
1402
- bucketable: bucketReason === void 0
1475
+ bucketable: bucketReason === void 0,
1476
+ groupable: groupReason === void 0,
1477
+ numeric: this.aggregateExpressions && physicalReason === void 0 && numericOperandProblem(fd) === void 0
1403
1478
  };
1479
+ if (groupReason) cap.groupReason = groupReason;
1404
1480
  if (filterOps.length > 0) cap.filterOps = filterOps;
1405
1481
  if (filterBy.compare) cap.filterReason = filterBy.compare;
1406
1482
  if (sortReason) cap.sortReason = sortReason;
@@ -1421,6 +1497,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1421
1497
  entry.fd
1422
1498
  ];
1423
1499
  }
1500
+ /** The declared decoration keys, in declaration order. */
1501
+ get decorationKeys() {
1502
+ return this._decorations.keys();
1503
+ }
1504
+ /** The capability of the declared decoration `key` (selectable only), `undefined` when `key` is none. */
1505
+ decorationCap(key) {
1506
+ return this._decorations.has(key) ? DECORATION_CAP : void 0;
1507
+ }
1508
+ /** The decoration `key` is visible: every path it `requires` passes `exists` (the hidden-field hook). */
1509
+ decorationVisible(key, exists) {
1510
+ return this._decorations.get(key)?.every(exists) === true;
1511
+ }
1424
1512
  /** Physical filter capability (adapter ∧ ¬writeOnly ∧ ¬encrypted) — ignores the manual-mode policy. */
1425
1513
  isPhysicallyFilterable(path) {
1426
1514
  return this._entries.get(path)?.physicalFilterable === true;
@@ -1445,6 +1533,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1445
1533
  * JSON-stored column" — clients pin that wording, so do not "align" it
1446
1534
  * with the core backstop's text.
1447
1535
  *
1536
+ * A declared decoration (`@DbDecorations`) is a virtual entry: `select` while
1537
+ * every path it requires passes `exists`, any other position a display-only
1538
+ * refusal (`groupedSelect` is a `$select` of an aggregate query), and hidden
1539
+ * sources answer `Unknown field` like a nonexistent path.
1540
+ *
1448
1541
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1449
1542
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1450
1543
  *
@@ -1454,8 +1547,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1454
1547
  * name the prefixed one. A filter on a navigation path names the predicate
1455
1548
  * alternative (`ticket=$some(status=…)`).
1456
1549
  */
1457
- check(local, op, exists, predicate = "compare", prefix = "") {
1550
+ check(local, gateOp, exists, predicate = "compare", prefix = "") {
1458
1551
  const path = prefix + local;
1552
+ const requires = prefix === "" ? this._decorations.get(local) : void 0;
1553
+ if (requires) {
1554
+ if (!requires.every(exists)) return unknownField(path);
1555
+ if (gateOp === "select") return void 0;
1556
+ return {
1557
+ path,
1558
+ message: `Field "${path}" is display-only and cannot be used in ${DISPLAY_ONLY_POSITION[gateOp]}`
1559
+ };
1560
+ }
1561
+ const op = gateOp === "groupedSelect" ? "select" : gateOp;
1459
1562
  if (!exists(local)) return unknownField(path);
1460
1563
  const { kind, parent: localParent } = classifyQueryPath(this, local);
1461
1564
  const parent = localParent === void 0 ? void 0 : prefix + localParent;
@@ -1485,6 +1588,10 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1485
1588
  path,
1486
1589
  message: `Bucketing field "${path}" is not permitted — ${entry.cap.bucketReason}`
1487
1590
  };
1591
+ case "groupBy": return entry.cap.groupable ? void 0 : {
1592
+ path,
1593
+ message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.cap.groupReason}`
1594
+ };
1488
1595
  default: return entry.physicalFilterable ? void 0 : {
1489
1596
  path,
1490
1597
  message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
@@ -1851,6 +1958,10 @@ function hasClientPredicate(filter) {
1851
1958
  * without copying).
1852
1959
  */
1853
1960
  function readRequestContext(endpoint, controls, filter) {
1961
+ if (endpoint === "insert") return controls.$onConflict === "ignore" ? {
1962
+ endpoint,
1963
+ onConflict: "ignore"
1964
+ } : { endpoint };
1854
1965
  if (!filter || Object.keys(filter).length === 0) return {
1855
1966
  endpoint,
1856
1967
  controls,
@@ -2139,7 +2250,7 @@ let AsReadableController = class AsReadableController {
2139
2250
  async parseRequest(endpoint, url) {
2140
2251
  let request;
2141
2252
  if (url !== void 0) {
2142
- const { parsed, hasNonControl } = endpoint === "one" ? this.parseControlsOnlyFromUrl(url) : {
2253
+ const { parsed, hasNonControl } = endpoint === "one" || endpoint === "insert" ? this.parseControlsOnlyFromUrl(url) : {
2143
2254
  parsed: this.parseQueryString(url),
2144
2255
  hasNonControl: false
2145
2256
  };
@@ -2559,8 +2670,8 @@ function isIdValidationSource(value) {
2559
2670
  const v = value;
2560
2671
  return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
2561
2672
  }
2562
- function validateSingleId(body, source, path = "") {
2563
- const errors = collectIdErrors(body, source, path);
2673
+ function validateSingleId(body, source, opts = {}) {
2674
+ const errors = collectIdErrors(body, source, opts);
2564
2675
  if (errors.length > 0) throw new ValidatorError(errors);
2565
2676
  return body;
2566
2677
  }
@@ -2577,11 +2688,12 @@ function validateMultiId(body, source, maxIds = Infinity) {
2577
2688
  details: []
2578
2689
  }]);
2579
2690
  const errors = [];
2580
- for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
2691
+ for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, { path: `[${i}]` }));
2581
2692
  if (errors.length > 0) throw new ValidatorError(errors);
2582
2693
  return body;
2583
2694
  }
2584
- function collectIdErrors(value, source, pathPrefix) {
2695
+ function collectIdErrors(value, source, opts) {
2696
+ const pathPrefix = opts.path ?? "";
2585
2697
  if (!isPlainObject$1(value)) return [{
2586
2698
  path: pathPrefix,
2587
2699
  message: "Expected JSON object for row identifier",
@@ -2602,14 +2714,21 @@ function collectIdErrors(value, source, pathPrefix) {
2602
2714
  const errors = [];
2603
2715
  for (const fieldName of match.fields) {
2604
2716
  const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
2605
- const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
2717
+ const err = checkScalar(value[fieldName], sub) ?? (opts.strictTypes === false ? void 0 : checkType(value[fieldName], cache.fieldByName.get(fieldName), sub));
2606
2718
  if (err) errors.push(err);
2607
2719
  }
2608
2720
  return errors;
2609
2721
  }
2610
- function checkScalar(value, fd, path) {
2722
+ /**
2723
+ * An identifier value is always a scalar: an object would reach the filter
2724
+ * as an operator expression (`{ $ne: null }`) whatever the field's type.
2725
+ */
2726
+ function checkScalar(value, path) {
2727
+ return typeof value === "object" && value !== null ? scalarMismatch(path, "a scalar", value) : void 0;
2728
+ }
2729
+ /** The value's type against the field's declared design type (default `string`). */
2730
+ function checkType(value, fd, path) {
2611
2731
  const expected = fd?.designType ?? "string";
2612
- if (typeof value === "object" && value !== null) return scalarMismatch(path, "a scalar", value);
2613
2732
  if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
2614
2733
  if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
2615
2734
  if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
@@ -2632,14 +2751,70 @@ function isPlainObject$1(value) {
2632
2751
  //#endregion
2633
2752
  //#region src/actions/id-cache.ts
2634
2753
  /**
2754
+ * EVERY id the client sent, in request order, with the identity of the id it
2755
+ * resolved to (since 0.1.148) — set only when the controller's
2756
+ * `resolveRowIds` changed or collapsed an id. The single model: refusals,
2757
+ * `reasons`, summaries and counts are judged and reported per request id
2758
+ * through {@link echoRequests}; the resolved (deduped) ids serve only the
2759
+ * row load and the handler.
2760
+ */
2761
+ const dbActionRequestIdsKey = key("atscript_db_action_request_ids");
2762
+ /** Number of request ids (`fallback` when no id was resolved to another). */
2763
+ function requestCount(ctx, fallback) {
2764
+ return ctx.has(dbActionRequestIdsKey) ? ctx.get(dbActionRequestIdsKey).length : fallback;
2765
+ }
2766
+ /** Number of request ids that resolved to one of `ids` (`ids.length` when none was rewritten). */
2767
+ function requestCountOf(ctx, ids) {
2768
+ if (!ctx.has(dbActionRequestIdsKey)) return ids.length;
2769
+ const keys = new Set(ids.map((id) => identityKey(id)));
2770
+ return ctx.get(dbActionRequestIdsKey).filter((r) => keys.has(r.key)).length;
2771
+ }
2772
+ /**
2773
+ * Entries keyed by a resolved id, reported for every REQUEST id that resolved
2774
+ * to it — in request order, each as the client sent it (`id` replaced). Two
2775
+ * aliases of one row come back exactly like two distinct rows (same order,
2776
+ * same count). Entries whose id no request resolved to stay as they are,
2777
+ * after the request ones.
2778
+ */
2779
+ function echoRequests(ctx, entries) {
2780
+ if (!ctx.has(dbActionRequestIdsKey)) return [...entries];
2781
+ const byKey = /* @__PURE__ */ new Map();
2782
+ const rest = [];
2783
+ for (const e of entries) {
2784
+ const k = identityKey(e.id);
2785
+ if (k === void 0) rest.push(e);
2786
+ else if (!byKey.has(k)) byKey.set(k, e);
2787
+ }
2788
+ const out = [];
2789
+ const matched = /* @__PURE__ */ new Set();
2790
+ for (const r of ctx.get(dbActionRequestIdsKey)) {
2791
+ const e = byKey.get(r.key);
2792
+ if (!e) continue;
2793
+ matched.add(r.key);
2794
+ out.push({
2795
+ ...e,
2796
+ id: r.id
2797
+ });
2798
+ }
2799
+ for (const [k, e] of byKey) if (!matched.has(k)) rest.push(e);
2800
+ return [...out, ...rest];
2801
+ }
2802
+ /** The id as the client sent it (the first, for an id several requests resolved to). */
2803
+ function requestIdOf(ctx, id) {
2804
+ return echoRequests(ctx, [{ id }])[0].id;
2805
+ }
2806
+ /**
2635
2807
  * Validates the body's `ids` against the action table's identifications. For
2636
2808
  * the controller's own table that is its `idSource` (since 0.1.134): a unique
2637
2809
  * index over a field `hasField` hides neither addresses a row nor appears in
2638
2810
  * the "must exactly match one of" message. An `opts.table` binding has no
2639
2811
  * visibility hook. The controller's `prepareRequest` (since 0.1.143) runs
2640
2812
  * first, so the visibility the ids are validated against is the request's.
2813
+ * Then, when the controller overrides `resolveRowIds` (since 0.1.148), the
2814
+ * validated ids go through it — a one-element array for a `'row'` action —
2815
+ * and the resolved ids (duplicates collapsed) are what every consumer sees.
2641
2816
  */
2642
- async function resolveValidatedId(ctx, validate) {
2817
+ async function resolveValidatedId(ctx, level, validate) {
2643
2818
  await awaitActionPrepared(ctx);
2644
2819
  let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
2645
2820
  if (!source) {
@@ -2649,9 +2824,21 @@ async function resolveValidatedId(ctx, validate) {
2649
2824
  if (!isIdValidationSource(source)) throw noTableError(ctx);
2650
2825
  const env = await ctx.get(dbActionBodySlot);
2651
2826
  validate(env.ids, source);
2652
- return env.ids;
2827
+ const scoped = ctx.get(scopedControllerSlot);
2828
+ const resolve = scoped?.[ROW_RESOLVES] ? scoped[ROW_RESOLVE_IDS] : void 0;
2829
+ if (!resolve) return env.ids;
2830
+ const requested = level === "row" ? [env.ids] : env.ids;
2831
+ const overlay = await ctx.get(dbActionOverlaySlot);
2832
+ const { ids, requests } = await resolve.call(scoped, requested, {
2833
+ purpose: "action",
2834
+ action: readCurrentActionMeta(ctx)?.name,
2835
+ level,
2836
+ overlay: overlay ?? void 0
2837
+ });
2838
+ if (requests) ctx.set(dbActionRequestIdsKey, requests);
2839
+ return level === "row" ? ids[0] : ids;
2653
2840
  }
2654
- const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
2841
+ const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, "row", validateSingleId));
2655
2842
  /**
2656
2843
  * The `'rows'` action's identifiers: the body's validated `ids`, or — for a
2657
2844
  * query target (`query`, since 0.1.147) — the identities of the rows it
@@ -2661,7 +2848,7 @@ const dbActionIdsSlot = cached(async (ctx) => {
2661
2848
  await awaitActionPrepared(ctx);
2662
2849
  const target = await ctx.get(dbActionQueryTargetSlot);
2663
2850
  if (target) return target.ids;
2664
- return await resolveValidatedId(ctx, (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
2851
+ return await resolveValidatedId(ctx, "rows", (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
2665
2852
  });
2666
2853
  const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
2667
2854
  const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
@@ -2681,29 +2868,70 @@ function asFetchTable(value) {
2681
2868
  function seedActionFields(ctx, table) {
2682
2869
  return actionRowFields(table, requiredFieldsOf(readCurrentActionMeta(ctx)?.opts), actionFieldVisibility(ctx));
2683
2870
  }
2871
+ const UNSCOPED = {
2872
+ kind: "resolved",
2873
+ scope: null
2874
+ };
2875
+ const DEFERRED = { kind: "deferred" };
2876
+ function inPreferredShape(ids, preferred) {
2877
+ return ids.every((id) => {
2878
+ return Object.keys(id).length === preferred.length && preferred.every((f) => f in id);
2879
+ });
2880
+ }
2881
+ async function resolvePreScope(ctx, level) {
2882
+ const ctrl = ctx.get(scopedControllerSlot);
2883
+ const action = readCurrentActionMeta(ctx)?.name;
2884
+ const scopeOf = ctrl?.[ACTION_SCOPED] ? ctrl[ACTION_SCOPE] : void 0;
2885
+ if (!ctrl || !scopeOf || action === void 0) return UNSCOPED;
2886
+ if (await ctx.get(dbActionOverlaySlot)) return DEFERRED;
2887
+ const table = asFetchTable(getActionTable(ctx));
2888
+ const preferred = table?.preferredId;
2889
+ if (!table || !preferred?.length) return DEFERRED;
2890
+ const [target, requested] = await Promise.all([level === "rows" ? ctx.get(dbActionQueryTargetSlot) : void 0, level === "row" ? ctx.get(dbActionIdSlot).then((id) => [id]) : ctx.get(dbActionIdsSlot)]);
2891
+ if (target || !inPreferredShape(requested, preferred)) return DEFERRED;
2892
+ const ids = level === "row" ? requested : dedupeIdentities(requested, preferred).ids;
2893
+ if (ids.length === 0) return UNSCOPED;
2894
+ return {
2895
+ kind: "resolved",
2896
+ scope: nonEmptyFilter(await scopeOf.call(ctrl, action, createScopeContext("execute", ids, table))) ?? null
2897
+ };
2898
+ }
2899
+ /**
2900
+ * {@link TPreScope} of the current action — once per event. An event runs one
2901
+ * action, so the first caller's `level` (`'row'` / `'rows'`) is the action's.
2902
+ */
2903
+ const dbActionPreScopeSlot = cached((ctx) => {
2904
+ let pending;
2905
+ return (level) => pending ??= resolvePreScope(ctx, level);
2906
+ });
2684
2907
  /**
2685
2908
  * Loaded row / rows are ANDed with the controller's row overlay (see
2686
2909
  * `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).
2910
+ * the action's `actionRowScope`: an out-of-scope id loads nothing — exactly
2911
+ * like a missing one (the same 404 on `'row'` actions, so the two can't be
2912
+ * told apart).
2690
2913
  *
2691
2914
  * 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).
2915
+ * read: fail-fast authorization) → the ids (body) → `resolveRowIds` (since
2916
+ * 0.1.148) → `actionRowScope` (pre-load, when there is no overlay and the
2917
+ * ids are in `preferredId` shape — its restriction joins the one row load;
2918
+ * since 0.1.148) → the row load → otherwise `actionRowScope` on the loaded
2919
+ * candidates (it needs them).
2694
2920
  */
2695
2921
  async function loadRow(ctx) {
2696
2922
  const overlay = await ctx.get(dbActionOverlaySlot);
2697
2923
  const id = await ctx.get(dbActionIdSlot);
2698
2924
  const table = asFetchTable(getActionTable(ctx));
2699
2925
  if (!table) throw noTableError(ctx);
2926
+ const pre = await ctx.get(dbActionPreScopeSlot)("row");
2700
2927
  const fields = seedActionFields(ctx, table);
2701
2928
  for (const k of Object.keys(id)) fields.add(k);
2702
- const loaded = await table.findOne({
2703
- filter: withOverlay(id, overlay),
2929
+ const idFilter = withOverlay(withOverlay(id, overlay), pre.kind === "resolved" ? pre.scope : null);
2930
+ let row = await table.findOne({
2931
+ filter: idFilter,
2704
2932
  controls: { $select: [...fields] }
2705
- });
2706
- const [row] = loaded == null ? [void 0] : await applyActionScope(ctx, table, [loaded]);
2933
+ }) ?? void 0;
2934
+ if (row !== void 0 && pre.kind === "deferred") [row] = await applyActionScope(ctx, table, [row]);
2707
2935
  if (row === void 0) throw new HttpError(404, "Row not found for action identifier");
2708
2936
  return row;
2709
2937
  }
@@ -2714,7 +2942,11 @@ async function loadRows(ctx) {
2714
2942
  if (!table) throw noTableError(ctx);
2715
2943
  const fields = seedActionFields(ctx, table);
2716
2944
  const target = await ctx.get(dbActionQueryTargetSlot);
2717
- if (!target) return applyActionScope(ctx, table, await findRowsByIds(table, ids, overlay, fields));
2945
+ if (!target) {
2946
+ const pre = await ctx.get(dbActionPreScopeSlot)("rows");
2947
+ if (pre.kind === "resolved") return findRowsByIds(table, ids, pre.scope ? withOverlay(pre.scope, overlay) : overlay, fields);
2948
+ return applyActionScope(ctx, table, await findRowsByIds(table, ids, overlay, fields));
2949
+ }
2718
2950
  const rows = await target.load(ids, fields);
2719
2951
  const stale = /* @__PURE__ */ new Set();
2720
2952
  for (let i = 0; i < rows.length; i++) if (rows[i] === void 0) stale.add(i);
@@ -2771,25 +3003,52 @@ function DbActionTarget() {
2771
3003
  var TargetBase = class {
2772
3004
  kind;
2773
3005
  matched;
3006
+ ctx;
2774
3007
  skipped = [];
2775
3008
  failed = [];
2776
3009
  processed = 0;
2777
- constructor(kind, matched) {
3010
+ /** Identities of every id handed to the handler so far. */
3011
+ handedKeys = /* @__PURE__ */ new Set();
3012
+ failedKeys = /* @__PURE__ */ new Set();
3013
+ constructor(kind, matched, ctx) {
2778
3014
  this.kind = kind;
2779
3015
  this.matched = matched;
3016
+ this.ctx = ctx;
2780
3017
  }
3018
+ /**
3019
+ * One failure per id, whether or not `resolveRowIds` rewrote any id: a
3020
+ * second `fail()` of the same identity is ignored (the first reason stands),
3021
+ * so `failed` and `processed` agree in both modes.
3022
+ */
2781
3023
  fail(id, reason) {
3024
+ const k = identityKey(id);
3025
+ if (k !== void 0) {
3026
+ if (this.failedKeys.has(k)) return;
3027
+ this.failedKeys.add(k);
3028
+ }
2782
3029
  this.failed.push({
2783
3030
  id,
2784
3031
  reason
2785
3032
  });
2786
3033
  }
3034
+ /**
3035
+ * A batch handed to the handler, counted per request id (two aliases of one
3036
+ * row count twice, like two distinct rows).
3037
+ */
3038
+ countProcessed(ids) {
3039
+ for (const id of ids) {
3040
+ const k = identityKey(id);
3041
+ if (k !== void 0) this.handedKeys.add(k);
3042
+ }
3043
+ this.processed += requestCountOf(this.ctx, ids);
3044
+ }
2787
3045
  summary() {
3046
+ const failed = echoRequests(this.ctx, this.failed);
2788
3047
  return {
2789
3048
  matched: this.matched,
2790
- processed: Math.max(0, this.processed - this.failed.length),
2791
- skipped: [...this.skipped],
2792
- failed: [...this.failed]
3049
+ processed: Math.max(0, this.processed - failed.length),
3050
+ skipped: echoRequests(this.ctx, this.skipped),
3051
+ failed
2793
3052
  };
2794
3053
  }
2795
3054
  };
@@ -2801,12 +3060,12 @@ var MaterializedTarget = class extends TargetBase {
2801
3060
  ids;
2802
3061
  rows;
2803
3062
  iterated = false;
2804
- constructor(kind, matched, ids, rows, skipped) {
2805
- super(kind, matched);
3063
+ constructor(kind, matched, ids, rows, skipped, ctx) {
3064
+ super(kind, matched, ctx);
2806
3065
  this.ids = ids;
2807
3066
  this.rows = rows;
2808
3067
  this.skipped.push(...skipped);
2809
- this.processed = ids.length;
3068
+ this.countProcessed(ids);
2810
3069
  }
2811
3070
  async *batches() {
2812
3071
  if (this.iterated) throw new Error("[moost-db actions] target.batches() is single-pass");
@@ -2822,21 +3081,19 @@ async function setMaterializedTarget(ctx) {
2822
3081
  const target = await resolveActionQueryTarget(ctx, false);
2823
3082
  const ids = await ctx.get(dbActionIdsSlot);
2824
3083
  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 () => {
3084
+ ctx.set(dbActionTargetKey, new MaterializedTarget(target ? "query" : "ids", target ? target.matched : requestCount(ctx, ids.length + skipped.length), ids, async () => {
2826
3085
  return (await ctx.get(dbActionRowsSlot)).filter((r) => r !== void 0);
2827
- }, skipped));
3086
+ }, skipped, ctx));
2828
3087
  }
2829
3088
  /** The `@DbActionTarget` surface: the target in batches, each gated when it is reached. */
2830
3089
  var StreamedTarget = class extends TargetBase {
2831
- ctx;
2832
3090
  source;
2833
3091
  batchSize;
2834
3092
  action;
2835
3093
  disabled;
2836
3094
  iterated = false;
2837
3095
  constructor(matched, ctx, source, batchSize, action, disabled) {
2838
- super(source.kind, matched);
2839
- this.ctx = ctx;
3096
+ super(source.kind, matched, ctx);
2840
3097
  this.source = source;
2841
3098
  this.batchSize = batchSize;
2842
3099
  this.action = action;
@@ -2854,7 +3111,7 @@ var StreamedTarget = class extends TargetBase {
2854
3111
  const batch = await this.gate(ids.slice(start, start + this.batchSize));
2855
3112
  this.next = start + this.batchSize;
2856
3113
  if (batch.ids.length === 0) continue;
2857
- this.processed += batch.ids.length;
3114
+ this.countProcessed(batch.ids);
2858
3115
  this.current = batch.ids;
2859
3116
  yield batch;
2860
3117
  this.current = void 0;
@@ -2862,30 +3119,66 @@ var StreamedTarget = class extends TargetBase {
2862
3119
  this.next = ids.length;
2863
3120
  }
2864
3121
  /**
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.
3122
+ * The summary of a run the handler failed after it received a batch, judged
3123
+ * per REQUEST id by its own request position — an alias after the abort
3124
+ * point is "not run" even when its row was judged (run, skipped) earlier,
3125
+ * exactly as the same request of distinct rows would be:
3126
+ * - at or after the position the run stopped at: `failed` as `"not run"`;
3127
+ * - before it, a row the handler failed itself: its reason;
3128
+ * - before it, the batch the handler held (uncertain): the error's message;
3129
+ * - before it, a row the gate skipped: `skipped`; a row that ran: `processed`.
3130
+ * `aborted` is set.
2868
3131
  */
2869
3132
  abort(error) {
2870
3133
  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)));
3134
+ const ids = this.source.ids;
3135
+ const requests = this.ctx.has(dbActionRequestIdsKey) ? this.ctx.get(dbActionRequestIdsKey) : ids.map((id, i) => ({
3136
+ id,
3137
+ key: identityKey(id) ?? `#${i}`
3138
+ }));
3139
+ const held = new Set((this.current ?? []).map((id) => identityKey(id)));
3140
+ let cutoff = requests.length;
3141
+ if (this.ctx.has(dbActionRequestIdsKey)) {
3142
+ const firstOf = (key) => requests.findIndex((r) => r.key === key);
3143
+ if (held.size > 0) cutoff = 1 + Math.max(...[...held].map(firstOf));
3144
+ else if (this.next < ids.length) cutoff = firstOf(identityKey(ids[this.next]));
3145
+ if (cutoff < 0) cutoff = requests.length;
3146
+ } else if (this.next < ids.length) cutoff = this.next;
3147
+ const failedBy = new Map(this.failed.map((f) => [identityKey(f.id), f]));
3148
+ const skippedBy = /* @__PURE__ */ new Map();
3149
+ for (const row of this.skipped) {
3150
+ const k = identityKey(row.id);
3151
+ if (!skippedBy.has(k)) skippedBy.set(k, row);
3152
+ }
3153
+ const failed = [];
3154
+ const skipped = [];
3155
+ let processed = 0;
3156
+ requests.forEach((r, i) => {
3157
+ const own = failedBy.get(r.key);
3158
+ const skip = skippedBy.get(r.key);
3159
+ if (i >= cutoff) failed.push({
3160
+ id: r.id,
3161
+ reason: "not run"
3162
+ });
3163
+ else if (own) failed.push({
3164
+ id: r.id,
3165
+ reason: own.reason
3166
+ });
3167
+ else if (held.has(r.key)) failed.push({
3168
+ id: r.id,
3169
+ reason: message
3170
+ });
3171
+ else if (skip) skipped.push({
3172
+ ...skip,
3173
+ id: r.id
3174
+ });
3175
+ else if (this.handedKeys.has(r.key)) processed++;
3176
+ });
2875
3177
  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
- ],
3178
+ matched: this.matched,
3179
+ processed,
3180
+ skipped,
3181
+ failed,
2889
3182
  aborted: {
2890
3183
  status: errorStatus(error),
2891
3184
  message
@@ -2980,7 +3273,7 @@ async function setStreamedTarget(ctx, action, disabled) {
2980
3273
  return findRowsByIds(table, batch, overlay, select);
2981
3274
  }
2982
3275
  };
2983
- const target = new StreamedTarget(query ? query.matched : source.ids.length, ctx, source, batchSize, action, disabled);
3276
+ const target = new StreamedTarget(query ? query.matched : requestCount(ctx, source.ids.length), ctx, source, batchSize, action, disabled);
2984
3277
  ctx.set(dbActionTargetKey, target);
2985
3278
  return {
2986
3279
  target,
@@ -3009,6 +3302,8 @@ const ACTION_SLOTS = [
3009
3302
  dbActionInputSlot,
3010
3303
  dbActionIdSlot,
3011
3304
  dbActionIdsSlot,
3305
+ dbActionRequestIdsKey,
3306
+ dbActionPreScopeSlot,
3012
3307
  dbActionRowSlot,
3013
3308
  dbActionRowsSlot,
3014
3309
  dbActionOverlaySlot,
@@ -3316,6 +3611,398 @@ function getDbEndpoint(target, method) {
3316
3611
  }
3317
3612
  }
3318
3613
  //#endregion
3614
+ //#region src/decorations/decoration-index.ts
3615
+ /** A decoration key is a valid `$select` URL name: top-level, no `$` prefix, no dots. */
3616
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3617
+ /**
3618
+ * The own readable paths a `requires` path stands for: itself when it is an
3619
+ * own field, else — a parent object of a flattened (SQL) readable — every own
3620
+ * field below it. Empty when the path is neither.
3621
+ */
3622
+ function ownLeaves(path, ownPaths) {
3623
+ if (ownPaths.has(path)) return [path];
3624
+ const prefix = `${path}.`;
3625
+ return [...ownPaths].filter((own) => own.startsWith(prefix));
3626
+ }
3627
+ /**
3628
+ * Validates `@DbDecorations` metadata against the bound readable and indexes
3629
+ * it. Throws `[moost-db]` errors (once per class — the caller memoizes).
3630
+ */
3631
+ function buildDecorationIndex(controller, meta, source) {
3632
+ const fail = (message) => {
3633
+ throw new Error(`[moost-db] ${controller}: @DbDecorations ${message}`);
3634
+ };
3635
+ const { type } = meta;
3636
+ if (type.type.kind !== "object") fail("expects an object interface");
3637
+ 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");
3638
+ const keys = [...type.type.props.keys()];
3639
+ const fieldPaths = /* @__PURE__ */ new Set();
3640
+ for (const path of source.flatMap?.keys() ?? []) for (let at = path.indexOf(".");; at = path.indexOf(".", at + 1)) {
3641
+ fieldPaths.add(at < 0 ? path : path.slice(0, at));
3642
+ if (at < 0) break;
3643
+ }
3644
+ const isField = (path) => fieldPaths.has(path);
3645
+ for (const key of keys) {
3646
+ if (!KEY_RE.test(key)) fail(`key "${key}" must be a plain top-level identifier (no "$" prefix, no dots)`);
3647
+ if (isField(key) || source.relations?.has(key) || source.navFields.has(key)) fail(`key "${key}" collides with a field or relation of the bound readable`);
3648
+ }
3649
+ const requires = /* @__PURE__ */ new Map();
3650
+ const leavesOf = /* @__PURE__ */ new Map();
3651
+ const nested = (path) => {
3652
+ const prefix = `${path}.`;
3653
+ const below = [...source.flatMap?.keys() ?? []].filter((p) => p.startsWith(prefix));
3654
+ return below.filter((p) => selfOrAncestor(p, source.navFields) === void 0 && !below.some((other) => other.startsWith(`${p}.`)));
3655
+ };
3656
+ for (const key of keys) requires.set(key, []);
3657
+ for (const [key, paths] of Object.entries(meta.requires)) {
3658
+ if (!requires.has(key)) fail(`\`requires\` names "${key}", which is not a declared decoration`);
3659
+ const resolved = [];
3660
+ for (const path of paths) {
3661
+ const leaves = ownLeaves(path, source.ownPaths);
3662
+ if (leaves.length === 0) fail(`"${key}" requires "${path}", which is not an own field of the bound readable`);
3663
+ 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)`);
3664
+ for (const leaf of leaves) {
3665
+ if (leavesOf.has(leaf)) continue;
3666
+ const below = nested(leaf);
3667
+ if (below.length > 0) leavesOf.set(leaf, below);
3668
+ }
3669
+ resolved.push(...leaves);
3670
+ }
3671
+ requires.set(key, [...new Set(resolved)]);
3672
+ }
3673
+ const visibleOn = /* @__PURE__ */ new Map();
3674
+ for (const [key, paths] of requires) {
3675
+ const all = new Set(paths);
3676
+ for (const path of paths) for (const leaf of leavesOf.get(path) ?? []) all.add(leaf);
3677
+ visibleOn.set(key, [...all]);
3678
+ }
3679
+ return {
3680
+ type,
3681
+ keys,
3682
+ keySet: new Set(keys),
3683
+ requires,
3684
+ visibleOn,
3685
+ leavesOf,
3686
+ memo: { meta: /* @__PURE__ */ new WeakMap() }
3687
+ };
3688
+ }
3689
+ //#endregion
3690
+ //#region src/select-shape.ts
3691
+ /** The included (`1` / `true`) and excluded (`0` / `false`) keys of a `$select` map. */
3692
+ function mapShape(map) {
3693
+ const included = [];
3694
+ const excluded = [];
3695
+ for (const [key, value] of Object.entries(map)) if (value === 1 || value === true) included.push(key);
3696
+ else if (value === 0 || value === false) excluded.push(key);
3697
+ return {
3698
+ included,
3699
+ excluded
3700
+ };
3701
+ }
3702
+ /** The one splitter every `$select` consumer in the controller reads. */
3703
+ function selectShape(raw) {
3704
+ if (raw === void 0 || raw === null) return { kind: "all" };
3705
+ if (Array.isArray(raw)) return {
3706
+ kind: "list",
3707
+ items: raw
3708
+ };
3709
+ const map = raw;
3710
+ return {
3711
+ kind: "map",
3712
+ map,
3713
+ ...mapShape(map)
3714
+ };
3715
+ }
3716
+ //#endregion
3717
+ //#region src/decorations/decoration-planner.ts
3718
+ const NO_DECORATIONS = /* @__PURE__ */ new Set();
3719
+ /**
3720
+ * The decoration plumbing of one controller (`@DbDecorations`, since 0.1.148):
3721
+ * rewrites a read's `$select` ({@link plan}), decides what the response
3722
+ * carries once the final projection is known ({@link serve}), and builds the
3723
+ * `/meta` view ({@link meta}). The request gate is not here — decorations are
3724
+ * virtual entries of the {@link FieldCapabilityIndex}.
3725
+ */
3726
+ var DecorationPlanner = class {
3727
+ index;
3728
+ host;
3729
+ constructor(index, host) {
3730
+ this.index = index;
3731
+ this.host = host;
3732
+ }
3733
+ visible(key) {
3734
+ return this.host.capabilities().decorationVisible(key, this.host.isVisible);
3735
+ }
3736
+ requiresOf(keys) {
3737
+ return [...new Set(keys.flatMap((key) => this.index.requires.get(key)))];
3738
+ }
3739
+ /**
3740
+ * Splits the wire `$select` of a read (non-grouped: the gate already
3741
+ * refused a decoration in a grouped one): `requested` is every declared
3742
+ * decoration key whose sources are visible and that the client named — or,
3743
+ * without a `$select`, all of them (an exclusion map: all minus the excluded
3744
+ * ones). The real `$select` is widened by the requested keys' `requires`;
3745
+ * the paths added only for the hook (`requiresOnly`) are stripped again.
3746
+ * The result keeps the representation of `raw` (array or map).
3747
+ */
3748
+ plan(raw) {
3749
+ const { keySet, keys } = this.index;
3750
+ const shape = selectShape(raw);
3751
+ if (shape.kind === "all") return {
3752
+ select: void 0,
3753
+ requested: keys.filter((k) => this.visible(k)),
3754
+ requiresOnly: []
3755
+ };
3756
+ const passthrough = {
3757
+ select: raw,
3758
+ requested: [],
3759
+ requiresOnly: []
3760
+ };
3761
+ if (shape.kind === "list") {
3762
+ const named = shape.items.filter((item) => typeof item === "string" && keySet.has(item));
3763
+ if (named.length === 0) return passthrough;
3764
+ const real = shape.items.filter((item) => !(typeof item === "string" && keySet.has(item)));
3765
+ const have = new Set(real.filter((item) => typeof item === "string"));
3766
+ return this.inclusion([...new Set(named)], real, have, (list) => list);
3767
+ }
3768
+ const { map, included, excluded } = shape;
3769
+ if (included.length === 0 && excluded.length > 0) {
3770
+ const skip = new Set(excluded);
3771
+ const requested = keys.filter((key) => !skip.has(key) && this.visible(key));
3772
+ const real = Object.fromEntries(Object.entries(map).filter(([k]) => !keySet.has(k)));
3773
+ const requiresOnly = /* @__PURE__ */ new Set();
3774
+ const remaining = new Set(Object.keys(real));
3775
+ const unexclude = (key) => {
3776
+ requiresOnly.add(key);
3777
+ remaining.delete(key);
3778
+ delete real[key];
3779
+ };
3780
+ for (const path of this.requiresOf(requested)) {
3781
+ const hit = selfOrAncestor(path, remaining);
3782
+ if (hit !== void 0) {
3783
+ unexclude(hit);
3784
+ continue;
3785
+ }
3786
+ const prefix = `${path}.`;
3787
+ for (const key of remaining) if (key.startsWith(prefix)) unexclude(key);
3788
+ }
3789
+ return {
3790
+ select: Object.keys(real).length > 0 ? real : void 0,
3791
+ requested,
3792
+ requiresOnly: [...requiresOnly]
3793
+ };
3794
+ }
3795
+ const named = included.filter((k) => keySet.has(k));
3796
+ if (named.length === 0) return passthrough;
3797
+ const real = included.filter((k) => !keySet.has(k));
3798
+ return this.inclusion(named, real, new Set(real), (list) => Object.fromEntries(list.map((k) => [k, 1])));
3799
+ }
3800
+ /**
3801
+ * The inclusion forms: `real` (the client's own selection) plus the
3802
+ * requested decorations' `requires` that it lacks, re-emitted by `emit`.
3803
+ */
3804
+ inclusion(named, real, have, emit) {
3805
+ const requested = named.filter((key) => this.visible(key));
3806
+ const extra = this.requiresOf(requested).filter((path) => selfOrAncestor(path, have) === void 0);
3807
+ const { list, added } = this.nonEmptyInclusion([...real, ...extra]);
3808
+ return {
3809
+ select: emit(list),
3810
+ requested,
3811
+ requiresOnly: [...extra.filter((path) => !this.host.preferred.has(path)), ...added],
3812
+ selected: [...have]
3813
+ };
3814
+ }
3815
+ /**
3816
+ * An inclusion list is never empty (an empty one selects everything): a
3817
+ * request naming only decorations with nothing to read selects the
3818
+ * `preferredId` fields (which the response carries anyway) or, without one,
3819
+ * a single visible field that is stripped again (`added`).
3820
+ */
3821
+ nonEmptyInclusion(list) {
3822
+ if (list.length > 0) return {
3823
+ list,
3824
+ added: []
3825
+ };
3826
+ const { preferred } = this.host;
3827
+ if (preferred.size > 0) return {
3828
+ list: [...preferred],
3829
+ added: []
3830
+ };
3831
+ const field = this.host.firstVisibleField();
3832
+ return field === void 0 ? {
3833
+ list,
3834
+ added: []
3835
+ } : {
3836
+ list: [field],
3837
+ added: [field]
3838
+ };
3839
+ }
3840
+ /**
3841
+ * The decoration step once the final projection is known: a requested key
3842
+ * is served only if every `requires` path survived `transformProjection`,
3843
+ * the seal and the preferred-id widening (a policy that strips a source
3844
+ * silently drops the decoration, as it drops a field). `kept` is the final
3845
+ * projection's paths (`null` = every field).
3846
+ */
3847
+ serve(plan, kept) {
3848
+ const { keys, requires, leavesOf } = this.index;
3849
+ const carried = (path, set) => selfOrAncestor(path, set) !== void 0 || (leavesOf.get(path)?.every((leaf) => selfOrAncestor(leaf, set) !== void 0) ?? false);
3850
+ const dropPaths = plan.requiresOnly.map((path) => path.split("."));
3851
+ const keepPaths = plan.requiresOnly.map((path) => (plan.selected ?? []).filter((sel) => sel.startsWith(`${path}.`)).map((sel) => sel.split(".")));
3852
+ const selectedPaths = plan.selected?.map((sel) => sel.split("."));
3853
+ if (plan.requested.length === 0) return {
3854
+ served: NO_DECORATIONS,
3855
+ dropKeys: keys,
3856
+ dropPaths,
3857
+ keepPaths,
3858
+ selectedPaths
3859
+ };
3860
+ const keptSet = kept === null ? void 0 : new Set(kept);
3861
+ const served = new Set(plan.requested.filter((key) => keptSet === void 0 || requires.get(key).every((path) => carried(path, keptSet))));
3862
+ return {
3863
+ served,
3864
+ dropKeys: keys.filter((key) => !served.has(key)),
3865
+ dropPaths,
3866
+ keepPaths,
3867
+ selectedPaths
3868
+ };
3869
+ }
3870
+ /**
3871
+ * The `/meta` envelope with the declared decorations: each one still
3872
+ * present in `decorations` (an overlay may delete props to hide a
3873
+ * decoration per principal) whose sources are visible is kept and gets its
3874
+ * `fields[key]` entry (from its virtual capability-index entry); the others
3875
+ * are pruned, and `decorations` is dropped when none is left. Runs after
3876
+ * `applyMetaOverlay` — an overlay never sees the decoration `fields`
3877
+ * entries. Memoized per input while visibility is not request-scoped.
3878
+ */
3879
+ meta(meta) {
3880
+ if (!meta.decorations) return meta;
3881
+ const { memo, keySet } = this.index;
3882
+ const memoize = !this.host.scoped;
3883
+ const hit = memoize ? memo.meta.get(meta) : void 0;
3884
+ if (hit) return hit;
3885
+ const capabilities = this.host.capabilities();
3886
+ const props = meta.decorations.type.props ?? {};
3887
+ const kept = {};
3888
+ const fields = { ...meta.fields };
3889
+ for (const [key, prop] of Object.entries(props)) {
3890
+ const cap = keySet.has(key) && this.visible(key) ? capabilities.decorationCap(key) : void 0;
3891
+ if (!cap) continue;
3892
+ kept[key] = prop;
3893
+ fields[key] = {
3894
+ sortable: cap.sortable,
3895
+ filterable: cap.filterable,
3896
+ decoration: true
3897
+ };
3898
+ }
3899
+ let out;
3900
+ if (Object.keys(kept).length === 0) {
3901
+ const { decorations: _dropped, ...rest } = meta;
3902
+ out = rest;
3903
+ } else out = {
3904
+ ...meta,
3905
+ fields,
3906
+ decorations: {
3907
+ ...meta.decorations,
3908
+ type: {
3909
+ ...meta.decorations.type,
3910
+ props: kept
3911
+ }
3912
+ }
3913
+ };
3914
+ if (memoize) memo.meta.set(meta, out);
3915
+ return out;
3916
+ }
3917
+ /** The declared interface serialized for `/meta.decorations`, once per class. */
3918
+ serialized(serialize) {
3919
+ return this.index.memo.serialized ??= serialize();
3920
+ }
3921
+ };
3922
+ /** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
3923
+ function stripDecorations(rows, read) {
3924
+ const { dropKeys, dropPaths, keepPaths, selectedPaths } = read;
3925
+ if (dropKeys.length === 0 && dropPaths.length === 0) return;
3926
+ const owned = (prefix) => selectedPaths !== void 0 && selectedPaths.some((sel) => prefix.every((part, i) => sel[i] === part));
3927
+ for (const row of rows) {
3928
+ for (const key of dropKeys) delete row[key];
3929
+ dropPaths.forEach((parts, i) => {
3930
+ const keep = keepPaths[i] ?? [];
3931
+ if (keep.length === 0) deleteDescending(row, parts, 0, selectedPaths !== void 0, owned);
3932
+ else pruneExcept(row, parts, 0, keep);
3933
+ });
3934
+ }
3935
+ }
3936
+ /** An object without keys, or an array (of any nesting) holding nothing but such values (what a strip left of a parent). */
3937
+ function isHollow(value) {
3938
+ if (Array.isArray(value)) return value.every((el) => isHollow(el));
3939
+ return isPlainObject(value) && Object.keys(value).length === 0;
3940
+ }
3941
+ /**
3942
+ * Deletes the path `parts` below `value`, descending through arrays of objects
3943
+ * (`items.qty` strips every element's `qty`). With `clean` (an inclusion read),
3944
+ * a parent the strip emptied — or a `null` one — goes too, unless the client
3945
+ * selected something at or below it.
3946
+ */
3947
+ function deleteDescending(value, parts, depth, clean, owned) {
3948
+ if (Array.isArray(value)) {
3949
+ const dropEmptied = clean && depth > 0 && !owned(parts.slice(0, depth));
3950
+ for (let i = value.length - 1; i >= 0; i--) {
3951
+ const el = value[i];
3952
+ const wasHollow = isHollow(el);
3953
+ deleteDescending(el, parts, depth, clean, owned);
3954
+ if (dropEmptied && !wasHollow && isHollow(el)) value.splice(i, 1);
3955
+ }
3956
+ return;
3957
+ }
3958
+ if (!isPlainObject(value)) return;
3959
+ const key = parts[depth];
3960
+ if (depth === parts.length - 1) {
3961
+ delete value[key];
3962
+ return;
3963
+ }
3964
+ const child = value[key];
3965
+ const prefix = parts.slice(0, depth + 1);
3966
+ if (child === null && clean && !owned(prefix)) {
3967
+ delete value[key];
3968
+ return;
3969
+ }
3970
+ deleteDescending(child, parts, depth + 1, clean, owned);
3971
+ if (clean && isHollow(child) && !owned(prefix)) delete value[key];
3972
+ }
3973
+ /**
3974
+ * Below `parts`, deletes everything except the branches leading to `keep`
3975
+ * (client-selected descendants), through arrays of objects as well.
3976
+ */
3977
+ function pruneExcept(value, parts, depth, keep) {
3978
+ if (Array.isArray(value)) {
3979
+ for (const el of value) pruneExcept(el, parts, depth, keep);
3980
+ return;
3981
+ }
3982
+ if (!isPlainObject(value)) return;
3983
+ if (depth < parts.length) {
3984
+ pruneExcept(value[parts[depth]], parts, depth + 1, keep);
3985
+ return;
3986
+ }
3987
+ prune(value, parts.length, keep.filter((k) => k.length > parts.length));
3988
+ }
3989
+ function prune(value, depth, branches) {
3990
+ if (Array.isArray(value)) {
3991
+ for (const el of value) prune(el, depth, branches);
3992
+ return;
3993
+ }
3994
+ if (!isPlainObject(value)) return;
3995
+ for (const key of Object.keys(value)) {
3996
+ const next = branches.filter((b) => b[depth] === key);
3997
+ if (next.length === 0) delete value[key];
3998
+ else if (next.some((b) => b.length > depth + 1)) prune(value[key], depth + 1, next);
3999
+ }
4000
+ }
4001
+ /** `true` when stripping `read` changes nothing — skip the post-hook step. */
4002
+ function stripsNothing(read) {
4003
+ return read === void 0 || read.dropKeys.length === 0 && read.dropPaths.length === 0;
4004
+ }
4005
+ //#endregion
3319
4006
  //#region src/decorators.ts
3320
4007
  /**
3321
4008
  * DI token under which the {@link AtscriptDbReadable} instance
@@ -3475,7 +4162,8 @@ const QUERY_CONTROLS = [
3475
4162
  "filter",
3476
4163
  "insights",
3477
4164
  ...dtoControls(QueryControlsDto),
3478
- "groupBy"
4165
+ "groupBy",
4166
+ "rowOrder"
3479
4167
  ];
3480
4168
  const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
3481
4169
  const ONE_CONTROLS = dtoControls(GetOneControlsDto);
@@ -3506,6 +4194,12 @@ function writeOnlyError(path, op) {
3506
4194
  const verdict = writeOnlyVerdict(path, op);
3507
4195
  return badRequest(verdict.path, verdict.message);
3508
4196
  }
4197
+ /**
4198
+ * Validated `@DbDecorations` per readable and controller class — a
4199
+ * `FOR_EVENT` controller is constructed per event, and must not re-validate
4200
+ * (nor re-serialize) its declaration each time.
4201
+ */
4202
+ const decorationIndexes = /* @__PURE__ */ new WeakMap();
3509
4203
  /** Controller classes already warned that `actionRowScope` has no row identity to match by. */
3510
4204
  const warnedNoIdentity = /* @__PURE__ */ new WeakSet();
3511
4205
  let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
@@ -3537,7 +4231,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3537
4231
  get capabilities() {
3538
4232
  const current = this._capabilities;
3539
4233
  if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
3540
- const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
4234
+ const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet, this._decorations?.visibleOn);
3541
4235
  this._capabilities = index;
3542
4236
  return index;
3543
4237
  }
@@ -3574,6 +4268,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3574
4268
  _derivedSource;
3575
4269
  /** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
3576
4270
  _targetWriteOnly = /* @__PURE__ */ new WeakMap();
4271
+ /** Own leaf paths per readable (bound + `$with` targets), see {@link _leavesOf}. */
4272
+ _targetLeaves = /* @__PURE__ */ new WeakMap();
3577
4273
  _indexFieldPathsCache;
3578
4274
  /** {@link _nativeSearch} per request, keyed by the request's parsed controls. */
3579
4275
  _nativeSearchByRequest = /* @__PURE__ */ new WeakMap();
@@ -3597,8 +4293,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3597
4293
  _gateFieldsMemo = /* @__PURE__ */ new WeakMap();
3598
4294
  /** `true` when a subclass overrides {@link allowedActions}. */
3599
4295
  _hasAllowedActions;
4296
+ /** The class's validated `@DbDecorations` (since 0.1.148), `undefined` when none is declared. */
4297
+ _decorations;
4298
+ /** The decoration plumbing of {@link _decorations} — see `DecorationPlanner`. */
4299
+ _planner;
3600
4300
  /** `true` when a subclass overrides {@link actionRowScope} (the gate, `$actions` and `/meta/actions` apply it). */
3601
4301
  _hasActionRowScope;
4302
+ /** @internal `true` when a subclass overrides {@link resolveRowIds} (every id-addressed endpoint calls it; since 0.1.148). */
4303
+ [ROW_RESOLVES];
3602
4304
  /** `true` when the class declares `@DbActionsFrom` (since 0.1.147). */
3603
4305
  _hasDelegations;
3604
4306
  /** `transformProjection` is overridden (a delegation's id paths are checked against it). */
@@ -3622,15 +4324,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3622
4324
  this._writeOnlySet = this._writeOnlyOf(resolved);
3623
4325
  this._derivedSource = this._derivedSourcesOf(resolved);
3624
4326
  this._invertibleFields = this._collectInvertibleFields();
4327
+ this._decorates = typeof this.decorateRows === "function";
4328
+ this._decorations = this._resolveDecorations(new.target);
3625
4329
  this._searchFallbackFields = this._collectSearchFallbackFields();
3626
4330
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
3627
4331
  this._quantityRefByPath = this._collectQuantityRefs();
3628
4332
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
3629
4333
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
3630
- this._decorates = typeof this.decorateRows === "function";
3631
4334
  const proto = _AsDbReadableController.prototype;
3632
4335
  this._hasRowOverlay = this.transformOne !== proto.transformOne || this.transformFilter !== proto.transformFilter;
3633
4336
  this._hasActionRowScope = this.actionRowScope !== proto.actionRowScope;
4337
+ this[ROW_RESOLVES] = this.resolveRowIds !== proto.resolveRowIds;
3634
4338
  this._hasProjectionHook = this.transformProjection !== proto.transformProjection;
3635
4339
  this._hasAllowedActions = this.allowedActions !== proto.allowedActions;
3636
4340
  this._hasDelegations = hasActionDelegations(new.target);
@@ -3647,6 +4351,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3647
4351
  sealedFor: (readable, prefix = "") => this._sealedFor(readable, prefix)
3648
4352
  };
3649
4353
  this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
4354
+ this._planner = this._decorations && new DecorationPlanner(this._decorations, {
4355
+ isVisible,
4356
+ scoped,
4357
+ preferred: this._preferredIdSet,
4358
+ capabilities: () => this.capabilities,
4359
+ firstVisibleField: () => this._invertibleFields.find((path) => isVisible(path))
4360
+ });
4361
+ }
4362
+ /**
4363
+ * The class's `@DbDecorations`, validated against the bound readable once
4364
+ * per class and readable (a `[moost-db]` error when invalid); warns once
4365
+ * when `decorateRows` is not implemented.
4366
+ */
4367
+ _resolveDecorations(ctor) {
4368
+ const meta = getAtscriptDbMate().read(ctor)?.atscript_db_decorations;
4369
+ if (!meta) return void 0;
4370
+ let perCtor = decorationIndexes.get(this.readable);
4371
+ if (!perCtor) decorationIndexes.set(this.readable, perCtor = /* @__PURE__ */ new Map());
4372
+ let index = perCtor.get(ctor);
4373
+ if (!index) {
4374
+ index = buildDecorationIndex(ctor.name, meta, {
4375
+ flatMap: this.readable.flatMap,
4376
+ relations: this.readable.relations,
4377
+ navFields: FieldCapabilityIndex.navPathsOf(this.readable),
4378
+ ownPaths: new Set(this._invertibleFields),
4379
+ writeOnly: this._writeOnlySet
4380
+ });
4381
+ perCtor.set(ctor, index);
4382
+ if (!this._decorates) this.logger.warn(`@DbDecorations declares ${index.keys.join(", ")} but ${ctor.name} does not implement decorateRows() — the keys are never filled`);
4383
+ }
4384
+ return index;
3650
4385
  }
3651
4386
  /**
3652
4387
  * The identifications this request may address rows through (since
@@ -3672,7 +4407,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3672
4407
  }
3673
4408
  _collectInvertibleFields() {
3674
4409
  const out = [];
3675
- const nav = this.capabilities.navFields;
4410
+ const nav = FieldCapabilityIndex.navPathsOf(this.readable);
3676
4411
  for (const fd of this.readable.fieldDescriptors) {
3677
4412
  if (fd.ignored) continue;
3678
4413
  if (selfOrAncestor(fd.path, nav) !== void 0) continue;
@@ -3762,7 +4497,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3762
4497
  checkCapabilities(parsed) {
3763
4498
  const capabilities = this.capabilities;
3764
4499
  const isVisible = this.fieldVisibility.isVisible;
3765
- const refs = collectQueryPaths$1(parsed);
4500
+ const select = parsed.controls?.$select;
4501
+ const refs = collectQueryPaths$1(parsed, Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0);
3766
4502
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
3767
4503
  const relState = { nodes: 0 };
3768
4504
  for (const ref of refs.filter) {
@@ -3778,9 +4514,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3778
4514
  const recorded = parsed.controls ? this._clientWith.get(parsed.controls) : void 0;
3779
4515
  const withRelError = this._relationGate().checkWith(recorded === void 0 ? liveWith : recorded?.tree, relState);
3780
4516
  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);
4517
+ for (const op of PATH_OPS) {
4518
+ const gateOp = op === "select" && refs.aggregateMode ? "groupedSelect" : op;
4519
+ for (const path of refs[op]) {
4520
+ const verdict = capabilities.check(path, gateOp, isVisible);
4521
+ if (verdict) return badRequest(verdict.path, verdict.message);
4522
+ }
3784
4523
  }
3785
4524
  const having = checkHavingKeys(refs);
3786
4525
  if (having) return badRequest(having.path, having.message);
@@ -3806,7 +4545,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3806
4545
  navFields: capabilities.navFields
3807
4546
  });
3808
4547
  } catch (error) {
3809
- if (!(error instanceof DbError)) throw error;
4548
+ if (!(error instanceof DbError) || error.code !== "INVALID_QUERY") throw error;
3810
4549
  const [issue] = error.errors;
3811
4550
  return badRequest(issue.path, issue.message);
3812
4551
  }
@@ -3984,12 +4723,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
3984
4723
  const sub = nested.$select ?? rel.$select;
3985
4724
  return {
3986
4725
  ...nested,
3987
- $select: this._sealSelect(sub, sealed)
4726
+ $select: this._sealSelect(sub, sealed, target)
3988
4727
  };
3989
4728
  });
3990
4729
  const out = {
3991
4730
  ...controls,
3992
- $select: this._sealSelect(select, vis.sealedFor(this.readable))
4731
+ $select: this._sealSelect(select, vis.sealedFor(this.readable), this.readable)
3993
4732
  };
3994
4733
  if ($with !== controls.$with) out.$with = $with;
3995
4734
  return out;
@@ -4258,34 +4997,24 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4258
4997
  * The rows the row-level action `actionName` may run on (since 0.1.145),
4259
4998
  * as an extra row filter; `undefined` or `{}` = no restriction (the
4260
4999
  * 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"
5000
+ * loaded under {@link rowOverlay}, then checked against this filter (or —
5001
+ * without an overlay — the filter joins the load), so an id outside it gets
5002
+ * the same 404 "Row not found for action identifier"
4263
5003
  * as a missing one — and reflected in `$actions` and
4264
5004
  * `GET /meta/actions`, which list the action only on rows inside it.
4265
5005
  *
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).
5006
+ * The hook receives the candidate rows (`ctx`), so a scope can depend on
5007
+ * them — what `ctx.purpose`, `ctx.ids` and `ctx.loadRows` hold, and when the
5008
+ * hook is asked before any load, is documented on {@link TDbActionScopeContext}.
5009
+ * Called only with at least one candidate, at most once per action per
5010
+ * evaluation. An answer restricting nothing (`undefined`, `null`, `{}`)
5011
+ * makes the gate load no row at all; a restriction is folded into the one
5012
+ * row load. The result only restricts (`ids ∧ rowOverlay ∧ scope`); a throw
5013
+ * fails the request — never a silent "allow". The filter runs straight
5014
+ * against the bound readable: it may use fields {@link hasField} hides, and
5015
+ * nothing of it reaches the response. Runs after {@link prepareRequest} and,
5016
+ * on the action route, after the request body is read and
5017
+ * {@link resolveRowIds}.
4289
5018
  *
4290
5019
  * Not overriding it costs nothing; a one-parameter override keeps working.
4291
5020
  * moost-db always passes `ctx` — it is optional in the signature only so
@@ -4351,6 +5080,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4351
5080
  transformProjection(projection) {
4352
5081
  return projection;
4353
5082
  }
5083
+ /**
5084
+ * The shared projection step of `/query`, `/pages`, `/geo` and `/one`:
5085
+ * splits the declared decoration keys out of the wire `$select`
5086
+ * ({@link DecorationPlanner.plan}), runs {@link transformProjection} on the
5087
+ * rest (decoration keys never reach it — a permission layer needs no
5088
+ * change; their `requires` paths are added so the hook's inputs are read),
5089
+ * then seals every projection level. `finish` completes it once the
5090
+ * endpoint is past its own checks: the preferred-id widening (an
5091
+ * `HttpError` for a mixed `$select`) and the decoration step — what every
5092
+ * read endpoint then passes to {@link _runReadWithActions}.
5093
+ */
5094
+ async _projectRead(controls) {
5095
+ const plan = this._planner?.plan(controls.$select);
5096
+ const transformed = await this.transformProjection(plan ? plan.select : controls.$select);
5097
+ const sealed = this._sealControls(controls, transformed);
5098
+ const finish = () => {
5099
+ const select = this.widenPreferredIdProjection(sealed.$select);
5100
+ if (select instanceof HttpError) return select;
5101
+ let kept;
5102
+ const keptPaths = () => kept === void 0 ? kept = this._resolveProjectionForAugmenter(select) : kept;
5103
+ return {
5104
+ select,
5105
+ kept: keptPaths,
5106
+ read: plan && this._planner.serve(plan, keptPaths())
5107
+ };
5108
+ };
5109
+ return {
5110
+ sealed,
5111
+ finish
5112
+ };
5113
+ }
4354
5114
  widenPreferredIdProjection(projection) {
4355
5115
  const widened = this.widenQuantityRefProjection(projection);
4356
5116
  if (widened instanceof HttpError) return widened;
@@ -4372,12 +5132,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4372
5132
  return out;
4373
5133
  }
4374
5134
  _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);
5135
+ if (Object.keys(projection).length === 0) return projection;
5136
+ const shape = mapShape(projection);
5137
+ const included = new Set(shape.included);
5138
+ const excluded = new Set(shape.excluded);
4381
5139
  if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
4382
5140
  if (excluded.size === 0) {
4383
5141
  let allPresent = true;
@@ -4433,12 +5191,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4433
5191
  return [...projection, ...toAdd];
4434
5192
  }
4435
5193
  _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);
5194
+ if (Object.keys(projection).length === 0) return projection;
5195
+ const shape = mapShape(projection);
5196
+ const included = new Set(shape.included);
5197
+ const excluded = new Set(shape.excluded);
4442
5198
  if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
4443
5199
  if (excluded.size === 0) {
4444
5200
  const toAdd = [];
@@ -4453,22 +5209,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4453
5209
  }
4454
5210
  /** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
4455
5211
  _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;
5212
+ const shape = selectShape(select);
5213
+ if (shape.kind === "all") return null;
5214
+ if (shape.kind === "list") return [...new Set(shape.items.filter((item) => typeof item === "string"))];
5215
+ const { included, excluded } = shape;
5216
+ if (included.length > 0 && excluded.length === 0) return [...included];
4472
5217
  if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
4473
5218
  throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
4474
5219
  }
@@ -4514,7 +5259,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4514
5259
  }
4515
5260
  return result;
4516
5261
  }
4517
- async _prepareAugmentation(controls, select) {
5262
+ async _prepareAugmentation(controls, projected) {
4518
5263
  if (!controls.$actions) return null;
4519
5264
  const [own, delegations] = await Promise.all([this._resolveAugmentEnvelopes(), this._activeDelegations()]);
4520
5265
  if (own === null && delegations.length === 0) return null;
@@ -4524,7 +5269,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4524
5269
  scopeOverlay = this.rowOverlay();
4525
5270
  scopeOverlay.catch(() => {});
4526
5271
  }
4527
- let resolvedProjection = this._resolveProjectionForAugmenter(select);
5272
+ let resolvedProjection = projected.kept();
4528
5273
  let widenedSelect = resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection);
4529
5274
  if (resolvedProjection !== null && delegations.length > 0) {
4530
5275
  const base = widenedSelect ?? resolvedProjection;
@@ -4629,6 +5374,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4629
5374
  [ACTION_OVERLAY]() {
4630
5375
  return this.rowOverlay();
4631
5376
  }
5377
+ /**
5378
+ * @internal {@link resolveRowIds} for an action's validated ids (since
5379
+ * 0.1.148): the output validated, duplicate identities collapsed, and the
5380
+ * ids as the client sent them kept for the error bodies and summaries.
5381
+ */
5382
+ async [ROW_RESOLVE_IDS](ids, ctx) {
5383
+ return applyResolvedIds(ids, await this._runResolveRowIds(ids, ctx));
5384
+ }
4632
5385
  /** @internal {@link actionRowScope} for the gate's loaded candidates (since 0.1.147). */
4633
5386
  [ACTION_SCOPE](action, ctx) {
4634
5387
  return this._actionScope(action, ctx);
@@ -4757,9 +5510,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4757
5510
  /**
4758
5511
  * `select` without the `sealed` paths (see {@link _sealControls}); an
4759
5512
  * exclusion of them is forced when there is no projection, or when every
4760
- * requested path was sealed.
5513
+ * requested path was sealed. An inclusion naming a PARENT of a sealed path
5514
+ * (`secret` over a write-only `secret.hash`) is replaced by the parent's
5515
+ * unsealed leaves, so a sealed descendant never rides along with it.
4761
5516
  */
4762
- _sealSelect(select, writeOnly) {
5517
+ _sealSelect(select, writeOnly, readable) {
4763
5518
  if (writeOnly.size === 0) return select;
4764
5519
  const exclusion = () => {
4765
5520
  const out = {};
@@ -4767,29 +5522,47 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4767
5522
  return out;
4768
5523
  };
4769
5524
  if (select === void 0) return exclusion();
5525
+ const expand = (path) => {
5526
+ if (writeOnly.has(path)) return [];
5527
+ const prefix = `${path}.`;
5528
+ let parent = false;
5529
+ for (const sealed of writeOnly) if (sealed.startsWith(prefix)) {
5530
+ parent = true;
5531
+ break;
5532
+ }
5533
+ if (!parent) return [path];
5534
+ return this._leavesOf(readable).filter((leaf) => leaf.startsWith(prefix) && selfOrAncestor(leaf, writeOnly) === void 0);
5535
+ };
4770
5536
  if (Array.isArray(select)) {
4771
- const kept = select.filter((item) => typeof item === "string" ? !writeOnly.has(item) : !writeOnly.has(item.$field ?? ""));
5537
+ const kept = [];
5538
+ for (const item of select) if (typeof item === "string") kept.push(...expand(item));
5539
+ else if (!writeOnly.has(item.$field ?? "")) kept.push(item);
4772
5540
  return kept.length > 0 ? kept : exclusion();
4773
5541
  }
4774
5542
  const entries = Object.entries(select);
4775
5543
  if (entries.length > 0 && (entries[0][1] === 1 || entries[0][1] === true)) {
4776
5544
  const out = {};
4777
- for (const [k, v] of entries) if (!writeOnly.has(k)) out[k] = v;
5545
+ for (const [k, v] of entries) for (const path of expand(k)) out[path] = v;
4778
5546
  return Object.keys(out).length > 0 ? out : exclusion();
4779
5547
  }
4780
5548
  const out = { ...select };
4781
5549
  for (const f of writeOnly) out[f] = 0;
4782
5550
  return out;
4783
5551
  }
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;
5552
+ /** Own leaf field paths (no navigation, no ignored field) of `readable`, once per readable. */
5553
+ _leavesOf(readable) {
5554
+ let leaves = this._targetLeaves.get(readable);
5555
+ if (!leaves) {
5556
+ const paths = [...readable.flatMap?.keys() ?? []].filter((path) => path !== "");
5557
+ const parents = /* @__PURE__ */ new Set();
5558
+ for (const path of paths) for (let at = path.indexOf("."); at >= 0; at = path.indexOf(".", at + 1)) parents.add(path.slice(0, at));
5559
+ const nav = readable.navFields ?? /* @__PURE__ */ new Set();
5560
+ const ignored = /* @__PURE__ */ new Set();
5561
+ for (const fd of readable.fieldDescriptors) if (fd.ignored) ignored.add(fd.path);
5562
+ leaves = paths.filter((path) => !parents.has(path) && selfOrAncestor(path, nav) === void 0 && selfOrAncestor(path, ignored) === void 0);
5563
+ this._targetLeaves.set(readable, leaves);
4792
5564
  }
5565
+ return leaves;
4793
5566
  }
4794
5567
  /**
4795
5568
  * Merges the `$search` fallback into the filter: a case-insensitive literal
@@ -4859,13 +5632,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4859
5632
  * subclass implements it. Returns the hook's result — `undefined`, with no
4860
5633
  * promise or microtask, when there is no hook or it is synchronous.
4861
5634
  */
4862
- _finishRows(rows, prep, ctx) {
5635
+ _finishRows(rows, prep, ctx, read) {
4863
5636
  const overlay = prep?.scopeOverlay;
4864
- if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx);
5637
+ if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx, read);
4865
5638
  return (async () => {
4866
5639
  const names = prep.envelopes.map((e) => e.info.name);
4867
5640
  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);
5641
+ await this._augmentAndDecorate(rows, prep, ctx, read, masks, delegated);
4869
5642
  })();
4870
5643
  }
4871
5644
  /**
@@ -4873,7 +5646,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4873
5646
  * ones (`delegated`: per delegation, per row) — then {@link decorateRows}
4874
5647
  * when implemented.
4875
5648
  */
4876
- _augmentAndDecorate(rows, prep, ctx, outOfScope, delegated) {
5649
+ _augmentAndDecorate(rows, prep, ctx, read, outOfScope, delegated) {
4877
5650
  if (prep) {
4878
5651
  augmentRowsWithActions({
4879
5652
  envelopes: prep.envelopes,
@@ -4884,7 +5657,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4884
5657
  });
4885
5658
  if (prep.delegations.length > 0) mergeDelegatedActions(rows, delegated ?? []);
4886
5659
  }
4887
- return this._decorates ? this.decorateRows(rows, ctx) : void 0;
5660
+ const decorated = this._decorates ? this.decorateRows(rows, ctx) : void 0;
5661
+ if (!read || stripsNothing(read)) return decorated;
5662
+ const strip = () => stripDecorations(rows, read);
5663
+ if (decorated === void 0) return strip();
5664
+ return Promise.resolve(decorated).then(strip);
4888
5665
  }
4889
5666
  /**
4890
5667
  * The app of the current event, through DI — never the one this
@@ -4960,9 +5737,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4960
5737
  */
4961
5738
  resolveMeta() {
4962
5739
  const own = super.resolveMeta();
4963
- if (!this._hasDelegations && !this._hasFieldOverridden) return own;
5740
+ if (!this._hasDelegations && !this._hasFieldOverridden && !this._planner) return own;
4964
5741
  return (async () => {
4965
- const [meta, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
5742
+ const [overlaid, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
5743
+ const meta = this._planner ? this._planner.meta(overlaid) : overlaid;
4966
5744
  const visible = this._applyIndexVisibility(meta);
4967
5745
  return delegated.length > 0 ? {
4968
5746
  ...visible,
@@ -5016,7 +5794,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5016
5794
  /** @internal Source side of a delegation: `GET /meta/actions` for one id, `names` only. */
5017
5795
  async [AVAILABLE_ACTIONS](id, names) {
5018
5796
  await this.parseRequest("availableActions");
5019
- return this._availableActions(id, names);
5797
+ return (await this._availableResolved(id, names)).own;
5020
5798
  }
5021
5799
  /** @internal Source side of a delegation: the `names` the caller may run (`allowedActions`). */
5022
5800
  async [ALLOWED_ACTIONS](names) {
@@ -5035,8 +5813,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5035
5813
  * `$actions=true`, then run {@link decorateRows}. Caller dispatches the
5036
5814
  * strategy to its read-method family (count vs no-count).
5037
5815
  */
5038
- async _runReadWithActions(endpoint, queryObj, controls, select, exec) {
5039
- const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, select), this._resolveReadStrategy(controls)]);
5816
+ async _runReadWithActions(endpoint, queryObj, controls, projected, exec) {
5817
+ const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveReadStrategy(controls)]);
5040
5818
  const result = await exec(prep?.widenedSelect ? {
5041
5819
  ...queryObj,
5042
5820
  controls: {
@@ -5046,19 +5824,120 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5046
5824
  } : queryObj, strategy);
5047
5825
  const pending = this._finishRows(result.data, prep, {
5048
5826
  endpoint,
5049
- projection: select,
5050
- controls
5051
- });
5827
+ projection: projected.select,
5828
+ controls,
5829
+ decorations: projected.read?.served ?? NO_DECORATIONS
5830
+ }, projected.read);
5052
5831
  if (pending) await pending;
5053
5832
  return result;
5054
5833
  }
5055
5834
  /**
5835
+ * Maps the ids an id-addressed endpoint received to the rows' current ids
5836
+ * (since 0.1.148) — the seam for stale or alias ids, e.g. a natural key that
5837
+ * was renamed. Called once per request, after {@link prepareRequest} and
5838
+ * after the request's own validation, before anything reads the row, by:
5839
+ *
5840
+ * | `ctx.purpose` | endpoint | `ids` |
5841
+ * | --- | --- | --- |
5842
+ * | `"one"` | `GET /one/:id`, `GET /one?…` | one id: the path string, or the `?`-form identification object |
5843
+ * | `"available"` | `GET /meta/actions/:id`, `?…` (and a view asking its source) | one id, as above |
5844
+ * | `"remove"` | `DELETE /:id`, `DELETE /?…` (`AsDbController`) | one id, as above |
5845
+ * | `"action"` | an action route | the body's validated ids (one for a `'row'` action) |
5846
+ *
5847
+ * Contract:
5848
+ *
5849
+ * - Return one id per input id, index-aligned (anything else is a 500). An
5850
+ * id that already names a row must come back UNCHANGED — the current
5851
+ * holder of a key wins over an alias; so must an id you cannot resolve
5852
+ * (never throw for an unknown alias: a custom error is an oracle — the
5853
+ * endpoint then answers its normal miss).
5854
+ * - The output is validated (a server bug is a 500, never a client 400): a
5855
+ * scalar is resolved like a path scalar (primary key first, then the
5856
+ * visible unique keys, inside the row overlay); an object must be one of
5857
+ * the visible identifications ({@link idSource}); an `"action"` id must
5858
+ * be such an object.
5859
+ * - The resolved id is never trusted for access: the endpoint still reads
5860
+ * or deletes it under {@link rowOverlay} and the visible identifications.
5861
+ * `ctx.overlay` is that overlay — resolve INSIDE it when an alias could
5862
+ * name several rows, so a row the caller can't reach never shadows one
5863
+ * they can. Don't log or return the canonical id in errors.
5864
+ * - Handlers (`@DbActionID()`, `useDbActionId()`), {@link rowOverlay} reads,
5865
+ * {@link actionRowScope} and `onRemove` / `guardRemove` receive the
5866
+ * resolved ids; error bodies and `summary()` echo the ids the client sent.
5867
+ * - Write bodies (`POST` / `PUT` / `PATCH`) are not resolved — use
5868
+ * `onWrite`. Not called by value-help controllers, `$actions` on a read
5869
+ * or a query target. Not overriding it costs nothing.
5870
+ *
5871
+ * It is NOT overridden by {@link resolveRowFilter}, which does not take part
5872
+ * in `/one` for real tables and views.
5873
+ *
5874
+ * ```ts
5875
+ * protected async resolveRowIds(ids: readonly TDbRowIdInput[], ctx: TDbRowIdsContext) {
5876
+ * return Promise.all(ids.map(async (id) => {
5877
+ * const key = typeof id === "object" ? id.code : id
5878
+ * if (typeof key !== "string") return id
5879
+ * // the current holder of the key wins; consult the alias table on a miss
5880
+ * // resolve INSIDE the overlay: a row the caller cannot reach never wins
5881
+ * const inScope = (code: string) =>
5882
+ * this.readable.count({ filter: ctx.overlay ? { $and: [{ code }, ctx.overlay] } : { code } })
5883
+ * if (await inScope(key)) return id
5884
+ * const alias = await aliases.findOne({ filter: { oldCode: key } })
5885
+ * if (!alias || !(await inScope(alias.newCode))) return id
5886
+ * return typeof id === "object" ? { code: alias.newCode } : alias.newCode
5887
+ * }))
5888
+ * }
5889
+ * ```
5890
+ *
5891
+ * @since 0.1.148
5892
+ */
5893
+ resolveRowIds(ids, _ctx) {
5894
+ return ids;
5895
+ }
5896
+ /** {@link resolveRowIds} with its output validated (a server bug is a 500). */
5897
+ async _runResolveRowIds(ids, ctx) {
5898
+ const out = await this.resolveRowIds(ids, ctx);
5899
+ if (!Array.isArray(out) || out.length !== ids.length) throw new HttpError(500, "resolveRowIds must return one id per request id");
5900
+ const source = this.idSource;
5901
+ for (const id of out) {
5902
+ if ((typeof id === "string" || typeof id === "number" || typeof id === "boolean") && ctx.purpose !== "action") continue;
5903
+ try {
5904
+ validateSingleId(id, source, { strictTypes: ctx.purpose === "action" });
5905
+ } catch (error) {
5906
+ if (error instanceof ValidatorError) throw new HttpError(500, "resolveRowIds returned an invalid id");
5907
+ throw error;
5908
+ }
5909
+ }
5910
+ return out;
5911
+ }
5912
+ /**
5913
+ * @internal The one id of an id-addressed endpoint through
5914
+ * {@link resolveRowIds} (identity, at no cost, when it is not overridden)
5915
+ * and the row overlay it was resolved inside — computed once, for the
5916
+ * endpoint's read or delete under that overlay.
5917
+ */
5918
+ async _resolveWithOverlay(id, purpose) {
5919
+ const overlay = await this.rowOverlay();
5920
+ if (!this[ROW_RESOLVES]) return {
5921
+ id,
5922
+ overlay
5923
+ };
5924
+ const [resolved] = await this._runResolveRowIds([id], {
5925
+ purpose,
5926
+ overlay
5927
+ });
5928
+ return {
5929
+ id: resolved,
5930
+ overlay
5931
+ };
5932
+ }
5933
+ /**
5056
5934
  * The filter addressing exactly the ONE row `id` means — the readable's
5057
5935
  * PK-first `resolveRowFilter` (since 0.1.143) under this request's
5058
5936
  * identifications (`_idOpts`). `scope` (the row overlay) restricts which
5059
5937
  * rows count while the id is pinned, so a row outside it never shadows one
5060
5938
  * inside it. Readables without it (partial mocks) fall back to
5061
- * `resolveIdFilter`.
5939
+ * `resolveIdFilter`. Not an alias seam: `/one` reads through `findOneByRow`
5940
+ * on every real table or view — map stale ids in {@link resolveRowIds}.
5062
5941
  */
5063
5942
  resolveRowFilter(id, scope) {
5064
5943
  const readable = this.readable;
@@ -5132,8 +6011,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5132
6011
  if (groupBy?.length && controls.$vector !== void 0) return new HttpError(400, "Cannot combine $vector and $groupBy in the same query");
5133
6012
  const error = this.validateParsed(parsed, "query");
5134
6013
  if (error) return error;
5135
- if (groupBy?.length) {
5136
- const sealed = this._findWriteOnlyInAggregate(groupBy, controls.$select);
6014
+ if (groupBy?.length && this._writeOnlySet.size > 0) {
6015
+ const refs = collectQueryPaths$1(parsed, true);
6016
+ const sealed = [
6017
+ ...refs.groupBy,
6018
+ ...refs.aggregate,
6019
+ ...refs.bucket,
6020
+ ...refs.select,
6021
+ ...refs.sort
6022
+ ].find((path) => this._writeOnlySet.has(path) && this.fieldVisibility.isVisible(path));
5137
6023
  if (sealed) return new HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
5138
6024
  }
5139
6025
  const gateError = this.checkCapabilities(parsed);
@@ -5147,9 +6033,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5147
6033
  insights: parsed.insights
5148
6034
  });
5149
6035
  }
5150
- const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(clientFilter), this.transformProjection(controls.$select)]);
6036
+ const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
5151
6037
  const filter = this.applySearchFallback(transformedFilter, controls);
5152
- const sealed = this._sealControls(controls, transformedSelect);
5153
6038
  if (controls.$count) return this.readable.count({
5154
6039
  filter,
5155
6040
  controls: {
@@ -5157,19 +6042,19 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5157
6042
  $select: sealed.$select
5158
6043
  }
5159
6044
  });
5160
- const select = this.widenPreferredIdProjection(sealed.$select);
5161
- if (select instanceof HttpError) return select;
6045
+ const projected = finish();
6046
+ if (projected instanceof HttpError) return projected;
5162
6047
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
5163
6048
  const queryObj = {
5164
6049
  filter,
5165
6050
  controls: {
5166
6051
  ...sealed,
5167
- $select: select,
6052
+ $select: projected.select,
5168
6053
  $limit: controls.$limit || 1e3,
5169
6054
  $threshold: threshold
5170
6055
  }
5171
6056
  };
5172
- return (await this._runReadWithActions("query", queryObj, controls, select, async (q, strategy) => {
6057
+ return (await this._runReadWithActions("query", queryObj, controls, projected, async (q, strategy) => {
5173
6058
  switch (strategy.kind) {
5174
6059
  case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
5175
6060
  case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
@@ -5190,23 +6075,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5190
6075
  const page = Math.max(Number(controls.$page || 1), 1);
5191
6076
  const size = Math.max(Number(controls.$size || 10), 1);
5192
6077
  const skip = (page - 1) * size;
5193
- const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(clientFilter), this.transformProjection(controls.$select)]);
6078
+ const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
5194
6079
  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;
6080
+ const projected = finish();
6081
+ if (projected instanceof HttpError) return projected;
5198
6082
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
5199
6083
  const query = {
5200
6084
  filter,
5201
6085
  controls: {
5202
6086
  ...sealed,
5203
- $select: select,
6087
+ $select: projected.select,
5204
6088
  $skip: skip,
5205
6089
  $limit: size,
5206
6090
  $threshold: threshold
5207
6091
  }
5208
6092
  };
5209
- const result = await this._runReadWithActions("pages", query, controls, select, async (q, strategy) => {
6093
+ const result = await this._runReadWithActions("pages", query, controls, projected, async (q, strategy) => {
5210
6094
  switch (strategy.kind) {
5211
6095
  case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
5212
6096
  case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
@@ -5248,10 +6132,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5248
6132
  const gateError = this.checkCapabilities(parsed);
5249
6133
  if (gateError) return gateError;
5250
6134
  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;
6135
+ const [filter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
6136
+ const projected = finish();
6137
+ if (projected instanceof HttpError) return projected;
5255
6138
  const paginated = controls.$page !== void 0 || controls.$size !== void 0;
5256
6139
  const page = Math.max(Number(controls.$page || 1), 1);
5257
6140
  const size = Math.max(Number(controls.$size || 10), 1);
@@ -5261,7 +6144,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5261
6144
  ...sealed,
5262
6145
  $center: void 0,
5263
6146
  $index: void 0,
5264
- $select: select,
6147
+ $select: projected.select,
5265
6148
  ...paginated ? {
5266
6149
  $skip: (page - 1) * size,
5267
6150
  $limit: size
@@ -5269,7 +6152,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5269
6152
  }
5270
6153
  };
5271
6154
  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));
6155
+ const result = await this._runReadWithActions("geo", queryObj, controls, projected, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
5273
6156
  return {
5274
6157
  data: result.data,
5275
6158
  page,
@@ -5278,7 +6161,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5278
6161
  count: result.count
5279
6162
  };
5280
6163
  }
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;
6164
+ 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
6165
  }
5283
6166
  /** Parses the `$center` control: `"lng,lat"` string (or tuple) → `[number, number]`. */
5284
6167
  _parseGeoCenter(raw) {
@@ -5327,21 +6210,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5327
6210
  const error = this.validateParsed(parsed, "getOne") ?? this.checkCapabilities(parsed);
5328
6211
  if (error) return error;
5329
6212
  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()]);
6213
+ const { sealed, finish } = await this._projectRead(controls);
6214
+ const projected = finish();
6215
+ if (projected instanceof HttpError) return projected;
6216
+ const [prep, { id: resolvedId, overlay }] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveWithOverlay(id, "one")]);
5334
6217
  const readControls = {
5335
6218
  ...sealed,
5336
- $select: prep?.widenedSelect ?? select
6219
+ $select: prep?.widenedSelect ?? projected.select
5337
6220
  };
5338
- const item = await this.returnOne(this._findRow(id, overlay, readControls));
6221
+ const item = await this.returnOne(this._findRow(resolvedId, overlay, readControls));
5339
6222
  if (item instanceof HttpError) return item;
5340
6223
  const pending = this._finishRows([item], prep, {
5341
6224
  endpoint: "one",
5342
- projection: select,
5343
- controls
5344
- });
6225
+ projection: projected.select,
6226
+ controls,
6227
+ decorations: projected.read?.served ?? NO_DECORATIONS
6228
+ }, projected.read);
5345
6229
  if (pending) await pending;
5346
6230
  return item;
5347
6231
  }
@@ -5358,10 +6242,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5358
6242
  */
5359
6243
  async availableActionsById(id) {
5360
6244
  await this.parseRequest("availableActions");
5361
- const own = await this._availableActions(id);
6245
+ const { own, id: resolved } = await this._availableResolved(id);
5362
6246
  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);
6247
+ return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved));
5365
6248
  }
5366
6249
  /**
5367
6250
  * **GET /meta/actions?field1=val1&…** — {@link availableActionsById} by
@@ -5371,13 +6254,50 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5371
6254
  async availableActions(query) {
5372
6255
  await this.parseRequest("availableActions");
5373
6256
  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
6257
  if (idObj instanceof HttpError) {
5376
- if (!(await this._activeDelegations()).some((d) => sourceIdOf(d) !== void 0)) return idObj;
5377
- return this._delegatedAvailable({ actions: [] }, sourceIdOf);
6258
+ if (!(await this._activeDelegations()).some((d) => this._sourceIdOf(d, void 0, query) !== void 0)) return idObj;
6259
+ return this._delegatedAvailable({ actions: [] }, (d) => this._sourceIdOf(d, void 0, query));
5378
6260
  }
5379
- const own = await this._availableActions(idObj);
5380
- return this._hasDelegations ? this._delegatedAvailable(own, sourceIdOf) : own;
6261
+ const { own, id: resolved } = await this._availableResolved(idObj);
6262
+ if (!this._hasDelegations) return own;
6263
+ return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved, query, this._identificationFields()));
6264
+ }
6265
+ /** Every field of every identification (primary key and unique indexes) the view addresses a row by. */
6266
+ _identificationFields() {
6267
+ return [...new Set(this.idSource.identifications.flatMap((i) => i.fields))];
6268
+ }
6269
+ /**
6270
+ * A delegation's source id: each source id field from the row's mapped path
6271
+ * of the RESOLVED `id`. A path of ANY of the view's identifications
6272
+ * (`consumed` — not just the one the request matched: `?id=1&code=T-OLD` names
6273
+ * `code` too) is NEVER taken from the raw `?` query: that value may be an
6274
+ * alias `resolveRowIds` rewrote, and the source would see (and answer for)
6275
+ * it. Paths outside the identification fall back to `fallback`'s (the raw
6276
+ * query's) value; `undefined` when a path has none.
6277
+ */
6278
+ _sourceIdOf(d, id, fallback, consumed = []) {
6279
+ const value = (path) => id?.[path] ?? (consumed.includes(path) ? void 0 : fallback?.[path]);
6280
+ return d.paths.every((path) => value(path) !== void 0) ? Object.fromEntries(Object.entries(d.idMap).map(([field, path]) => [field, value(path)])) : void 0;
6281
+ }
6282
+ /**
6283
+ * {@link _availableActions} for a request id (`names`: only those actions):
6284
+ * through {@link resolveRowIds} (`"available"`) first, the row overlay
6285
+ * computed once for both — and the resolved id returned as an object (a
6286
+ * scalar is the single-field `preferredId` value) for the delegated part to
6287
+ * derive its source id from.
6288
+ */
6289
+ async _availableResolved(id, names) {
6290
+ const { id: resolved, overlay } = await this._resolveWithOverlay(id, "available");
6291
+ const own = await this._availableActions(resolved, overlay, names);
6292
+ if (typeof resolved === "object") return {
6293
+ own,
6294
+ id: resolved
6295
+ };
6296
+ const preferred = this.readable.preferredId;
6297
+ return {
6298
+ own,
6299
+ id: preferred.length === 1 ? { [preferred[0]]: resolved } : void 0
6300
+ };
5381
6301
  }
5382
6302
  /**
5383
6303
  * **POST /delegated-actions/:name** — a query target for a `@DbActionsFrom`
@@ -5549,8 +6469,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5549
6469
  * actions are then checked on it (`purpose: "available"`) exactly like
5550
6470
  * `$actions` rows.
5551
6471
  */
5552
- async _availableActions(id, names) {
5553
- const [envelopes, overlay] = await Promise.all([names ? this._envelopesNamed(names) : this._resolveAugmentEnvelopes(), this.rowOverlay()]);
6472
+ async _availableActions(id, overlay, names) {
6473
+ const envelopes = await (names ? this._envelopesNamed(names) : this._resolveAugmentEnvelopes());
5554
6474
  if (!envelopes?.length) return { actions: [] };
5555
6475
  const idKeys = id !== null && typeof id === "object" ? Object.keys(id) : [];
5556
6476
  const fieldsOf = envelopes.map((e) => {
@@ -5631,7 +6551,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5631
6551
  if (fd.encrypted) entry.encrypted = true;
5632
6552
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
5633
6553
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
6554
+ if (cap.groupable) entry.groupable = true;
5634
6555
  if (cap.bucketable) entry.bucketable = true;
6556
+ if (cap.numeric) entry.numeric = true;
5635
6557
  if (fd.derived) entry.derived = true;
5636
6558
  if (fd.computed) entry.computed = true;
5637
6559
  fields[path] = entry;
@@ -5646,11 +6568,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5646
6568
  relations,
5647
6569
  fields,
5648
6570
  type: this.getSerializedType(),
6571
+ ...this._decorations && { decorations: this._planner.serialized(() => this.serializeForMeta(this._decorations.type)) },
5649
6572
  actions: this.buildActions(),
5650
6573
  crud: this.buildCrud(),
5651
6574
  versionColumn: this.readable.versionColumn,
5652
6575
  ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] },
5653
- aggregateFns: [...capabilities.aggregateFns]
6576
+ aggregateFns: [...capabilities.aggregateFns],
6577
+ aggregateExpressions: capabilities.aggregateExpressions
5654
6578
  };
5655
6579
  }
5656
6580
  buildCrud() {
@@ -5781,7 +6705,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5781
6705
  buildCrud() {
5782
6706
  return {
5783
6707
  ...super.buildCrud(),
5784
- insert: [],
6708
+ insert: this.readable.dbAdapter?.supportsInsertIgnore() ? ["onConflict"] : [],
5785
6709
  update: [],
5786
6710
  replace: [],
5787
6711
  remove: []
@@ -5801,7 +6725,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5801
6725
  return data;
5802
6726
  }
5803
6727
  /**
5804
- * Intercepts delete operations. Return `undefined` to abort (500 "Not
6728
+ * Intercepts delete operations. Receives the id {@link resolveRowIds}
6729
+ * resolved (the one the request carried when it is not overridden; since
6730
+ * 0.1.148). Return `undefined` to abort (500 "Not
5805
6731
  * deleted"); return an `Error` instance to respond with that error.
5806
6732
  * Runs outside any transaction. May be async (e.g. to resolve composite
5807
6733
  * ids from external state).
@@ -5938,8 +6864,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5938
6864
  * table's transaction and an out-of-scope row is not deleted — a 404,
5939
6865
  * exactly like a missing one.
5940
6866
  */
5941
- async _deleteOrThrow(id) {
5942
- const scope = await this.rowOverlay();
6867
+ async _deleteOrThrow(id, scope) {
5943
6868
  const args = scope ? [{
5944
6869
  ...this._removeArgs[0],
5945
6870
  scope
@@ -5950,16 +6875,34 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
5950
6875
  }
5951
6876
  /**
5952
6877
  * **POST /** — inserts one or many records.
6878
+ *
6879
+ * `?$onConflict=ignore` (since 0.1.148) skips rows colliding on the primary
6880
+ * key or a unique index instead of answering 409. The response then is
6881
+ * `{ insertedId?, conflict }` for an object body and
6882
+ * `{ insertedCount, insertedIds, inserted, conflicts }` for an array body.
6883
+ * Any other `$` control on POST answers 400.
5953
6884
  */
5954
- async insert(payload) {
5955
- await this.parseRequest("insert");
6885
+ async insert(payload, url) {
6886
+ const { controls } = await this.parseRequest("insert", url ?? "");
6887
+ const onConflict = this._readOnConflict(controls);
5956
6888
  assertWriteShape(payload);
6889
+ const args = onConflict ? [{
6890
+ ...this._writeArgs[0],
6891
+ onConflict
6892
+ }] : this._writeArgs;
5957
6893
  if (Array.isArray(payload)) {
5958
6894
  const rows = await this._writeBody("insertMany", payload, true);
5959
- return this.table.insertMany(rows, ...this._writeArgs);
6895
+ return this.table.insertMany(rows, ...args);
5960
6896
  }
5961
6897
  const row = await this._writeBody("insert", payload, false);
5962
- return this.table.insertOne(row, ...this._writeArgs);
6898
+ return this.table.insertOne(row, ...args);
6899
+ }
6900
+ /** The only POST control: `$onConflict` (`error` | `ignore`). Anything else `$…` → 400. */
6901
+ _readOnConflict(controls) {
6902
+ for (const key of Object.keys(controls)) if (key !== "$onConflict") throw badRequest("", `Unsupported control "${key}" on insert`);
6903
+ const mode = controls.$onConflict;
6904
+ if (mode !== void 0 && mode !== "error" && mode !== "ignore") throw badRequest("", `$onConflict must be "error" or "ignore", got ${JSON.stringify(mode)}`);
6905
+ return mode === "ignore" ? "ignore" : void 0;
5963
6906
  }
5964
6907
  /**
5965
6908
  * **PUT /** — fully replaces one or many records matched by primary key.
@@ -6025,7 +6968,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
6025
6968
  if (typeof table.recordFilter === "function") try {
6026
6969
  filter = table.recordFilter(data, this._idOpts);
6027
6970
  } catch (error) {
6028
- if (!(error instanceof DbError)) throw error;
6971
+ if (!(error instanceof DbError) || error.code === "SPACE_CLOSED") throw error;
6029
6972
  filter = null;
6030
6973
  }
6031
6974
  else filter = await this.resolveRowFilter(data);
@@ -6046,8 +6989,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
6046
6989
  */
6047
6990
  async remove(id) {
6048
6991
  await this.parseRequest("remove");
6049
- const resolvedId = await this._checkHook(this.onRemove(id), "Not deleted");
6050
- return this._deleteOrThrow(resolvedId);
6992
+ const { id: resolved, overlay } = await this._resolveWithOverlay(id, "remove");
6993
+ const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
6994
+ return this._deleteOrThrow(resolvedId, overlay);
6051
6995
  }
6052
6996
  /**
6053
6997
  * **DELETE /?field1=val1&field2=val2** — removes a record by composite key
@@ -6057,15 +7001,17 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
6057
7001
  await this.parseRequest("remove");
6058
7002
  const idObj = this.extractIdShape(query);
6059
7003
  if (idObj instanceof HttpError) throw idObj;
6060
- const resolvedId = await this._checkHook(this.onRemove(idObj), "Not deleted");
6061
- return this._deleteOrThrow(resolvedId);
7004
+ const { id: resolved, overlay } = await this._resolveWithOverlay(idObj, "remove");
7005
+ const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
7006
+ return this._deleteOrThrow(resolvedId, overlay);
6062
7007
  }
6063
7008
  };
6064
7009
  __decorate([
6065
7010
  Post(""),
6066
7011
  __decorateParam(0, Body()),
7012
+ __decorateParam(1, Url()),
6067
7013
  __decorateMetadata("design:type", Function),
6068
- __decorateMetadata("design:paramtypes", [Object]),
7014
+ __decorateMetadata("design:paramtypes", [Object, String]),
6069
7015
  __decorateMetadata("design:returntype", Promise)
6070
7016
  ], AsDbController.prototype, "insert", null);
6071
7017
  __decorate([
@@ -6562,7 +7508,7 @@ function buildGateInterceptor(opts) {
6562
7508
  await ctx.get(dbActionOverlaySlot);
6563
7509
  if (level === "row") {
6564
7510
  const verdict = judgeRow(action, disabled, await ctx.get(dbActionRowSlot));
6565
- if (verdict) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdict)]);
7511
+ if (verdict) throw rowDisabledError(ctx, action, await ctx.get(dbActionIdSlot), verdictReason(verdict));
6566
7512
  return;
6567
7513
  }
6568
7514
  if (await queryTargetReply(ctx, reply)) return;
@@ -6586,8 +7532,7 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
6586
7532
  const existingRows = [];
6587
7533
  for (const row of rows) if (row !== void 0) existingRows.push(row);
6588
7534
  const verdicts = disabled ? judgeRows(action, disabled, existingRows) : void 0;
6589
- const failingIds = [];
6590
- const failingReasons = [];
7535
+ const failing = [];
6591
7536
  const passingRows = [];
6592
7537
  const passingIds = [];
6593
7538
  const skipped = [];
@@ -6598,8 +7543,10 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
6598
7543
  const verdict = row === void 0 ? void 0 : verdicts?.[verdictIndex++];
6599
7544
  if (row === void 0 || verdict) {
6600
7545
  const reason = verdictReason(verdict);
6601
- failingIds.push(ids[i]);
6602
- failingReasons.push(reason);
7546
+ failing.push({
7547
+ id: ids[i],
7548
+ reason
7549
+ });
6603
7550
  const skipReason = reason ?? (stale?.has(i) ? "stale" : void 0);
6604
7551
  skipped.push(skipReason === void 0 ? { id: ids[i] } : {
6605
7552
  id: ids[i],
@@ -6611,27 +7558,47 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
6611
7558
  }
6612
7559
  }
6613
7560
  if (onDisabledRows === "skip") {
6614
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
6615
- if (failingIds.length > 0) {
7561
+ if (passingRows.length === 0) throw disabledError(ctx, action, failing);
7562
+ if (failing.length > 0) {
6616
7563
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
6617
7564
  ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
6618
7565
  ctx.set(dbActionSkippedKey, skipped);
6619
7566
  }
6620
7567
  return;
6621
7568
  }
6622
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
7569
+ if (failing.length > 0) throw disabledError(ctx, action, failing);
7570
+ }
7571
+ /**
7572
+ * The 409 of a `'rows'` gate: every failing REQUEST id in request order, each
7573
+ * as the client sent it, with its own reason (`resolveRowIds`, since 0.1.148)
7574
+ * — {@link echoRequests} is the one place that maps resolved ids back.
7575
+ */
7576
+ function disabledError(ctx, action, failing) {
7577
+ const echoed = echoRequests(ctx, failing);
7578
+ return new ActionDisabledError(action, void 0, echoed.map((f) => f.id), echoed.map((f) => f.reason));
7579
+ }
7580
+ /** The 409 of a `'row'` gate: the id as the client sent it. */
7581
+ function rowDisabledError(ctx, action, id, reason) {
7582
+ return new ActionDisabledError(action, requestIdOf(ctx, id), void 0, [reason]);
7583
+ }
7584
+ /** The action's scope restricts (or needs the loaded rows to decide) — see `TPreScope`. */
7585
+ async function needsScopeLoad(ctx, level) {
7586
+ const pre = await ctx.get(dbActionPreScopeSlot)(level);
7587
+ return pre.kind === "deferred" || pre.scope !== null;
6623
7588
  }
6624
7589
  /**
6625
7590
  * Interceptor for `'row'` / `'rows'` actions without `disabled` (and for a
6626
7591
  * `@DbActionRow*` handler of any other level: bound-table injection only):
6627
7592
  * 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.
7593
+ * injects the bound table and — only when there is something to verify —
7594
+ * checks the requested ids before the handler runs by loading the row(s) the
7595
+ * handler would get: `'row'` → the 404 of a missing row; `'rows'` →
7596
+ * out-of-scope and missing ids fail like disabled rows with no reason
7597
+ * (`onDisabledRows`). Something to verify: a row overlay (`transformOne` /
7598
+ * `transformFilter` overridden, non-empty), a non-empty `actionRowScope` for
7599
+ * the action (the hook is asked first, with the request's ids — an override
7600
+ * that restricts nothing verifies nothing; since 0.1.148), or a query target
7601
+ * (since 0.1.147). Nothing to verify → no query.
6635
7602
  */
6636
7603
  function buildThinInterceptor(opts) {
6637
7604
  const { table, scope } = opts;
@@ -6642,12 +7609,12 @@ function buildThinInterceptor(opts) {
6642
7609
  if (!scope) return;
6643
7610
  const overlay = await ctx.get(dbActionOverlaySlot);
6644
7611
  if (scope.level === "row") {
6645
- if (overlay || isActionScoped(ctx)) await ctx.get(dbActionRowSlot);
7612
+ if (overlay || await needsScopeLoad(ctx, "row")) await ctx.get(dbActionRowSlot);
6646
7613
  return;
6647
7614
  }
6648
7615
  if (await queryTargetReply(ctx, reply)) return;
6649
7616
  const target = await ctx.get(dbActionQueryTargetSlot);
6650
- if (overlay || target || isActionScoped(ctx)) await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
7617
+ if (overlay || target || await needsScopeLoad(ctx, "rows")) await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
6651
7618
  await setMaterializedTarget(ctx);
6652
7619
  }, ACTION_GATE_PRIORITY);
6653
7620
  }
@@ -7070,6 +8037,52 @@ function DbActionsFrom(source, opts = {}) {
7070
8037
  */
7071
8038
  const perRow = (fn) => (rows) => rows.map(fn);
7072
8039
  //#endregion
8040
+ //#region src/decorations/db-decorations.decorator.ts
8041
+ /**
8042
+ * Declares display-only (decoration) fields of a table or view controller —
8043
+ * values `decorateRows` computes and attaches to rows (since 0.1.148).
8044
+ *
8045
+ * `type` is a plain atscript interface (no `@db.table` / `@db.view`) whose
8046
+ * top-level props are the decorations: their `@meta.label`, `@expect.*` and
8047
+ * `@ui.*` annotations travel in `/meta.decorations`, each key is listed in
8048
+ * `/meta.fields` with `decoration: true`, and a client may name it in
8049
+ * `$select`. A decoration is never filterable, sortable or groupable, and it
8050
+ * is not part of `/meta.type` (forms and write validation never see it).
8051
+ *
8052
+ * ```ts
8053
+ * @TableController(TicketTable)
8054
+ * @DbDecorations(TicketDecorations, { requires: { ownerName: ["ownerId"] } })
8055
+ * export class TicketsController extends AsDbController<typeof TicketTable> {
8056
+ * protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
8057
+ * if (ctx.decorations.has("ownerName")) {
8058
+ * // read rows[i].ownerId, set rows[i].ownerName
8059
+ * }
8060
+ * }
8061
+ * }
8062
+ * ```
8063
+ *
8064
+ * Validated once per class at first use (a `[moost-db]` error): the type is an
8065
+ * object interface; keys are top-level identifiers that collide with no field
8066
+ * or relation of the readable; every `requires` path is an own, readable
8067
+ * (not `@db.writeOnly`) field or a parent object of own fields (on SQL a nested
8068
+ * object is flattened to leaf columns; the hook still gets the whole object). Inherited under `@Inherit()`. Not supported on
8069
+ * value-help controllers.
8070
+ *
8071
+ * @since 0.1.148
8072
+ */
8073
+ function DbDecorations(type, opts = {}) {
8074
+ if (!isAnnotatedType(type)) throw new Error("[moost-db] @DbDecorations: expects a compiled atscript interface");
8075
+ const meta = {
8076
+ type,
8077
+ requires: Object.fromEntries(Object.entries(opts.requires ?? {}).map(([key, paths]) => [key, [...paths ?? []]]))
8078
+ };
8079
+ const decorate = getAtscriptDbMate().decorate("atscript_db_decorations", meta);
8080
+ return (target) => {
8081
+ if (isAsValueHelpControllerSubclass(target)) throw new Error(`[moost-db] ${target.name} is a value-help controller — @DbDecorations is not supported there.`);
8082
+ return decorate(target);
8083
+ };
8084
+ }
8085
+ //#endregion
7073
8086
  //#region src/permissions/crud-handlers.ts
7074
8087
  /**
7075
8088
  * The handler method(s) serving each CRUD op on `AsDbReadableController` /
@@ -7103,4 +8116,4 @@ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
7103
8116
  one: ["runGetOne", "runGetOneComposite"]
7104
8117
  });
7105
8118
  //#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 };
8119
+ 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 };