@atscript/moost-db 0.1.57 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { ValidatorError, defineAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
2
2
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
3
- import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, Resolve, TInterceptorPriority, defineInterceptor, getMoostMate, useControllerContext } from "moost";
3
+ import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, Resolve, TInterceptorPriority, defineBeforeInterceptor, defineInterceptor, getMoostMate, useControllerContext } from "moost";
4
4
  import { parseUrl } from "@uniqu/url";
5
5
  import { DbError } from "@atscript/db";
6
+ import { cached, current, defineWook, key } from "@wooksjs/event-core";
6
7
  import { useBody } from "@wooksjs/http-body";
7
8
  //#region src/validation-interceptor.ts
8
9
  const dbErrorCodeToStatus = { CONFLICT: 409 };
@@ -180,13 +181,39 @@ function findSortOffender(sort, isAllowed) {
180
181
  }
181
182
  }
182
183
  //#endregion
184
+ //#region src/actions/controller-registry.ts
185
+ let asDbReadableCtor = null;
186
+ let asValueHelpCtor = null;
187
+ function registerAsDbReadableController(ctor) {
188
+ asDbReadableCtor = ctor;
189
+ }
190
+ function registerAsValueHelpController(ctor) {
191
+ asValueHelpCtor = ctor;
192
+ }
193
+ function isAsDbReadableControllerSubclass(ctor) {
194
+ if (!asDbReadableCtor) return false;
195
+ return asDbReadableCtor.prototype.isPrototypeOf(ctor.prototype);
196
+ }
197
+ function isAsValueHelpControllerSubclass(ctor) {
198
+ if (!asValueHelpCtor) return false;
199
+ return asValueHelpCtor.prototype.isPrototypeOf(ctor.prototype);
200
+ }
201
+ function isAsDbReadableControllerInstance(value) {
202
+ return !!asDbReadableCtor && value instanceof asDbReadableCtor;
203
+ }
204
+ //#endregion
183
205
  //#region src/actions/keys.ts
206
+ /** Log-message prefix for warnings emitted from the actions subsystem. */
207
+ const WARN_PREFIX = "[moost-db actions]";
184
208
  /** Method-level metadata key — written by `@DbAction(name, opts)`. */
185
209
  const MOOST_DB_ACTION = "atscript_db_action";
186
210
  /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
187
211
  const MOOST_DB_ACTIONS = "atscript_db_actions";
188
212
  /** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
189
213
  const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
214
+ /** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
215
+ const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
216
+ const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
190
217
  /**
191
218
  * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
192
219
  * read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
@@ -204,17 +231,50 @@ function mergeActionMeta(current, patch) {
204
231
  };
205
232
  }
206
233
  //#endregion
234
+ //#region src/actions/param-level.ts
235
+ function scanParamLevel(params) {
236
+ let single = false;
237
+ let multi = false;
238
+ let hasRowParam = false;
239
+ let hasBody = false;
240
+ for (const p of params) {
241
+ const kind = p[MOOST_DB_ACTION_PARAM];
242
+ if (kind === "pk") single = true;
243
+ else if (kind === "pks") multi = true;
244
+ if (p["atscript_db_action_row"]) {
245
+ single = true;
246
+ hasRowParam = true;
247
+ }
248
+ if (p["atscript_db_action_rows"]) {
249
+ multi = true;
250
+ hasRowParam = true;
251
+ }
252
+ if (p.paramSource === "BODY") hasBody = true;
253
+ }
254
+ return {
255
+ level: single && multi ? "table" : single ? "row" : multi ? "rows" : "table",
256
+ single,
257
+ multi,
258
+ hasRowParam,
259
+ hasBody
260
+ };
261
+ }
262
+ //#endregion
207
263
  //#region src/actions/discover.ts
208
- /** Optional fields shared between method opts and class-level entries. */
264
+ /**
265
+ * Pure structural-copy fields. `disabled` and `requiredFields` are handled
266
+ * as special cases in {@link emitInfo} so the function-to-string transform
267
+ * stays out of the copy loop.
268
+ */
209
269
  const OPTIONAL_FIELDS = [
210
270
  "icon",
211
271
  "intent",
212
272
  "description",
213
273
  "order",
214
274
  "default",
215
- "promptText"
275
+ "promptText",
276
+ "shortcut"
216
277
  ];
217
- const WARN_PREFIX = "[moost-db actions]";
218
278
  const actionsCache = /* @__PURE__ */ new WeakMap();
219
279
  /**
220
280
  * Discover all actions declared on a controller and produce the `/meta` array.
@@ -255,9 +315,21 @@ function collectMethodActions(ctor, overview, logger, out) {
255
315
  const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
256
316
  if (!levelInfer) continue;
257
317
  if (levelInfer.bodyConflict) {
258
- logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs with @Body() — dropping`);
318
+ logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs/@DbActionRow*/@DbActionRows with @Body() — dropping`);
259
319
  continue;
260
320
  }
321
+ if (levelInfer.level === "table" && action.opts.disabled !== void 0) {
322
+ logger.warn(`${WARN_PREFIX} action "${action.name}" — \`disabled\` is not allowed at the 'table' level; row-state predicates are not meaningful when no row is in scope. Use @Authenticate / arbac for table-level access — dropping`);
323
+ continue;
324
+ }
325
+ if (action.opts.disabled !== void 0 || levelInfer.hasRowParam) {
326
+ const extendsReadable = isAsDbReadableControllerSubclass(ctor);
327
+ const hasOptsTable = action.opts.table != null;
328
+ if (!extendsReadable && !hasOptsTable) {
329
+ logger.warn(`${WARN_PREFIX} action "${action.name}" declares a gate or row injection but the controller does not extend AsDbReadableController and \`opts.table\` is not provided. Either extend AsDbReadableController / AsDbController or pass \`opts.table\` on @DbAction — dropping`);
330
+ continue;
331
+ }
332
+ }
261
333
  const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
262
334
  if (!postEntry) {
263
335
  logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
@@ -280,40 +352,32 @@ function collectMethodActions(ctor, overview, logger, out) {
280
352
  processor: "backend",
281
353
  value: path
282
354
  };
283
- copyOptionalFields(info, action.opts);
355
+ emitInfo(info, action.opts, action.name, logger);
284
356
  out.push(info);
285
357
  }
286
358
  }
287
359
  function inferMethodLevel(params, actionName, logger) {
288
- let hasPk = false;
289
- let hasPks = false;
290
- let hasBody = false;
291
- for (const p of params) {
292
- const kind = p[MOOST_DB_ACTION_PARAM];
293
- if (kind === "pk") hasPk = true;
294
- else if (kind === "pks") hasPks = true;
295
- if (p.paramSource === "BODY") hasBody = true;
296
- }
297
- if (hasPk && hasPks) {
298
- logger.warn(`${WARN_PREFIX} action "${actionName}" has both @DbActionPK and @DbActionPKs — dropping`);
360
+ const scan = scanParamLevel(params);
361
+ if (scan.single && scan.multi) {
362
+ logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionPK / @DbActionRow vs @DbActionPKs / @DbActionRows) — dropping`);
299
363
  return null;
300
364
  }
301
- const level = hasPk ? "row" : hasPks ? "rows" : "table";
302
365
  return {
303
- level,
304
- bodyConflict: hasBody && level !== "table"
366
+ level: scan.level,
367
+ bodyConflict: scan.hasBody && scan.level !== "table",
368
+ hasRowParam: scan.hasRowParam
305
369
  };
306
370
  }
307
371
  function collectClassActions(ctor, logger, out) {
308
372
  const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
309
373
  if (!list) return;
310
- for (const { name, entry, forcedLevel } of list) {
311
- const built = buildClassEntry(name, entry, forcedLevel, logger);
374
+ for (const { name, entry } of list) {
375
+ const built = buildClassEntry(name, entry, logger);
312
376
  if (built) out.push(built);
313
377
  }
314
378
  }
315
- function buildClassEntry(name, entry, forcedLevel, logger) {
316
- const level = forcedLevel ?? entry.level;
379
+ function buildClassEntry(name, entry, logger) {
380
+ const level = entry.level;
317
381
  if (!level) {
318
382
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
319
383
  return null;
@@ -322,6 +386,10 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
322
386
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
323
387
  return null;
324
388
  }
389
+ if (level === "table" && entry.disabled !== void 0) {
390
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` is not allowed at the 'table' level — dropping`);
391
+ return null;
392
+ }
325
393
  const processor = entry.processor;
326
394
  let value;
327
395
  if (processor === "navigate" || processor === "backend") {
@@ -349,7 +417,7 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
349
417
  processor,
350
418
  value
351
419
  };
352
- copyOptionalFields(info, entry);
420
+ emitInfo(info, entry, name, logger);
353
421
  return info;
354
422
  }
355
423
  function applyDefaultPerLevel(actions, logger) {
@@ -363,6 +431,26 @@ function applyDefaultPerLevel(actions, logger) {
363
431
  } else winners.set(a.level, a.name);
364
432
  }
365
433
  }
434
+ /**
435
+ * Emit structural-copy fields plus `disabled` (stringified) and
436
+ * `requiredFields` (forwarded verbatim — server doesn't auto-derive).
437
+ * `requiredFields` without `disabled` is dropped with a warning before the
438
+ * structural copy runs, so method-decorator and class-level-dict origins
439
+ * stay symmetric.
440
+ */
441
+ function emitInfo(info, source, name, logger) {
442
+ const disabled = source.disabled;
443
+ const hasDisabled = typeof disabled === "function";
444
+ let requiredFields = source.requiredFields;
445
+ if (!hasDisabled && requiredFields !== void 0) {
446
+ logger.warn(`${WARN_PREFIX} action "${name}" has \`requiredFields\` without \`disabled\` — \`requiredFields\` is purely a UI hint and meaningless without a predicate. Dropping \`requiredFields\` from /meta.`);
447
+ requiredFields = void 0;
448
+ }
449
+ copyOptionalFields(info, source);
450
+ if (Array.isArray(info.promptText)) info.promptText = info.promptText.slice();
451
+ if (hasDisabled) info.disabled = disabled.toString();
452
+ if (Array.isArray(requiredFields)) info.requiredFields = requiredFields.slice();
453
+ }
366
454
  function copyOptionalFields(info, source) {
367
455
  for (const key of OPTIONAL_FIELDS) {
368
456
  const value = source[key];
@@ -1005,6 +1093,7 @@ AsDbReadableController = __decorate([
1005
1093
  __decorateParam(0, Inject(READABLE_DEF)),
1006
1094
  __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$3 : Object])
1007
1095
  ], AsDbReadableController);
1096
+ registerAsDbReadableController(AsDbReadableController);
1008
1097
  //#endregion
1009
1098
  //#region src/as-db.controller.ts
1010
1099
  var _ref$2, _ref2$1;
@@ -1303,6 +1392,7 @@ AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramt
1303
1392
  String,
1304
1393
  typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object
1305
1394
  ])], AsValueHelpController);
1395
+ registerAsValueHelpController(AsValueHelpController);
1306
1396
  //#endregion
1307
1397
  //#region src/as-json-value-help.controller.ts
1308
1398
  var _ref;
@@ -1463,89 +1553,43 @@ function applySelect(rows, select) {
1463
1553
  });
1464
1554
  }
1465
1555
  //#endregion
1466
- //#region src/actions/db-action.decorator.ts
1467
- /**
1468
- * Mark a controller method as a database action surfaced via `/meta`.
1469
- *
1470
- * Metadata-only — pair with `@Post(...)` for Moost to bind the route. The
1471
- * meta builder reads this metadata plus the bound POST path lazily and
1472
- * emits the action with `processor: 'backend'`. Order vs.
1473
- * `@DbActionDefault()` does not matter — both merge into the same slot.
1474
- *
1475
- * @example
1476
- * ```ts
1477
- * @Post('actions/block')
1478
- * @DbAction('block', { label: 'Block', icon: 'i-as-block', intent: 'negative' })
1479
- * async blockUser(@DbActionPK() id: string) { ... }
1480
- * ```
1481
- */
1482
- function DbAction(name, opts = {}) {
1483
- return getMoostMate().decorate((current) => {
1484
- const meta = current;
1485
- return {
1486
- ...current,
1487
- [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1488
- name,
1489
- opts
1490
- })
1491
- };
1492
- });
1556
+ //#region src/actions/action-disabled-error.ts
1557
+ function buildMessage(action, pks) {
1558
+ if (pks !== void 0) return `Action "${action}" is disabled for ${pks.length} of the selected rows`;
1559
+ return `Action "${action}" is disabled for this row`;
1493
1560
  }
1494
- //#endregion
1495
- //#region src/actions/db-action-default.decorator.ts
1496
1561
  /**
1497
- * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1498
- * Equivalent to passing `opts.default = true`. Decorator order does not matter.
1562
+ * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1563
+ * with Moost's existing error mapper to produce HTTP 409 with the wire body
1564
+ * defined by {@link ActionDisabledErrorBody}.
1565
+ *
1566
+ * - `'row'`-level rejection: pass `(action, pk)` — the body emits `pk`.
1567
+ * - `'rows'`-level rejection: pass `(action, undefined, pks)` — the body
1568
+ * emits `pks` (the FULL list of failing PKs in reject mode; the FULL list
1569
+ * of request PKs in skip mode with zero survivors).
1499
1570
  */
1500
- function DbActionDefault() {
1501
- return getMoostMate().decorate((current) => {
1502
- const meta = current;
1503
- return {
1504
- ...current,
1505
- [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
1571
+ var ActionDisabledError = class extends HttpError {
1572
+ name = "ActionDisabledError";
1573
+ constructor(action, pk, pks) {
1574
+ const body = {
1575
+ name: "ActionDisabledError",
1576
+ message: buildMessage(action, pks),
1577
+ statusCode: 409,
1578
+ action
1506
1579
  };
1507
- });
1508
- }
1580
+ if (pks !== void 0) body.pks = pks;
1581
+ else if (pk !== void 0) body.pk = pk;
1582
+ super(409, body);
1583
+ }
1584
+ };
1509
1585
  //#endregion
1510
- //#region src/actions/pk-source.ts
1511
- /**
1512
- * Extract the PK validation source from a controller instance. Looks for
1513
- * `readable` (set by {@link AsDbReadableController}) or `table` (set by
1514
- * {@link AsDbController}).
1515
- *
1516
- * If the controller has no typed table attached (e.g. a value-help
1517
- * controller, or a plain Moost controller without `@TableController`),
1518
- * throws an HTTP 500 — this is a **server misconfiguration**, not a client
1519
- * error. The body parser has nothing to validate against, so the request
1520
- * cannot proceed. Use `@Body()` and parse the PK manually if you need to
1521
- * accept PK-shaped bodies on a controller without an attached table.
1522
- */
1523
- function resolvePkSource(controller) {
1524
- const c = controller;
1525
- const candidate = c.readable ?? c.table;
1526
- if (!isPkValidationSource(candidate)) throw new HttpError(500, "@DbActionPK/@DbActionPKs requires a controller with an attached table (via @TableController / @ReadableController). Use @Body() instead if your controller has no typed table.");
1527
- return candidate;
1528
- }
1586
+ //#region src/actions/pk-validation.ts
1529
1587
  function isPkValidationSource(value) {
1530
1588
  if (!value || typeof value !== "object") return false;
1531
1589
  const v = value;
1532
1590
  return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1533
1591
  }
1534
1592
  /**
1535
- * Build a parameter decorator that parses the JSON request body, validates
1536
- * it against the bound table's PK schema with `validate`, and tags the param
1537
- * so {@link discoverActions} can infer the action's `level`.
1538
- */
1539
- function createPkParamDecorator(kind, validate, resolverName) {
1540
- return ApplyDecorators(getMoostMate().decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => {
1541
- const body = await useBody().parseBody();
1542
- validate(body, resolvePkSource(useControllerContext().getController()));
1543
- return body;
1544
- }, resolverName));
1545
- }
1546
- //#endregion
1547
- //#region src/actions/pk-validation.ts
1548
- /**
1549
1593
  * Validate a JSON-decoded body against a single-row PK shape (scalar or
1550
1594
  * composite). Throws {@link ValidatorError} with structured `errors` so the
1551
1595
  * existing validation interceptor returns HTTP 400.
@@ -1630,6 +1674,229 @@ function isPlainObject(value) {
1630
1674
  return typeof value === "object" && value !== null && !Array.isArray(value);
1631
1675
  }
1632
1676
  //#endregion
1677
+ //#region src/actions/pk-cache.ts
1678
+ const boundTableKey = key("atscript_db_action_bound_table");
1679
+ function getActionTable(ctx) {
1680
+ if (ctx.has(boundTableKey)) {
1681
+ const fromSlot = ctx.get(boundTableKey);
1682
+ if (fromSlot) return fromSlot;
1683
+ }
1684
+ const ctrl = useControllerContext(ctx).getController();
1685
+ if (ctrl) {
1686
+ const t = ctrl.readable ?? ctrl.table;
1687
+ if (t) return t;
1688
+ }
1689
+ return null;
1690
+ }
1691
+ function noTableError(ctx) {
1692
+ const cc = useControllerContext(ctx);
1693
+ const ctrl = cc.getController();
1694
+ const methodName = cc.getMethod();
1695
+ let actionName;
1696
+ if (ctrl && methodName) actionName = getMoostMate().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION]?.name;
1697
+ return new HttpError(500, `${WARN_PREFIX} ${actionName ? `"${actionName}"` : "<unknown>"}: controller has no readable/table property and the action declares no opts.table. Either expose readable/table on the controller, extend AsDbReadableController, or pass opts.table on @DbAction.`);
1698
+ }
1699
+ async function resolveValidatedPk(ctx, validate) {
1700
+ const table = getActionTable(ctx);
1701
+ if (!isPkValidationSource(table)) throw noTableError(ctx);
1702
+ const body = await useBody(ctx).parseBody();
1703
+ validate(body, table);
1704
+ return body;
1705
+ }
1706
+ const dbActionPkSlot = cached((ctx) => resolveValidatedPk(ctx, validateSinglePk));
1707
+ const dbActionPksSlot = cached(async (ctx) => {
1708
+ return await resolveValidatedPk(ctx, validateMultiPk);
1709
+ });
1710
+ const useDbActionPk = defineWook((ctx) => ({ load: () => ctx.get(dbActionPkSlot) }));
1711
+ const useDbActionPks = defineWook((ctx) => ({ load: () => ctx.get(dbActionPksSlot) }));
1712
+ //#endregion
1713
+ //#region src/actions/row-cache.ts
1714
+ function asFetchTable(value) {
1715
+ if (!value || typeof value !== "object") return null;
1716
+ const v = value;
1717
+ return Array.isArray(v.primaryKeys) && typeof v.findById === "function" && typeof v.findMany === "function" ? v : null;
1718
+ }
1719
+ function noTable() {
1720
+ throw new HttpError(500, `${WARN_PREFIX} cached row wook: no bound table`);
1721
+ }
1722
+ async function loadRow(ctx) {
1723
+ const pk = await ctx.get(dbActionPkSlot);
1724
+ const row = await (asFetchTable(getActionTable(ctx)) ?? noTable()).findById(pk);
1725
+ if (row == null) throw new HttpError(404, "Row not found for action PK");
1726
+ return row;
1727
+ }
1728
+ async function loadRows(ctx) {
1729
+ const pks = await ctx.get(dbActionPksSlot);
1730
+ const table = asFetchTable(getActionTable(ctx)) ?? noTable();
1731
+ if (pks.length === 0) return [];
1732
+ const { primaryKeys } = table;
1733
+ const rows = await table.findMany({ filter: buildPksFilter(pks, primaryKeys) });
1734
+ if (primaryKeys.length === 1) {
1735
+ const field = primaryKeys[0];
1736
+ const byKey = /* @__PURE__ */ new Map();
1737
+ for (const row of rows) byKey.set(row[field], row);
1738
+ const ordered = [];
1739
+ for (const pk of pks) {
1740
+ const found = byKey.get(pk);
1741
+ if (found !== void 0) ordered.push(found);
1742
+ }
1743
+ return ordered;
1744
+ }
1745
+ const byKey = /* @__PURE__ */ new Map();
1746
+ for (const row of rows) byKey.set(compositeKey(row, primaryKeys), row);
1747
+ const ordered = [];
1748
+ for (const pk of pks) {
1749
+ const found = byKey.get(compositeKey(pk, primaryKeys));
1750
+ if (found !== void 0) ordered.push(found);
1751
+ }
1752
+ return ordered;
1753
+ }
1754
+ function buildPksFilter(pks, primaryKeys) {
1755
+ if (primaryKeys.length === 1) return { [primaryKeys[0]]: { $in: pks } };
1756
+ return { $or: pks.map((pk) => {
1757
+ const obj = pk;
1758
+ const clause = {};
1759
+ for (const field of primaryKeys) clause[field] = obj[field];
1760
+ return clause;
1761
+ }) };
1762
+ }
1763
+ function compositeKey(obj, primaryKeys) {
1764
+ let out = "";
1765
+ for (const f of primaryKeys) {
1766
+ if (out !== "") out += "\0";
1767
+ const v = obj[f];
1768
+ if (v === null) out += "n";
1769
+ else if (v === void 0) out += "u";
1770
+ else if (typeof v === "string") out += `s\x02${v}`;
1771
+ else if (typeof v === "number") out += `n\x02${v}`;
1772
+ else if (typeof v === "boolean") out += `b\x02${v}`;
1773
+ else out += `j\x02${JSON.stringify(v)}`;
1774
+ }
1775
+ return out;
1776
+ }
1777
+ const dbActionRowSlot = cached((ctx) => loadRow(ctx));
1778
+ const dbActionRowsSlot = cached((ctx) => loadRows(ctx));
1779
+ const useDbActionRow = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowSlot) }));
1780
+ const useDbActionRows = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowsSlot) }));
1781
+ //#endregion
1782
+ //#region src/actions/gate-interceptor.ts
1783
+ const GATE_PRIORITY = TInterceptorPriority.AFTER_GUARD;
1784
+ function injectBoundTable(table) {
1785
+ const ctx = current();
1786
+ if (ctx.has(boundTableKey)) return;
1787
+ const controller = useControllerContext(ctx).getController();
1788
+ if (isAsDbReadableControllerInstance(controller)) {
1789
+ ctx.set(boundTableKey, controller.readable);
1790
+ return;
1791
+ }
1792
+ if (table != null) ctx.set(boundTableKey, table);
1793
+ }
1794
+ function buildGateInterceptor(opts) {
1795
+ const { action, level, disabled, onDisabledRows, table } = opts;
1796
+ return defineBeforeInterceptor(async () => {
1797
+ injectBoundTable(table);
1798
+ const ctx = current();
1799
+ if (level === "row") {
1800
+ if (disabled(await ctx.get(dbActionRowSlot))) throw new ActionDisabledError(action, await ctx.get(dbActionPkSlot));
1801
+ return;
1802
+ }
1803
+ const pks = await ctx.get(dbActionPksSlot);
1804
+ const rows = await ctx.get(dbActionRowsSlot);
1805
+ const failingPks = [];
1806
+ const passingRows = [];
1807
+ const passingPks = [];
1808
+ for (let i = 0; i < rows.length; i++) if (disabled(rows[i])) failingPks.push(pks[i]);
1809
+ else {
1810
+ passingRows.push(rows[i]);
1811
+ passingPks.push(pks[i]);
1812
+ }
1813
+ if (onDisabledRows === "skip") {
1814
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...pks]);
1815
+ if (failingPks.length > 0) {
1816
+ ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
1817
+ ctx.set(dbActionPksSlot, Promise.resolve(passingPks));
1818
+ }
1819
+ return;
1820
+ }
1821
+ if (failingPks.length > 0) throw new ActionDisabledError(action, void 0, failingPks);
1822
+ }, GATE_PRIORITY);
1823
+ }
1824
+ /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
1825
+ function buildThinInterceptor(opts) {
1826
+ const { table } = opts;
1827
+ return defineBeforeInterceptor(() => {
1828
+ injectBoundTable(table);
1829
+ }, GATE_PRIORITY);
1830
+ }
1831
+ //#endregion
1832
+ //#region src/actions/db-action.decorator.ts
1833
+ /**
1834
+ * Mark a controller method as a database action surfaced via `/meta`. Writes
1835
+ * `MOOST_DB_ACTION` metadata and registers a Moost interceptor when needed
1836
+ * (gate when `disabled` is set, thin bound-table injector when only
1837
+ * `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
1838
+ * is undefined and emits a warning.
1839
+ */
1840
+ function DbAction(name, opts = {}) {
1841
+ const mate = getMoostMate();
1842
+ return ((target, propertyKey, descriptor) => {
1843
+ const priorName = mate.read(target, propertyKey)?.[MOOST_DB_ACTION]?.name;
1844
+ if (priorName) console.warn(`${WARN_PREFIX} stacking @DbAction on the same method is undefined; declare one per method. Detected: "${priorName}" and "${name}".`);
1845
+ mate.decorate((current) => {
1846
+ const meta = current;
1847
+ return {
1848
+ ...current,
1849
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1850
+ name,
1851
+ opts
1852
+ })
1853
+ };
1854
+ })(target, propertyKey, descriptor);
1855
+ if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
1856
+ const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
1857
+ if (!!opts.disabled && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
1858
+ action: name,
1859
+ level: scan.level,
1860
+ disabled: opts.disabled,
1861
+ onDisabledRows: opts.onDisabledRows ?? "reject",
1862
+ table: opts.table
1863
+ }))(target, propertyKey, descriptor);
1864
+ else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: opts.table }))(target, propertyKey, descriptor);
1865
+ return descriptor;
1866
+ });
1867
+ }
1868
+ //#endregion
1869
+ //#region src/actions/db-action-default.decorator.ts
1870
+ /**
1871
+ * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1872
+ * Equivalent to passing `opts.default = true`. Decorator order does not matter.
1873
+ */
1874
+ function DbActionDefault() {
1875
+ return getMoostMate().decorate((current) => {
1876
+ const meta = current;
1877
+ return {
1878
+ ...current,
1879
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
1880
+ };
1881
+ });
1882
+ }
1883
+ //#endregion
1884
+ //#region src/actions/pk-source.ts
1885
+ /**
1886
+ * Build a parameter decorator that reads its value from the cached PK wook
1887
+ * (single or multi). Validation runs inside the wook factory exactly once
1888
+ * per request, regardless of how many readers consume the value (`@DbActionPK*`
1889
+ * resolver, gate interceptor, cached row wook, in-handler composables).
1890
+ *
1891
+ * Marks the param so {@link discoverActions} can infer the action's `level`.
1892
+ */
1893
+ function createPkParamDecorator(kind) {
1894
+ const mate = getMoostMate();
1895
+ const slot = kind === "pk" ? dbActionPkSlot : dbActionPksSlot;
1896
+ const resolverName = kind === "pk" ? "dbActionPk" : "dbActionPks";
1897
+ return ApplyDecorators(mate.decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => current().get(slot), resolverName));
1898
+ }
1899
+ //#endregion
1633
1900
  //#region src/actions/db-action-pk.decorator.ts
1634
1901
  /**
1635
1902
  * Parameter resolver that reads the primary key from the JSON request body
@@ -1644,9 +1911,13 @@ function isPlainObject(value) {
1644
1911
  *
1645
1912
  * Marks the param so {@link discoverActions} can infer the action's `level`
1646
1913
  * as `'row'`.
1914
+ *
1915
+ * Implementation note: the resolver is a thin reader of the cached PK wook
1916
+ * — validation logic lives in the wook factory, which runs once per request
1917
+ * regardless of how many readers consume the value.
1647
1918
  */
1648
1919
  function DbActionPK() {
1649
- return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
1920
+ return createPkParamDecorator("pk");
1650
1921
  }
1651
1922
  //#endregion
1652
1923
  //#region src/actions/db-action-pks.decorator.ts
@@ -1659,9 +1930,44 @@ function DbActionPK() {
1659
1930
  *
1660
1931
  * Validation is strict — no type coercion. Marks the param so
1661
1932
  * {@link discoverActions} can infer the action's `level` as `'rows'`.
1933
+ *
1934
+ * In `'rows'` skip mode the resolved value reflects the gate interceptor's
1935
+ * filtered subset (the cached PK slot is overwritten in place); see
1936
+ * {@link dbActionPksSlot} for precedence details.
1662
1937
  */
1663
1938
  function DbActionPKs() {
1664
- return createPkParamDecorator("pks", validateMultiPk, "dbActionPks");
1939
+ return createPkParamDecorator("pks");
1940
+ }
1941
+ //#endregion
1942
+ //#region src/actions/db-action-row.decorator.ts
1943
+ function createRowParamDecorator(metaKey, slot, resolverName) {
1944
+ return ApplyDecorators(getMoostMate().decorate(metaKey, true), Resolve(async () => current().get(slot), resolverName));
1945
+ }
1946
+ /**
1947
+ * Parameter decorator that injects the row whose PK was supplied in the
1948
+ * request body. Reads from the cached row wook (fetched once per request,
1949
+ * shared with the gate interceptor when `disabled` is set).
1950
+ *
1951
+ * Marks the param so {@link discoverActions} infers the action's `level` as
1952
+ * `'row'`. Co-occurrence with `@DbActionRows()` (or any multi-cardinality
1953
+ * decorator) drops the action with a warning.
1954
+ *
1955
+ * In `'skip'` mode this returns the gate's filtered row; the original
1956
+ * request-body row is not retrievable.
1957
+ */
1958
+ function DbActionRow() {
1959
+ return createRowParamDecorator(MOOST_DB_ACTION_ROW, dbActionRowSlot, "dbActionRow");
1960
+ }
1961
+ /**
1962
+ * Parameter decorator that injects the rows-array fetched by primary keys
1963
+ * from the request body. Reads from the cached row-array wook.
1964
+ *
1965
+ * Marks the param so {@link discoverActions} infers the action's `level` as
1966
+ * `'rows'`. In `'rows'` + `'skip'` mode the resolved value contains only the
1967
+ * gate's surviving rows.
1968
+ */
1969
+ function DbActionRows() {
1970
+ return createRowParamDecorator(MOOST_DB_ACTION_ROWS, dbActionRowsSlot, "dbActionRows");
1665
1971
  }
1666
1972
  //#endregion
1667
1973
  //#region src/actions/db-actions.decorator.ts
@@ -1697,11 +2003,16 @@ function DbRowsActions(dict) {
1697
2003
  }
1698
2004
  function classLevelActions(dict, forcedLevel) {
1699
2005
  const entries = [];
1700
- for (const [name, entry] of Object.entries(dict)) entries.push({
1701
- name,
1702
- entry,
1703
- forcedLevel
1704
- });
2006
+ for (const [name, entry] of Object.entries(dict)) {
2007
+ const merged = forcedLevel ? {
2008
+ ...entry,
2009
+ level: forcedLevel
2010
+ } : entry;
2011
+ entries.push({
2012
+ name,
2013
+ entry: merged
2014
+ });
2015
+ }
1705
2016
  return getMoostMate().decorate((current) => {
1706
2017
  const existing = current["atscript_db_actions"] ?? [];
1707
2018
  return {
@@ -1711,4 +2022,4 @@ function classLevelActions(dict, forcedLevel) {
1711
2022
  });
1712
2023
  }
1713
2024
  //#endregion
1714
- export { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionPK, DbActionPKs, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, validationErrorTransform };
2025
+ export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionPK, DbActionPKs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, useDbActionPk, useDbActionPks, useDbActionRow, useDbActionRows, validationErrorTransform };