@atscript/moost-db 0.1.140 → 0.1.142

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,13 +737,17 @@ 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
- * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
740
- * other `db.*` (table, column, index, default, etc.). Override in subclass
741
- * to customise.
744
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, `db.rel.*`, the `db.*`
745
+ * keys the shared db validator plugin reads in db-client (`db.json`,
746
+ * `db.patch.strategy`, `db.default*`, `db.column.version`,
747
+ * `db.column.derived`) and the client-facing `db.http.path` /
748
+ * `db.writeOnly`; strips every other `db.*` (table, column, index, etc.).
749
+ * Override in subclass to customise — keep the validator keys, or client
750
+ * preflight diverges from the server.
742
751
  */
743
752
  getSerializeOptions() {
744
753
  return {
@@ -748,7 +757,7 @@ let AsReadableController = class AsReadableController {
748
757
  key,
749
758
  value
750
759
  };
751
- if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version") return {
760
+ if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version" || key === "db.column.derived") return {
752
761
  key,
753
762
  value
754
763
  };
@@ -973,10 +982,14 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
973
982
  ])], AsReadableController);
974
983
  //#endregion
975
984
  //#region src/actions/verdict.ts
976
- /** Assert that a `disabled` predicate returned a `boolean[]` of the expected length; throws HTTP 500 otherwise. */
985
+ /** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
977
986
  function assertVerdictLength(action, verdicts, expected) {
978
987
  if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new _moostjs_event_http.HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
979
988
  }
989
+ /** The reason a verdict carries — a non-empty string; `undefined` for `true` / falsy verdicts. */
990
+ function verdictReason(verdict) {
991
+ return typeof verdict === "string" && verdict !== "" ? verdict : void 0;
992
+ }
980
993
  //#endregion
981
994
  //#region src/actions/list-augmenter.ts
982
995
  const candidateCache = /* @__PURE__ */ new WeakMap();
@@ -1017,7 +1030,8 @@ function computeStripFields(candidates, resolvedProjection) {
1017
1030
  return strip;
1018
1031
  }
1019
1032
  /**
1020
- * Sets `$actions` on every row and strips the columns fetched only for an
1033
+ * Sets `$actions` on every row (plus `$disabledReasons` on rows where a
1034
+ * predicate returned a reason string) and strips the columns fetched only for an
1021
1035
  * action's `requiredFields` — IN PLACE; returns the same array, typed as
1022
1036
  * augmented.
1023
1037
  */
@@ -1034,15 +1048,19 @@ function augmentRowsWithActions(args) {
1034
1048
  for (let i = 0; i < rows.length; i++) {
1035
1049
  const row = rows[i];
1036
1050
  const names = [];
1051
+ let reasons;
1037
1052
  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);
1053
+ const name = candidates[j].envelope.info.name;
1054
+ const verdict = verdicts[j]?.[i];
1055
+ if (!verdict) {
1056
+ names.push(name);
1041
1057
  continue;
1042
1058
  }
1043
- if (!v[i]) names.push(candidates[j].envelope.info.name);
1059
+ const reason = verdictReason(verdict);
1060
+ if (reason !== void 0) (reasons ??= {})[name] = reason;
1044
1061
  }
1045
1062
  row.$actions = names;
1063
+ if (reasons) row.$disabledReasons = reasons;
1046
1064
  }
1047
1065
  if (resolvedProjection !== null) {
1048
1066
  const stripFields = computeStripFields(candidates, resolvedProjection);
@@ -2433,6 +2451,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2433
2451
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2434
2452
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2435
2453
  if (cap.bucketable) entry.bucketable = true;
2454
+ if (fd.derived) entry.derived = true;
2436
2455
  fields[path] = entry;
2437
2456
  }
2438
2457
  return {
@@ -3116,6 +3135,7 @@ function assertExposed(app, models, options) {
3116
3135
  }
3117
3136
  const missing = [];
3118
3137
  for (const model of models) {
3138
+ if ((0, _atscript_db.aliasTargetOf)(model)) continue;
3119
3139
  const httpPath = model.metadata.get("db.http.path");
3120
3140
  if (!auditAll && httpPath === void 0) continue;
3121
3141
  if (excluded.has(model) || exposed.has(model)) continue;
@@ -3127,31 +3147,58 @@ function assertExposed(app, models, options) {
3127
3147
  }
3128
3148
  //#endregion
3129
3149
  //#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`;
3150
+ /** Distinct reasons quoted in a mixed `'rows'` message before `(+N more)`. */
3151
+ const MAX_LISTED_REASONS = 3;
3152
+ function sharedReason(reasons) {
3153
+ const first = reasons[0];
3154
+ if (first === null || first === void 0) return void 0;
3155
+ return reasons.every((r) => r === first) ? first : void 0;
3156
+ }
3157
+ function rowsMessage(action, count, reasons) {
3158
+ const base = `Action "${action}" is disabled for ${count} of the selected rows`;
3159
+ const distinct = [...new Set(reasons.filter((r) => r !== null))];
3160
+ if (distinct.length === 0) return base;
3161
+ const listed = distinct.slice(0, MAX_LISTED_REASONS).join("; ");
3162
+ const more = distinct.length - MAX_LISTED_REASONS;
3163
+ return `${base}: ${listed}${more > 0 ? ` (+${more} more)` : ""}`;
3133
3164
  }
3134
3165
  /**
3135
3166
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
3136
3167
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
3137
3168
  * defined by {@link ActionDisabledErrorBody}.
3138
3169
  *
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).
3170
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
3171
+ * body emits `id` (+ `reason` when given).
3172
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
3173
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
3174
+ * list of request IDs in skip mode with zero survivors) and, when any
3175
+ * reason exists, `reasons` aligned with `ids`.
3176
+ *
3177
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
3178
+ * non-empty string counts as "no reason".
3143
3179
  */
3144
3180
  var ActionDisabledError = class extends _moostjs_event_http.HttpError {
3145
3181
  name = "ActionDisabledError";
3146
- constructor(action, id, ids) {
3182
+ constructor(action, id, ids, reasons) {
3147
3183
  const body = {
3148
3184
  name: "ActionDisabledError",
3149
- message: buildMessage(action, ids),
3185
+ message: "",
3150
3186
  statusCode: 409,
3151
3187
  action
3152
3188
  };
3153
- if (ids !== void 0) body.ids = ids;
3154
- else if (id !== void 0) body.id = id;
3189
+ if (ids !== void 0) {
3190
+ const aligned = ids.map((_, i) => verdictReason(reasons?.[i]) ?? null);
3191
+ const reason = sharedReason(aligned);
3192
+ body.message = reason ?? rowsMessage(action, ids.length, aligned);
3193
+ body.ids = ids;
3194
+ if (reason !== void 0) body.reason = reason;
3195
+ if (aligned.some((r) => r !== null)) body.reasons = aligned;
3196
+ } else {
3197
+ const reason = verdictReason(reasons?.[0]);
3198
+ body.message = reason ?? `Action "${action}" is disabled for this row`;
3199
+ if (id !== void 0) body.id = id;
3200
+ if (reason !== void 0) body.reason = reason;
3201
+ }
3155
3202
  super(409, body);
3156
3203
  }
3157
3204
  };
@@ -3442,7 +3489,7 @@ function buildGateInterceptor(opts) {
3442
3489
  if (level === "row") {
3443
3490
  const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
3444
3491
  assertVerdictLength(action, verdicts, 1);
3445
- if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot));
3492
+ if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
3446
3493
  return;
3447
3494
  }
3448
3495
  const ids = await ctx.get(dbActionIdsSlot);
@@ -3452,26 +3499,30 @@ function buildGateInterceptor(opts) {
3452
3499
  const verdicts = disabled(existingRows);
3453
3500
  assertVerdictLength(action, verdicts, existingRows.length);
3454
3501
  const failingIds = [];
3502
+ const failingReasons = [];
3455
3503
  const passingRows = [];
3456
3504
  const passingIds = [];
3457
3505
  let verdictIndex = 0;
3458
3506
  for (let i = 0; i < ids.length; i++) {
3459
3507
  const row = rows[i];
3460
- if (row === void 0 || verdicts[verdictIndex++]) failingIds.push(ids[i]);
3461
- else {
3508
+ const verdict = row === void 0 ? void 0 : verdicts[verdictIndex++];
3509
+ if (row === void 0 || verdict) {
3510
+ failingIds.push(ids[i]);
3511
+ failingReasons.push(verdictReason(verdict));
3512
+ } else {
3462
3513
  passingRows.push(row);
3463
3514
  passingIds.push(ids[i]);
3464
3515
  }
3465
3516
  }
3466
3517
  if (onDisabledRows === "skip") {
3467
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
3518
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
3468
3519
  if (failingIds.length > 0) {
3469
3520
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
3470
3521
  ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
3471
3522
  }
3472
3523
  return;
3473
3524
  }
3474
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
3525
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
3475
3526
  }, GATE_PRIORITY);
3476
3527
  }
3477
3528
  /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
@@ -3760,7 +3811,8 @@ function InputForm(formType, validatorOpts) {
3760
3811
  /**
3761
3812
  * Lift a per-row predicate into the batch shape required by
3762
3813
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
3763
- * preserved — `true` from `fn` means the action is disabled for that row.
3814
+ * preserved — `true` from `fn` means the action is disabled for that row; a
3815
+ * non-empty string disables it with that reason.
3764
3816
  *
3765
3817
  * ```ts
3766
3818
  * @DbAction<Order>('archive', {
package/dist/index.d.cts CHANGED
@@ -77,13 +77,17 @@ 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
- * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
85
- * other `db.*` (table, column, index, default, etc.). Override in subclass
86
- * to customise.
84
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, `db.rel.*`, the `db.*`
85
+ * keys the shared db validator plugin reads in db-client (`db.json`,
86
+ * `db.patch.strategy`, `db.default*`, `db.column.version`,
87
+ * `db.column.derived`) and the client-facing `db.http.path` /
88
+ * `db.writeOnly`; strips every other `db.*` (table, column, index, etc.).
89
+ * Override in subclass to customise — keep the validator keys, or client
90
+ * preflight diverges from the server.
87
91
  */
88
92
  protected getSerializeOptions(): TSerializeOptions;
89
93
  private _queryControlsValidator?;
@@ -589,7 +593,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
589
593
  *
590
594
  * Convention (not enforced): name decoration keys with a `$` prefix, like
591
595
  * `$actions` and `$distance`, so they can never collide with a field name.
592
- * Do not overwrite `$actions`. Columns the
596
+ * Do not overwrite `$actions` or `$disabledReasons`. Columns the
593
597
  * hook needs but the client did not select must be added in
594
598
  * {@link transformProjection} — they are then part of the response.
595
599
  *
@@ -1006,6 +1010,15 @@ declare class AsJsonValueHelpController<T extends TAtscriptAnnotatedType = TAtsc
1006
1010
  }
1007
1011
  //#endregion
1008
1012
  //#region src/actions/types.d.ts
1013
+ /**
1014
+ * One entry of a `disabled` predicate's result. Truthy = the action is
1015
+ * disabled for that row; falsy (`false`, `""`) = enabled. A non-empty string
1016
+ * disables the action AND carries a human-readable reason — surfaced in the
1017
+ * 409 `ActionDisabledError` message/body and in the row's `$disabledReasons`.
1018
+ *
1019
+ * @since 0.1.141 (`string`)
1020
+ */
1021
+ type TDbActionDisabledVerdict = boolean | string;
1009
1022
  /** `'rows'`-level batch policy — controls whether failing rows reject or are filtered out. */
1010
1023
  type TOnDisabledRows = "reject" | "skip";
1011
1024
  /**
@@ -1034,15 +1047,17 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1034
1047
  */
1035
1048
  requiredFields: R;
1036
1049
  /**
1037
- * Sync batch gate predicate — returns a parallel `boolean[]` aligned with
1038
- * the input. `true` = disabled for the corresponding row. The `rows`
1050
+ * Sync batch gate predicate — returns a parallel array aligned with the
1051
+ * input. Per entry: truthy = disabled for the corresponding row; a
1052
+ * non-empty string also gives the reason (see
1053
+ * {@link TDbActionDisabledVerdict}). The `rows`
1039
1054
  * argument is type-narrowed to `Pick<FlatOf<TRow>, R[number]>[]`; reading
1040
1055
  * a field not listed in `requiredFields` is a compile error.
1041
1056
  *
1042
1057
  * Promise return is NOT permitted — the predicate is consumed in the
1043
1058
  * same tick by the gate and the augmenter.
1044
1059
  */
1045
- disabled?: (rows: DisabledRowsArg<TRow, R>) => boolean[];
1060
+ disabled?: (rows: DisabledRowsArg<TRow, R>) => TDbActionDisabledVerdict[];
1046
1061
  /**
1047
1062
  * `'rows'`-level batch policy. Default `'reject'`.
1048
1063
  *
@@ -1062,7 +1077,7 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1062
1077
  */
1063
1078
  interface LooseGate {
1064
1079
  requiredFields?: string[];
1065
- disabled?: (rows: any[]) => boolean[];
1080
+ disabled?: (rows: any[]) => TDbActionDisabledVerdict[];
1066
1081
  onDisabledRows?: TOnDisabledRows;
1067
1082
  }
1068
1083
  type GateOpts<TRow, R extends readonly FlatKey<TRow>[]> = unknown extends TRow ? LooseGate : NoGate | WithGate<TRow, R>;
@@ -1700,17 +1715,21 @@ declare const useDbActionInput: import("@wooksjs/event-core").WookComposable<{
1700
1715
  * Wire-body shape for server-side gate rejections. The `name` discriminator
1701
1716
  * lets `@atscript/db-client` recognise the response and construct the typed
1702
1717
  * `ActionDisabledError` subclass. The shape extends Moost's standard
1703
- * `ServerError` envelope (`{ message, statusCode, errors? }`) with three
1704
- * additional fields:
1718
+ * `ServerError` envelope (`{ message, statusCode, errors? }`) with:
1705
1719
  *
1706
1720
  * - `name: 'ActionDisabledError'` — discriminator the client matches.
1707
1721
  * - `action` — the `@DbAction` name that rejected the request.
1708
1722
  * - `id?` — present only for `'row'`-level rejections.
1709
1723
  * - `ids?` — present only for `'rows'`-level rejections.
1724
+ * - `reason?` — the reason shared by every rejected row, when the
1725
+ * `disabled` predicate returned one (since 0.1.141).
1726
+ * - `reasons?` — `'rows'` level only: index-aligned with `ids`, `null` where
1727
+ * that row carries no reason. Present only when at least one reason exists
1728
+ * (since 0.1.141).
1710
1729
  *
1711
1730
  * `message` is populated with a human-readable string so generic
1712
1731
  * `ClientError` consumers (which read `body.message`) still get something
1713
- * useful without typed-catch dispatch.
1732
+ * useful without typed-catch dispatch — the reason itself when there is one.
1714
1733
  */
1715
1734
  interface ActionDisabledErrorBody {
1716
1735
  name: "ActionDisabledError";
@@ -1719,27 +1738,37 @@ interface ActionDisabledErrorBody {
1719
1738
  action: string;
1720
1739
  id?: Record<string, unknown>;
1721
1740
  ids?: Record<string, unknown>[];
1741
+ /** @since 0.1.141 */
1742
+ reason?: string;
1743
+ /** @since 0.1.141 */
1744
+ reasons?: (string | null)[];
1722
1745
  }
1723
1746
  /**
1724
1747
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1725
1748
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
1726
1749
  * defined by {@link ActionDisabledErrorBody}.
1727
1750
  *
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).
1751
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
1752
+ * body emits `id` (+ `reason` when given).
1753
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
1754
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
1755
+ * list of request IDs in skip mode with zero survivors) and, when any
1756
+ * reason exists, `reasons` aligned with `ids`.
1757
+ *
1758
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
1759
+ * non-empty string counts as "no reason".
1732
1760
  */
1733
1761
  declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1734
1762
  name: string;
1735
- constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[]);
1763
+ constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[], reasons?: readonly (string | null | undefined)[]);
1736
1764
  }
1737
1765
  //#endregion
1738
1766
  //#region src/actions/per-row.d.ts
1739
1767
  /**
1740
1768
  * Lift a per-row predicate into the batch shape required by
1741
1769
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
1742
- * preserved — `true` from `fn` means the action is disabled for that row.
1770
+ * preserved — `true` from `fn` means the action is disabled for that row; a
1771
+ * non-empty string disables it with that reason.
1743
1772
  *
1744
1773
  * ```ts
1745
1774
  * @DbAction<Order>('archive', {
@@ -1748,7 +1777,7 @@ declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1748
1777
  * })
1749
1778
  * ```
1750
1779
  */
1751
- declare const perRow: <TRow>(fn: (row: TRow) => boolean) => (rows: TRow[]) => boolean[];
1780
+ declare const perRow: <TRow>(fn: (row: TRow) => TDbActionDisabledVerdict) => (rows: TRow[]) => TDbActionDisabledVerdict[];
1752
1781
  //#endregion
1753
1782
  //#region src/permissions/crud-controls.d.ts
1754
1783
  declare const QUERY_CONTROLS: readonly string[];
@@ -1785,7 +1814,10 @@ declare function resolveProp(type: TAtscriptAnnotatedType, field: string): TAtsc
1785
1814
  /**
1786
1815
  * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
1787
1816
  * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
1788
- * bounded by chain length.
1817
+ * bounded by chain length. Only chain hops count — a plain ref (`field: ""`,
1818
+ * a column typed with a named type) is the end of the chain. A hop into a
1819
+ * `@db.alias` type (a view's join alias, since 0.1.141) continues on the
1820
+ * aliased table / view — the alias is no value domain for a picker.
1789
1821
  */
1790
1822
  declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef | undefined;
1791
1823
  /**
@@ -1801,4 +1833,4 @@ declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef |
1801
1833
  */
1802
1834
  declare function applyTerminalRefs(serialized: TSerializedAnnotatedType, runtime: TAtscriptAnnotatedType, options: TSerializeOptions): TSerializedAnnotatedType;
1803
1835
  //#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 };
1836
+ 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,13 +77,17 @@ 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
- * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
85
- * other `db.*` (table, column, index, default, etc.). Override in subclass
86
- * to customise.
84
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, `db.rel.*`, the `db.*`
85
+ * keys the shared db validator plugin reads in db-client (`db.json`,
86
+ * `db.patch.strategy`, `db.default*`, `db.column.version`,
87
+ * `db.column.derived`) and the client-facing `db.http.path` /
88
+ * `db.writeOnly`; strips every other `db.*` (table, column, index, etc.).
89
+ * Override in subclass to customise — keep the validator keys, or client
90
+ * preflight diverges from the server.
87
91
  */
88
92
  protected getSerializeOptions(): TSerializeOptions;
89
93
  private _queryControlsValidator?;
@@ -589,7 +593,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
589
593
  *
590
594
  * Convention (not enforced): name decoration keys with a `$` prefix, like
591
595
  * `$actions` and `$distance`, so they can never collide with a field name.
592
- * Do not overwrite `$actions`. Columns the
596
+ * Do not overwrite `$actions` or `$disabledReasons`. Columns the
593
597
  * hook needs but the client did not select must be added in
594
598
  * {@link transformProjection} — they are then part of the response.
595
599
  *
@@ -1006,6 +1010,15 @@ declare class AsJsonValueHelpController<T extends TAtscriptAnnotatedType = TAtsc
1006
1010
  }
1007
1011
  //#endregion
1008
1012
  //#region src/actions/types.d.ts
1013
+ /**
1014
+ * One entry of a `disabled` predicate's result. Truthy = the action is
1015
+ * disabled for that row; falsy (`false`, `""`) = enabled. A non-empty string
1016
+ * disables the action AND carries a human-readable reason — surfaced in the
1017
+ * 409 `ActionDisabledError` message/body and in the row's `$disabledReasons`.
1018
+ *
1019
+ * @since 0.1.141 (`string`)
1020
+ */
1021
+ type TDbActionDisabledVerdict = boolean | string;
1009
1022
  /** `'rows'`-level batch policy — controls whether failing rows reject or are filtered out. */
1010
1023
  type TOnDisabledRows = "reject" | "skip";
1011
1024
  /**
@@ -1034,15 +1047,17 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1034
1047
  */
1035
1048
  requiredFields: R;
1036
1049
  /**
1037
- * Sync batch gate predicate — returns a parallel `boolean[]` aligned with
1038
- * the input. `true` = disabled for the corresponding row. The `rows`
1050
+ * Sync batch gate predicate — returns a parallel array aligned with the
1051
+ * input. Per entry: truthy = disabled for the corresponding row; a
1052
+ * non-empty string also gives the reason (see
1053
+ * {@link TDbActionDisabledVerdict}). The `rows`
1039
1054
  * argument is type-narrowed to `Pick<FlatOf<TRow>, R[number]>[]`; reading
1040
1055
  * a field not listed in `requiredFields` is a compile error.
1041
1056
  *
1042
1057
  * Promise return is NOT permitted — the predicate is consumed in the
1043
1058
  * same tick by the gate and the augmenter.
1044
1059
  */
1045
- disabled?: (rows: DisabledRowsArg<TRow, R>) => boolean[];
1060
+ disabled?: (rows: DisabledRowsArg<TRow, R>) => TDbActionDisabledVerdict[];
1046
1061
  /**
1047
1062
  * `'rows'`-level batch policy. Default `'reject'`.
1048
1063
  *
@@ -1062,7 +1077,7 @@ interface WithGate<TRow, R extends readonly FlatKey<TRow>[]> {
1062
1077
  */
1063
1078
  interface LooseGate {
1064
1079
  requiredFields?: string[];
1065
- disabled?: (rows: any[]) => boolean[];
1080
+ disabled?: (rows: any[]) => TDbActionDisabledVerdict[];
1066
1081
  onDisabledRows?: TOnDisabledRows;
1067
1082
  }
1068
1083
  type GateOpts<TRow, R extends readonly FlatKey<TRow>[]> = unknown extends TRow ? LooseGate : NoGate | WithGate<TRow, R>;
@@ -1700,17 +1715,21 @@ declare const useDbActionInput: import("@wooksjs/event-core").WookComposable<{
1700
1715
  * Wire-body shape for server-side gate rejections. The `name` discriminator
1701
1716
  * lets `@atscript/db-client` recognise the response and construct the typed
1702
1717
  * `ActionDisabledError` subclass. The shape extends Moost's standard
1703
- * `ServerError` envelope (`{ message, statusCode, errors? }`) with three
1704
- * additional fields:
1718
+ * `ServerError` envelope (`{ message, statusCode, errors? }`) with:
1705
1719
  *
1706
1720
  * - `name: 'ActionDisabledError'` — discriminator the client matches.
1707
1721
  * - `action` — the `@DbAction` name that rejected the request.
1708
1722
  * - `id?` — present only for `'row'`-level rejections.
1709
1723
  * - `ids?` — present only for `'rows'`-level rejections.
1724
+ * - `reason?` — the reason shared by every rejected row, when the
1725
+ * `disabled` predicate returned one (since 0.1.141).
1726
+ * - `reasons?` — `'rows'` level only: index-aligned with `ids`, `null` where
1727
+ * that row carries no reason. Present only when at least one reason exists
1728
+ * (since 0.1.141).
1710
1729
  *
1711
1730
  * `message` is populated with a human-readable string so generic
1712
1731
  * `ClientError` consumers (which read `body.message`) still get something
1713
- * useful without typed-catch dispatch.
1732
+ * useful without typed-catch dispatch — the reason itself when there is one.
1714
1733
  */
1715
1734
  interface ActionDisabledErrorBody {
1716
1735
  name: "ActionDisabledError";
@@ -1719,27 +1738,37 @@ interface ActionDisabledErrorBody {
1719
1738
  action: string;
1720
1739
  id?: Record<string, unknown>;
1721
1740
  ids?: Record<string, unknown>[];
1741
+ /** @since 0.1.141 */
1742
+ reason?: string;
1743
+ /** @since 0.1.141 */
1744
+ reasons?: (string | null)[];
1722
1745
  }
1723
1746
  /**
1724
1747
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1725
1748
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
1726
1749
  * defined by {@link ActionDisabledErrorBody}.
1727
1750
  *
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).
1751
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
1752
+ * body emits `id` (+ `reason` when given).
1753
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
1754
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
1755
+ * list of request IDs in skip mode with zero survivors) and, when any
1756
+ * reason exists, `reasons` aligned with `ids`.
1757
+ *
1758
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
1759
+ * non-empty string counts as "no reason".
1732
1760
  */
1733
1761
  declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1734
1762
  name: string;
1735
- constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[]);
1763
+ constructor(action: string, id?: Record<string, unknown>, ids?: Record<string, unknown>[], reasons?: readonly (string | null | undefined)[]);
1736
1764
  }
1737
1765
  //#endregion
1738
1766
  //#region src/actions/per-row.d.ts
1739
1767
  /**
1740
1768
  * Lift a per-row predicate into the batch shape required by
1741
1769
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
1742
- * preserved — `true` from `fn` means the action is disabled for that row.
1770
+ * preserved — `true` from `fn` means the action is disabled for that row; a
1771
+ * non-empty string disables it with that reason.
1743
1772
  *
1744
1773
  * ```ts
1745
1774
  * @DbAction<Order>('archive', {
@@ -1748,7 +1777,7 @@ declare class ActionDisabledError extends HttpError<ActionDisabledErrorBody> {
1748
1777
  * })
1749
1778
  * ```
1750
1779
  */
1751
- declare const perRow: <TRow>(fn: (row: TRow) => boolean) => (rows: TRow[]) => boolean[];
1780
+ declare const perRow: <TRow>(fn: (row: TRow) => TDbActionDisabledVerdict) => (rows: TRow[]) => TDbActionDisabledVerdict[];
1752
1781
  //#endregion
1753
1782
  //#region src/permissions/crud-controls.d.ts
1754
1783
  declare const QUERY_CONTROLS: readonly string[];
@@ -1785,7 +1814,10 @@ declare function resolveProp(type: TAtscriptAnnotatedType, field: string): TAtsc
1785
1814
  /**
1786
1815
  * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
1787
1816
  * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
1788
- * bounded by chain length.
1817
+ * bounded by chain length. Only chain hops count — a plain ref (`field: ""`,
1818
+ * a column typed with a named type) is the end of the chain. A hop into a
1819
+ * `@db.alias` type (a view's join alias, since 0.1.141) continues on the
1820
+ * aliased table / view — the alias is no value domain for a picker.
1789
1821
  */
1790
1822
  declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef | undefined;
1791
1823
  /**
@@ -1801,4 +1833,4 @@ declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef |
1801
1833
  */
1802
1834
  declare function applyTerminalRefs(serialized: TSerializedAnnotatedType, runtime: TAtscriptAnnotatedType, options: TSerializeOptions): TSerializedAnnotatedType;
1803
1835
  //#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 };
1836
+ 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,13 +736,17 @@ 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
- * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
739
- * other `db.*` (table, column, index, default, etc.). Override in subclass
740
- * to customise.
743
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, `db.rel.*`, the `db.*`
744
+ * keys the shared db validator plugin reads in db-client (`db.json`,
745
+ * `db.patch.strategy`, `db.default*`, `db.column.version`,
746
+ * `db.column.derived`) and the client-facing `db.http.path` /
747
+ * `db.writeOnly`; strips every other `db.*` (table, column, index, etc.).
748
+ * Override in subclass to customise — keep the validator keys, or client
749
+ * preflight diverges from the server.
741
750
  */
742
751
  getSerializeOptions() {
743
752
  return {
@@ -747,7 +756,7 @@ let AsReadableController = class AsReadableController {
747
756
  key,
748
757
  value
749
758
  };
750
- if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version") return {
759
+ if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version" || key === "db.column.derived") return {
751
760
  key,
752
761
  value
753
762
  };
@@ -972,10 +981,14 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
972
981
  ])], AsReadableController);
973
982
  //#endregion
974
983
  //#region src/actions/verdict.ts
975
- /** Assert that a `disabled` predicate returned a `boolean[]` of the expected length; throws HTTP 500 otherwise. */
984
+ /** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
976
985
  function assertVerdictLength(action, verdicts, expected) {
977
986
  if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
978
987
  }
988
+ /** The reason a verdict carries — a non-empty string; `undefined` for `true` / falsy verdicts. */
989
+ function verdictReason(verdict) {
990
+ return typeof verdict === "string" && verdict !== "" ? verdict : void 0;
991
+ }
979
992
  //#endregion
980
993
  //#region src/actions/list-augmenter.ts
981
994
  const candidateCache = /* @__PURE__ */ new WeakMap();
@@ -1016,7 +1029,8 @@ function computeStripFields(candidates, resolvedProjection) {
1016
1029
  return strip;
1017
1030
  }
1018
1031
  /**
1019
- * Sets `$actions` on every row and strips the columns fetched only for an
1032
+ * Sets `$actions` on every row (plus `$disabledReasons` on rows where a
1033
+ * predicate returned a reason string) and strips the columns fetched only for an
1020
1034
  * action's `requiredFields` — IN PLACE; returns the same array, typed as
1021
1035
  * augmented.
1022
1036
  */
@@ -1033,15 +1047,19 @@ function augmentRowsWithActions(args) {
1033
1047
  for (let i = 0; i < rows.length; i++) {
1034
1048
  const row = rows[i];
1035
1049
  const names = [];
1050
+ let reasons;
1036
1051
  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);
1052
+ const name = candidates[j].envelope.info.name;
1053
+ const verdict = verdicts[j]?.[i];
1054
+ if (!verdict) {
1055
+ names.push(name);
1040
1056
  continue;
1041
1057
  }
1042
- if (!v[i]) names.push(candidates[j].envelope.info.name);
1058
+ const reason = verdictReason(verdict);
1059
+ if (reason !== void 0) (reasons ??= {})[name] = reason;
1043
1060
  }
1044
1061
  row.$actions = names;
1062
+ if (reasons) row.$disabledReasons = reasons;
1045
1063
  }
1046
1064
  if (resolvedProjection !== null) {
1047
1065
  const stripFields = computeStripFields(candidates, resolvedProjection);
@@ -2432,6 +2450,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2432
2450
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2433
2451
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2434
2452
  if (cap.bucketable) entry.bucketable = true;
2453
+ if (fd.derived) entry.derived = true;
2435
2454
  fields[path] = entry;
2436
2455
  }
2437
2456
  return {
@@ -3115,6 +3134,7 @@ function assertExposed(app, models, options) {
3115
3134
  }
3116
3135
  const missing = [];
3117
3136
  for (const model of models) {
3137
+ if (aliasTargetOf(model)) continue;
3118
3138
  const httpPath = model.metadata.get("db.http.path");
3119
3139
  if (!auditAll && httpPath === void 0) continue;
3120
3140
  if (excluded.has(model) || exposed.has(model)) continue;
@@ -3126,31 +3146,58 @@ function assertExposed(app, models, options) {
3126
3146
  }
3127
3147
  //#endregion
3128
3148
  //#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`;
3149
+ /** Distinct reasons quoted in a mixed `'rows'` message before `(+N more)`. */
3150
+ const MAX_LISTED_REASONS = 3;
3151
+ function sharedReason(reasons) {
3152
+ const first = reasons[0];
3153
+ if (first === null || first === void 0) return void 0;
3154
+ return reasons.every((r) => r === first) ? first : void 0;
3155
+ }
3156
+ function rowsMessage(action, count, reasons) {
3157
+ const base = `Action "${action}" is disabled for ${count} of the selected rows`;
3158
+ const distinct = [...new Set(reasons.filter((r) => r !== null))];
3159
+ if (distinct.length === 0) return base;
3160
+ const listed = distinct.slice(0, MAX_LISTED_REASONS).join("; ");
3161
+ const more = distinct.length - MAX_LISTED_REASONS;
3162
+ return `${base}: ${listed}${more > 0 ? ` (+${more} more)` : ""}`;
3132
3163
  }
3133
3164
  /**
3134
3165
  * Thrown by the gate interceptor when `disabled` returns truthy. Composes
3135
3166
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
3136
3167
  * defined by {@link ActionDisabledErrorBody}.
3137
3168
  *
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).
3169
+ * - `'row'`-level rejection: pass `(action, id, undefined, [reason])` — the
3170
+ * body emits `id` (+ `reason` when given).
3171
+ * - `'rows'`-level rejection: pass `(action, undefined, ids, reasons?)` — the
3172
+ * body emits `ids` (the FULL list of failing IDs in reject mode; the FULL
3173
+ * list of request IDs in skip mode with zero survivors) and, when any
3174
+ * reason exists, `reasons` aligned with `ids`.
3175
+ *
3176
+ * `reasons` entries are verdict reasons (`verdictReason`): anything but a
3177
+ * non-empty string counts as "no reason".
3142
3178
  */
3143
3179
  var ActionDisabledError = class extends HttpError {
3144
3180
  name = "ActionDisabledError";
3145
- constructor(action, id, ids) {
3181
+ constructor(action, id, ids, reasons) {
3146
3182
  const body = {
3147
3183
  name: "ActionDisabledError",
3148
- message: buildMessage(action, ids),
3184
+ message: "",
3149
3185
  statusCode: 409,
3150
3186
  action
3151
3187
  };
3152
- if (ids !== void 0) body.ids = ids;
3153
- else if (id !== void 0) body.id = id;
3188
+ if (ids !== void 0) {
3189
+ const aligned = ids.map((_, i) => verdictReason(reasons?.[i]) ?? null);
3190
+ const reason = sharedReason(aligned);
3191
+ body.message = reason ?? rowsMessage(action, ids.length, aligned);
3192
+ body.ids = ids;
3193
+ if (reason !== void 0) body.reason = reason;
3194
+ if (aligned.some((r) => r !== null)) body.reasons = aligned;
3195
+ } else {
3196
+ const reason = verdictReason(reasons?.[0]);
3197
+ body.message = reason ?? `Action "${action}" is disabled for this row`;
3198
+ if (id !== void 0) body.id = id;
3199
+ if (reason !== void 0) body.reason = reason;
3200
+ }
3154
3201
  super(409, body);
3155
3202
  }
3156
3203
  };
@@ -3441,7 +3488,7 @@ function buildGateInterceptor(opts) {
3441
3488
  if (level === "row") {
3442
3489
  const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
3443
3490
  assertVerdictLength(action, verdicts, 1);
3444
- if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot));
3491
+ if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
3445
3492
  return;
3446
3493
  }
3447
3494
  const ids = await ctx.get(dbActionIdsSlot);
@@ -3451,26 +3498,30 @@ function buildGateInterceptor(opts) {
3451
3498
  const verdicts = disabled(existingRows);
3452
3499
  assertVerdictLength(action, verdicts, existingRows.length);
3453
3500
  const failingIds = [];
3501
+ const failingReasons = [];
3454
3502
  const passingRows = [];
3455
3503
  const passingIds = [];
3456
3504
  let verdictIndex = 0;
3457
3505
  for (let i = 0; i < ids.length; i++) {
3458
3506
  const row = rows[i];
3459
- if (row === void 0 || verdicts[verdictIndex++]) failingIds.push(ids[i]);
3460
- else {
3507
+ const verdict = row === void 0 ? void 0 : verdicts[verdictIndex++];
3508
+ if (row === void 0 || verdict) {
3509
+ failingIds.push(ids[i]);
3510
+ failingReasons.push(verdictReason(verdict));
3511
+ } else {
3461
3512
  passingRows.push(row);
3462
3513
  passingIds.push(ids[i]);
3463
3514
  }
3464
3515
  }
3465
3516
  if (onDisabledRows === "skip") {
3466
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
3517
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
3467
3518
  if (failingIds.length > 0) {
3468
3519
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
3469
3520
  ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
3470
3521
  }
3471
3522
  return;
3472
3523
  }
3473
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
3524
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
3474
3525
  }, GATE_PRIORITY);
3475
3526
  }
3476
3527
  /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
@@ -3759,7 +3810,8 @@ function InputForm(formType, validatorOpts) {
3759
3810
  /**
3760
3811
  * Lift a per-row predicate into the batch shape required by
3761
3812
  * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
3762
- * preserved — `true` from `fn` means the action is disabled for that row.
3813
+ * preserved — `true` from `fn` means the action is disabled for that row; a
3814
+ * non-empty string disables it with that reason.
3763
3815
  *
3764
3816
  * ```ts
3765
3817
  * @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.142",
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.142"
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.142"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",