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