@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.cjs CHANGED
@@ -4,6 +4,7 @@ let _moostjs_event_http = require("@moostjs/event-http");
4
4
  let moost = require("moost");
5
5
  let _uniqu_url = require("@uniqu/url");
6
6
  let _atscript_db = require("@atscript/db");
7
+ let _wooksjs_event_core = require("@wooksjs/event-core");
7
8
  let _wooksjs_http_body = require("@wooksjs/http-body");
8
9
  //#region src/validation-interceptor.ts
9
10
  const dbErrorCodeToStatus = { CONFLICT: 409 };
@@ -181,13 +182,39 @@ function findSortOffender(sort, isAllowed) {
181
182
  }
182
183
  }
183
184
  //#endregion
185
+ //#region src/actions/controller-registry.ts
186
+ let asDbReadableCtor = null;
187
+ let asValueHelpCtor = null;
188
+ function registerAsDbReadableController(ctor) {
189
+ asDbReadableCtor = ctor;
190
+ }
191
+ function registerAsValueHelpController(ctor) {
192
+ asValueHelpCtor = ctor;
193
+ }
194
+ function isAsDbReadableControllerSubclass(ctor) {
195
+ if (!asDbReadableCtor) return false;
196
+ return asDbReadableCtor.prototype.isPrototypeOf(ctor.prototype);
197
+ }
198
+ function isAsValueHelpControllerSubclass(ctor) {
199
+ if (!asValueHelpCtor) return false;
200
+ return asValueHelpCtor.prototype.isPrototypeOf(ctor.prototype);
201
+ }
202
+ function isAsDbReadableControllerInstance(value) {
203
+ return !!asDbReadableCtor && value instanceof asDbReadableCtor;
204
+ }
205
+ //#endregion
184
206
  //#region src/actions/keys.ts
207
+ /** Log-message prefix for warnings emitted from the actions subsystem. */
208
+ const WARN_PREFIX = "[moost-db actions]";
185
209
  /** Method-level metadata key — written by `@DbAction(name, opts)`. */
186
210
  const MOOST_DB_ACTION = "atscript_db_action";
187
211
  /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
188
212
  const MOOST_DB_ACTIONS = "atscript_db_actions";
189
213
  /** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
190
214
  const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
215
+ /** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
216
+ const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
217
+ const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
191
218
  /**
192
219
  * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
193
220
  * read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
@@ -205,17 +232,50 @@ function mergeActionMeta(current, patch) {
205
232
  };
206
233
  }
207
234
  //#endregion
235
+ //#region src/actions/param-level.ts
236
+ function scanParamLevel(params) {
237
+ let single = false;
238
+ let multi = false;
239
+ let hasRowParam = false;
240
+ let hasBody = false;
241
+ for (const p of params) {
242
+ const kind = p[MOOST_DB_ACTION_PARAM];
243
+ if (kind === "pk") single = true;
244
+ else if (kind === "pks") multi = true;
245
+ if (p["atscript_db_action_row"]) {
246
+ single = true;
247
+ hasRowParam = true;
248
+ }
249
+ if (p["atscript_db_action_rows"]) {
250
+ multi = true;
251
+ hasRowParam = true;
252
+ }
253
+ if (p.paramSource === "BODY") hasBody = true;
254
+ }
255
+ return {
256
+ level: single && multi ? "table" : single ? "row" : multi ? "rows" : "table",
257
+ single,
258
+ multi,
259
+ hasRowParam,
260
+ hasBody
261
+ };
262
+ }
263
+ //#endregion
208
264
  //#region src/actions/discover.ts
209
- /** Optional fields shared between method opts and class-level entries. */
265
+ /**
266
+ * Pure structural-copy fields. `disabled` and `requiredFields` are handled
267
+ * as special cases in {@link emitInfo} so the function-to-string transform
268
+ * stays out of the copy loop.
269
+ */
210
270
  const OPTIONAL_FIELDS = [
211
271
  "icon",
212
272
  "intent",
213
273
  "description",
214
274
  "order",
215
275
  "default",
216
- "promptText"
276
+ "promptText",
277
+ "shortcut"
217
278
  ];
218
- const WARN_PREFIX = "[moost-db actions]";
219
279
  const actionsCache = /* @__PURE__ */ new WeakMap();
220
280
  /**
221
281
  * Discover all actions declared on a controller and produce the `/meta` array.
@@ -256,9 +316,21 @@ function collectMethodActions(ctor, overview, logger, out) {
256
316
  const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
257
317
  if (!levelInfer) continue;
258
318
  if (levelInfer.bodyConflict) {
259
- logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs with @Body() — dropping`);
319
+ logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs/@DbActionRow*/@DbActionRows with @Body() — dropping`);
320
+ continue;
321
+ }
322
+ if (levelInfer.level === "table" && action.opts.disabled !== void 0) {
323
+ 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`);
260
324
  continue;
261
325
  }
326
+ if (action.opts.disabled !== void 0 || levelInfer.hasRowParam) {
327
+ const extendsReadable = isAsDbReadableControllerSubclass(ctor);
328
+ const hasOptsTable = action.opts.table != null;
329
+ if (!extendsReadable && !hasOptsTable) {
330
+ 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`);
331
+ continue;
332
+ }
333
+ }
262
334
  const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
263
335
  if (!postEntry) {
264
336
  logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
@@ -281,40 +353,32 @@ function collectMethodActions(ctor, overview, logger, out) {
281
353
  processor: "backend",
282
354
  value: path
283
355
  };
284
- copyOptionalFields(info, action.opts);
356
+ emitInfo(info, action.opts, action.name, logger);
285
357
  out.push(info);
286
358
  }
287
359
  }
288
360
  function inferMethodLevel(params, actionName, logger) {
289
- let hasPk = false;
290
- let hasPks = false;
291
- let hasBody = false;
292
- for (const p of params) {
293
- const kind = p[MOOST_DB_ACTION_PARAM];
294
- if (kind === "pk") hasPk = true;
295
- else if (kind === "pks") hasPks = true;
296
- if (p.paramSource === "BODY") hasBody = true;
297
- }
298
- if (hasPk && hasPks) {
299
- logger.warn(`${WARN_PREFIX} action "${actionName}" has both @DbActionPK and @DbActionPKs — dropping`);
361
+ const scan = scanParamLevel(params);
362
+ if (scan.single && scan.multi) {
363
+ logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionPK / @DbActionRow vs @DbActionPKs / @DbActionRows) — dropping`);
300
364
  return null;
301
365
  }
302
- const level = hasPk ? "row" : hasPks ? "rows" : "table";
303
366
  return {
304
- level,
305
- bodyConflict: hasBody && level !== "table"
367
+ level: scan.level,
368
+ bodyConflict: scan.hasBody && scan.level !== "table",
369
+ hasRowParam: scan.hasRowParam
306
370
  };
307
371
  }
308
372
  function collectClassActions(ctor, logger, out) {
309
373
  const list = (0, moost.getMoostMate)().read(ctor)?.[MOOST_DB_ACTIONS];
310
374
  if (!list) return;
311
- for (const { name, entry, forcedLevel } of list) {
312
- const built = buildClassEntry(name, entry, forcedLevel, logger);
375
+ for (const { name, entry } of list) {
376
+ const built = buildClassEntry(name, entry, logger);
313
377
  if (built) out.push(built);
314
378
  }
315
379
  }
316
- function buildClassEntry(name, entry, forcedLevel, logger) {
317
- const level = forcedLevel ?? entry.level;
380
+ function buildClassEntry(name, entry, logger) {
381
+ const level = entry.level;
318
382
  if (!level) {
319
383
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
320
384
  return null;
@@ -323,6 +387,10 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
323
387
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
324
388
  return null;
325
389
  }
390
+ if (level === "table" && entry.disabled !== void 0) {
391
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` is not allowed at the 'table' level — dropping`);
392
+ return null;
393
+ }
326
394
  const processor = entry.processor;
327
395
  let value;
328
396
  if (processor === "navigate" || processor === "backend") {
@@ -350,7 +418,7 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
350
418
  processor,
351
419
  value
352
420
  };
353
- copyOptionalFields(info, entry);
421
+ emitInfo(info, entry, name, logger);
354
422
  return info;
355
423
  }
356
424
  function applyDefaultPerLevel(actions, logger) {
@@ -364,6 +432,26 @@ function applyDefaultPerLevel(actions, logger) {
364
432
  } else winners.set(a.level, a.name);
365
433
  }
366
434
  }
435
+ /**
436
+ * Emit structural-copy fields plus `disabled` (stringified) and
437
+ * `requiredFields` (forwarded verbatim — server doesn't auto-derive).
438
+ * `requiredFields` without `disabled` is dropped with a warning before the
439
+ * structural copy runs, so method-decorator and class-level-dict origins
440
+ * stay symmetric.
441
+ */
442
+ function emitInfo(info, source, name, logger) {
443
+ const disabled = source.disabled;
444
+ const hasDisabled = typeof disabled === "function";
445
+ let requiredFields = source.requiredFields;
446
+ if (!hasDisabled && requiredFields !== void 0) {
447
+ 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.`);
448
+ requiredFields = void 0;
449
+ }
450
+ copyOptionalFields(info, source);
451
+ if (Array.isArray(info.promptText)) info.promptText = info.promptText.slice();
452
+ if (hasDisabled) info.disabled = disabled.toString();
453
+ if (Array.isArray(requiredFields)) info.requiredFields = requiredFields.slice();
454
+ }
367
455
  function copyOptionalFields(info, source) {
368
456
  for (const key of OPTIONAL_FIELDS) {
369
457
  const value = source[key];
@@ -473,13 +561,6 @@ let AsReadableController = class AsReadableController {
473
561
  }
474
562
  };
475
563
  }
476
- /**
477
- * Whether this controller is read-only (no write endpoints).
478
- * Returns `true` by default; {@link AsDbController} overrides to `false`.
479
- */
480
- _isReadOnly() {
481
- return true;
482
- }
483
564
  _queryControlsValidator;
484
565
  _pagesControlsValidator;
485
566
  _getOneControlsValidator;
@@ -539,37 +620,31 @@ let AsReadableController = class AsReadableController {
539
620
  return item;
540
621
  }
541
622
  /**
542
- * **GET /meta** — returns the bound interface's metadata envelope.
543
- *
544
- * Base implementation delegates to {@link buildMetaResponse}, which subclasses
545
- * override to add source-specific fields (relations, searchable flags, etc.).
546
- * The response is cached on the instance; async overrides must cache any
547
- * extra enrichment themselves.
623
+ * **GET /meta** — returns the bound interface's metadata envelope. The
624
+ * static envelope is cached; {@link applyMetaOverlay} runs per request so
625
+ * subclasses can prune the response by principal.
548
626
  */
549
627
  async meta() {
550
- if (this._metaResponse) return this._metaResponse;
551
- const response = await this.buildMetaResponse();
552
- this._metaResponse = response;
553
- return response;
628
+ if (!this._metaResponse) this._metaResponse = this.buildMetaResponse();
629
+ return this.applyMetaOverlay(this._metaResponse);
554
630
  }
555
631
  /**
556
632
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
557
- * fields. Defaults return a minimal envelope with the serialized type and the
558
- * read-only flag; value-help dicts populate their capability hints here.
559
- * Subclasses that fully replace the envelope must call {@link buildActions}
560
- * directly so `@DbAction*` decorators still surface.
633
+ * fields. Subclasses that fully replace the envelope must call
634
+ * {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
635
+ * decorators and CRUD permissions still surface.
561
636
  */
562
- async buildMetaResponse() {
637
+ buildMetaResponse() {
563
638
  return {
564
639
  searchable: false,
565
640
  vectorSearchable: false,
566
641
  searchIndexes: [],
567
642
  primaryKeys: [],
568
- readOnly: this._isReadOnly(),
569
643
  relations: [],
570
644
  fields: {},
571
645
  type: this.getSerializedType(),
572
- actions: this.buildActions()
646
+ actions: this.buildActions(),
647
+ crud: this.buildCrud()
573
648
  };
574
649
  }
575
650
  /**
@@ -580,6 +655,25 @@ let AsReadableController = class AsReadableController {
580
655
  buildActions() {
581
656
  return discoverActions(this.constructor, this.app, this.logger);
582
657
  }
658
+ /**
659
+ * Declares the built-in CRUD operations this controller exposes. Subclasses
660
+ * override to add their keys; the bare base only exposes `/meta`. See
661
+ * `docs/http/permissions.md` for the wire shape and overlay rules.
662
+ */
663
+ buildCrud() {
664
+ return {};
665
+ }
666
+ /**
667
+ * Per-request overlay applied to the cached `/meta` envelope. Default no-op.
668
+ * Subclasses may shallow-clone and prune `crud` keys, `crud[op]` arrays, or
669
+ * `actions[]` based on the current request principal (read via Moost
670
+ * composables). The cached envelope MUST NOT be mutated — see
671
+ * `docs/http/permissions.md` for the full contract, including the
672
+ * "discoverability only" caveat.
673
+ */
674
+ applyMetaOverlay(meta) {
675
+ return meta;
676
+ }
583
677
  };
584
678
  __decorate([
585
679
  (0, _moostjs_event_http.Get)("meta"),
@@ -658,6 +752,23 @@ const ReadableController = (readable, prefix) => {
658
752
  */
659
753
  const ViewController = ReadableController;
660
754
  //#endregion
755
+ //#region src/permissions/crud-controls.ts
756
+ /**
757
+ * Static control whitelists per read op. Each list is the matching DTO's
758
+ * `$`-properties (stripped) plus the URL-grammar extras (`filter`,
759
+ * `insights`, `groupBy`) that bypass the DTO. The DTO is the single source of
760
+ * truth — adding a `$control` there auto-extends the whitelist here.
761
+ */
762
+ const dtoControls = (Dto) => [...Dto.type.props.keys()].map((k) => k.startsWith("$") ? k.slice(1) : k);
763
+ const QUERY_CONTROLS = [
764
+ "filter",
765
+ "insights",
766
+ ...dtoControls(QueryControlsDto),
767
+ "groupBy"
768
+ ];
769
+ const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
770
+ const ONE_CONTROLS = dtoControls(GetOneControlsDto);
771
+ //#endregion
661
772
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
662
773
  function __decorateParam(paramIndex, decorator) {
663
774
  return function(target, key) {
@@ -907,7 +1018,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
907
1018
  * vector-searchable flags, field-descriptor-derived filter/sort hints, and
908
1019
  * the configured primary keys.
909
1020
  */
910
- async buildMetaResponse() {
1021
+ buildMetaResponse() {
911
1022
  const relations = [];
912
1023
  for (const [name, rel] of this.readable.relations) relations.push({
913
1024
  name,
@@ -932,11 +1043,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
932
1043
  vectorSearchable: this.readable.isVectorSearchable(),
933
1044
  searchIndexes: this.readable.getSearchIndexes(),
934
1045
  primaryKeys: [...this.readable.primaryKeys],
935
- readOnly: this._isReadOnly(),
936
1046
  relations,
937
1047
  fields,
938
1048
  type: this.getSerializedType(),
939
- actions: this.buildActions()
1049
+ actions: this.buildActions(),
1050
+ crud: this.buildCrud()
1051
+ };
1052
+ }
1053
+ buildCrud() {
1054
+ return {
1055
+ ...super.buildCrud(),
1056
+ query: [...QUERY_CONTROLS],
1057
+ pages: [...PAGES_CONTROLS],
1058
+ one: [...ONE_CONTROLS]
940
1059
  };
941
1060
  }
942
1061
  };
@@ -975,6 +1094,7 @@ AsDbReadableController = __decorate([
975
1094
  __decorateParam(0, (0, moost.Inject)(READABLE_DEF)),
976
1095
  __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof moost.Moost !== "undefined" && moost.Moost) === "function" ? _ref$3 : Object])
977
1096
  ], AsDbReadableController);
1097
+ registerAsDbReadableController(AsDbReadableController);
978
1098
  //#endregion
979
1099
  //#region src/as-db.controller.ts
980
1100
  var _ref$2, _ref2$1;
@@ -986,8 +1106,14 @@ let AsDbController = class AsDbController extends AsDbReadableController {
986
1106
  constructor(table, app) {
987
1107
  super(table, app);
988
1108
  }
989
- _isReadOnly() {
990
- return false;
1109
+ buildCrud() {
1110
+ return {
1111
+ ...super.buildCrud(),
1112
+ insert: [],
1113
+ update: [],
1114
+ replace: [],
1115
+ remove: []
1116
+ };
991
1117
  }
992
1118
  /**
993
1119
  * Intercepts write operations. Return `undefined` to abort.
@@ -1204,7 +1330,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1204
1330
  * client picker UI (which controls to render); the server does not enforce
1205
1331
  * these flags at request time.
1206
1332
  */
1207
- async buildMetaResponse() {
1333
+ buildMetaResponse() {
1208
1334
  const fields = {};
1209
1335
  for (const [path, meta] of this.fieldMeta) fields[path] = {
1210
1336
  sortable: meta.has("ui.dict.sortable"),
@@ -1215,16 +1341,24 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1215
1341
  vectorSearchable: false,
1216
1342
  searchIndexes: [],
1217
1343
  primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1218
- readOnly: this._isReadOnly(),
1219
1344
  relations: [],
1220
1345
  fields,
1221
1346
  type: this.getSerializedType(),
1222
- actions: []
1347
+ actions: [],
1348
+ crud: this.buildCrud()
1223
1349
  };
1224
1350
  }
1225
1351
  buildActions() {
1226
1352
  return [];
1227
1353
  }
1354
+ buildCrud() {
1355
+ return {
1356
+ ...super.buildCrud(),
1357
+ query: [...QUERY_CONTROLS],
1358
+ pages: [...PAGES_CONTROLS],
1359
+ one: [...ONE_CONTROLS]
1360
+ };
1361
+ }
1228
1362
  };
1229
1363
  __decorate([
1230
1364
  (0, _moostjs_event_http.Get)("query"),
@@ -1259,6 +1393,7 @@ AsValueHelpController = __decorate([(0, moost.Inherit)(), __decorateMetadata("de
1259
1393
  String,
1260
1394
  typeof (_ref$1 = typeof moost.Moost !== "undefined" && moost.Moost) === "function" ? _ref$1 : Object
1261
1395
  ])], AsValueHelpController);
1396
+ registerAsValueHelpController(AsValueHelpController);
1262
1397
  //#endregion
1263
1398
  //#region src/as-json-value-help.controller.ts
1264
1399
  var _ref;
@@ -1419,89 +1554,43 @@ function applySelect(rows, select) {
1419
1554
  });
1420
1555
  }
1421
1556
  //#endregion
1422
- //#region src/actions/db-action.decorator.ts
1423
- /**
1424
- * Mark a controller method as a database action surfaced via `/meta`.
1425
- *
1426
- * Metadata-only — pair with `@Post(...)` for Moost to bind the route. The
1427
- * meta builder reads this metadata plus the bound POST path lazily and
1428
- * emits the action with `processor: 'backend'`. Order vs.
1429
- * `@DbActionDefault()` does not matter — both merge into the same slot.
1430
- *
1431
- * @example
1432
- * ```ts
1433
- * @Post('actions/block')
1434
- * @DbAction('block', { label: 'Block', icon: 'i-as-block', intent: 'negative' })
1435
- * async blockUser(@DbActionPK() id: string) { ... }
1436
- * ```
1437
- */
1438
- function DbAction(name, opts = {}) {
1439
- return (0, moost.getMoostMate)().decorate((current) => {
1440
- const meta = current;
1441
- return {
1442
- ...current,
1443
- [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1444
- name,
1445
- opts
1446
- })
1447
- };
1448
- });
1557
+ //#region src/actions/action-disabled-error.ts
1558
+ function buildMessage(action, pks) {
1559
+ if (pks !== void 0) return `Action "${action}" is disabled for ${pks.length} of the selected rows`;
1560
+ return `Action "${action}" is disabled for this row`;
1449
1561
  }
1450
- //#endregion
1451
- //#region src/actions/db-action-default.decorator.ts
1452
1562
  /**
1453
- * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1454
- * Equivalent to passing `opts.default = true`. Decorator order does not matter.
1563
+ * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1564
+ * with Moost's existing error mapper to produce HTTP 409 with the wire body
1565
+ * defined by {@link ActionDisabledErrorBody}.
1566
+ *
1567
+ * - `'row'`-level rejection: pass `(action, pk)` — the body emits `pk`.
1568
+ * - `'rows'`-level rejection: pass `(action, undefined, pks)` — the body
1569
+ * emits `pks` (the FULL list of failing PKs in reject mode; the FULL list
1570
+ * of request PKs in skip mode with zero survivors).
1455
1571
  */
1456
- function DbActionDefault() {
1457
- return (0, moost.getMoostMate)().decorate((current) => {
1458
- const meta = current;
1459
- return {
1460
- ...current,
1461
- [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
1572
+ var ActionDisabledError = class extends _moostjs_event_http.HttpError {
1573
+ name = "ActionDisabledError";
1574
+ constructor(action, pk, pks) {
1575
+ const body = {
1576
+ name: "ActionDisabledError",
1577
+ message: buildMessage(action, pks),
1578
+ statusCode: 409,
1579
+ action
1462
1580
  };
1463
- });
1464
- }
1581
+ if (pks !== void 0) body.pks = pks;
1582
+ else if (pk !== void 0) body.pk = pk;
1583
+ super(409, body);
1584
+ }
1585
+ };
1465
1586
  //#endregion
1466
- //#region src/actions/pk-source.ts
1467
- /**
1468
- * Extract the PK validation source from a controller instance. Looks for
1469
- * `readable` (set by {@link AsDbReadableController}) or `table` (set by
1470
- * {@link AsDbController}).
1471
- *
1472
- * If the controller has no typed table attached (e.g. a value-help
1473
- * controller, or a plain Moost controller without `@TableController`),
1474
- * throws an HTTP 500 — this is a **server misconfiguration**, not a client
1475
- * error. The body parser has nothing to validate against, so the request
1476
- * cannot proceed. Use `@Body()` and parse the PK manually if you need to
1477
- * accept PK-shaped bodies on a controller without an attached table.
1478
- */
1479
- function resolvePkSource(controller) {
1480
- const c = controller;
1481
- const candidate = c.readable ?? c.table;
1482
- if (!isPkValidationSource(candidate)) throw new _moostjs_event_http.HttpError(500, "@DbActionPK/@DbActionPKs requires a controller with an attached table (via @TableController / @ReadableController). Use @Body() instead if your controller has no typed table.");
1483
- return candidate;
1484
- }
1587
+ //#region src/actions/pk-validation.ts
1485
1588
  function isPkValidationSource(value) {
1486
1589
  if (!value || typeof value !== "object") return false;
1487
1590
  const v = value;
1488
1591
  return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1489
1592
  }
1490
1593
  /**
1491
- * Build a parameter decorator that parses the JSON request body, validates
1492
- * it against the bound table's PK schema with `validate`, and tags the param
1493
- * so {@link discoverActions} can infer the action's `level`.
1494
- */
1495
- function createPkParamDecorator(kind, validate, resolverName) {
1496
- return (0, moost.ApplyDecorators)((0, moost.getMoostMate)().decorate(MOOST_DB_ACTION_PARAM, kind), (0, moost.Resolve)(async () => {
1497
- const body = await (0, _wooksjs_http_body.useBody)().parseBody();
1498
- validate(body, resolvePkSource((0, moost.useControllerContext)().getController()));
1499
- return body;
1500
- }, resolverName));
1501
- }
1502
- //#endregion
1503
- //#region src/actions/pk-validation.ts
1504
- /**
1505
1594
  * Validate a JSON-decoded body against a single-row PK shape (scalar or
1506
1595
  * composite). Throws {@link ValidatorError} with structured `errors` so the
1507
1596
  * existing validation interceptor returns HTTP 400.
@@ -1586,6 +1675,229 @@ function isPlainObject(value) {
1586
1675
  return typeof value === "object" && value !== null && !Array.isArray(value);
1587
1676
  }
1588
1677
  //#endregion
1678
+ //#region src/actions/pk-cache.ts
1679
+ const boundTableKey = (0, _wooksjs_event_core.key)("atscript_db_action_bound_table");
1680
+ function getActionTable(ctx) {
1681
+ if (ctx.has(boundTableKey)) {
1682
+ const fromSlot = ctx.get(boundTableKey);
1683
+ if (fromSlot) return fromSlot;
1684
+ }
1685
+ const ctrl = (0, moost.useControllerContext)(ctx).getController();
1686
+ if (ctrl) {
1687
+ const t = ctrl.readable ?? ctrl.table;
1688
+ if (t) return t;
1689
+ }
1690
+ return null;
1691
+ }
1692
+ function noTableError(ctx) {
1693
+ const cc = (0, moost.useControllerContext)(ctx);
1694
+ const ctrl = cc.getController();
1695
+ const methodName = cc.getMethod();
1696
+ let actionName;
1697
+ if (ctrl && methodName) actionName = (0, moost.getMoostMate)().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION]?.name;
1698
+ return new _moostjs_event_http.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.`);
1699
+ }
1700
+ async function resolveValidatedPk(ctx, validate) {
1701
+ const table = getActionTable(ctx);
1702
+ if (!isPkValidationSource(table)) throw noTableError(ctx);
1703
+ const body = await (0, _wooksjs_http_body.useBody)(ctx).parseBody();
1704
+ validate(body, table);
1705
+ return body;
1706
+ }
1707
+ const dbActionPkSlot = (0, _wooksjs_event_core.cached)((ctx) => resolveValidatedPk(ctx, validateSinglePk));
1708
+ const dbActionPksSlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
1709
+ return await resolveValidatedPk(ctx, validateMultiPk);
1710
+ });
1711
+ const useDbActionPk = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionPkSlot) }));
1712
+ const useDbActionPks = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionPksSlot) }));
1713
+ //#endregion
1714
+ //#region src/actions/row-cache.ts
1715
+ function asFetchTable(value) {
1716
+ if (!value || typeof value !== "object") return null;
1717
+ const v = value;
1718
+ return Array.isArray(v.primaryKeys) && typeof v.findById === "function" && typeof v.findMany === "function" ? v : null;
1719
+ }
1720
+ function noTable() {
1721
+ throw new _moostjs_event_http.HttpError(500, `${WARN_PREFIX} cached row wook: no bound table`);
1722
+ }
1723
+ async function loadRow(ctx) {
1724
+ const pk = await ctx.get(dbActionPkSlot);
1725
+ const row = await (asFetchTable(getActionTable(ctx)) ?? noTable()).findById(pk);
1726
+ if (row == null) throw new _moostjs_event_http.HttpError(404, "Row not found for action PK");
1727
+ return row;
1728
+ }
1729
+ async function loadRows(ctx) {
1730
+ const pks = await ctx.get(dbActionPksSlot);
1731
+ const table = asFetchTable(getActionTable(ctx)) ?? noTable();
1732
+ if (pks.length === 0) return [];
1733
+ const { primaryKeys } = table;
1734
+ const rows = await table.findMany({ filter: buildPksFilter(pks, primaryKeys) });
1735
+ if (primaryKeys.length === 1) {
1736
+ const field = primaryKeys[0];
1737
+ const byKey = /* @__PURE__ */ new Map();
1738
+ for (const row of rows) byKey.set(row[field], row);
1739
+ const ordered = [];
1740
+ for (const pk of pks) {
1741
+ const found = byKey.get(pk);
1742
+ if (found !== void 0) ordered.push(found);
1743
+ }
1744
+ return ordered;
1745
+ }
1746
+ const byKey = /* @__PURE__ */ new Map();
1747
+ for (const row of rows) byKey.set(compositeKey(row, primaryKeys), row);
1748
+ const ordered = [];
1749
+ for (const pk of pks) {
1750
+ const found = byKey.get(compositeKey(pk, primaryKeys));
1751
+ if (found !== void 0) ordered.push(found);
1752
+ }
1753
+ return ordered;
1754
+ }
1755
+ function buildPksFilter(pks, primaryKeys) {
1756
+ if (primaryKeys.length === 1) return { [primaryKeys[0]]: { $in: pks } };
1757
+ return { $or: pks.map((pk) => {
1758
+ const obj = pk;
1759
+ const clause = {};
1760
+ for (const field of primaryKeys) clause[field] = obj[field];
1761
+ return clause;
1762
+ }) };
1763
+ }
1764
+ function compositeKey(obj, primaryKeys) {
1765
+ let out = "";
1766
+ for (const f of primaryKeys) {
1767
+ if (out !== "") out += "\0";
1768
+ const v = obj[f];
1769
+ if (v === null) out += "n";
1770
+ else if (v === void 0) out += "u";
1771
+ else if (typeof v === "string") out += `s\x02${v}`;
1772
+ else if (typeof v === "number") out += `n\x02${v}`;
1773
+ else if (typeof v === "boolean") out += `b\x02${v}`;
1774
+ else out += `j\x02${JSON.stringify(v)}`;
1775
+ }
1776
+ return out;
1777
+ }
1778
+ const dbActionRowSlot = (0, _wooksjs_event_core.cached)((ctx) => loadRow(ctx));
1779
+ const dbActionRowsSlot = (0, _wooksjs_event_core.cached)((ctx) => loadRows(ctx));
1780
+ const useDbActionRow = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionRowSlot) }));
1781
+ const useDbActionRows = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionRowsSlot) }));
1782
+ //#endregion
1783
+ //#region src/actions/gate-interceptor.ts
1784
+ const GATE_PRIORITY = moost.TInterceptorPriority.AFTER_GUARD;
1785
+ function injectBoundTable(table) {
1786
+ const ctx = (0, _wooksjs_event_core.current)();
1787
+ if (ctx.has(boundTableKey)) return;
1788
+ const controller = (0, moost.useControllerContext)(ctx).getController();
1789
+ if (isAsDbReadableControllerInstance(controller)) {
1790
+ ctx.set(boundTableKey, controller.readable);
1791
+ return;
1792
+ }
1793
+ if (table != null) ctx.set(boundTableKey, table);
1794
+ }
1795
+ function buildGateInterceptor(opts) {
1796
+ const { action, level, disabled, onDisabledRows, table } = opts;
1797
+ return (0, moost.defineBeforeInterceptor)(async () => {
1798
+ injectBoundTable(table);
1799
+ const ctx = (0, _wooksjs_event_core.current)();
1800
+ if (level === "row") {
1801
+ if (disabled(await ctx.get(dbActionRowSlot))) throw new ActionDisabledError(action, await ctx.get(dbActionPkSlot));
1802
+ return;
1803
+ }
1804
+ const pks = await ctx.get(dbActionPksSlot);
1805
+ const rows = await ctx.get(dbActionRowsSlot);
1806
+ const failingPks = [];
1807
+ const passingRows = [];
1808
+ const passingPks = [];
1809
+ for (let i = 0; i < rows.length; i++) if (disabled(rows[i])) failingPks.push(pks[i]);
1810
+ else {
1811
+ passingRows.push(rows[i]);
1812
+ passingPks.push(pks[i]);
1813
+ }
1814
+ if (onDisabledRows === "skip") {
1815
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...pks]);
1816
+ if (failingPks.length > 0) {
1817
+ ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
1818
+ ctx.set(dbActionPksSlot, Promise.resolve(passingPks));
1819
+ }
1820
+ return;
1821
+ }
1822
+ if (failingPks.length > 0) throw new ActionDisabledError(action, void 0, failingPks);
1823
+ }, GATE_PRIORITY);
1824
+ }
1825
+ /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
1826
+ function buildThinInterceptor(opts) {
1827
+ const { table } = opts;
1828
+ return (0, moost.defineBeforeInterceptor)(() => {
1829
+ injectBoundTable(table);
1830
+ }, GATE_PRIORITY);
1831
+ }
1832
+ //#endregion
1833
+ //#region src/actions/db-action.decorator.ts
1834
+ /**
1835
+ * Mark a controller method as a database action surfaced via `/meta`. Writes
1836
+ * `MOOST_DB_ACTION` metadata and registers a Moost interceptor when needed
1837
+ * (gate when `disabled` is set, thin bound-table injector when only
1838
+ * `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
1839
+ * is undefined and emits a warning.
1840
+ */
1841
+ function DbAction(name, opts = {}) {
1842
+ const mate = (0, moost.getMoostMate)();
1843
+ return ((target, propertyKey, descriptor) => {
1844
+ const priorName = mate.read(target, propertyKey)?.[MOOST_DB_ACTION]?.name;
1845
+ if (priorName) console.warn(`${WARN_PREFIX} stacking @DbAction on the same method is undefined; declare one per method. Detected: "${priorName}" and "${name}".`);
1846
+ mate.decorate((current) => {
1847
+ const meta = current;
1848
+ return {
1849
+ ...current,
1850
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1851
+ name,
1852
+ opts
1853
+ })
1854
+ };
1855
+ })(target, propertyKey, descriptor);
1856
+ if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
1857
+ const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
1858
+ if (!!opts.disabled && (scan.level === "row" || scan.level === "rows")) (0, moost.Intercept)(buildGateInterceptor({
1859
+ action: name,
1860
+ level: scan.level,
1861
+ disabled: opts.disabled,
1862
+ onDisabledRows: opts.onDisabledRows ?? "reject",
1863
+ table: opts.table
1864
+ }))(target, propertyKey, descriptor);
1865
+ else if (scan.hasRowParam) (0, moost.Intercept)(buildThinInterceptor({ table: opts.table }))(target, propertyKey, descriptor);
1866
+ return descriptor;
1867
+ });
1868
+ }
1869
+ //#endregion
1870
+ //#region src/actions/db-action-default.decorator.ts
1871
+ /**
1872
+ * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1873
+ * Equivalent to passing `opts.default = true`. Decorator order does not matter.
1874
+ */
1875
+ function DbActionDefault() {
1876
+ return (0, moost.getMoostMate)().decorate((current) => {
1877
+ const meta = current;
1878
+ return {
1879
+ ...current,
1880
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
1881
+ };
1882
+ });
1883
+ }
1884
+ //#endregion
1885
+ //#region src/actions/pk-source.ts
1886
+ /**
1887
+ * Build a parameter decorator that reads its value from the cached PK wook
1888
+ * (single or multi). Validation runs inside the wook factory exactly once
1889
+ * per request, regardless of how many readers consume the value (`@DbActionPK*`
1890
+ * resolver, gate interceptor, cached row wook, in-handler composables).
1891
+ *
1892
+ * Marks the param so {@link discoverActions} can infer the action's `level`.
1893
+ */
1894
+ function createPkParamDecorator(kind) {
1895
+ const mate = (0, moost.getMoostMate)();
1896
+ const slot = kind === "pk" ? dbActionPkSlot : dbActionPksSlot;
1897
+ const resolverName = kind === "pk" ? "dbActionPk" : "dbActionPks";
1898
+ return (0, moost.ApplyDecorators)(mate.decorate(MOOST_DB_ACTION_PARAM, kind), (0, moost.Resolve)(async () => (0, _wooksjs_event_core.current)().get(slot), resolverName));
1899
+ }
1900
+ //#endregion
1589
1901
  //#region src/actions/db-action-pk.decorator.ts
1590
1902
  /**
1591
1903
  * Parameter resolver that reads the primary key from the JSON request body
@@ -1600,9 +1912,13 @@ function isPlainObject(value) {
1600
1912
  *
1601
1913
  * Marks the param so {@link discoverActions} can infer the action's `level`
1602
1914
  * as `'row'`.
1915
+ *
1916
+ * Implementation note: the resolver is a thin reader of the cached PK wook
1917
+ * — validation logic lives in the wook factory, which runs once per request
1918
+ * regardless of how many readers consume the value.
1603
1919
  */
1604
1920
  function DbActionPK() {
1605
- return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
1921
+ return createPkParamDecorator("pk");
1606
1922
  }
1607
1923
  //#endregion
1608
1924
  //#region src/actions/db-action-pks.decorator.ts
@@ -1615,9 +1931,44 @@ function DbActionPK() {
1615
1931
  *
1616
1932
  * Validation is strict — no type coercion. Marks the param so
1617
1933
  * {@link discoverActions} can infer the action's `level` as `'rows'`.
1934
+ *
1935
+ * In `'rows'` skip mode the resolved value reflects the gate interceptor's
1936
+ * filtered subset (the cached PK slot is overwritten in place); see
1937
+ * {@link dbActionPksSlot} for precedence details.
1618
1938
  */
1619
1939
  function DbActionPKs() {
1620
- return createPkParamDecorator("pks", validateMultiPk, "dbActionPks");
1940
+ return createPkParamDecorator("pks");
1941
+ }
1942
+ //#endregion
1943
+ //#region src/actions/db-action-row.decorator.ts
1944
+ function createRowParamDecorator(metaKey, slot, resolverName) {
1945
+ return (0, moost.ApplyDecorators)((0, moost.getMoostMate)().decorate(metaKey, true), (0, moost.Resolve)(async () => (0, _wooksjs_event_core.current)().get(slot), resolverName));
1946
+ }
1947
+ /**
1948
+ * Parameter decorator that injects the row whose PK was supplied in the
1949
+ * request body. Reads from the cached row wook (fetched once per request,
1950
+ * shared with the gate interceptor when `disabled` is set).
1951
+ *
1952
+ * Marks the param so {@link discoverActions} infers the action's `level` as
1953
+ * `'row'`. Co-occurrence with `@DbActionRows()` (or any multi-cardinality
1954
+ * decorator) drops the action with a warning.
1955
+ *
1956
+ * In `'skip'` mode this returns the gate's filtered row; the original
1957
+ * request-body row is not retrievable.
1958
+ */
1959
+ function DbActionRow() {
1960
+ return createRowParamDecorator(MOOST_DB_ACTION_ROW, dbActionRowSlot, "dbActionRow");
1961
+ }
1962
+ /**
1963
+ * Parameter decorator that injects the rows-array fetched by primary keys
1964
+ * from the request body. Reads from the cached row-array wook.
1965
+ *
1966
+ * Marks the param so {@link discoverActions} infers the action's `level` as
1967
+ * `'rows'`. In `'rows'` + `'skip'` mode the resolved value contains only the
1968
+ * gate's surviving rows.
1969
+ */
1970
+ function DbActionRows() {
1971
+ return createRowParamDecorator(MOOST_DB_ACTION_ROWS, dbActionRowsSlot, "dbActionRows");
1621
1972
  }
1622
1973
  //#endregion
1623
1974
  //#region src/actions/db-actions.decorator.ts
@@ -1653,11 +2004,16 @@ function DbRowsActions(dict) {
1653
2004
  }
1654
2005
  function classLevelActions(dict, forcedLevel) {
1655
2006
  const entries = [];
1656
- for (const [name, entry] of Object.entries(dict)) entries.push({
1657
- name,
1658
- entry,
1659
- forcedLevel
1660
- });
2007
+ for (const [name, entry] of Object.entries(dict)) {
2008
+ const merged = forcedLevel ? {
2009
+ ...entry,
2010
+ level: forcedLevel
2011
+ } : entry;
2012
+ entries.push({
2013
+ name,
2014
+ entry: merged
2015
+ });
2016
+ }
1661
2017
  return (0, moost.getMoostMate)().decorate((current) => {
1662
2018
  const existing = current["atscript_db_actions"] ?? [];
1663
2019
  return {
@@ -1667,6 +2023,7 @@ function classLevelActions(dict, forcedLevel) {
1667
2023
  });
1668
2024
  }
1669
2025
  //#endregion
2026
+ exports.ActionDisabledError = ActionDisabledError;
1670
2027
  Object.defineProperty(exports, "AsDbController", {
1671
2028
  enumerable: true,
1672
2029
  get: function() {
@@ -1701,10 +2058,15 @@ exports.DbAction = DbAction;
1701
2058
  exports.DbActionDefault = DbActionDefault;
1702
2059
  exports.DbActionPK = DbActionPK;
1703
2060
  exports.DbActionPKs = DbActionPKs;
2061
+ exports.DbActionRow = DbActionRow;
2062
+ exports.DbActionRows = DbActionRows;
1704
2063
  exports.DbActions = DbActions;
1705
2064
  exports.DbRowActions = DbRowActions;
1706
2065
  exports.DbRowsActions = DbRowsActions;
1707
2066
  exports.DbTableActions = DbTableActions;
2067
+ exports.ONE_CONTROLS = ONE_CONTROLS;
2068
+ exports.PAGES_CONTROLS = PAGES_CONTROLS;
2069
+ exports.QUERY_CONTROLS = QUERY_CONTROLS;
1708
2070
  exports.READABLE_DEF = READABLE_DEF;
1709
2071
  exports.ReadableController = ReadableController;
1710
2072
  exports.TABLE_DEF = TABLE_DEF;
@@ -1712,4 +2074,8 @@ exports.TableController = TableController;
1712
2074
  exports.UseValidationErrorTransform = UseValidationErrorTransform;
1713
2075
  exports.ViewController = ViewController;
1714
2076
  exports.discoverActions = discoverActions;
2077
+ exports.useDbActionPk = useDbActionPk;
2078
+ exports.useDbActionPks = useDbActionPks;
2079
+ exports.useDbActionRow = useDbActionRow;
2080
+ exports.useDbActionRows = useDbActionRows;
1715
2081
  exports.validationErrorTransform = validationErrorTransform;