@atscript/moost-db 0.1.147 → 0.1.149

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