@atscript/moost-db 0.1.57 → 0.1.59

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 };
@@ -97,7 +98,7 @@ var SelectControlDto = class {
97
98
  throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
98
99
  }
99
100
  };
100
- defineAnnotatedType("object", QueryControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$count", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
101
+ defineAnnotatedType("object", QueryControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$count", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
101
102
  defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedType().designType("string").tags("string").annotate("expect.pattern", {
102
103
  pattern: "^\\d+$",
103
104
  flags: "u",
@@ -106,8 +107,8 @@ defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedTyp
106
107
  pattern: "^\\d+$",
107
108
  flags: "u",
108
109
  message: "Expected positive number"
109
- }, true).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
110
- defineAnnotatedType("object", GetOneControlsDto).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
110
+ }, true).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
111
+ defineAnnotatedType("object", GetOneControlsDto).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
111
112
  defineAnnotatedType("object", WithRelationDto).prop("name", defineAnnotatedType().designType("string").tags("string").$type).prop("filter", defineAnnotatedType().refTo(WithFilterDto).optional().$type).prop("controls", defineAnnotatedType().refTo(WithRelationControlsDto).optional().$type).prop("insights", defineAnnotatedType().refTo(WithFilterDto).optional().$type);
112
113
  defineAnnotatedType("object", WithRelationControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
113
114
  defineAnnotatedType("object", WithFilterDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("string").tags("string").$type).item(defineAnnotatedType().designType("number").tags("number").$type).item(defineAnnotatedType().designType("boolean").tags("boolean").$type).item(defineAnnotatedType().designType("null").tags("null").$type).item(defineAnnotatedType().refTo(WithFilterDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithFilterDto).$type).$type).$type);
@@ -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
- /** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
212
+ /** Param-level metadata key — written by `@DbActionID()` / `@DbActionIDs()`. 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,39 +231,70 @@ 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 === "id") single = true;
243
+ else if (kind === "ids") 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
+ /** Structural-copy fields; `disabled` is handled separately in {@link emitInfo} (function-to-string transform). */
209
265
  const OPTIONAL_FIELDS = [
210
266
  "icon",
211
267
  "intent",
212
268
  "description",
213
269
  "order",
214
270
  "default",
215
- "promptText"
271
+ "promptText",
272
+ "shortcut"
216
273
  ];
217
- const WARN_PREFIX = "[moost-db actions]";
218
274
  const actionsCache = /* @__PURE__ */ new WeakMap();
219
- /**
220
- * Discover all actions declared on a controller and produce the `/meta` array.
221
- * Reads class + method metadata via `getMoostMate()` and resolves bound POST
222
- * paths through the Moost controller overview.
223
- *
224
- * Result is memoized per controller constructor — discovery walks every
225
- * handler entry and reads decorator metadata, which is wasted work to repeat
226
- * across instances.
227
- */
275
+ const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
276
+ /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
228
277
  function discoverActions(controllerCtor, app, logger) {
229
278
  const cached = actionsCache.get(controllerCtor);
230
279
  if (cached) return cached;
231
280
  const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
232
281
  const out = [];
233
- collectMethodActions(controllerCtor, overview, logger, out);
234
- collectClassActions(controllerCtor, logger, out);
282
+ const seen = /* @__PURE__ */ new Set();
283
+ collectMethodActions(controllerCtor, overview, logger, out, seen);
284
+ collectClassActions(controllerCtor, logger, out, seen);
235
285
  applyDefaultPerLevel(out, logger);
236
286
  actionsCache.set(controllerCtor, out);
237
287
  return out;
238
288
  }
239
- function collectMethodActions(ctor, overview, logger, out) {
289
+ /** Row/rows-level subset of {@link discoverActions}; memoized per ctor. */
290
+ function discoverRowLevelActions(controllerCtor, app, logger) {
291
+ const cached = rowLevelActionsCache.get(controllerCtor);
292
+ if (cached) return cached;
293
+ const filtered = discoverActions(controllerCtor, app, logger).filter((e) => e.info.level === "row" || e.info.level === "rows");
294
+ rowLevelActionsCache.set(controllerCtor, filtered);
295
+ return filtered;
296
+ }
297
+ function collectMethodActions(ctor, overview, logger, out, seen) {
240
298
  if (!overview) return;
241
299
  const byMethod = /* @__PURE__ */ new Map();
242
300
  for (const h of overview.handlers) {
@@ -255,9 +313,25 @@ function collectMethodActions(ctor, overview, logger, out) {
255
313
  const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
256
314
  if (!levelInfer) continue;
257
315
  if (levelInfer.bodyConflict) {
258
- logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs with @Body() — dropping`);
316
+ logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionID*/@DbActionIDs/@DbActionRow*/@DbActionRows with @Body() — dropping`);
317
+ continue;
318
+ }
319
+ if (levelInfer.level === "table" && action.opts.disabled !== void 0) {
320
+ 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`);
321
+ continue;
322
+ }
323
+ if (action.opts.disabled !== void 0 && !isNonEmptyStringArray(action.opts.requiredFields)) {
324
+ logger.warn(`${WARN_PREFIX} action "${action.name}" — \`disabled\` requires a non-empty \`requiredFields\` array (the predicate's field dependencies must be declared explicitly) — dropping`);
259
325
  continue;
260
326
  }
327
+ if (action.opts.disabled !== void 0 || levelInfer.hasRowParam) {
328
+ const extendsReadable = isAsDbReadableControllerSubclass(ctor);
329
+ const hasOptsTable = action.opts.table != null;
330
+ if (!extendsReadable && !hasOptsTable) {
331
+ 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`);
332
+ continue;
333
+ }
334
+ }
261
335
  const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
262
336
  if (!postEntry) {
263
337
  logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
@@ -273,6 +347,10 @@ function collectMethodActions(ctor, overview, logger, out) {
273
347
  logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
274
348
  continue;
275
349
  }
350
+ if (seen.has(action.name)) {
351
+ logger.warn(`${WARN_PREFIX} duplicate action name "${action.name}" within controller — dropping the second declaration`);
352
+ continue;
353
+ }
276
354
  const info = {
277
355
  name: action.name,
278
356
  label,
@@ -280,40 +358,46 @@ function collectMethodActions(ctor, overview, logger, out) {
280
358
  processor: "backend",
281
359
  value: path
282
360
  };
283
- copyOptionalFields(info, action.opts);
284
- out.push(info);
361
+ emitInfo(info, action.opts);
362
+ seen.add(action.name);
363
+ out.push({
364
+ info,
365
+ raw: action.opts
366
+ });
285
367
  }
286
368
  }
287
369
  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`);
370
+ const scan = scanParamLevel(params);
371
+ if (scan.single && scan.multi) {
372
+ logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionID / @DbActionRow vs @DbActionIDs / @DbActionRows) — dropping`);
299
373
  return null;
300
374
  }
301
- const level = hasPk ? "row" : hasPks ? "rows" : "table";
302
375
  return {
303
- level,
304
- bodyConflict: hasBody && level !== "table"
376
+ level: scan.level,
377
+ bodyConflict: scan.hasBody && scan.level !== "table",
378
+ hasRowParam: scan.hasRowParam
305
379
  };
306
380
  }
307
- function collectClassActions(ctor, logger, out) {
381
+ function collectClassActions(ctor, logger, out, seen) {
308
382
  const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
309
383
  if (!list) return;
310
- for (const { name, entry, forcedLevel } of list) {
311
- const built = buildClassEntry(name, entry, forcedLevel, logger);
312
- if (built) out.push(built);
384
+ for (const { name, entry } of list) {
385
+ if (seen.has(name)) {
386
+ logger.warn(`${WARN_PREFIX} duplicate action name "${name}" within controller — dropping the second declaration`);
387
+ continue;
388
+ }
389
+ const built = buildClassEntry(name, entry, logger);
390
+ if (built) {
391
+ seen.add(name);
392
+ out.push({
393
+ info: built,
394
+ raw: entry
395
+ });
396
+ }
313
397
  }
314
398
  }
315
- function buildClassEntry(name, entry, forcedLevel, logger) {
316
- const level = forcedLevel ?? entry.level;
399
+ function buildClassEntry(name, entry, logger) {
400
+ const level = entry.level;
317
401
  if (!level) {
318
402
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
319
403
  return null;
@@ -322,6 +406,14 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
322
406
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
323
407
  return null;
324
408
  }
409
+ if (level === "table" && entry.disabled !== void 0) {
410
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` is not allowed at the 'table' level — dropping`);
411
+ return null;
412
+ }
413
+ if (entry.disabled !== void 0 && !isNonEmptyStringArray(entry.requiredFields)) {
414
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` requires a non-empty \`requiredFields\` array (the predicate's field dependencies must be declared explicitly) — dropping`);
415
+ return null;
416
+ }
325
417
  const processor = entry.processor;
326
418
  let value;
327
419
  if (processor === "navigate" || processor === "backend") {
@@ -349,26 +441,37 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
349
441
  processor,
350
442
  value
351
443
  };
352
- copyOptionalFields(info, entry);
444
+ emitInfo(info, entry);
353
445
  return info;
354
446
  }
355
- function applyDefaultPerLevel(actions, logger) {
447
+ function applyDefaultPerLevel(envelopes, logger) {
356
448
  const winners = /* @__PURE__ */ new Map();
357
- for (const a of actions) {
358
- if (!a.default) continue;
359
- const existing = winners.get(a.level);
449
+ for (const { info } of envelopes) {
450
+ if (!info.default) continue;
451
+ const existing = winners.get(info.level);
360
452
  if (existing) {
361
- a.default = false;
362
- logger.warn(`${WARN_PREFIX} duplicate default action at level "${a.level}": "${existing}" wins, "${a.name}" demoted`);
363
- } else winners.set(a.level, a.name);
453
+ info.default = false;
454
+ logger.warn(`${WARN_PREFIX} duplicate default action at level "${info.level}": "${existing}" wins, "${info.name}" demoted`);
455
+ } else winners.set(info.level, info.name);
364
456
  }
365
457
  }
458
+ /** Emit structural-copy fields plus stringified `disabled`. `requiredFields` is server-internal (never on the wire). */
459
+ function emitInfo(info, source) {
460
+ const disabled = source.disabled;
461
+ const hasDisabled = typeof disabled === "function";
462
+ copyOptionalFields(info, source);
463
+ if (Array.isArray(info.promptText)) info.promptText = info.promptText.slice();
464
+ if (hasDisabled) info.disabled = disabled.toString();
465
+ }
366
466
  function copyOptionalFields(info, source) {
367
467
  for (const key of OPTIONAL_FIELDS) {
368
468
  const value = source[key];
369
469
  if (value !== void 0) info[key] = value;
370
470
  }
371
471
  }
472
+ function isNonEmptyStringArray(value) {
473
+ return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string");
474
+ }
372
475
  //#endregion
373
476
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
374
477
  function __decorateMetadata(k, v) {
@@ -551,6 +654,7 @@ let AsReadableController = class AsReadableController {
551
654
  vectorSearchable: false,
552
655
  searchIndexes: [],
553
656
  primaryKeys: [],
657
+ preferredId: [],
554
658
  relations: [],
555
659
  fields: {},
556
660
  type: this.getSerializedType(),
@@ -564,7 +668,7 @@ let AsReadableController = class AsReadableController {
564
668
  * controllers — see {@link AsValueHelpController#buildMetaResponse}.
565
669
  */
566
670
  buildActions() {
567
- return discoverActions(this.constructor, this.app, this.logger);
671
+ return discoverActions(this.constructor, this.app, this.logger).map((e) => e.info);
568
672
  }
569
673
  /**
570
674
  * Declares the built-in CRUD operations this controller exposes. Subclasses
@@ -599,6 +703,80 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
599
703
  Object
600
704
  ])], AsReadableController);
601
705
  //#endregion
706
+ //#region src/actions/verdict.ts
707
+ /** Assert that a `disabled` predicate returned a `boolean[]` of the expected length; throws HTTP 500 otherwise. */
708
+ function assertVerdictLength(action, verdicts, expected) {
709
+ if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
710
+ }
711
+ //#endregion
712
+ //#region src/actions/list-augmenter.ts
713
+ const candidateCache = /* @__PURE__ */ new WeakMap();
714
+ /** WHY: envelopes are immutable post-discovery, so derived `Candidate` shape is cached for the envelope's lifetime; `null` sentinel pins table-level skip. */
715
+ function getCandidate(e) {
716
+ const cached = candidateCache.get(e);
717
+ if (cached !== void 0) return cached;
718
+ if (e.info.level !== "row" && e.info.level !== "rows") {
719
+ candidateCache.set(e, null);
720
+ return null;
721
+ }
722
+ const raw = e.raw;
723
+ const c = {
724
+ envelope: e,
725
+ disabledFn: typeof raw.disabled === "function" ? raw.disabled : void 0,
726
+ requiredFields: Array.isArray(raw.requiredFields) ? raw.requiredFields : []
727
+ };
728
+ candidateCache.set(e, c);
729
+ return c;
730
+ }
731
+ function collectCandidates(envelopes) {
732
+ const out = [];
733
+ for (const e of envelopes) {
734
+ const c = getCandidate(e);
735
+ if (c !== null) out.push(c);
736
+ }
737
+ return out;
738
+ }
739
+ function computeStripFields(candidates, resolvedProjection) {
740
+ let userSet = null;
741
+ let strip = null;
742
+ for (const c of candidates) for (const f of c.requiredFields) {
743
+ if (userSet === null) userSet = new Set(resolvedProjection);
744
+ if (userSet.has(f)) continue;
745
+ if (strip === null) strip = /* @__PURE__ */ new Set();
746
+ strip.add(f);
747
+ }
748
+ return strip;
749
+ }
750
+ function augmentRowsWithActions(args) {
751
+ const { envelopes, rows, resolvedProjection } = args;
752
+ const candidates = collectCandidates(envelopes);
753
+ if (candidates.length === 0 || rows.length === 0) return rows;
754
+ const verdicts = candidates.map((c) => {
755
+ if (!c.disabledFn) return void 0;
756
+ const out = c.disabledFn(rows);
757
+ assertVerdictLength(c.envelope.info.name, out, rows.length);
758
+ return out;
759
+ });
760
+ for (let i = 0; i < rows.length; i++) {
761
+ const row = rows[i];
762
+ const names = [];
763
+ for (let j = 0; j < candidates.length; j++) {
764
+ const v = verdicts[j];
765
+ if (v === void 0) {
766
+ names.push(candidates[j].envelope.info.name);
767
+ continue;
768
+ }
769
+ if (!v[i]) names.push(candidates[j].envelope.info.name);
770
+ }
771
+ row.$actions = names;
772
+ }
773
+ if (resolvedProjection !== null) {
774
+ const stripFields = computeStripFields(candidates, resolvedProjection);
775
+ if (stripFields !== null) for (const row of rows) for (const f of stripFields) delete row[f];
776
+ }
777
+ return rows;
778
+ }
779
+ //#endregion
602
780
  //#region src/decorators.ts
603
781
  /**
604
782
  * DI token under which the {@link AtscriptDbReadable} instance
@@ -693,10 +871,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
693
871
  /** Reference to the underlying readable (table or view). */
694
872
  readable;
695
873
  _gates;
874
+ _preferredIdSet;
875
+ _compositeIdShapes;
876
+ _overlayIsNoOp;
696
877
  constructor(readable, app) {
697
878
  super(readable.type, readable.tableName, app, readable.isView ? "view" : "table");
698
879
  this.readable = readable;
699
880
  this._gates = this._buildGates();
881
+ this._preferredIdSet = new Set(readable.preferredId ?? []);
882
+ this._compositeIdShapes = (readable.identifications ?? []).filter((id) => id.fields.length >= 2);
883
+ const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
884
+ this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
700
885
  }
701
886
  _buildGates() {
702
887
  const meta = this.readable.type.metadata;
@@ -764,16 +949,169 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
764
949
  transformProjection(projection) {
765
950
  return projection;
766
951
  }
952
+ widenPreferredIdProjection(projection) {
953
+ if (this._preferredIdSet.size === 0 || projection === void 0) return projection;
954
+ if (Array.isArray(projection)) return this._widenArrayProjection(projection);
955
+ return this._widenMapProjection(projection);
956
+ }
957
+ _widenArrayProjection(projection) {
958
+ const stringItems = /* @__PURE__ */ new Set();
959
+ for (const item of projection) if (typeof item === "string") stringItems.add(item);
960
+ let allPresent = true;
961
+ for (const field of this._preferredIdSet) if (!stringItems.has(field)) {
962
+ allPresent = false;
963
+ break;
964
+ }
965
+ if (allPresent) return projection;
966
+ const out = [...projection];
967
+ for (const field of this._preferredIdSet) if (!stringItems.has(field)) out.push(field);
968
+ return out;
969
+ }
970
+ _widenMapProjection(projection) {
971
+ const entries = Object.entries(projection);
972
+ if (entries.length === 0) return projection;
973
+ const included = /* @__PURE__ */ new Set();
974
+ const excluded = /* @__PURE__ */ new Set();
975
+ for (const [k, v] of entries) if (v === 1 || v === true) included.add(k);
976
+ else if (v === 0 || v === false) excluded.add(k);
977
+ if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
978
+ if (excluded.size === 0) {
979
+ let allPresent = true;
980
+ for (const field of this._preferredIdSet) if (!included.has(field)) {
981
+ allPresent = false;
982
+ break;
983
+ }
984
+ if (allPresent) return projection;
985
+ const widened = {};
986
+ for (const k of included) widened[k] = 1;
987
+ for (const field of this._preferredIdSet) widened[field] = 1;
988
+ return widened;
989
+ }
990
+ const widened = {};
991
+ for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !excluded.has(fd.path)) widened[fd.path] = 1;
992
+ for (const field of this._preferredIdSet) widened[field] = 1;
993
+ return widened;
994
+ }
995
+ /** WHY: the URL parser only auto-coerces `$count`; every other boolean control reaches us as `"true"`/`"1"` and would fail DTO validation. */
996
+ _coerceActionsControl(controls) {
997
+ const v = controls.$actions;
998
+ if (typeof v === "string") controls.$actions = v === "true" || v === "1" || v === "";
999
+ }
1000
+ /** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
1001
+ _resolveProjectionForAugmenter(select) {
1002
+ if (select === void 0) return null;
1003
+ if (Array.isArray(select)) {
1004
+ const out = [];
1005
+ const seen = /* @__PURE__ */ new Set();
1006
+ for (const item of select) if (typeof item === "string" && !seen.has(item)) {
1007
+ seen.add(item);
1008
+ out.push(item);
1009
+ }
1010
+ return out;
1011
+ }
1012
+ const obj = select;
1013
+ const included = [];
1014
+ const excluded = [];
1015
+ for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
1016
+ else if (v === 0 || v === false) excluded.push(k);
1017
+ if (included.length > 0 && excluded.length === 0) return included;
1018
+ if (excluded.length > 0 && included.length === 0) {
1019
+ const excludedSet = new Set(excluded);
1020
+ const out = [];
1021
+ for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !excludedSet.has(fd.path)) out.push(fd.path);
1022
+ return out;
1023
+ }
1024
+ throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
1025
+ }
1026
+ /** WHY: filter row/rows envelopes by the per-request `applyMetaOverlay` action set; skip `meta()` when overlay is identity. */
1027
+ async _resolveAugmentEnvelopes() {
1028
+ const rowLevelEnvelopes = discoverRowLevelActions(this.constructor, this.app, this.logger);
1029
+ if (rowLevelEnvelopes.length === 0) return null;
1030
+ if (this._overlayIsNoOp) return rowLevelEnvelopes;
1031
+ const overlayMeta = await this.meta();
1032
+ const allowedNames = new Set(overlayMeta.actions.map((a) => a.name));
1033
+ const filtered = rowLevelEnvelopes.filter((e) => allowedNames.has(e.info.name));
1034
+ return filtered.length === 0 ? null : filtered;
1035
+ }
1036
+ /** Returns a widened `$select` only when at least one `requiredFields` entry is missing; `null` means "no widening needed". */
1037
+ _widenSelectForActions(envelopes, baseSelect) {
1038
+ let resultSet = null;
1039
+ let result = null;
1040
+ for (const e of envelopes) {
1041
+ const raw = e.raw;
1042
+ if (!Array.isArray(raw.requiredFields)) continue;
1043
+ for (const f of raw.requiredFields) {
1044
+ if (resultSet ? resultSet.has(f) : baseSelect.includes(f)) continue;
1045
+ if (resultSet === null) {
1046
+ resultSet = new Set(baseSelect);
1047
+ result = [...baseSelect];
1048
+ }
1049
+ resultSet.add(f);
1050
+ result.push(f);
1051
+ }
1052
+ }
1053
+ return result;
1054
+ }
1055
+ async _prepareAugmentation(controls, select) {
1056
+ if (!controls.$actions) return null;
1057
+ const envelopes = await this._resolveAugmentEnvelopes();
1058
+ if (envelopes === null) return null;
1059
+ const resolvedProjection = this._resolveProjectionForAugmenter(select);
1060
+ return {
1061
+ envelopes,
1062
+ resolvedProjection,
1063
+ widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection)
1064
+ };
1065
+ }
1066
+ async _resolveReadStrategy(controls) {
1067
+ const searchTerm = controls.$search;
1068
+ const indexName = controls.$index;
1069
+ const vectorField = controls.$vector;
1070
+ if (vectorField !== void 0 && searchTerm) return {
1071
+ kind: "vector",
1072
+ vector: await this.computeEmbedding(searchTerm, vectorField || void 0),
1073
+ vectorField
1074
+ };
1075
+ if (searchTerm && this.readable.isSearchable()) return {
1076
+ kind: "search",
1077
+ term: searchTerm,
1078
+ index: indexName
1079
+ };
1080
+ return { kind: "plain" };
1081
+ }
1082
+ /**
1083
+ * Shared `query` / `pages` pipeline: prepare actions augmentation + read
1084
+ * strategy in parallel, pre-widen $select for `requiredFields`, run
1085
+ * `exec`, and augment `result.data` with `$actions` when the request set
1086
+ * `$actions=true`. Caller dispatches the strategy to its read-method
1087
+ * family (count vs no-count).
1088
+ */
1089
+ async _runReadWithActions(queryObj, controls, select, exec) {
1090
+ const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, select), this._resolveReadStrategy(controls)]);
1091
+ const result = await exec(prep?.widenedSelect ? {
1092
+ ...queryObj,
1093
+ controls: {
1094
+ ...queryObj.controls,
1095
+ $select: prep.widenedSelect
1096
+ }
1097
+ } : queryObj, strategy);
1098
+ if (!prep) return result;
1099
+ result.data = augmentRowsWithActions({
1100
+ envelopes: prep.envelopes,
1101
+ rows: result.data,
1102
+ resolvedProjection: prep.resolvedProjection
1103
+ });
1104
+ return result;
1105
+ }
767
1106
  /**
768
1107
  * Extracts a composite identifier object from query params.
769
1108
  * Tries composite primary key first, then compound unique indexes.
770
1109
  */
771
1110
  extractCompositeId(query) {
772
- const pkFields = this.readable.primaryKeys;
773
- if (pkFields.length > 1) {
1111
+ for (const id of this._compositeIdShapes) {
774
1112
  const idObj = {};
775
1113
  let allPresent = true;
776
- for (const field of pkFields) {
1114
+ for (const field of id.fields) {
777
1115
  if (query[field] === void 0) {
778
1116
  allPresent = false;
779
1117
  break;
@@ -782,19 +1120,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
782
1120
  }
783
1121
  if (allPresent) return idObj;
784
1122
  }
785
- for (const index of this.readable.indexes.values()) {
786
- if (index.type !== "unique" || index.fields.length < 2) continue;
787
- const idObj = {};
788
- let allPresent = true;
789
- for (const indexField of index.fields) {
790
- if (query[indexField.name] === void 0) {
791
- allPresent = false;
792
- break;
793
- }
794
- idObj[indexField.name] = query[indexField.name];
795
- }
796
- if (allPresent) return idObj;
797
- }
798
1123
  return new HttpError(400, "Query params do not match any composite primary key or compound unique index");
799
1124
  }
800
1125
  /**
@@ -803,6 +1128,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
803
1128
  async query(url) {
804
1129
  const parsed = this.parseQueryString(url);
805
1130
  const controls = parsed.controls;
1131
+ this._coerceActionsControl(controls);
806
1132
  if (controls.$groupBy?.length) {
807
1133
  if (controls.$with?.length) return new HttpError(400, "Cannot combine $with and $groupBy in the same query");
808
1134
  if (parsed.insights) {
@@ -820,17 +1146,16 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
820
1146
  if (error) return error;
821
1147
  const gateError = this.checkGates(parsed.filter, controls, this._gates);
822
1148
  if (gateError) return gateError;
823
- const [filter, select] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
1149
+ const [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
824
1150
  if (controls.$count) return this.readable.count({
825
1151
  filter,
826
1152
  controls: {
827
1153
  ...controls,
828
- $select: select
1154
+ $select: rawSelect
829
1155
  }
830
1156
  });
831
- const searchTerm = controls.$search;
832
- const indexName = controls.$index;
833
- const vectorField = controls.$vector;
1157
+ const select = this.widenPreferredIdProjection(rawSelect);
1158
+ if (select instanceof HttpError) return select;
834
1159
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
835
1160
  const queryObj = {
836
1161
  filter,
@@ -841,19 +1166,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
841
1166
  $threshold: threshold
842
1167
  }
843
1168
  };
844
- if (vectorField !== void 0 && searchTerm) {
845
- const vector = await this.computeEmbedding(searchTerm, vectorField || void 0);
846
- if (vectorField) return this.readable.vectorSearch(vectorField, vector, queryObj);
847
- return this.readable.vectorSearch(vector, queryObj);
848
- }
849
- if (searchTerm && this.readable.isSearchable()) return this.readable.search(searchTerm, queryObj, indexName);
850
- return this.readable.findMany(queryObj);
1169
+ return (await this._runReadWithActions(queryObj, controls, select, async (q, strategy) => {
1170
+ switch (strategy.kind) {
1171
+ case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
1172
+ case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
1173
+ case "plain": return { data: await this.readable.findMany(q) };
1174
+ }
1175
+ })).data;
851
1176
  }
852
1177
  /**
853
1178
  * **GET /pages** — returns paginated records with metadata.
854
1179
  */
855
1180
  async pages(url) {
856
1181
  const parsed = this.parseQueryString(url);
1182
+ this._coerceActionsControl(parsed.controls);
857
1183
  const error = this.validateParsed(parsed, "pages");
858
1184
  if (error) return error;
859
1185
  const controls = parsed.controls;
@@ -862,10 +1188,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
862
1188
  const page = Math.max(Number(controls.$page || 1), 1);
863
1189
  const size = Math.max(Number(controls.$size || 10), 1);
864
1190
  const skip = (page - 1) * size;
865
- const [filter, select] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
866
- const searchTerm = controls.$search;
867
- const indexName = controls.$index;
868
- const vectorField = controls.$vector;
1191
+ const [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
1192
+ const select = this.widenPreferredIdProjection(rawSelect);
1193
+ if (select instanceof HttpError) return select;
869
1194
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
870
1195
  const query = {
871
1196
  filter,
@@ -877,13 +1202,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
877
1202
  $threshold: threshold
878
1203
  }
879
1204
  };
880
- let result;
881
- if (vectorField !== void 0 && searchTerm) {
882
- const vector = await this.computeEmbedding(searchTerm, vectorField || void 0);
883
- if (vectorField) result = await this.readable.vectorSearchWithCount(vectorField, vector, query);
884
- else result = await this.readable.vectorSearchWithCount(vector, query);
885
- } else if (searchTerm && this.readable.isSearchable()) result = await this.readable.searchWithCount(searchTerm, query, indexName);
886
- else result = await this.readable.findManyWithCount(query);
1205
+ const result = await this._runReadWithActions(query, controls, select, async (q, strategy) => {
1206
+ switch (strategy.kind) {
1207
+ case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
1208
+ case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
1209
+ case "plain": return this.readable.findManyWithCount(q);
1210
+ }
1211
+ });
887
1212
  return {
888
1213
  data: result.data,
889
1214
  page,
@@ -897,15 +1222,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
897
1222
  */
898
1223
  async getOne(id, url) {
899
1224
  const parsed = this.parseQueryString(url);
1225
+ this._coerceActionsControl(parsed.controls);
900
1226
  if (Object.keys(parsed.filter).length > 0) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
901
1227
  const error = this.validateParsed(parsed, "getOne");
902
1228
  if (error) return error;
903
- const select = await this.transformProjection(parsed.controls.$select);
904
- const controls = {
905
- ...parsed.controls,
906
- $select: select
907
- };
908
- return this.returnOne(this.readable.findById(id, { controls }));
1229
+ const rawSelect = await this.transformProjection(parsed.controls.$select);
1230
+ const select = this.widenPreferredIdProjection(rawSelect);
1231
+ if (select instanceof HttpError) return select;
1232
+ return this._findByIdAndAugment(id, parsed.controls, select);
909
1233
  }
910
1234
  /**
911
1235
  * **GET /one?field1=val1&field2=val2** — retrieves a single record by composite key
@@ -915,12 +1239,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
915
1239
  const idObj = this.extractCompositeId(query);
916
1240
  if (idObj instanceof HttpError) return idObj;
917
1241
  const parsed = this.parseQueryString(url);
918
- const select = await this.transformProjection(parsed.controls.$select);
1242
+ this._coerceActionsControl(parsed.controls);
1243
+ const rawSelect = await this.transformProjection(parsed.controls.$select);
1244
+ const select = this.widenPreferredIdProjection(rawSelect);
1245
+ if (select instanceof HttpError) return select;
1246
+ return this._findByIdAndAugment(idObj, parsed.controls, select);
1247
+ }
1248
+ async _findByIdAndAugment(id, parsedControls, select) {
1249
+ const prep = await this._prepareAugmentation(parsedControls, select);
1250
+ const initialSelect = prep?.widenedSelect ?? select;
919
1251
  const controls = {
920
- ...parsed.controls,
921
- $select: select
1252
+ ...parsedControls,
1253
+ $select: initialSelect
922
1254
  };
923
- return this.returnOne(this.readable.findById(idObj, { controls }));
1255
+ const row = await this.readable.findById(id, { controls });
1256
+ const item = await this.returnOne(Promise.resolve(row));
1257
+ if (item instanceof HttpError) return item;
1258
+ if (!prep) return item;
1259
+ const [augmented] = augmentRowsWithActions({
1260
+ envelopes: prep.envelopes,
1261
+ rows: [item],
1262
+ resolvedProjection: prep.resolvedProjection
1263
+ });
1264
+ return augmented;
924
1265
  }
925
1266
  /**
926
1267
  * **GET /meta** — returns table/view metadata for UI.
@@ -954,6 +1295,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
954
1295
  vectorSearchable: this.readable.isVectorSearchable(),
955
1296
  searchIndexes: this.readable.getSearchIndexes(),
956
1297
  primaryKeys: [...this.readable.primaryKeys],
1298
+ preferredId: [...this.readable.preferredId],
957
1299
  relations,
958
1300
  fields,
959
1301
  type: this.getSerializedType(),
@@ -1005,6 +1347,7 @@ AsDbReadableController = __decorate([
1005
1347
  __decorateParam(0, Inject(READABLE_DEF)),
1006
1348
  __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$3 : Object])
1007
1349
  ], AsDbReadableController);
1350
+ registerAsDbReadableController(AsDbReadableController);
1008
1351
  //#endregion
1009
1352
  //#region src/as-db.controller.ts
1010
1353
  var _ref$2, _ref2$1;
@@ -1251,6 +1594,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1251
1594
  vectorSearchable: false,
1252
1595
  searchIndexes: [],
1253
1596
  primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1597
+ preferredId: this.primaryKey ? [this.primaryKey] : [],
1254
1598
  relations: [],
1255
1599
  fields,
1256
1600
  type: this.getSerializedType(),
@@ -1303,6 +1647,7 @@ AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramt
1303
1647
  String,
1304
1648
  typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object
1305
1649
  ])], AsValueHelpController);
1650
+ registerAsValueHelpController(AsValueHelpController);
1306
1651
  //#endregion
1307
1652
  //#region src/as-json-value-help.controller.ts
1308
1653
  var _ref;
@@ -1463,151 +1808,120 @@ function applySelect(rows, select) {
1463
1808
  });
1464
1809
  }
1465
1810
  //#endregion
1466
- //#region src/actions/db-action.decorator.ts
1811
+ //#region src/actions/action-disabled-error.ts
1812
+ function buildMessage(action, ids) {
1813
+ if (ids !== void 0) return `Action "${action}" is disabled for ${ids.length} of the selected rows`;
1814
+ return `Action "${action}" is disabled for this row`;
1815
+ }
1467
1816
  /**
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.
1817
+ * Thrown by the gate interceptor when `disabled` returns truthy. Composes
1818
+ * with Moost's existing error mapper to produce HTTP 409 with the wire body
1819
+ * defined by {@link ActionDisabledErrorBody}.
1474
1820
  *
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
- * ```
1821
+ * - `'row'`-level rejection: pass `(action, id)` — the body emits `id`.
1822
+ * - `'rows'`-level rejection: pass `(action, undefined, ids)` — the body
1823
+ * emits `ids` (the FULL list of failing IDs in reject mode; the FULL list
1824
+ * of request IDs in skip mode with zero survivors).
1481
1825
  */
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
- })
1826
+ var ActionDisabledError = class extends HttpError {
1827
+ name = "ActionDisabledError";
1828
+ constructor(action, id, ids) {
1829
+ const body = {
1830
+ name: "ActionDisabledError",
1831
+ message: buildMessage(action, ids),
1832
+ statusCode: 409,
1833
+ action
1491
1834
  };
1492
- });
1493
- }
1835
+ if (ids !== void 0) body.ids = ids;
1836
+ else if (id !== void 0) body.id = id;
1837
+ super(409, body);
1838
+ }
1839
+ };
1494
1840
  //#endregion
1495
- //#region src/actions/db-action-default.decorator.ts
1496
- /**
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.
1499
- */
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 } })
1506
- };
1507
- });
1841
+ //#region src/actions/current-action.ts
1842
+ /** Read the current action's `TDbActionMeta` from the wook context. Returns undefined outside a controller (e.g. direct-wook test paths). */
1843
+ function readCurrentActionMeta(ctx) {
1844
+ let ctrl;
1845
+ let methodName;
1846
+ try {
1847
+ const cc = useControllerContext(ctx);
1848
+ ctrl = cc.getController();
1849
+ methodName = cc.getMethod();
1850
+ } catch {
1851
+ return;
1852
+ }
1853
+ if (!ctrl || !methodName) return void 0;
1854
+ return getMoostMate().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION];
1508
1855
  }
1509
1856
  //#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;
1857
+ //#region src/actions/id-validation.ts
1858
+ const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
1859
+ function getSourceCache(source) {
1860
+ let cache = SOURCE_CACHE.get(source);
1861
+ if (cache) return cache;
1862
+ const identifications = source.getIdentifications();
1863
+ const byKeySig = /* @__PURE__ */ new Map();
1864
+ for (const ident of identifications) byKeySig.set(fieldsSig(ident.fields), ident);
1865
+ const fieldByName = /* @__PURE__ */ new Map();
1866
+ for (const fd of source.fieldDescriptors) fieldByName.set(fd.path, fd);
1867
+ cache = {
1868
+ byKeySig,
1869
+ fieldByName,
1870
+ formatted: identifications.map((id) => `[${id.fields.join(", ")}]`).join(", ")
1871
+ };
1872
+ SOURCE_CACHE.set(source, cache);
1873
+ return cache;
1528
1874
  }
1529
- function isPkValidationSource(value) {
1875
+ function fieldsSig(fields) {
1876
+ return fields.toSorted().join("");
1877
+ }
1878
+ function isIdValidationSource(value) {
1530
1879
  if (!value || typeof value !== "object") return false;
1531
1880
  const v = value;
1532
- return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1533
- }
1534
- /**
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));
1881
+ return typeof v.getIdentifications === "function" && Array.isArray(v.fieldDescriptors);
1545
1882
  }
1546
- //#endregion
1547
- //#region src/actions/pk-validation.ts
1548
- /**
1549
- * Validate a JSON-decoded body against a single-row PK shape (scalar or
1550
- * composite). Throws {@link ValidatorError} with structured `errors` so the
1551
- * existing validation interceptor returns HTTP 400.
1552
- */
1553
- function validateSinglePk(body, source, path = "") {
1554
- const errors = collectPkErrors(body, source, path);
1883
+ function validateSingleId(body, source, path = "") {
1884
+ const errors = collectIdErrors(body, source, path);
1555
1885
  if (errors.length > 0) throw new ValidatorError(errors);
1886
+ return body;
1556
1887
  }
1557
- /**
1558
- * Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
1559
- * The body MUST be an array; each element is validated against the PK schema.
1560
- */
1561
- function validateMultiPk(body, source) {
1888
+ function validateMultiId(body, source) {
1562
1889
  if (!Array.isArray(body)) throw new ValidatorError([{
1563
1890
  path: "",
1564
- message: "Expected JSON array of primary keys",
1891
+ message: "Expected JSON array of identifier objects",
1565
1892
  details: []
1566
1893
  }]);
1567
1894
  const errors = [];
1568
- for (let i = 0; i < body.length; i++) errors.push(...collectPkErrors(body[i], source, `[${i}]`));
1895
+ for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
1569
1896
  if (errors.length > 0) throw new ValidatorError(errors);
1897
+ return body;
1570
1898
  }
1571
- function collectPkErrors(value, source, pathPrefix) {
1572
- const pkFields = source.primaryKeys;
1573
- if (pkFields.length === 0) return [{
1899
+ function collectIdErrors(value, source, pathPrefix) {
1900
+ if (!isPlainObject(value)) return [{
1901
+ path: pathPrefix,
1902
+ message: "Expected JSON object for row identifier",
1903
+ details: []
1904
+ }];
1905
+ const cache = getSourceCache(source);
1906
+ if (cache.byKeySig.size === 0) return [{
1907
+ path: pathPrefix,
1908
+ message: "Table has no identifier configured",
1909
+ details: []
1910
+ }];
1911
+ const match = cache.byKeySig.get(fieldsSig(Object.keys(value)));
1912
+ if (!match) return [{
1574
1913
  path: pathPrefix,
1575
- message: "Table has no primary key configured",
1914
+ message: `Identifier fields must exactly match one of: ${cache.formatted}`,
1576
1915
  details: []
1577
1916
  }];
1578
1917
  const errors = [];
1579
- if (pkFields.length === 1) {
1580
- const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
1581
- if (err) errors.push(err);
1582
- return errors;
1583
- }
1584
- if (!isPlainObject(value)) {
1585
- errors.push({
1586
- path: pathPrefix,
1587
- message: "Expected JSON object for composite primary key",
1588
- details: []
1589
- });
1590
- return errors;
1591
- }
1592
- for (const fieldName of pkFields) {
1918
+ for (const fieldName of match.fields) {
1593
1919
  const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
1594
- if (!(fieldName in value)) {
1595
- errors.push({
1596
- path: sub,
1597
- message: `Missing primary-key field "${fieldName}"`,
1598
- details: []
1599
- });
1600
- continue;
1601
- }
1602
- const fd = findFieldDescriptor(source, fieldName);
1603
- const err = checkScalar(value[fieldName], fd, sub);
1920
+ const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
1604
1921
  if (err) errors.push(err);
1605
1922
  }
1606
1923
  return errors;
1607
1924
  }
1608
- function findFieldDescriptor(source, name) {
1609
- for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
1610
- }
1611
1925
  function checkScalar(value, fd, path) {
1612
1926
  const expected = fd?.designType ?? "string";
1613
1927
  if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
@@ -1617,7 +1931,7 @@ function checkScalar(value, fd, path) {
1617
1931
  function scalarMismatch(path, expected, value) {
1618
1932
  return {
1619
1933
  path,
1620
- message: `Expected primary-key value to be ${expected}, got ${describe(value)}`,
1934
+ message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
1621
1935
  details: []
1622
1936
  };
1623
1937
  }
@@ -1630,38 +1944,333 @@ function isPlainObject(value) {
1630
1944
  return typeof value === "object" && value !== null && !Array.isArray(value);
1631
1945
  }
1632
1946
  //#endregion
1633
- //#region src/actions/db-action-pk.decorator.ts
1947
+ //#region src/actions/id-cache.ts
1948
+ const boundTableKey = key("atscript_db_action_bound_table");
1949
+ function getActionTable(ctx) {
1950
+ const fromSlot = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
1951
+ if (fromSlot) return fromSlot;
1952
+ const ctrl = useControllerContext(ctx).getController();
1953
+ return ctrl?.readable ?? ctrl?.table ?? null;
1954
+ }
1955
+ function noTableError(ctx) {
1956
+ const actionName = readCurrentActionMeta(ctx)?.name;
1957
+ 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.`);
1958
+ }
1959
+ async function resolveValidatedId(ctx, validate) {
1960
+ const table = getActionTable(ctx);
1961
+ if (!isIdValidationSource(table)) throw noTableError(ctx);
1962
+ const body = await useBody(ctx).parseBody();
1963
+ validate(body, table);
1964
+ return body;
1965
+ }
1966
+ const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
1967
+ const dbActionIdsSlot = cached(async (ctx) => {
1968
+ return await resolveValidatedId(ctx, validateMultiId);
1969
+ });
1970
+ const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
1971
+ const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
1972
+ //#endregion
1973
+ //#region src/actions/row-cache.ts
1974
+ function asFetchTable(value) {
1975
+ if (!value || typeof value !== "object") return null;
1976
+ const v = value;
1977
+ if (Array.isArray(v.primaryKeys) && typeof v.findOne === "function" && typeof v.findMany === "function") return v;
1978
+ return null;
1979
+ }
1980
+ function stringifyScalar(value) {
1981
+ if (value === null) return "null";
1982
+ if (value === void 0) return "undefined";
1983
+ return String(value);
1984
+ }
1985
+ /** Returns the action's `requiredFields` or null when called outside a controller context (e.g. direct wook usage in tests). */
1986
+ function readActionFieldSet(ctx) {
1987
+ const action = readCurrentActionMeta(ctx);
1988
+ if (!action) return null;
1989
+ const opts = action.opts;
1990
+ return Array.isArray(opts.requiredFields) ? opts.requiredFields : null;
1991
+ }
1992
+ function seedActionFields(ctx, table) {
1993
+ const fields = /* @__PURE__ */ new Set();
1994
+ for (const f of table.preferredId ?? table.primaryKeys) fields.add(f);
1995
+ const action = readActionFieldSet(ctx);
1996
+ if (action) for (const f of action) fields.add(f);
1997
+ return fields;
1998
+ }
1999
+ async function loadRow(ctx) {
2000
+ const id = await ctx.get(dbActionIdSlot);
2001
+ const table = asFetchTable(getActionTable(ctx));
2002
+ if (!table) throw noTableError(ctx);
2003
+ const fields = seedActionFields(ctx, table);
2004
+ for (const k of Object.keys(id)) fields.add(k);
2005
+ const row = await table.findOne({
2006
+ filter: id,
2007
+ controls: { $select: [...fields] }
2008
+ });
2009
+ if (row == null) throw new HttpError(404, "Row not found for action identifier");
2010
+ return row;
2011
+ }
2012
+ async function loadRows(ctx) {
2013
+ const ids = await ctx.get(dbActionIdsSlot);
2014
+ const table = asFetchTable(getActionTable(ctx));
2015
+ if (!table) throw noTableError(ctx);
2016
+ if (ids.length === 0) return [];
2017
+ const fields = seedActionFields(ctx, table);
2018
+ const idKeys = [];
2019
+ const shapes = /* @__PURE__ */ new Map();
2020
+ const dedupedIds = [];
2021
+ const seenKeys = /* @__PURE__ */ new Set();
2022
+ for (const id of ids) {
2023
+ const sortedFields = Object.keys(id).toSorted();
2024
+ const sig = sortedFields.join("");
2025
+ let key = "";
2026
+ for (const f of sortedFields) {
2027
+ fields.add(f);
2028
+ key += `${f}\x1f${stringifyScalar(id[f])}\x1e`;
2029
+ }
2030
+ idKeys.push(key);
2031
+ if (!shapes.has(sig)) shapes.set(sig, sortedFields);
2032
+ if (!seenKeys.has(key)) {
2033
+ seenKeys.add(key);
2034
+ dedupedIds.push(id);
2035
+ }
2036
+ }
2037
+ const rows = await table.findMany({
2038
+ filter: { $or: dedupedIds },
2039
+ controls: { $select: [...fields] }
2040
+ });
2041
+ const rowByKey = /* @__PURE__ */ new Map();
2042
+ for (const row of rows) for (const sortedFields of shapes.values()) {
2043
+ let key = "";
2044
+ let ok = true;
2045
+ for (const f of sortedFields) {
2046
+ const v = row[f];
2047
+ if (v === void 0) {
2048
+ ok = false;
2049
+ break;
2050
+ }
2051
+ key += `${f}\x1f${stringifyScalar(v)}\x1e`;
2052
+ }
2053
+ if (ok && !rowByKey.has(key)) rowByKey.set(key, row);
2054
+ }
2055
+ return ids.map((_, i) => rowByKey.get(idKeys[i]));
2056
+ }
2057
+ const dbActionRowSlot = cached((ctx) => loadRow(ctx));
2058
+ const dbActionRowsSlot = cached((ctx) => loadRows(ctx));
2059
+ const useDbActionRow = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowSlot) }));
2060
+ const useDbActionRows = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowsSlot) }));
2061
+ //#endregion
2062
+ //#region src/actions/gate-interceptor.ts
2063
+ const GATE_PRIORITY = TInterceptorPriority.AFTER_GUARD;
2064
+ function injectBoundTable(table) {
2065
+ const ctx = current();
2066
+ if (ctx.has(boundTableKey)) return;
2067
+ const controller = useControllerContext(ctx).getController();
2068
+ if (isAsDbReadableControllerInstance(controller)) {
2069
+ ctx.set(boundTableKey, controller.readable);
2070
+ return;
2071
+ }
2072
+ if (table != null) ctx.set(boundTableKey, table);
2073
+ }
2074
+ function buildGateInterceptor(opts) {
2075
+ const { action, level, disabled, onDisabledRows, table } = opts;
2076
+ return defineBeforeInterceptor(async () => {
2077
+ injectBoundTable(table);
2078
+ const ctx = current();
2079
+ if (level === "row") {
2080
+ const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
2081
+ assertVerdictLength(action, verdicts, 1);
2082
+ if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot));
2083
+ return;
2084
+ }
2085
+ const ids = await ctx.get(dbActionIdsSlot);
2086
+ const rows = await ctx.get(dbActionRowsSlot);
2087
+ const existingRows = [];
2088
+ for (const row of rows) if (row !== void 0) existingRows.push(row);
2089
+ const verdicts = disabled(existingRows);
2090
+ assertVerdictLength(action, verdicts, existingRows.length);
2091
+ const failingIds = [];
2092
+ const passingRows = [];
2093
+ const passingIds = [];
2094
+ let verdictIndex = 0;
2095
+ for (let i = 0; i < ids.length; i++) {
2096
+ const row = rows[i];
2097
+ if (row === void 0 || verdicts[verdictIndex++]) failingIds.push(ids[i]);
2098
+ else {
2099
+ passingRows.push(row);
2100
+ passingIds.push(ids[i]);
2101
+ }
2102
+ }
2103
+ if (onDisabledRows === "skip") {
2104
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
2105
+ if (failingIds.length > 0) {
2106
+ ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
2107
+ ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
2108
+ }
2109
+ return;
2110
+ }
2111
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
2112
+ }, GATE_PRIORITY);
2113
+ }
2114
+ /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
2115
+ function buildThinInterceptor(opts) {
2116
+ const { table } = opts;
2117
+ return defineBeforeInterceptor(() => {
2118
+ injectBoundTable(table);
2119
+ }, GATE_PRIORITY);
2120
+ }
2121
+ //#endregion
2122
+ //#region src/actions/db-action.decorator.ts
2123
+ /**
2124
+ * Mark a controller method as a database action surfaced via `/meta`. Writes
2125
+ * `MOOST_DB_ACTION` metadata and registers a Moost interceptor when needed
2126
+ * (gate when `disabled` is set, thin bound-table injector when only
2127
+ * `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
2128
+ * is undefined and emits a warning.
2129
+ *
2130
+ * Generic over `TRow` (annotate at the call site: `@DbAction<Order>(...)`)
2131
+ * and `R` (the literal `requiredFields` tuple, inferred via `const R`).
2132
+ * The `disabled` predicate's `rows` argument is type-narrowed to
2133
+ * `Pick<FlatOf<TRow>, R[number]>[]`.
2134
+ */
2135
+ function DbAction(name, opts = {}) {
2136
+ const mate = getMoostMate();
2137
+ return ((target, propertyKey, descriptor) => {
2138
+ const priorName = mate.read(target, propertyKey)?.[MOOST_DB_ACTION]?.name;
2139
+ if (priorName) console.warn(`${WARN_PREFIX} stacking @DbAction on the same method is undefined; declare one per method. Detected: "${priorName}" and "${name}".`);
2140
+ mate.decorate((current) => {
2141
+ const meta = current;
2142
+ return {
2143
+ ...current,
2144
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, {
2145
+ name,
2146
+ opts
2147
+ })
2148
+ };
2149
+ })(target, propertyKey, descriptor);
2150
+ if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
2151
+ const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
2152
+ const rawOpts = opts;
2153
+ if (typeof rawOpts.disabled === "function" && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
2154
+ action: name,
2155
+ level: scan.level,
2156
+ disabled: rawOpts.disabled,
2157
+ onDisabledRows: rawOpts.onDisabledRows ?? "reject",
2158
+ table: rawOpts.table
2159
+ }))(target, propertyKey, descriptor);
2160
+ else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: rawOpts.table }))(target, propertyKey, descriptor);
2161
+ return descriptor;
2162
+ });
2163
+ }
2164
+ //#endregion
2165
+ //#region src/actions/db-action-default.decorator.ts
2166
+ /**
2167
+ * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
2168
+ * Equivalent to passing `opts.default = true`. Decorator order does not matter.
2169
+ */
2170
+ function DbActionDefault() {
2171
+ return getMoostMate().decorate((current) => {
2172
+ const meta = current;
2173
+ return {
2174
+ ...current,
2175
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
2176
+ };
2177
+ });
2178
+ }
2179
+ //#endregion
2180
+ //#region src/actions/id-source.ts
1634
2181
  /**
1635
- * Parameter resolver that reads the primary key from the JSON request body
1636
- * and validates it against the bound table's PK schema.
2182
+ * Build a parameter decorator that reads its value from the cached ID wook
2183
+ * (single or multi). Validation runs inside the wook factory exactly once
2184
+ * per request, regardless of how many readers consume the value (`@DbActionID*`
2185
+ * resolver, gate interceptor, cached row wook, in-handler composables).
2186
+ *
2187
+ * Marks the param so {@link discoverActions} can infer the action's `level`.
2188
+ */
2189
+ function createIdParamDecorator(kind) {
2190
+ const mate = getMoostMate();
2191
+ const resolverName = kind === "id" ? "dbActionId" : "dbActionIds";
2192
+ const resolver = kind === "id" ? async () => current().get(dbActionIdSlot) : async () => current().get(dbActionIdsSlot);
2193
+ return ApplyDecorators(mate.decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(resolver, resolverName));
2194
+ }
2195
+ //#endregion
2196
+ //#region src/actions/db-action-id.decorator.ts
2197
+ /**
2198
+ * Parameter resolver that reads a row identifier from the JSON request body
2199
+ * and validates it against the bound table's legitimate identifiers.
2200
+ *
2201
+ * Body shape is always a JSON object — no scalar form. The object's key set
2202
+ * MUST exactly match one of the table's legitimate identifications:
1637
2203
  *
1638
- * - Single-field PK → JSON-encoded scalar (`"abc"`, `42`, `true`).
1639
- * - Composite PK → JSON object with all PK fields.
2204
+ * - Single-field PK → `{ id: "abc" }` (or whatever the PK prop is named).
2205
+ * - Composite PK → `{ tenantId: "...", userId: "..." }`.
2206
+ * - Single-field unique index → `{ slug: "alpha" }`.
2207
+ * - Compound unique index → `{ tenantId: "...", slug: "..." }`.
1640
2208
  *
1641
- * Validation is strict no type coercion. Mismatches throw a
2209
+ * Strict unknown fields are rejected, no type coercion. Mismatches throw a
1642
2210
  * `ValidatorError` which the existing validation interceptor surfaces as
1643
2211
  * HTTP 400 with the same envelope as DTO failures.
1644
2212
  *
1645
2213
  * Marks the param so {@link discoverActions} can infer the action's `level`
1646
2214
  * as `'row'`.
2215
+ *
2216
+ * Implementation note: the resolver is a thin reader of the cached ID wook
2217
+ * — validation logic lives in the wook factory, which runs once per request
2218
+ * regardless of how many readers consume the value.
1647
2219
  */
1648
- function DbActionPK() {
1649
- return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
2220
+ function DbActionID() {
2221
+ return createIdParamDecorator("id");
1650
2222
  }
1651
2223
  //#endregion
1652
- //#region src/actions/db-action-pks.decorator.ts
2224
+ //#region src/actions/db-action-ids.decorator.ts
1653
2225
  /**
1654
- * Parameter resolver that reads a JSON array of primary keys from the request
1655
- * body and validates each entry against the bound table's PK schema.
2226
+ * Parameter resolver that reads a JSON array of row identifiers from the
2227
+ * request body and validates each entry against the bound table.
2228
+ *
2229
+ * Body shape is always a JSON array of objects — no scalar form. Each
2230
+ * element's key set MUST exactly match one of the table's legitimate
2231
+ * identifications (PK or any unique index). Elements MAY mix shapes:
2232
+ * `[{ id: "1" }, { slug: "alpha" }]` is valid when both `id` is the PK
2233
+ * and `slug` is a unique index.
1656
2234
  *
1657
- * - Scalar PK JSON array of scalars (`["a","b","c"]`).
1658
- * - Composite PK JSON array of objects.
2235
+ * Strict unknown fields are rejected, no type coercion. Marks the param
2236
+ * so {@link discoverActions} can infer the action's `level` as `'rows'`.
1659
2237
  *
1660
- * Validation is strict no type coercion. Marks the param so
1661
- * {@link discoverActions} can infer the action's `level` as `'rows'`.
2238
+ * In `'rows'` skip mode the resolved value reflects the gate interceptor's
2239
+ * filtered subset (the cached ID slot is overwritten in place); see
2240
+ * {@link dbActionIdsSlot} for precedence details.
1662
2241
  */
1663
- function DbActionPKs() {
1664
- return createPkParamDecorator("pks", validateMultiPk, "dbActionPks");
2242
+ function DbActionIDs() {
2243
+ return createIdParamDecorator("ids");
2244
+ }
2245
+ //#endregion
2246
+ //#region src/actions/db-action-row.decorator.ts
2247
+ function createRowParamDecorator(metaKey, slot, resolverName) {
2248
+ return ApplyDecorators(getMoostMate().decorate(metaKey, true), Resolve(async () => current().get(slot), resolverName));
2249
+ }
2250
+ /**
2251
+ * Parameter decorator that injects the row whose identifier was supplied in
2252
+ * the request body.
2253
+ *
2254
+ * Marks the param so {@link discoverActions} infers the action's `level` as
2255
+ * `'row'`. Co-occurrence with `@DbActionRows()` (or any multi-cardinality
2256
+ * decorator) drops the action with a warning.
2257
+ *
2258
+ * In `'skip'` mode this returns the gate's filtered row; the original
2259
+ * request-body row is not retrievable.
2260
+ */
2261
+ function DbActionRow() {
2262
+ return createRowParamDecorator(MOOST_DB_ACTION_ROW, dbActionRowSlot, "dbActionRow");
2263
+ }
2264
+ /**
2265
+ * Parameter decorator that injects the rows fetched by the identifiers
2266
+ * supplied in the request body.
2267
+ *
2268
+ * Marks the param so {@link discoverActions} infers the action's `level` as
2269
+ * `'rows'`. In `'rows'` + `'skip'` mode the resolved value contains only the
2270
+ * gate's surviving rows.
2271
+ */
2272
+ function DbActionRows() {
2273
+ return createRowParamDecorator(MOOST_DB_ACTION_ROWS, dbActionRowsSlot, "dbActionRows");
1665
2274
  }
1666
2275
  //#endregion
1667
2276
  //#region src/actions/db-actions.decorator.ts
@@ -1672,10 +2281,9 @@ function DbActionPKs() {
1672
2281
  * the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
1673
2282
  * `@DbRowsActions`) to avoid repeating `level`.
1674
2283
  *
1675
- * The dictionary key serves as the action `name`. Entries do NOT bind any
1676
- * HTTP route the meta builder surfaces them in `/meta` only. For
1677
- * `processor: 'backend'`, the dev-supplied `value` MUST point to a real
1678
- * `@Post`-bound endpoint accepting the level-determined body shape.
2284
+ * Generic over `TRow` (annotate at the call site: `@DbActions<Order>(...)`)
2285
+ * and `D` (the literal dict, captured via `const D`). Each entry's
2286
+ * `disabled` predicate is type-narrowed by its own `requiredFields` literal.
1679
2287
  *
1680
2288
  * Multiple `@DbActions` (and shortcut) decorators on the same class
1681
2289
  * accumulate.
@@ -1697,11 +2305,16 @@ function DbRowsActions(dict) {
1697
2305
  }
1698
2306
  function classLevelActions(dict, forcedLevel) {
1699
2307
  const entries = [];
1700
- for (const [name, entry] of Object.entries(dict)) entries.push({
1701
- name,
1702
- entry,
1703
- forcedLevel
1704
- });
2308
+ for (const [name, entry] of Object.entries(dict)) {
2309
+ const merged = forcedLevel ? {
2310
+ ...entry,
2311
+ level: forcedLevel
2312
+ } : entry;
2313
+ entries.push({
2314
+ name,
2315
+ entry: merged
2316
+ });
2317
+ }
1705
2318
  return getMoostMate().decorate((current) => {
1706
2319
  const existing = current["atscript_db_actions"] ?? [];
1707
2320
  return {
@@ -1711,4 +2324,19 @@ function classLevelActions(dict, forcedLevel) {
1711
2324
  });
1712
2325
  }
1713
2326
  //#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 };
2327
+ //#region src/actions/per-row.ts
2328
+ /**
2329
+ * Lift a per-row predicate into the batch shape required by
2330
+ * `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
2331
+ * preserved — `true` from `fn` means the action is disabled for that row.
2332
+ *
2333
+ * ```ts
2334
+ * @DbAction<Order>('archive', {
2335
+ * requiredFields: ['status'],
2336
+ * disabled: perRow(r => r.status === 'archived'),
2337
+ * })
2338
+ * ```
2339
+ */
2340
+ const perRow = (fn) => (rows) => rows.map(fn);
2341
+ //#endregion
2342
+ export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, perRow, useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows, validationErrorTransform };