@atscript/moost-db 0.1.56 → 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`);
319
+ continue;
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`);
259
323
  continue;
260
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];
@@ -472,13 +560,6 @@ let AsReadableController = class AsReadableController {
472
560
  }
473
561
  };
474
562
  }
475
- /**
476
- * Whether this controller is read-only (no write endpoints).
477
- * Returns `true` by default; {@link AsDbController} overrides to `false`.
478
- */
479
- _isReadOnly() {
480
- return true;
481
- }
482
563
  _queryControlsValidator;
483
564
  _pagesControlsValidator;
484
565
  _getOneControlsValidator;
@@ -538,37 +619,31 @@ let AsReadableController = class AsReadableController {
538
619
  return item;
539
620
  }
540
621
  /**
541
- * **GET /meta** — returns the bound interface's metadata envelope.
542
- *
543
- * Base implementation delegates to {@link buildMetaResponse}, which subclasses
544
- * override to add source-specific fields (relations, searchable flags, etc.).
545
- * The response is cached on the instance; async overrides must cache any
546
- * extra enrichment themselves.
622
+ * **GET /meta** — returns the bound interface's metadata envelope. The
623
+ * static envelope is cached; {@link applyMetaOverlay} runs per request so
624
+ * subclasses can prune the response by principal.
547
625
  */
548
626
  async meta() {
549
- if (this._metaResponse) return this._metaResponse;
550
- const response = await this.buildMetaResponse();
551
- this._metaResponse = response;
552
- return response;
627
+ if (!this._metaResponse) this._metaResponse = this.buildMetaResponse();
628
+ return this.applyMetaOverlay(this._metaResponse);
553
629
  }
554
630
  /**
555
631
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
556
- * fields. Defaults return a minimal envelope with the serialized type and the
557
- * read-only flag; value-help dicts populate their capability hints here.
558
- * Subclasses that fully replace the envelope must call {@link buildActions}
559
- * directly so `@DbAction*` decorators still surface.
632
+ * fields. Subclasses that fully replace the envelope must call
633
+ * {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
634
+ * decorators and CRUD permissions still surface.
560
635
  */
561
- async buildMetaResponse() {
636
+ buildMetaResponse() {
562
637
  return {
563
638
  searchable: false,
564
639
  vectorSearchable: false,
565
640
  searchIndexes: [],
566
641
  primaryKeys: [],
567
- readOnly: this._isReadOnly(),
568
642
  relations: [],
569
643
  fields: {},
570
644
  type: this.getSerializedType(),
571
- actions: this.buildActions()
645
+ actions: this.buildActions(),
646
+ crud: this.buildCrud()
572
647
  };
573
648
  }
574
649
  /**
@@ -579,6 +654,25 @@ let AsReadableController = class AsReadableController {
579
654
  buildActions() {
580
655
  return discoverActions(this.constructor, this.app, this.logger);
581
656
  }
657
+ /**
658
+ * Declares the built-in CRUD operations this controller exposes. Subclasses
659
+ * override to add their keys; the bare base only exposes `/meta`. See
660
+ * `docs/http/permissions.md` for the wire shape and overlay rules.
661
+ */
662
+ buildCrud() {
663
+ return {};
664
+ }
665
+ /**
666
+ * Per-request overlay applied to the cached `/meta` envelope. Default no-op.
667
+ * Subclasses may shallow-clone and prune `crud` keys, `crud[op]` arrays, or
668
+ * `actions[]` based on the current request principal (read via Moost
669
+ * composables). The cached envelope MUST NOT be mutated — see
670
+ * `docs/http/permissions.md` for the full contract, including the
671
+ * "discoverability only" caveat.
672
+ */
673
+ applyMetaOverlay(meta) {
674
+ return meta;
675
+ }
582
676
  };
583
677
  __decorate([
584
678
  Get("meta"),
@@ -657,6 +751,23 @@ const ReadableController = (readable, prefix) => {
657
751
  */
658
752
  const ViewController = ReadableController;
659
753
  //#endregion
754
+ //#region src/permissions/crud-controls.ts
755
+ /**
756
+ * Static control whitelists per read op. Each list is the matching DTO's
757
+ * `$`-properties (stripped) plus the URL-grammar extras (`filter`,
758
+ * `insights`, `groupBy`) that bypass the DTO. The DTO is the single source of
759
+ * truth — adding a `$control` there auto-extends the whitelist here.
760
+ */
761
+ const dtoControls = (Dto) => [...Dto.type.props.keys()].map((k) => k.startsWith("$") ? k.slice(1) : k);
762
+ const QUERY_CONTROLS = [
763
+ "filter",
764
+ "insights",
765
+ ...dtoControls(QueryControlsDto),
766
+ "groupBy"
767
+ ];
768
+ const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
769
+ const ONE_CONTROLS = dtoControls(GetOneControlsDto);
770
+ //#endregion
660
771
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
661
772
  function __decorateParam(paramIndex, decorator) {
662
773
  return function(target, key) {
@@ -906,7 +1017,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
906
1017
  * vector-searchable flags, field-descriptor-derived filter/sort hints, and
907
1018
  * the configured primary keys.
908
1019
  */
909
- async buildMetaResponse() {
1020
+ buildMetaResponse() {
910
1021
  const relations = [];
911
1022
  for (const [name, rel] of this.readable.relations) relations.push({
912
1023
  name,
@@ -931,11 +1042,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
931
1042
  vectorSearchable: this.readable.isVectorSearchable(),
932
1043
  searchIndexes: this.readable.getSearchIndexes(),
933
1044
  primaryKeys: [...this.readable.primaryKeys],
934
- readOnly: this._isReadOnly(),
935
1045
  relations,
936
1046
  fields,
937
1047
  type: this.getSerializedType(),
938
- actions: this.buildActions()
1048
+ actions: this.buildActions(),
1049
+ crud: this.buildCrud()
1050
+ };
1051
+ }
1052
+ buildCrud() {
1053
+ return {
1054
+ ...super.buildCrud(),
1055
+ query: [...QUERY_CONTROLS],
1056
+ pages: [...PAGES_CONTROLS],
1057
+ one: [...ONE_CONTROLS]
939
1058
  };
940
1059
  }
941
1060
  };
@@ -974,6 +1093,7 @@ AsDbReadableController = __decorate([
974
1093
  __decorateParam(0, Inject(READABLE_DEF)),
975
1094
  __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$3 : Object])
976
1095
  ], AsDbReadableController);
1096
+ registerAsDbReadableController(AsDbReadableController);
977
1097
  //#endregion
978
1098
  //#region src/as-db.controller.ts
979
1099
  var _ref$2, _ref2$1;
@@ -985,8 +1105,14 @@ let AsDbController = class AsDbController extends AsDbReadableController {
985
1105
  constructor(table, app) {
986
1106
  super(table, app);
987
1107
  }
988
- _isReadOnly() {
989
- return false;
1108
+ buildCrud() {
1109
+ return {
1110
+ ...super.buildCrud(),
1111
+ insert: [],
1112
+ update: [],
1113
+ replace: [],
1114
+ remove: []
1115
+ };
990
1116
  }
991
1117
  /**
992
1118
  * Intercepts write operations. Return `undefined` to abort.
@@ -1203,7 +1329,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1203
1329
  * client picker UI (which controls to render); the server does not enforce
1204
1330
  * these flags at request time.
1205
1331
  */
1206
- async buildMetaResponse() {
1332
+ buildMetaResponse() {
1207
1333
  const fields = {};
1208
1334
  for (const [path, meta] of this.fieldMeta) fields[path] = {
1209
1335
  sortable: meta.has("ui.dict.sortable"),
@@ -1214,16 +1340,24 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1214
1340
  vectorSearchable: false,
1215
1341
  searchIndexes: [],
1216
1342
  primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1217
- readOnly: this._isReadOnly(),
1218
1343
  relations: [],
1219
1344
  fields,
1220
1345
  type: this.getSerializedType(),
1221
- actions: []
1346
+ actions: [],
1347
+ crud: this.buildCrud()
1222
1348
  };
1223
1349
  }
1224
1350
  buildActions() {
1225
1351
  return [];
1226
1352
  }
1353
+ buildCrud() {
1354
+ return {
1355
+ ...super.buildCrud(),
1356
+ query: [...QUERY_CONTROLS],
1357
+ pages: [...PAGES_CONTROLS],
1358
+ one: [...ONE_CONTROLS]
1359
+ };
1360
+ }
1227
1361
  };
1228
1362
  __decorate([
1229
1363
  Get("query"),
@@ -1258,6 +1392,7 @@ AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramt
1258
1392
  String,
1259
1393
  typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object
1260
1394
  ])], AsValueHelpController);
1395
+ registerAsValueHelpController(AsValueHelpController);
1261
1396
  //#endregion
1262
1397
  //#region src/as-json-value-help.controller.ts
1263
1398
  var _ref;
@@ -1418,89 +1553,43 @@ function applySelect(rows, select) {
1418
1553
  });
1419
1554
  }
1420
1555
  //#endregion
1421
- //#region src/actions/db-action.decorator.ts
1422
- /**
1423
- * Mark a controller method as a database action surfaced via `/meta`.
1424
- *
1425
- * Metadata-only — pair with `@Post(...)` for Moost to bind the route. The
1426
- * meta builder reads this metadata plus the bound POST path lazily and
1427
- * emits the action with `processor: 'backend'`. Order vs.
1428
- * `@DbActionDefault()` does not matter — both merge into the same slot.
1429
- *
1430
- * @example
1431
- * ```ts
1432
- * @Post('actions/block')
1433
- * @DbAction('block', { label: 'Block', icon: 'i-as-block', intent: 'negative' })
1434
- * async blockUser(@DbActionPK() id: string) { ... }
1435
- * ```
1436
- */
1437
- function DbAction(name, opts = {}) {
1438
- return getMoostMate().decorate((current) => {
1439
- const meta = current;
1440
- return {
1441
- ...current,
1442
- [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1443
- name,
1444
- opts
1445
- })
1446
- };
1447
- });
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`;
1448
1560
  }
1449
- //#endregion
1450
- //#region src/actions/db-action-default.decorator.ts
1451
1561
  /**
1452
- * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1453
- * 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).
1454
1570
  */
1455
- function DbActionDefault() {
1456
- return getMoostMate().decorate((current) => {
1457
- const meta = current;
1458
- return {
1459
- ...current,
1460
- [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
1461
1579
  };
1462
- });
1463
- }
1580
+ if (pks !== void 0) body.pks = pks;
1581
+ else if (pk !== void 0) body.pk = pk;
1582
+ super(409, body);
1583
+ }
1584
+ };
1464
1585
  //#endregion
1465
- //#region src/actions/pk-source.ts
1466
- /**
1467
- * Extract the PK validation source from a controller instance. Looks for
1468
- * `readable` (set by {@link AsDbReadableController}) or `table` (set by
1469
- * {@link AsDbController}).
1470
- *
1471
- * If the controller has no typed table attached (e.g. a value-help
1472
- * controller, or a plain Moost controller without `@TableController`),
1473
- * throws an HTTP 500 — this is a **server misconfiguration**, not a client
1474
- * error. The body parser has nothing to validate against, so the request
1475
- * cannot proceed. Use `@Body()` and parse the PK manually if you need to
1476
- * accept PK-shaped bodies on a controller without an attached table.
1477
- */
1478
- function resolvePkSource(controller) {
1479
- const c = controller;
1480
- const candidate = c.readable ?? c.table;
1481
- 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.");
1482
- return candidate;
1483
- }
1586
+ //#region src/actions/pk-validation.ts
1484
1587
  function isPkValidationSource(value) {
1485
1588
  if (!value || typeof value !== "object") return false;
1486
1589
  const v = value;
1487
1590
  return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1488
1591
  }
1489
1592
  /**
1490
- * Build a parameter decorator that parses the JSON request body, validates
1491
- * it against the bound table's PK schema with `validate`, and tags the param
1492
- * so {@link discoverActions} can infer the action's `level`.
1493
- */
1494
- function createPkParamDecorator(kind, validate, resolverName) {
1495
- return ApplyDecorators(getMoostMate().decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => {
1496
- const body = await useBody().parseBody();
1497
- validate(body, resolvePkSource(useControllerContext().getController()));
1498
- return body;
1499
- }, resolverName));
1500
- }
1501
- //#endregion
1502
- //#region src/actions/pk-validation.ts
1503
- /**
1504
1593
  * Validate a JSON-decoded body against a single-row PK shape (scalar or
1505
1594
  * composite). Throws {@link ValidatorError} with structured `errors` so the
1506
1595
  * existing validation interceptor returns HTTP 400.
@@ -1585,6 +1674,229 @@ function isPlainObject(value) {
1585
1674
  return typeof value === "object" && value !== null && !Array.isArray(value);
1586
1675
  }
1587
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
1588
1900
  //#region src/actions/db-action-pk.decorator.ts
1589
1901
  /**
1590
1902
  * Parameter resolver that reads the primary key from the JSON request body
@@ -1599,9 +1911,13 @@ function isPlainObject(value) {
1599
1911
  *
1600
1912
  * Marks the param so {@link discoverActions} can infer the action's `level`
1601
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.
1602
1918
  */
1603
1919
  function DbActionPK() {
1604
- return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
1920
+ return createPkParamDecorator("pk");
1605
1921
  }
1606
1922
  //#endregion
1607
1923
  //#region src/actions/db-action-pks.decorator.ts
@@ -1614,9 +1930,44 @@ function DbActionPK() {
1614
1930
  *
1615
1931
  * Validation is strict — no type coercion. Marks the param so
1616
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.
1617
1937
  */
1618
1938
  function DbActionPKs() {
1619
- 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");
1620
1971
  }
1621
1972
  //#endregion
1622
1973
  //#region src/actions/db-actions.decorator.ts
@@ -1652,11 +2003,16 @@ function DbRowsActions(dict) {
1652
2003
  }
1653
2004
  function classLevelActions(dict, forcedLevel) {
1654
2005
  const entries = [];
1655
- for (const [name, entry] of Object.entries(dict)) entries.push({
1656
- name,
1657
- entry,
1658
- forcedLevel
1659
- });
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
+ }
1660
2016
  return getMoostMate().decorate((current) => {
1661
2017
  const existing = current["atscript_db_actions"] ?? [];
1662
2018
  return {
@@ -1666,4 +2022,4 @@ function classLevelActions(dict, forcedLevel) {
1666
2022
  });
1667
2023
  }
1668
2024
  //#endregion
1669
- export { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionPK, DbActionPKs, DbActions, DbRowActions, DbRowsActions, DbTableActions, 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 };