@atscript/moost-db 0.1.140 → 0.1.141

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
@@ -546,14 +546,18 @@ function resolveProp(type, field) {
546
546
  /**
547
547
  * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
548
548
  * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
549
- * bounded by chain length.
549
+ * bounded by chain length. Only chain hops count — a plain ref (`field: ""`,
550
+ * a column typed with a named type) is the end of the chain. A hop into a
551
+ * `@db.alias` type (a view's join alias, since 0.1.141) continues on the
552
+ * aliased table / view — the alias is no value domain for a picker.
550
553
  */
551
554
  function resolveTerminalRef(def) {
552
555
  const ref = def.ref;
553
- if (!ref) return void 0;
556
+ if (!ref?.field) return void 0;
554
557
  let type = ref.type();
555
558
  let field = ref.field;
556
559
  if (!type) return void 0;
560
+ type = (0, _atscript_db.aliasTargetOf)(type) ?? type;
557
561
  let fk = def.metadata.has("db.rel.FK");
558
562
  const visited = new Set([`${type.id ?? ""}.${field}`]);
559
563
  for (;;) {
@@ -561,9 +565,10 @@ function resolveTerminalRef(def) {
561
565
  if (!prop) break;
562
566
  if (prop.metadata.has("db.rel.FK")) fk = true;
563
567
  const next = prop.ref;
564
- if (!next) break;
565
- const nextType = next.type();
566
- if (!nextType) break;
568
+ if (!next?.field) break;
569
+ const resolved = next.type();
570
+ if (!resolved) break;
571
+ const nextType = (0, _atscript_db.aliasTargetOf)(resolved) ?? resolved;
567
572
  const key = `${nextType.id ?? ""}.${next.field}`;
568
573
  if (visited.has(key)) break;
569
574
  visited.add(key);
@@ -615,7 +620,7 @@ function walk(node, def, shallow) {
615
620
  const sProp = sType.props[name];
616
621
  if (!sProp) continue;
617
622
  if (isNav(prop.metadata)) continue;
618
- if (prop.ref && sProp.ref && !("type" in sProp.ref.type)) {
623
+ if (prop.ref?.field && sProp.ref && !("type" in sProp.ref.type)) {
619
624
  const terminal = resolveTerminalRef(prop);
620
625
  if (terminal) {
621
626
  const direct = prop.ref.type();
@@ -732,9 +737,9 @@ let AsReadableController = class AsReadableController {
732
737
  * The shallow shape emits `{ field, type: { id, metadata } }` for every FK,
733
738
  * which carries the target's `db.http.path` so clients can resolve value-help
734
739
  * URLs and lazy-fetch target `/meta` when deeper structure is needed. Nav
735
- * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are not `.ref` nodes
736
- * and always expand fully regardless of `refDepth` — the write-payload shape
737
- * clients need is unaffected.
740
+ * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) carry only a plain
741
+ * `ref` (`field: ""`) and their bodies always expand fully regardless of
742
+ * `refDepth` — the write-payload shape clients need is unaffected.
738
743
  *
739
744
  * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
740
745
  * other `db.*` (table, column, index, default, etc.). Override in subclass
@@ -973,10 +978,14 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
973
978
  ])], AsReadableController);
974
979
  //#endregion
975
980
  //#region src/actions/verdict.ts
976
- /** Assert that a `disabled` predicate returned a `boolean[]` of the expected length; throws HTTP 500 otherwise. */
981
+ /** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
977
982
  function assertVerdictLength(action, verdicts, expected) {
978
983
  if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new _moostjs_event_http.HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
979
984
  }
985
+ /** The reason a verdict carries — a non-empty string; `undefined` for `true` / falsy verdicts. */
986
+ function verdictReason(verdict) {
987
+ return typeof verdict === "string" && verdict !== "" ? verdict : void 0;
988
+ }
980
989
  //#endregion
981
990
  //#region src/actions/list-augmenter.ts
982
991
  const candidateCache = /* @__PURE__ */ new WeakMap();
@@ -1017,7 +1026,8 @@ function computeStripFields(candidates, resolvedProjection) {
1017
1026
  return strip;
1018
1027
  }
1019
1028
  /**
1020
- * Sets `$actions` on every row and strips the columns fetched only for an
1029
+ * Sets `$actions` on every row (plus `$disabledReasons` on rows where a
1030
+ * predicate returned a reason string) and strips the columns fetched only for an
1021
1031
  * action's `requiredFields` — IN PLACE; returns the same array, typed as
1022
1032
  * augmented.
1023
1033
  */
@@ -1034,15 +1044,19 @@ function augmentRowsWithActions(args) {
1034
1044
  for (let i = 0; i < rows.length; i++) {
1035
1045
  const row = rows[i];
1036
1046
  const names = [];
1047
+ let reasons;
1037
1048
  for (let j = 0; j < candidates.length; j++) {
1038
- const v = verdicts[j];
1039
- if (v === void 0) {
1040
- names.push(candidates[j].envelope.info.name);
1049
+ const name = candidates[j].envelope.info.name;
1050
+ const verdict = verdicts[j]?.[i];
1051
+ if (!verdict) {
1052
+ names.push(name);
1041
1053
  continue;
1042
1054
  }
1043
- if (!v[i]) names.push(candidates[j].envelope.info.name);
1055
+ const reason = verdictReason(verdict);
1056
+ if (reason !== void 0) (reasons ??= {})[name] = reason;
1044
1057
  }
1045
1058
  row.$actions = names;
1059
+ if (reasons) row.$disabledReasons = reasons;
1046
1060
  }
1047
1061
  if (resolvedProjection !== null) {
1048
1062
  const stripFields = computeStripFields(candidates, resolvedProjection);
@@ -2433,6 +2447,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2433
2447
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2434
2448
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2435
2449
  if (cap.bucketable) entry.bucketable = true;
2450
+ if (fd.derived) entry.derived = true;
2436
2451
  fields[path] = entry;
2437
2452
  }
2438
2453
  return {
@@ -3116,6 +3131,7 @@ function assertExposed(app, models, options) {
3116
3131
  }
3117
3132
  const missing = [];
3118
3133
  for (const model of models) {
3134
+ if ((0, _atscript_db.aliasTargetOf)(model)) continue;
3119
3135
  const httpPath = model.metadata.get("db.http.path");
3120
3136
  if (!auditAll && httpPath === void 0) continue;
3121
3137
  if (excluded.has(model) || exposed.has(model)) continue;
@@ -3127,31 +3143,58 @@ function assertExposed(app, models, options) {
3127
3143
  }
3128
3144
  //#endregion
3129
3145
  //#region src/actions/action-disabled-error.ts
3130
- function buildMessage(action, ids) {
3131
- if (ids !== void 0) return `Action "${action}" is disabled for ${ids.length} of the selected rows`;
3132
- return `Action "${action}" is disabled for this row`;
3146
+ /** Distinct reasons quoted in a mixed `'rows'` message before `(+N more)`. */
3147
+ const MAX_LISTED_REASONS = 3;
3148
+ function sharedReason(reasons) {
3149
+ const first = reasons[0];
3150
+ if (first === null || first === void 0) return void 0;
3151
+ return reasons.every((r) => r === first) ? first : void 0;
3152
+ }
3153
+ function rowsMessage(action, count, reasons) {
3154
+ const base = `Action "${action}" is disabled for ${count} of the selected rows`;
3155
+ const distinct = [...new Set(reasons.filter((r) => r !== null))];
3156
+ if (distinct.length === 0) return base;
3157
+ const listed = distinct.slice(0, MAX_LISTED_REASONS).join("; ");
3158
+ const more = distinct.length - MAX_LISTED_REASONS;
3159
+ return `${base}: ${listed}${more > 0 ? ` (+${more} more)` : ""}`;
3133
3160
  }
3134
3161
  /**
3135
3162
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
3136
3163
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
3137
3164
  * defined by {@link ActionDisabledErrorBody}.
3138
3165
  *
3139
- * - `'row'`-level rejection: pass `(action, id)` — the body emits `id`.
3140
- * - `'rows'`-level rejection: pass `(action, undefined, ids)` — the body
3141
- * emits `ids` (the FULL list of failing IDs in reject mode; the FULL list
3142
- * of request IDs in skip mode with zero survivors).
3166
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
3167
+ * body emits `id` (+ `reason` when given).
3168
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
3169
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
3170
+ * list of request IDs in skip mode with zero survivors) and, when any
3171
+ * reason exists, `reasons` aligned with `ids`.
3172
+ *
3173
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
3174
+ * non-empty string counts as "no reason".
3143
3175
  */
3144
3176
  var ActionDisabledError = class extends _moostjs_event_http.HttpError {
3145
3177
  name = "ActionDisabledError";
3146
- constructor(action, id, ids) {
3178
+ constructor(action, id, ids, reasons) {
3147
3179
  const body = {
3148
3180
  name: "ActionDisabledError",
3149
- message: buildMessage(action, ids),
3181
+ message: "",
3150
3182
  statusCode: 409,
3151
3183
  action
3152
3184
  };
3153
- if (ids !== void 0) body.ids = ids;
3154
- else if (id !== void 0) body.id = id;
3185
+ if (ids !== void 0) {
3186
+ const aligned = ids.map((_, i) => verdictReason(reasons?.[i]) ?? null);
3187
+ const reason = sharedReason(aligned);
3188
+ body.message = reason ?? rowsMessage(action, ids.length, aligned);
3189
+ body.ids = ids;
3190
+ if (reason !== void 0) body.reason = reason;
3191
+ if (aligned.some((r) => r !== null)) body.reasons = aligned;
3192
+ } else {
3193
+ const reason = verdictReason(reasons?.[0]);
3194
+ body.message = reason ?? `Action "${action}" is disabled for this row`;
3195
+ if (id !== void 0) body.id = id;
3196
+ if (reason !== void 0) body.reason = reason;
3197
+ }
3155
3198
  super(409, body);
3156
3199
  }
3157
3200
  };
@@ -3442,7 +3485,7 @@ function buildGateInterceptor(opts) {
3442
3485
  if (level === "row") {
3443
3486
  const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
3444
3487
  assertVerdictLength(action, verdicts, 1);
3445
- if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot));
3488
+ if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
3446
3489
  return;
3447
3490
  }
3448
3491
  const ids = await ctx.get(dbActionIdsSlot);
@@ -3452,26 +3495,30 @@ function buildGateInterceptor(opts) {
3452
3495
  const verdicts = disabled(existingRows);
3453
3496
  assertVerdictLength(action, verdicts, existingRows.length);
3454
3497
  const failingIds = [];
3498
+ const failingReasons = [];
3455
3499
  const passingRows = [];
3456
3500
  const passingIds = [];
3457
3501
  let verdictIndex = 0;
3458
3502
  for (let i = 0; i < ids.length; i++) {
3459
3503
  const row = rows[i];
3460
- if (row === void 0 || verdicts[verdictIndex++]) failingIds.push(ids[i]);
3461
- else {
3504
+ const verdict = row === void 0 ? void 0 : verdicts[verdictIndex++];
3505
+ if (row === void 0 || verdict) {
3506
+ failingIds.push(ids[i]);
3507
+ failingReasons.push(verdictReason(verdict));
3508
+ } else {
3462
3509
  passingRows.push(row);
3463
3510
  passingIds.push(ids[i]);
3464
3511
  }
3465
3512
  }
3466
3513
  if (onDisabledRows === "skip") {
3467
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
3514
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
3468
3515
  if (failingIds.length > 0) {
3469
3516
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
3470
3517
  ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
3471
3518
  }
3472
3519
  return;
3473
3520
  }
3474
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
3521
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
3475
3522
  }, GATE_PRIORITY);
3476
3523
  }
3477
3524
  /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
@@ -3760,7 +3807,8 @@ function InputForm(formType, validatorOpts) {
3760
3807
  /**
3761
3808
  * Lift a per-row predicate into the batch shape required by
3762
3809
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
3763
- * preserved — `true` from `fn` means the action is disabled for that row.
3810
+ * preserved — `true` from `fn` means the action is disabled for that row; a
3811
+ * non-empty string disables it with that reason.
3764
3812
  *
3765
3813
  * ```ts
3766
3814
  * @DbAction<Order>('archive', {
package/dist/index.d.cts CHANGED
@@ -77,9 +77,9 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
77
77
  * The shallow shape emits `{ field, type: { id, metadata } }` for every FK,
78
78
  * which carries the target's `db.http.path` so clients can resolve value-help
79
79
  * URLs and lazy-fetch target `/meta` when deeper structure is needed. Nav
80
- * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are not `.ref` nodes
81
- * and always expand fully regardless of `refDepth` — the write-payload shape
82
- * clients need is unaffected.
80
+ * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) carry only a plain
81
+ * `ref` (`field: ""`) and their bodies always expand fully regardless of
82
+ * `refDepth` — the write-payload shape clients need is unaffected.
83
83
  *
84
84
  * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
85
85
  * other `db.*` (table, column, index, default, etc.). Override in subclass
@@ -589,7 +589,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
589
589
  *
590
590
  * Convention (not enforced): name decoration keys with a `$` prefix, like
591
591
  * `$actions` and `$distance`, so they can never collide with a field name.
592
- * Do not overwrite `$actions`. Columns the
592
+ * Do not overwrite `$actions` or `$disabledReasons`. Columns the
593
593
  * hook needs but the client did not select must be added in
594
594
  * {@link transformProjection} — they are then part of the response.
595
595
  *
@@ -1006,6 +1006,15 @@ declare class AsJsonValueHelpController<T extends TAtscriptAnnotatedType = TAtsc
1006
1006
  }
1007
1007
  //#endregion
1008
1008
  //#region src/actions/types.d.ts
1009
+ /**
1010
+ * One entry of a `disabled` predicate's result. Truthy = the action is
1011
+ * disabled for that row; falsy (`false`, `""`) = enabled. A non-empty string
1012
+ * disables the action AND carries a human-readable reason — surfaced in the
1013
+ * 409 `ActionDisabledError` message/body and in the row's `$disabledReasons`.
1014
+ *
1015
+ * @since 0.1.141 (`string`)
1016
+ */
1017
+ type TDbActionDisabledVerdict = boolean | string;
1009
1018
  /** `'rows'`-level batch policy — controls whether failing rows reject or are filtered out. */
1010
1019
  type TOnDisabledRows = "reject" | "skip";
1011
1020
  /**
@@ -1034,15 +1043,17 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1034
1043
  */
1035
1044
  requiredFields: R;
1036
1045
  /**
1037
- * Sync batch gate predicate — returns a parallel `boolean[]` aligned with
1038
- * the input. `true` = disabled for the corresponding row. The `rows`
1046
+ * Sync batch gate predicate — returns a parallel array aligned with the
1047
+ * input. Per entry: truthy = disabled for the corresponding row; a
1048
+ * non-empty string also gives the reason (see
1049
+ * {@link TDbActionDisabledVerdict}). The `rows`
1039
1050
  * argument is type-narrowed to `Pick<FlatOf<TRow>, R[number]>[]`; reading
1040
1051
  * a field not listed in `requiredFields` is a compile error.
1041
1052
  *
1042
1053
  * Promise return is NOT permitted — the predicate is consumed in the
1043
1054
  * same tick by the gate and the augmenter.
1044
1055
  */
1045
- disabled?: (rows: DisabledRowsArg<TRow, R>) => boolean[];
1056
+ disabled?: (rows: DisabledRowsArg<TRow, R>) => TDbActionDisabledVerdict[];
1046
1057
  /**
1047
1058
  * `'rows'`-level batch policy. Default `'reject'`.
1048
1059
  *
@@ -1062,7 +1073,7 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1062
1073
  */
1063
1074
  interface LooseGate {
1064
1075
  requiredFields?: string[];
1065
- disabled?: (rows: any[]) => boolean[];
1076
+ disabled?: (rows: any[]) => TDbActionDisabledVerdict[];
1066
1077
  onDisabledRows?: TOnDisabledRows;
1067
1078
  }
1068
1079
  type GateOpts<TRow, R extends readonly FlatKey<TRow>[]> = unknown extends TRow ? LooseGate : NoGate | WithGate<TRow, R>;
@@ -1700,17 +1711,21 @@ declare const useDbActionInput: import("@wooksjs/event-core").WookComposable<{
1700
1711
  * Wire-body shape for server-side gate rejections. The `name` discriminator
1701
1712
  * lets `@atscript/db-client` recognise the response and construct the typed
1702
1713
  * `ActionDisabledError` subclass. The shape extends Moost's standard
1703
- * `ServerError` envelope (`{ message, statusCode, errors? }`) with three
1704
- * additional fields:
1714
+ * `ServerError` envelope (`{ message, statusCode, errors? }`) with:
1705
1715
  *
1706
1716
  * - `name: 'ActionDisabledError'` — discriminator the client matches.
1707
1717
  * - `action` — the `@DbAction` name that rejected the request.
1708
1718
  * - `id?` — present only for `'row'`-level rejections.
1709
1719
  * - `ids?` — present only for `'rows'`-level rejections.
1720
+ * - `reason?` — the reason shared by every rejected row, when the
1721
+ * `disabled` predicate returned one (since 0.1.141).
1722
+ * - `reasons?` — `'rows'` level only: index-aligned with `ids`, `null` where
1723
+ * that row carries no reason. Present only when at least one reason exists
1724
+ * (since 0.1.141).
1710
1725
  *
1711
1726
  * `message` is populated with a human-readable string so generic
1712
1727
  * `ClientError` consumers (which read `body.message`) still get something
1713
- * useful without typed-catch dispatch.
1728
+ * useful without typed-catch dispatch — the reason itself when there is one.
1714
1729
  */
1715
1730
  interface ActionDisabledErrorBody {
1716
1731
  name: "ActionDisabledError";
@@ -1719,27 +1734,37 @@ interface ActionDisabledErrorBody {
1719
1734
  action: string;
1720
1735
  id?: Record<string, unknown>;
1721
1736
  ids?: Record<string, unknown>[];
1737
+ /** @since 0.1.141 */
1738
+ reason?: string;
1739
+ /** @since 0.1.141 */
1740
+ reasons?: (string | null)[];
1722
1741
  }
1723
1742
  /**
1724
1743
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1725
1744
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
1726
1745
  * defined by {@link ActionDisabledErrorBody}.
1727
1746
  *
1728
- * - `'row'`-level rejection: pass `(action, id)` — the body emits `id`.
1729
- * - `'rows'`-level rejection: pass `(action, undefined, ids)` — the body
1730
- * emits `ids` (the FULL list of failing IDs in reject mode; the FULL list
1731
- * of request IDs in skip mode with zero survivors).
1747
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
1748
+ * body emits `id` (+ `reason` when given).
1749
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
1750
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
1751
+ * list of request IDs in skip mode with zero survivors) and, when any
1752
+ * reason exists, `reasons` aligned with `ids`.
1753
+ *
1754
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
1755
+ * non-empty string counts as "no reason".
1732
1756
  */
1733
1757
  declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1734
1758
  name: string;
1735
- constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[]);
1759
+ constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[], reasons?: readonly (string | null | undefined)[]);
1736
1760
  }
1737
1761
  //#endregion
1738
1762
  //#region src/actions/per-row.d.ts
1739
1763
  /**
1740
1764
  * Lift a per-row predicate into the batch shape required by
1741
1765
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
1742
- * preserved — `true` from `fn` means the action is disabled for that row.
1766
+ * preserved — `true` from `fn` means the action is disabled for that row; a
1767
+ * non-empty string disables it with that reason.
1743
1768
  *
1744
1769
  * ```ts
1745
1770
  * @DbAction<Order>('archive', {
@@ -1748,7 +1773,7 @@ declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1748
1773
  * })
1749
1774
  * ```
1750
1775
  */
1751
- declare const perRow: <TRow>(fn: (row: TRow) => boolean) => (rows: TRow[]) => boolean[];
1776
+ declare const perRow: <TRow>(fn: (row: TRow) => TDbActionDisabledVerdict) => (rows: TRow[]) => TDbActionDisabledVerdict[];
1752
1777
  //#endregion
1753
1778
  //#region src/permissions/crud-controls.d.ts
1754
1779
  declare const QUERY_CONTROLS: readonly string[];
@@ -1785,7 +1810,10 @@ declare function resolveProp(type: TAtscriptAnnotatedType, field: string): TAtsc
1785
1810
  /**
1786
1811
  * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
1787
1812
  * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
1788
- * bounded by chain length.
1813
+ * bounded by chain length. Only chain hops count — a plain ref (`field: ""`,
1814
+ * a column typed with a named type) is the end of the chain. A hop into a
1815
+ * `@db.alias` type (a view's join alias, since 0.1.141) continues on the
1816
+ * aliased table / view — the alias is no value domain for a picker.
1789
1817
  */
1790
1818
  declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef | undefined;
1791
1819
  /**
@@ -1801,4 +1829,4 @@ declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef |
1801
1829
  */
1802
1830
  declare function applyTerminalRefs(serialized: TSerializedAnnotatedType, runtime: TAtscriptAnnotatedType, options: TSerializeOptions): TSerializedAnnotatedType;
1803
1831
  //#endregion
1804
- export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, TDbDecorateContext, TDbDecorateEndpoint, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
1832
+ export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionDisabledVerdict, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, TDbDecorateContext, TDbDecorateEndpoint, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/dist/index.d.mts CHANGED
@@ -77,9 +77,9 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
77
77
  * The shallow shape emits `{ field, type: { id, metadata } }` for every FK,
78
78
  * which carries the target's `db.http.path` so clients can resolve value-help
79
79
  * URLs and lazy-fetch target `/meta` when deeper structure is needed. Nav
80
- * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are not `.ref` nodes
81
- * and always expand fully regardless of `refDepth` — the write-payload shape
82
- * clients need is unaffected.
80
+ * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) carry only a plain
81
+ * `ref` (`field: ""`) and their bodies always expand fully regardless of
82
+ * `refDepth` — the write-payload shape clients need is unaffected.
83
83
  *
84
84
  * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
85
85
  * other `db.*` (table, column, index, default, etc.). Override in subclass
@@ -589,7 +589,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
589
589
  *
590
590
  * Convention (not enforced): name decoration keys with a `$` prefix, like
591
591
  * `$actions` and `$distance`, so they can never collide with a field name.
592
- * Do not overwrite `$actions`. Columns the
592
+ * Do not overwrite `$actions` or `$disabledReasons`. Columns the
593
593
  * hook needs but the client did not select must be added in
594
594
  * {@link transformProjection} — they are then part of the response.
595
595
  *
@@ -1006,6 +1006,15 @@ declare class AsJsonValueHelpController<T extends TAtscriptAnnotatedType = TAtsc
1006
1006
  }
1007
1007
  //#endregion
1008
1008
  //#region src/actions/types.d.ts
1009
+ /**
1010
+ * One entry of a `disabled` predicate's result. Truthy = the action is
1011
+ * disabled for that row; falsy (`false`, `""`) = enabled. A non-empty string
1012
+ * disables the action AND carries a human-readable reason — surfaced in the
1013
+ * 409 `ActionDisabledError` message/body and in the row's `$disabledReasons`.
1014
+ *
1015
+ * @since 0.1.141 (`string`)
1016
+ */
1017
+ type TDbActionDisabledVerdict = boolean | string;
1009
1018
  /** `'rows'`-level batch policy — controls whether failing rows reject or are filtered out. */
1010
1019
  type TOnDisabledRows = "reject" | "skip";
1011
1020
  /**
@@ -1034,15 +1043,17 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1034
1043
  */
1035
1044
  requiredFields: R;
1036
1045
  /**
1037
- * Sync batch gate predicate — returns a parallel `boolean[]` aligned with
1038
- * the input. `true` = disabled for the corresponding row. The `rows`
1046
+ * Sync batch gate predicate — returns a parallel array aligned with the
1047
+ * input. Per entry: truthy = disabled for the corresponding row; a
1048
+ * non-empty string also gives the reason (see
1049
+ * {@link TDbActionDisabledVerdict}). The `rows`
1039
1050
  * argument is type-narrowed to `Pick<FlatOf<TRow>, R[number]>[]`; reading
1040
1051
  * a field not listed in `requiredFields` is a compile error.
1041
1052
  *
1042
1053
  * Promise return is NOT permitted — the predicate is consumed in the
1043
1054
  * same tick by the gate and the augmenter.
1044
1055
  */
1045
- disabled?: (rows: DisabledRowsArg<TRow, R>) => boolean[];
1056
+ disabled?: (rows: DisabledRowsArg<TRow, R>) => TDbActionDisabledVerdict[];
1046
1057
  /**
1047
1058
  * `'rows'`-level batch policy. Default `'reject'`.
1048
1059
  *
@@ -1062,7 +1073,7 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1062
1073
  */
1063
1074
  interface LooseGate {
1064
1075
  requiredFields?: string[];
1065
- disabled?: (rows: any[]) => boolean[];
1076
+ disabled?: (rows: any[]) => TDbActionDisabledVerdict[];
1066
1077
  onDisabledRows?: TOnDisabledRows;
1067
1078
  }
1068
1079
  type GateOpts<TRow, R extends readonly FlatKey<TRow>[]> = unknown extends TRow ? LooseGate : NoGate | WithGate<TRow, R>;
@@ -1700,17 +1711,21 @@ declare const useDbActionInput: import("@wooksjs/event-core").WookComposable<{
1700
1711
  * Wire-body shape for server-side gate rejections. The `name` discriminator
1701
1712
  * lets `@atscript/db-client` recognise the response and construct the typed
1702
1713
  * `ActionDisabledError` subclass. The shape extends Moost's standard
1703
- * `ServerError` envelope (`{ message, statusCode, errors? }`) with three
1704
- * additional fields:
1714
+ * `ServerError` envelope (`{ message, statusCode, errors? }`) with:
1705
1715
  *
1706
1716
  * - `name: 'ActionDisabledError'` — discriminator the client matches.
1707
1717
  * - `action` — the `@DbAction` name that rejected the request.
1708
1718
  * - `id?` — present only for `'row'`-level rejections.
1709
1719
  * - `ids?` — present only for `'rows'`-level rejections.
1720
+ * - `reason?` — the reason shared by every rejected row, when the
1721
+ * `disabled` predicate returned one (since 0.1.141).
1722
+ * - `reasons?` — `'rows'` level only: index-aligned with `ids`, `null` where
1723
+ * that row carries no reason. Present only when at least one reason exists
1724
+ * (since 0.1.141).
1710
1725
  *
1711
1726
  * `message` is populated with a human-readable string so generic
1712
1727
  * `ClientError` consumers (which read `body.message`) still get something
1713
- * useful without typed-catch dispatch.
1728
+ * useful without typed-catch dispatch — the reason itself when there is one.
1714
1729
  */
1715
1730
  interface ActionDisabledErrorBody {
1716
1731
  name: "ActionDisabledError";
@@ -1719,27 +1734,37 @@ interface ActionDisabledErrorBody {
1719
1734
  action: string;
1720
1735
  id?: Record<string, unknown>;
1721
1736
  ids?: Record<string, unknown>[];
1737
+ /** @since 0.1.141 */
1738
+ reason?: string;
1739
+ /** @since 0.1.141 */
1740
+ reasons?: (string | null)[];
1722
1741
  }
1723
1742
  /**
1724
1743
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1725
1744
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
1726
1745
  * defined by {@link ActionDisabledErrorBody}.
1727
1746
  *
1728
- * - `'row'`-level rejection: pass `(action, id)` — the body emits `id`.
1729
- * - `'rows'`-level rejection: pass `(action, undefined, ids)` — the body
1730
- * emits `ids` (the FULL list of failing IDs in reject mode; the FULL list
1731
- * of request IDs in skip mode with zero survivors).
1747
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
1748
+ * body emits `id` (+ `reason` when given).
1749
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
1750
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
1751
+ * list of request IDs in skip mode with zero survivors) and, when any
1752
+ * reason exists, `reasons` aligned with `ids`.
1753
+ *
1754
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
1755
+ * non-empty string counts as "no reason".
1732
1756
  */
1733
1757
  declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1734
1758
  name: string;
1735
- constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[]);
1759
+ constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[], reasons?: readonly (string | null | undefined)[]);
1736
1760
  }
1737
1761
  //#endregion
1738
1762
  //#region src/actions/per-row.d.ts
1739
1763
  /**
1740
1764
  * Lift a per-row predicate into the batch shape required by
1741
1765
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
1742
- * preserved — `true` from `fn` means the action is disabled for that row.
1766
+ * preserved — `true` from `fn` means the action is disabled for that row; a
1767
+ * non-empty string disables it with that reason.
1743
1768
  *
1744
1769
  * ```ts
1745
1770
  * @DbAction<Order>('archive', {
@@ -1748,7 +1773,7 @@ declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1748
1773
  * })
1749
1774
  * ```
1750
1775
  */
1751
- declare const perRow: <TRow>(fn: (row: TRow) => boolean) => (rows: TRow[]) => boolean[];
1776
+ declare const perRow: <TRow>(fn: (row: TRow) => TDbActionDisabledVerdict) => (rows: TRow[]) => TDbActionDisabledVerdict[];
1752
1777
  //#endregion
1753
1778
  //#region src/permissions/crud-controls.d.ts
1754
1779
  declare const QUERY_CONTROLS: readonly string[];
@@ -1785,7 +1810,10 @@ declare function resolveProp(type: TAtscriptAnnotatedType, field: string): TAtsc
1785
1810
  /**
1786
1811
  * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
1787
1812
  * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
1788
- * bounded by chain length.
1813
+ * bounded by chain length. Only chain hops count — a plain ref (`field: ""`,
1814
+ * a column typed with a named type) is the end of the chain. A hop into a
1815
+ * `@db.alias` type (a view's join alias, since 0.1.141) continues on the
1816
+ * aliased table / view — the alias is no value domain for a picker.
1789
1817
  */
1790
1818
  declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef | undefined;
1791
1819
  /**
@@ -1801,4 +1829,4 @@ declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef |
1801
1829
  */
1802
1830
  declare function applyTerminalRefs(serialized: TSerializedAnnotatedType, runtime: TAtscriptAnnotatedType, options: TSerializeOptions): TSerializedAnnotatedType;
1803
1831
  //#endregion
1804
- export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, TDbDecorateContext, TDbDecorateEndpoint, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
1832
+ export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionDisabledVerdict, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, TDbDecorateContext, TDbDecorateEndpoint, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotate
3
3
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
4
4
  import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Provide, Resolve, TInterceptorPriority, defineBeforeInterceptor, defineInterceptor, getMoostMate, useControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isJsonValueField, isPlainObject, narrowerFilterOps, normalizeComputedSelect, reconcileCas, unsupportedOperatorMessage } from "@atscript/db";
6
+ import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isJsonValueField, isPlainObject, narrowerFilterOps, normalizeComputedSelect, reconcileCas, unsupportedOperatorMessage } from "@atscript/db";
7
7
  import { BUCKET_UNITS } from "@uniqu/core";
8
8
  import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
9
9
  import { cached, current, defineWook, key } from "@wooksjs/event-core";
@@ -545,14 +545,18 @@ function resolveProp(type, field) {
545
545
  /**
546
546
  * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
547
547
  * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
548
- * bounded by chain length.
548
+ * bounded by chain length. Only chain hops count — a plain ref (`field: ""`,
549
+ * a column typed with a named type) is the end of the chain. A hop into a
550
+ * `@db.alias` type (a view's join alias, since 0.1.141) continues on the
551
+ * aliased table / view — the alias is no value domain for a picker.
549
552
  */
550
553
  function resolveTerminalRef(def) {
551
554
  const ref = def.ref;
552
- if (!ref) return void 0;
555
+ if (!ref?.field) return void 0;
553
556
  let type = ref.type();
554
557
  let field = ref.field;
555
558
  if (!type) return void 0;
559
+ type = aliasTargetOf(type) ?? type;
556
560
  let fk = def.metadata.has("db.rel.FK");
557
561
  const visited = new Set([`${type.id ?? ""}.${field}`]);
558
562
  for (;;) {
@@ -560,9 +564,10 @@ function resolveTerminalRef(def) {
560
564
  if (!prop) break;
561
565
  if (prop.metadata.has("db.rel.FK")) fk = true;
562
566
  const next = prop.ref;
563
- if (!next) break;
564
- const nextType = next.type();
565
- if (!nextType) break;
567
+ if (!next?.field) break;
568
+ const resolved = next.type();
569
+ if (!resolved) break;
570
+ const nextType = aliasTargetOf(resolved) ?? resolved;
566
571
  const key = `${nextType.id ?? ""}.${next.field}`;
567
572
  if (visited.has(key)) break;
568
573
  visited.add(key);
@@ -614,7 +619,7 @@ function walk(node, def, shallow) {
614
619
  const sProp = sType.props[name];
615
620
  if (!sProp) continue;
616
621
  if (isNav(prop.metadata)) continue;
617
- if (prop.ref && sProp.ref && !("type" in sProp.ref.type)) {
622
+ if (prop.ref?.field && sProp.ref && !("type" in sProp.ref.type)) {
618
623
  const terminal = resolveTerminalRef(prop);
619
624
  if (terminal) {
620
625
  const direct = prop.ref.type();
@@ -731,9 +736,9 @@ let AsReadableController = class AsReadableController {
731
736
  * The shallow shape emits `{ field, type: { id, metadata } }` for every FK,
732
737
  * which carries the target's `db.http.path` so clients can resolve value-help
733
738
  * URLs and lazy-fetch target `/meta` when deeper structure is needed. Nav
734
- * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are not `.ref` nodes
735
- * and always expand fully regardless of `refDepth` — the write-payload shape
736
- * clients need is unaffected.
739
+ * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) carry only a plain
740
+ * `ref` (`field: ""`) and their bodies always expand fully regardless of
741
+ * `refDepth` — the write-payload shape clients need is unaffected.
737
742
  *
738
743
  * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
739
744
  * other `db.*` (table, column, index, default, etc.). Override in subclass
@@ -972,10 +977,14 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
972
977
  ])], AsReadableController);
973
978
  //#endregion
974
979
  //#region src/actions/verdict.ts
975
- /** Assert that a `disabled` predicate returned a `boolean[]` of the expected length; throws HTTP 500 otherwise. */
980
+ /** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
976
981
  function assertVerdictLength(action, verdicts, expected) {
977
982
  if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
978
983
  }
984
+ /** The reason a verdict carries — a non-empty string; `undefined` for `true` / falsy verdicts. */
985
+ function verdictReason(verdict) {
986
+ return typeof verdict === "string" && verdict !== "" ? verdict : void 0;
987
+ }
979
988
  //#endregion
980
989
  //#region src/actions/list-augmenter.ts
981
990
  const candidateCache = /* @__PURE__ */ new WeakMap();
@@ -1016,7 +1025,8 @@ function computeStripFields(candidates, resolvedProjection) {
1016
1025
  return strip;
1017
1026
  }
1018
1027
  /**
1019
- * Sets `$actions` on every row and strips the columns fetched only for an
1028
+ * Sets `$actions` on every row (plus `$disabledReasons` on rows where a
1029
+ * predicate returned a reason string) and strips the columns fetched only for an
1020
1030
  * action's `requiredFields` — IN PLACE; returns the same array, typed as
1021
1031
  * augmented.
1022
1032
  */
@@ -1033,15 +1043,19 @@ function augmentRowsWithActions(args) {
1033
1043
  for (let i = 0; i < rows.length; i++) {
1034
1044
  const row = rows[i];
1035
1045
  const names = [];
1046
+ let reasons;
1036
1047
  for (let j = 0; j < candidates.length; j++) {
1037
- const v = verdicts[j];
1038
- if (v === void 0) {
1039
- names.push(candidates[j].envelope.info.name);
1048
+ const name = candidates[j].envelope.info.name;
1049
+ const verdict = verdicts[j]?.[i];
1050
+ if (!verdict) {
1051
+ names.push(name);
1040
1052
  continue;
1041
1053
  }
1042
- if (!v[i]) names.push(candidates[j].envelope.info.name);
1054
+ const reason = verdictReason(verdict);
1055
+ if (reason !== void 0) (reasons ??= {})[name] = reason;
1043
1056
  }
1044
1057
  row.$actions = names;
1058
+ if (reasons) row.$disabledReasons = reasons;
1045
1059
  }
1046
1060
  if (resolvedProjection !== null) {
1047
1061
  const stripFields = computeStripFields(candidates, resolvedProjection);
@@ -2432,6 +2446,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2432
2446
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2433
2447
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2434
2448
  if (cap.bucketable) entry.bucketable = true;
2449
+ if (fd.derived) entry.derived = true;
2435
2450
  fields[path] = entry;
2436
2451
  }
2437
2452
  return {
@@ -3115,6 +3130,7 @@ function assertExposed(app, models, options) {
3115
3130
  }
3116
3131
  const missing = [];
3117
3132
  for (const model of models) {
3133
+ if (aliasTargetOf(model)) continue;
3118
3134
  const httpPath = model.metadata.get("db.http.path");
3119
3135
  if (!auditAll && httpPath === void 0) continue;
3120
3136
  if (excluded.has(model) || exposed.has(model)) continue;
@@ -3126,31 +3142,58 @@ function assertExposed(app, models, options) {
3126
3142
  }
3127
3143
  //#endregion
3128
3144
  //#region src/actions/action-disabled-error.ts
3129
- function buildMessage(action, ids) {
3130
- if (ids !== void 0) return `Action "${action}" is disabled for ${ids.length} of the selected rows`;
3131
- return `Action "${action}" is disabled for this row`;
3145
+ /** Distinct reasons quoted in a mixed `'rows'` message before `(+N more)`. */
3146
+ const MAX_LISTED_REASONS = 3;
3147
+ function sharedReason(reasons) {
3148
+ const first = reasons[0];
3149
+ if (first === null || first === void 0) return void 0;
3150
+ return reasons.every((r) => r === first) ? first : void 0;
3151
+ }
3152
+ function rowsMessage(action, count, reasons) {
3153
+ const base = `Action "${action}" is disabled for ${count} of the selected rows`;
3154
+ const distinct = [...new Set(reasons.filter((r) => r !== null))];
3155
+ if (distinct.length === 0) return base;
3156
+ const listed = distinct.slice(0, MAX_LISTED_REASONS).join("; ");
3157
+ const more = distinct.length - MAX_LISTED_REASONS;
3158
+ return `${base}: ${listed}${more > 0 ? ` (+${more} more)` : ""}`;
3132
3159
  }
3133
3160
  /**
3134
3161
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
3135
3162
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
3136
3163
  * defined by {@link ActionDisabledErrorBody}.
3137
3164
  *
3138
- * - `'row'`-level rejection: pass `(action, id)` — the body emits `id`.
3139
- * - `'rows'`-level rejection: pass `(action, undefined, ids)` — the body
3140
- * emits `ids` (the FULL list of failing IDs in reject mode; the FULL list
3141
- * of request IDs in skip mode with zero survivors).
3165
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
3166
+ * body emits `id` (+ `reason` when given).
3167
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
3168
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
3169
+ * list of request IDs in skip mode with zero survivors) and, when any
3170
+ * reason exists, `reasons` aligned with `ids`.
3171
+ *
3172
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
3173
+ * non-empty string counts as "no reason".
3142
3174
  */
3143
3175
  var ActionDisabledError = class extends HttpError {
3144
3176
  name = "ActionDisabledError";
3145
- constructor(action, id, ids) {
3177
+ constructor(action, id, ids, reasons) {
3146
3178
  const body = {
3147
3179
  name: "ActionDisabledError",
3148
- message: buildMessage(action, ids),
3180
+ message: "",
3149
3181
  statusCode: 409,
3150
3182
  action
3151
3183
  };
3152
- if (ids !== void 0) body.ids = ids;
3153
- else if (id !== void 0) body.id = id;
3184
+ if (ids !== void 0) {
3185
+ const aligned = ids.map((_, i) => verdictReason(reasons?.[i]) ?? null);
3186
+ const reason = sharedReason(aligned);
3187
+ body.message = reason ?? rowsMessage(action, ids.length, aligned);
3188
+ body.ids = ids;
3189
+ if (reason !== void 0) body.reason = reason;
3190
+ if (aligned.some((r) => r !== null)) body.reasons = aligned;
3191
+ } else {
3192
+ const reason = verdictReason(reasons?.[0]);
3193
+ body.message = reason ?? `Action "${action}" is disabled for this row`;
3194
+ if (id !== void 0) body.id = id;
3195
+ if (reason !== void 0) body.reason = reason;
3196
+ }
3154
3197
  super(409, body);
3155
3198
  }
3156
3199
  };
@@ -3441,7 +3484,7 @@ function buildGateInterceptor(opts) {
3441
3484
  if (level === "row") {
3442
3485
  const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
3443
3486
  assertVerdictLength(action, verdicts, 1);
3444
- if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot));
3487
+ if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
3445
3488
  return;
3446
3489
  }
3447
3490
  const ids = await ctx.get(dbActionIdsSlot);
@@ -3451,26 +3494,30 @@ function buildGateInterceptor(opts) {
3451
3494
  const verdicts = disabled(existingRows);
3452
3495
  assertVerdictLength(action, verdicts, existingRows.length);
3453
3496
  const failingIds = [];
3497
+ const failingReasons = [];
3454
3498
  const passingRows = [];
3455
3499
  const passingIds = [];
3456
3500
  let verdictIndex = 0;
3457
3501
  for (let i = 0; i < ids.length; i++) {
3458
3502
  const row = rows[i];
3459
- if (row === void 0 || verdicts[verdictIndex++]) failingIds.push(ids[i]);
3460
- else {
3503
+ const verdict = row === void 0 ? void 0 : verdicts[verdictIndex++];
3504
+ if (row === void 0 || verdict) {
3505
+ failingIds.push(ids[i]);
3506
+ failingReasons.push(verdictReason(verdict));
3507
+ } else {
3461
3508
  passingRows.push(row);
3462
3509
  passingIds.push(ids[i]);
3463
3510
  }
3464
3511
  }
3465
3512
  if (onDisabledRows === "skip") {
3466
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
3513
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
3467
3514
  if (failingIds.length > 0) {
3468
3515
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
3469
3516
  ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
3470
3517
  }
3471
3518
  return;
3472
3519
  }
3473
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
3520
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
3474
3521
  }, GATE_PRIORITY);
3475
3522
  }
3476
3523
  /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
@@ -3759,7 +3806,8 @@ function InputForm(formType, validatorOpts) {
3759
3806
  /**
3760
3807
  * Lift a per-row predicate into the batch shape required by
3761
3808
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
3762
- * preserved — `true` from `fn` means the action is disabled for that row.
3809
+ * preserved — `true` from `fn` means the action is disabled for that row; a
3810
+ * non-empty string disables it with that reason.
3763
3811
  *
3764
3812
  * ```ts
3765
3813
  * @DbAction<Order>('archive', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.140",
3
+ "version": "0.1.141",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -44,27 +44,27 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@uniqu/url": "^0.1.11",
47
- "@atscript/db-memory": "^0.1.140"
47
+ "@atscript/db-memory": "^0.1.141"
48
48
  },
49
49
  "devDependencies": {
50
- "@atscript/core": "^0.1.92",
51
- "@atscript/typescript": "^0.1.92",
50
+ "@atscript/core": "^0.1.95",
51
+ "@atscript/typescript": "^0.1.95",
52
52
  "@moostjs/event-http": "^0.6.37",
53
53
  "@uniqu/core": "^0.1.11",
54
54
  "@wooksjs/event-core": "^0.7.23",
55
55
  "@wooksjs/event-http": "^0.7.23",
56
56
  "@wooksjs/http-body": "^0.7.23",
57
57
  "moost": "^0.6.37",
58
- "unplugin-atscript": "^0.1.92"
58
+ "unplugin-atscript": "^0.1.95"
59
59
  },
60
60
  "peerDependencies": {
61
- "@atscript/typescript": "^0.1.92",
61
+ "@atscript/typescript": "^0.1.95",
62
62
  "@moostjs/event-http": "^0.6.37",
63
63
  "@uniqu/core": "^0.1.11",
64
64
  "@wooksjs/event-core": "^0.7.23",
65
65
  "@wooksjs/http-body": "^0.7.23",
66
66
  "moost": "^0.6.37",
67
- "@atscript/db": "^0.1.140"
67
+ "@atscript/db": "^0.1.141"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",