@atscript/moost-db 0.1.58 → 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
@@ -98,7 +98,7 @@ var SelectControlDto = class {
98
98
  throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
99
99
  }
100
100
  };
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);
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);
102
102
  defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedType().designType("string").tags("string").annotate("expect.pattern", {
103
103
  pattern: "^\\d+$",
104
104
  flags: "u",
@@ -107,8 +107,8 @@ defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedTyp
107
107
  pattern: "^\\d+$",
108
108
  flags: "u",
109
109
  message: "Expected positive number"
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);
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);
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);
112
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);
113
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);
114
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);
@@ -209,7 +209,7 @@ const WARN_PREFIX = "[moost-db actions]";
209
209
  const MOOST_DB_ACTION = "atscript_db_action";
210
210
  /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
211
211
  const MOOST_DB_ACTIONS = "atscript_db_actions";
212
- /** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
212
+ /** Param-level metadata key — written by `@DbActionID()` / `@DbActionIDs()`. Drives level inference. */
213
213
  const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
214
214
  /** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
215
215
  const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
@@ -239,8 +239,8 @@ function scanParamLevel(params) {
239
239
  let hasBody = false;
240
240
  for (const p of params) {
241
241
  const kind = p[MOOST_DB_ACTION_PARAM];
242
- if (kind === "pk") single = true;
243
- else if (kind === "pks") multi = true;
242
+ if (kind === "id") single = true;
243
+ else if (kind === "ids") multi = true;
244
244
  if (p["atscript_db_action_row"]) {
245
245
  single = true;
246
246
  hasRowParam = true;
@@ -261,11 +261,7 @@ function scanParamLevel(params) {
261
261
  }
262
262
  //#endregion
263
263
  //#region src/actions/discover.ts
264
- /**
265
- * Pure structural-copy fields. `disabled` and `requiredFields` are handled
266
- * as special cases in {@link emitInfo} so the function-to-string transform
267
- * stays out of the copy loop.
268
- */
264
+ /** Structural-copy fields; `disabled` is handled separately in {@link emitInfo} (function-to-string transform). */
269
265
  const OPTIONAL_FIELDS = [
270
266
  "icon",
271
267
  "intent",
@@ -276,27 +272,29 @@ const OPTIONAL_FIELDS = [
276
272
  "shortcut"
277
273
  ];
278
274
  const actionsCache = /* @__PURE__ */ new WeakMap();
279
- /**
280
- * Discover all actions declared on a controller and produce the `/meta` array.
281
- * Reads class + method metadata via `getMoostMate()` and resolves bound POST
282
- * paths through the Moost controller overview.
283
- *
284
- * Result is memoized per controller constructor — discovery walks every
285
- * handler entry and reads decorator metadata, which is wasted work to repeat
286
- * across instances.
287
- */
275
+ const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
276
+ /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
288
277
  function discoverActions(controllerCtor, app, logger) {
289
278
  const cached = actionsCache.get(controllerCtor);
290
279
  if (cached) return cached;
291
280
  const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
292
281
  const out = [];
293
- collectMethodActions(controllerCtor, overview, logger, out);
294
- collectClassActions(controllerCtor, logger, out);
282
+ const seen = /* @__PURE__ */ new Set();
283
+ collectMethodActions(controllerCtor, overview, logger, out, seen);
284
+ collectClassActions(controllerCtor, logger, out, seen);
295
285
  applyDefaultPerLevel(out, logger);
296
286
  actionsCache.set(controllerCtor, out);
297
287
  return out;
298
288
  }
299
- 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) {
300
298
  if (!overview) return;
301
299
  const byMethod = /* @__PURE__ */ new Map();
302
300
  for (const h of overview.handlers) {
@@ -315,13 +313,17 @@ function collectMethodActions(ctor, overview, logger, out) {
315
313
  const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
316
314
  if (!levelInfer) continue;
317
315
  if (levelInfer.bodyConflict) {
318
- logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs/@DbActionRow*/@DbActionRows with @Body() — dropping`);
316
+ logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionID*/@DbActionIDs/@DbActionRow*/@DbActionRows with @Body() — dropping`);
319
317
  continue;
320
318
  }
321
319
  if (levelInfer.level === "table" && action.opts.disabled !== void 0) {
322
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`);
323
321
  continue;
324
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`);
325
+ continue;
326
+ }
325
327
  if (action.opts.disabled !== void 0 || levelInfer.hasRowParam) {
326
328
  const extendsReadable = isAsDbReadableControllerSubclass(ctor);
327
329
  const hasOptsTable = action.opts.table != null;
@@ -345,6 +347,10 @@ function collectMethodActions(ctor, overview, logger, out) {
345
347
  logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
346
348
  continue;
347
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
+ }
348
354
  const info = {
349
355
  name: action.name,
350
356
  label,
@@ -352,14 +358,18 @@ function collectMethodActions(ctor, overview, logger, out) {
352
358
  processor: "backend",
353
359
  value: path
354
360
  };
355
- emitInfo(info, action.opts, action.name, logger);
356
- out.push(info);
361
+ emitInfo(info, action.opts);
362
+ seen.add(action.name);
363
+ out.push({
364
+ info,
365
+ raw: action.opts
366
+ });
357
367
  }
358
368
  }
359
369
  function inferMethodLevel(params, actionName, logger) {
360
370
  const scan = scanParamLevel(params);
361
371
  if (scan.single && scan.multi) {
362
- logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionPK / @DbActionRow vs @DbActionPKs / @DbActionRows) — dropping`);
372
+ logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionID / @DbActionRow vs @DbActionIDs / @DbActionRows) — dropping`);
363
373
  return null;
364
374
  }
365
375
  return {
@@ -368,12 +378,22 @@ function inferMethodLevel(params, actionName, logger) {
368
378
  hasRowParam: scan.hasRowParam
369
379
  };
370
380
  }
371
- function collectClassActions(ctor, logger, out) {
381
+ function collectClassActions(ctor, logger, out, seen) {
372
382
  const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
373
383
  if (!list) return;
374
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
+ }
375
389
  const built = buildClassEntry(name, entry, logger);
376
- if (built) out.push(built);
390
+ if (built) {
391
+ seen.add(name);
392
+ out.push({
393
+ info: built,
394
+ raw: entry
395
+ });
396
+ }
377
397
  }
378
398
  }
379
399
  function buildClassEntry(name, entry, logger) {
@@ -390,6 +410,10 @@ function buildClassEntry(name, entry, logger) {
390
410
  logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` is not allowed at the 'table' level — dropping`);
391
411
  return null;
392
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
+ }
393
417
  const processor = entry.processor;
394
418
  let value;
395
419
  if (processor === "navigate" || processor === "backend") {
@@ -417,39 +441,27 @@ function buildClassEntry(name, entry, logger) {
417
441
  processor,
418
442
  value
419
443
  };
420
- emitInfo(info, entry, name, logger);
444
+ emitInfo(info, entry);
421
445
  return info;
422
446
  }
423
- function applyDefaultPerLevel(actions, logger) {
447
+ function applyDefaultPerLevel(envelopes, logger) {
424
448
  const winners = /* @__PURE__ */ new Map();
425
- for (const a of actions) {
426
- if (!a.default) continue;
427
- const existing = winners.get(a.level);
449
+ for (const { info } of envelopes) {
450
+ if (!info.default) continue;
451
+ const existing = winners.get(info.level);
428
452
  if (existing) {
429
- a.default = false;
430
- logger.warn(`${WARN_PREFIX} duplicate default action at level "${a.level}": "${existing}" wins, "${a.name}" demoted`);
431
- } 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);
432
456
  }
433
457
  }
434
- /**
435
- * Emit structural-copy fields plus `disabled` (stringified) and
436
- * `requiredFields` (forwarded verbatim — server doesn't auto-derive).
437
- * `requiredFields` without `disabled` is dropped with a warning before the
438
- * structural copy runs, so method-decorator and class-level-dict origins
439
- * stay symmetric.
440
- */
441
- function emitInfo(info, source, name, logger) {
458
+ /** Emit structural-copy fields plus stringified `disabled`. `requiredFields` is server-internal (never on the wire). */
459
+ function emitInfo(info, source) {
442
460
  const disabled = source.disabled;
443
461
  const hasDisabled = typeof disabled === "function";
444
- let requiredFields = source.requiredFields;
445
- if (!hasDisabled && requiredFields !== void 0) {
446
- logger.warn(`${WARN_PREFIX} action "${name}" has \`requiredFields\` without \`disabled\` — \`requiredFields\` is purely a UI hint and meaningless without a predicate. Dropping \`requiredFields\` from /meta.`);
447
- requiredFields = void 0;
448
- }
449
462
  copyOptionalFields(info, source);
450
463
  if (Array.isArray(info.promptText)) info.promptText = info.promptText.slice();
451
464
  if (hasDisabled) info.disabled = disabled.toString();
452
- if (Array.isArray(requiredFields)) info.requiredFields = requiredFields.slice();
453
465
  }
454
466
  function copyOptionalFields(info, source) {
455
467
  for (const key of OPTIONAL_FIELDS) {
@@ -457,6 +469,9 @@ function copyOptionalFields(info, source) {
457
469
  if (value !== void 0) info[key] = value;
458
470
  }
459
471
  }
472
+ function isNonEmptyStringArray(value) {
473
+ return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string");
474
+ }
460
475
  //#endregion
461
476
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
462
477
  function __decorateMetadata(k, v) {
@@ -639,6 +654,7 @@ let AsReadableController = class AsReadableController {
639
654
  vectorSearchable: false,
640
655
  searchIndexes: [],
641
656
  primaryKeys: [],
657
+ preferredId: [],
642
658
  relations: [],
643
659
  fields: {},
644
660
  type: this.getSerializedType(),
@@ -652,7 +668,7 @@ let AsReadableController = class AsReadableController {
652
668
  * controllers — see {@link AsValueHelpController#buildMetaResponse}.
653
669
  */
654
670
  buildActions() {
655
- return discoverActions(this.constructor, this.app, this.logger);
671
+ return discoverActions(this.constructor, this.app, this.logger).map((e) => e.info);
656
672
  }
657
673
  /**
658
674
  * Declares the built-in CRUD operations this controller exposes. Subclasses
@@ -687,6 +703,80 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
687
703
  Object
688
704
  ])], AsReadableController);
689
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
690
780
  //#region src/decorators.ts
691
781
  /**
692
782
  * DI token under which the {@link AtscriptDbReadable} instance
@@ -781,10 +871,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
781
871
  /** Reference to the underlying readable (table or view). */
782
872
  readable;
783
873
  _gates;
874
+ _preferredIdSet;
875
+ _compositeIdShapes;
876
+ _overlayIsNoOp;
784
877
  constructor(readable, app) {
785
878
  super(readable.type, readable.tableName, app, readable.isView ? "view" : "table");
786
879
  this.readable = readable;
787
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;
788
885
  }
789
886
  _buildGates() {
790
887
  const meta = this.readable.type.metadata;
@@ -852,16 +949,169 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
852
949
  transformProjection(projection) {
853
950
  return projection;
854
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
+ }
855
1106
  /**
856
1107
  * Extracts a composite identifier object from query params.
857
1108
  * Tries composite primary key first, then compound unique indexes.
858
1109
  */
859
1110
  extractCompositeId(query) {
860
- const pkFields = this.readable.primaryKeys;
861
- if (pkFields.length > 1) {
1111
+ for (const id of this._compositeIdShapes) {
862
1112
  const idObj = {};
863
1113
  let allPresent = true;
864
- for (const field of pkFields) {
1114
+ for (const field of id.fields) {
865
1115
  if (query[field] === void 0) {
866
1116
  allPresent = false;
867
1117
  break;
@@ -870,19 +1120,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
870
1120
  }
871
1121
  if (allPresent) return idObj;
872
1122
  }
873
- for (const index of this.readable.indexes.values()) {
874
- if (index.type !== "unique" || index.fields.length < 2) continue;
875
- const idObj = {};
876
- let allPresent = true;
877
- for (const indexField of index.fields) {
878
- if (query[indexField.name] === void 0) {
879
- allPresent = false;
880
- break;
881
- }
882
- idObj[indexField.name] = query[indexField.name];
883
- }
884
- if (allPresent) return idObj;
885
- }
886
1123
  return new HttpError(400, "Query params do not match any composite primary key or compound unique index");
887
1124
  }
888
1125
  /**
@@ -891,6 +1128,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
891
1128
  async query(url) {
892
1129
  const parsed = this.parseQueryString(url);
893
1130
  const controls = parsed.controls;
1131
+ this._coerceActionsControl(controls);
894
1132
  if (controls.$groupBy?.length) {
895
1133
  if (controls.$with?.length) return new HttpError(400, "Cannot combine $with and $groupBy in the same query");
896
1134
  if (parsed.insights) {
@@ -908,17 +1146,16 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
908
1146
  if (error) return error;
909
1147
  const gateError = this.checkGates(parsed.filter, controls, this._gates);
910
1148
  if (gateError) return gateError;
911
- 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)]);
912
1150
  if (controls.$count) return this.readable.count({
913
1151
  filter,
914
1152
  controls: {
915
1153
  ...controls,
916
- $select: select
1154
+ $select: rawSelect
917
1155
  }
918
1156
  });
919
- const searchTerm = controls.$search;
920
- const indexName = controls.$index;
921
- const vectorField = controls.$vector;
1157
+ const select = this.widenPreferredIdProjection(rawSelect);
1158
+ if (select instanceof HttpError) return select;
922
1159
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
923
1160
  const queryObj = {
924
1161
  filter,
@@ -929,19 +1166,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
929
1166
  $threshold: threshold
930
1167
  }
931
1168
  };
932
- if (vectorField !== void 0 && searchTerm) {
933
- const vector = await this.computeEmbedding(searchTerm, vectorField || void 0);
934
- if (vectorField) return this.readable.vectorSearch(vectorField, vector, queryObj);
935
- return this.readable.vectorSearch(vector, queryObj);
936
- }
937
- if (searchTerm && this.readable.isSearchable()) return this.readable.search(searchTerm, queryObj, indexName);
938
- 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;
939
1176
  }
940
1177
  /**
941
1178
  * **GET /pages** — returns paginated records with metadata.
942
1179
  */
943
1180
  async pages(url) {
944
1181
  const parsed = this.parseQueryString(url);
1182
+ this._coerceActionsControl(parsed.controls);
945
1183
  const error = this.validateParsed(parsed, "pages");
946
1184
  if (error) return error;
947
1185
  const controls = parsed.controls;
@@ -950,10 +1188,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
950
1188
  const page = Math.max(Number(controls.$page || 1), 1);
951
1189
  const size = Math.max(Number(controls.$size || 10), 1);
952
1190
  const skip = (page - 1) * size;
953
- const [filter, select] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
954
- const searchTerm = controls.$search;
955
- const indexName = controls.$index;
956
- 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;
957
1194
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
958
1195
  const query = {
959
1196
  filter,
@@ -965,13 +1202,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
965
1202
  $threshold: threshold
966
1203
  }
967
1204
  };
968
- let result;
969
- if (vectorField !== void 0 && searchTerm) {
970
- const vector = await this.computeEmbedding(searchTerm, vectorField || void 0);
971
- if (vectorField) result = await this.readable.vectorSearchWithCount(vectorField, vector, query);
972
- else result = await this.readable.vectorSearchWithCount(vector, query);
973
- } else if (searchTerm && this.readable.isSearchable()) result = await this.readable.searchWithCount(searchTerm, query, indexName);
974
- 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
+ });
975
1212
  return {
976
1213
  data: result.data,
977
1214
  page,
@@ -985,15 +1222,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
985
1222
  */
986
1223
  async getOne(id, url) {
987
1224
  const parsed = this.parseQueryString(url);
1225
+ this._coerceActionsControl(parsed.controls);
988
1226
  if (Object.keys(parsed.filter).length > 0) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
989
1227
  const error = this.validateParsed(parsed, "getOne");
990
1228
  if (error) return error;
991
- const select = await this.transformProjection(parsed.controls.$select);
992
- const controls = {
993
- ...parsed.controls,
994
- $select: select
995
- };
996
- 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);
997
1233
  }
998
1234
  /**
999
1235
  * **GET /one?field1=val1&field2=val2** — retrieves a single record by composite key
@@ -1003,12 +1239,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1003
1239
  const idObj = this.extractCompositeId(query);
1004
1240
  if (idObj instanceof HttpError) return idObj;
1005
1241
  const parsed = this.parseQueryString(url);
1006
- 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;
1007
1251
  const controls = {
1008
- ...parsed.controls,
1009
- $select: select
1252
+ ...parsedControls,
1253
+ $select: initialSelect
1010
1254
  };
1011
- 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;
1012
1265
  }
1013
1266
  /**
1014
1267
  * **GET /meta** — returns table/view metadata for UI.
@@ -1042,6 +1295,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1042
1295
  vectorSearchable: this.readable.isVectorSearchable(),
1043
1296
  searchIndexes: this.readable.getSearchIndexes(),
1044
1297
  primaryKeys: [...this.readable.primaryKeys],
1298
+ preferredId: [...this.readable.preferredId],
1045
1299
  relations,
1046
1300
  fields,
1047
1301
  type: this.getSerializedType(),
@@ -1340,6 +1594,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1340
1594
  vectorSearchable: false,
1341
1595
  searchIndexes: [],
1342
1596
  primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1597
+ preferredId: this.primaryKey ? [this.primaryKey] : [],
1343
1598
  relations: [],
1344
1599
  fields,
1345
1600
  type: this.getSerializedType(),
@@ -1554,8 +1809,8 @@ function applySelect(rows, select) {
1554
1809
  }
1555
1810
  //#endregion
1556
1811
  //#region src/actions/action-disabled-error.ts
1557
- function buildMessage(action, pks) {
1558
- if (pks !== void 0) return `Action "${action}" is disabled for ${pks.length} of the selected rows`;
1812
+ function buildMessage(action, ids) {
1813
+ if (ids !== void 0) return `Action "${action}" is disabled for ${ids.length} of the selected rows`;
1559
1814
  return `Action "${action}" is disabled for this row`;
1560
1815
  }
1561
1816
  /**
@@ -1563,95 +1818,110 @@ function buildMessage(action, pks) {
1563
1818
  * with Moost's existing error mapper to produce HTTP 409 with the wire body
1564
1819
  * defined by {@link ActionDisabledErrorBody}.
1565
1820
  *
1566
- * - `'row'`-level rejection: pass `(action, pk)` — the body emits `pk`.
1567
- * - `'rows'`-level rejection: pass `(action, undefined, pks)` — the body
1568
- * emits `pks` (the FULL list of failing PKs in reject mode; the FULL list
1569
- * of request PKs in skip mode with zero survivors).
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).
1570
1825
  */
1571
1826
  var ActionDisabledError = class extends HttpError {
1572
1827
  name = "ActionDisabledError";
1573
- constructor(action, pk, pks) {
1828
+ constructor(action, id, ids) {
1574
1829
  const body = {
1575
1830
  name: "ActionDisabledError",
1576
- message: buildMessage(action, pks),
1831
+ message: buildMessage(action, ids),
1577
1832
  statusCode: 409,
1578
1833
  action
1579
1834
  };
1580
- if (pks !== void 0) body.pks = pks;
1581
- else if (pk !== void 0) body.pk = pk;
1835
+ if (ids !== void 0) body.ids = ids;
1836
+ else if (id !== void 0) body.id = id;
1582
1837
  super(409, body);
1583
1838
  }
1584
1839
  };
1585
1840
  //#endregion
1586
- //#region src/actions/pk-validation.ts
1587
- function isPkValidationSource(value) {
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];
1855
+ }
1856
+ //#endregion
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;
1874
+ }
1875
+ function fieldsSig(fields) {
1876
+ return fields.toSorted().join("");
1877
+ }
1878
+ function isIdValidationSource(value) {
1588
1879
  if (!value || typeof value !== "object") return false;
1589
1880
  const v = value;
1590
- return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1881
+ return typeof v.getIdentifications === "function" && Array.isArray(v.fieldDescriptors);
1591
1882
  }
1592
- /**
1593
- * Validate a JSON-decoded body against a single-row PK shape (scalar or
1594
- * composite). Throws {@link ValidatorError} with structured `errors` so the
1595
- * existing validation interceptor returns HTTP 400.
1596
- */
1597
- function validateSinglePk(body, source, path = "") {
1598
- const errors = collectPkErrors(body, source, path);
1883
+ function validateSingleId(body, source, path = "") {
1884
+ const errors = collectIdErrors(body, source, path);
1599
1885
  if (errors.length > 0) throw new ValidatorError(errors);
1886
+ return body;
1600
1887
  }
1601
- /**
1602
- * Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
1603
- * The body MUST be an array; each element is validated against the PK schema.
1604
- */
1605
- function validateMultiPk(body, source) {
1888
+ function validateMultiId(body, source) {
1606
1889
  if (!Array.isArray(body)) throw new ValidatorError([{
1607
1890
  path: "",
1608
- message: "Expected JSON array of primary keys",
1891
+ message: "Expected JSON array of identifier objects",
1609
1892
  details: []
1610
1893
  }]);
1611
1894
  const errors = [];
1612
- 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}]`));
1613
1896
  if (errors.length > 0) throw new ValidatorError(errors);
1897
+ return body;
1614
1898
  }
1615
- function collectPkErrors(value, source, pathPrefix) {
1616
- const pkFields = source.primaryKeys;
1617
- 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 [{
1618
1913
  path: pathPrefix,
1619
- message: "Table has no primary key configured",
1914
+ message: `Identifier fields must exactly match one of: ${cache.formatted}`,
1620
1915
  details: []
1621
1916
  }];
1622
1917
  const errors = [];
1623
- if (pkFields.length === 1) {
1624
- const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
1625
- if (err) errors.push(err);
1626
- return errors;
1627
- }
1628
- if (!isPlainObject(value)) {
1629
- errors.push({
1630
- path: pathPrefix,
1631
- message: "Expected JSON object for composite primary key",
1632
- details: []
1633
- });
1634
- return errors;
1635
- }
1636
- for (const fieldName of pkFields) {
1918
+ for (const fieldName of match.fields) {
1637
1919
  const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
1638
- if (!(fieldName in value)) {
1639
- errors.push({
1640
- path: sub,
1641
- message: `Missing primary-key field "${fieldName}"`,
1642
- details: []
1643
- });
1644
- continue;
1645
- }
1646
- const fd = findFieldDescriptor(source, fieldName);
1647
- const err = checkScalar(value[fieldName], fd, sub);
1920
+ const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
1648
1921
  if (err) errors.push(err);
1649
1922
  }
1650
1923
  return errors;
1651
1924
  }
1652
- function findFieldDescriptor(source, name) {
1653
- for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
1654
- }
1655
1925
  function checkScalar(value, fd, path) {
1656
1926
  const expected = fd?.designType ?? "string";
1657
1927
  if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
@@ -1661,7 +1931,7 @@ function checkScalar(value, fd, path) {
1661
1931
  function scalarMismatch(path, expected, value) {
1662
1932
  return {
1663
1933
  path,
1664
- message: `Expected primary-key value to be ${expected}, got ${describe(value)}`,
1934
+ message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
1665
1935
  details: []
1666
1936
  };
1667
1937
  }
@@ -1674,105 +1944,115 @@ function isPlainObject(value) {
1674
1944
  return typeof value === "object" && value !== null && !Array.isArray(value);
1675
1945
  }
1676
1946
  //#endregion
1677
- //#region src/actions/pk-cache.ts
1947
+ //#region src/actions/id-cache.ts
1678
1948
  const boundTableKey = key("atscript_db_action_bound_table");
1679
1949
  function getActionTable(ctx) {
1680
- if (ctx.has(boundTableKey)) {
1681
- const fromSlot = ctx.get(boundTableKey);
1682
- if (fromSlot) return fromSlot;
1683
- }
1950
+ const fromSlot = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
1951
+ if (fromSlot) return fromSlot;
1684
1952
  const ctrl = useControllerContext(ctx).getController();
1685
- if (ctrl) {
1686
- const t = ctrl.readable ?? ctrl.table;
1687
- if (t) return t;
1688
- }
1689
- return null;
1953
+ return ctrl?.readable ?? ctrl?.table ?? null;
1690
1954
  }
1691
1955
  function noTableError(ctx) {
1692
- const cc = useControllerContext(ctx);
1693
- const ctrl = cc.getController();
1694
- const methodName = cc.getMethod();
1695
- let actionName;
1696
- if (ctrl && methodName) actionName = getMoostMate().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION]?.name;
1956
+ const actionName = readCurrentActionMeta(ctx)?.name;
1697
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.`);
1698
1958
  }
1699
- async function resolveValidatedPk(ctx, validate) {
1959
+ async function resolveValidatedId(ctx, validate) {
1700
1960
  const table = getActionTable(ctx);
1701
- if (!isPkValidationSource(table)) throw noTableError(ctx);
1961
+ if (!isIdValidationSource(table)) throw noTableError(ctx);
1702
1962
  const body = await useBody(ctx).parseBody();
1703
1963
  validate(body, table);
1704
1964
  return body;
1705
1965
  }
1706
- const dbActionPkSlot = cached((ctx) => resolveValidatedPk(ctx, validateSinglePk));
1707
- const dbActionPksSlot = cached(async (ctx) => {
1708
- return await resolveValidatedPk(ctx, validateMultiPk);
1966
+ const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
1967
+ const dbActionIdsSlot = cached(async (ctx) => {
1968
+ return await resolveValidatedId(ctx, validateMultiId);
1709
1969
  });
1710
- const useDbActionPk = defineWook((ctx) => ({ load: () => ctx.get(dbActionPkSlot) }));
1711
- const useDbActionPks = defineWook((ctx) => ({ load: () => ctx.get(dbActionPksSlot) }));
1970
+ const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
1971
+ const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
1712
1972
  //#endregion
1713
1973
  //#region src/actions/row-cache.ts
1714
1974
  function asFetchTable(value) {
1715
1975
  if (!value || typeof value !== "object") return null;
1716
1976
  const v = value;
1717
- return Array.isArray(v.primaryKeys) && typeof v.findById === "function" && typeof v.findMany === "function" ? v : null;
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;
1718
1991
  }
1719
- function noTable() {
1720
- throw new HttpError(500, `${WARN_PREFIX} cached row wook: no bound table`);
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;
1721
1998
  }
1722
1999
  async function loadRow(ctx) {
1723
- const pk = await ctx.get(dbActionPkSlot);
1724
- const row = await (asFetchTable(getActionTable(ctx)) ?? noTable()).findById(pk);
1725
- if (row == null) throw new HttpError(404, "Row not found for action PK");
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");
1726
2010
  return row;
1727
2011
  }
1728
2012
  async function loadRows(ctx) {
1729
- const pks = await ctx.get(dbActionPksSlot);
1730
- const table = asFetchTable(getActionTable(ctx)) ?? noTable();
1731
- if (pks.length === 0) return [];
1732
- const { primaryKeys } = table;
1733
- const rows = await table.findMany({ filter: buildPksFilter(pks, primaryKeys) });
1734
- if (primaryKeys.length === 1) {
1735
- const field = primaryKeys[0];
1736
- const byKey = /* @__PURE__ */ new Map();
1737
- for (const row of rows) byKey.set(row[field], row);
1738
- const ordered = [];
1739
- for (const pk of pks) {
1740
- const found = byKey.get(pk);
1741
- if (found !== void 0) ordered.push(found);
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);
1742
2035
  }
1743
- return ordered;
1744
- }
1745
- const byKey = /* @__PURE__ */ new Map();
1746
- for (const row of rows) byKey.set(compositeKey(row, primaryKeys), row);
1747
- const ordered = [];
1748
- for (const pk of pks) {
1749
- const found = byKey.get(compositeKey(pk, primaryKeys));
1750
- if (found !== void 0) ordered.push(found);
1751
- }
1752
- return ordered;
1753
- }
1754
- function buildPksFilter(pks, primaryKeys) {
1755
- if (primaryKeys.length === 1) return { [primaryKeys[0]]: { $in: pks } };
1756
- return { $or: pks.map((pk) => {
1757
- const obj = pk;
1758
- const clause = {};
1759
- for (const field of primaryKeys) clause[field] = obj[field];
1760
- return clause;
1761
- }) };
1762
- }
1763
- function compositeKey(obj, primaryKeys) {
1764
- let out = "";
1765
- for (const f of primaryKeys) {
1766
- if (out !== "") out += "\0";
1767
- const v = obj[f];
1768
- if (v === null) out += "n";
1769
- else if (v === void 0) out += "u";
1770
- else if (typeof v === "string") out += `s\x02${v}`;
1771
- else if (typeof v === "number") out += `n\x02${v}`;
1772
- else if (typeof v === "boolean") out += `b\x02${v}`;
1773
- else out += `j\x02${JSON.stringify(v)}`;
1774
2036
  }
1775
- return out;
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]));
1776
2056
  }
1777
2057
  const dbActionRowSlot = cached((ctx) => loadRow(ctx));
1778
2058
  const dbActionRowsSlot = cached((ctx) => loadRows(ctx));
@@ -1797,28 +2077,38 @@ function buildGateInterceptor(opts) {
1797
2077
  injectBoundTable(table);
1798
2078
  const ctx = current();
1799
2079
  if (level === "row") {
1800
- if (disabled(await ctx.get(dbActionRowSlot))) throw new ActionDisabledError(action, await ctx.get(dbActionPkSlot));
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));
1801
2083
  return;
1802
2084
  }
1803
- const pks = await ctx.get(dbActionPksSlot);
2085
+ const ids = await ctx.get(dbActionIdsSlot);
1804
2086
  const rows = await ctx.get(dbActionRowsSlot);
1805
- const failingPks = [];
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 = [];
1806
2092
  const passingRows = [];
1807
- const passingPks = [];
1808
- for (let i = 0; i < rows.length; i++) if (disabled(rows[i])) failingPks.push(pks[i]);
1809
- else {
1810
- passingRows.push(rows[i]);
1811
- passingPks.push(pks[i]);
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
+ }
1812
2102
  }
1813
2103
  if (onDisabledRows === "skip") {
1814
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...pks]);
1815
- if (failingPks.length > 0) {
2104
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
2105
+ if (failingIds.length > 0) {
1816
2106
  ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
1817
- ctx.set(dbActionPksSlot, Promise.resolve(passingPks));
2107
+ ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
1818
2108
  }
1819
2109
  return;
1820
2110
  }
1821
- if (failingPks.length > 0) throw new ActionDisabledError(action, void 0, failingPks);
2111
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
1822
2112
  }, GATE_PRIORITY);
1823
2113
  }
1824
2114
  /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
@@ -1836,6 +2126,11 @@ function buildThinInterceptor(opts) {
1836
2126
  * (gate when `disabled` is set, thin bound-table injector when only
1837
2127
  * `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
1838
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]>[]`.
1839
2134
  */
1840
2135
  function DbAction(name, opts = {}) {
1841
2136
  const mate = getMoostMate();
@@ -1854,14 +2149,15 @@ function DbAction(name, opts = {}) {
1854
2149
  })(target, propertyKey, descriptor);
1855
2150
  if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
1856
2151
  const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
1857
- if (!!opts.disabled && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
2152
+ const rawOpts = opts;
2153
+ if (typeof rawOpts.disabled === "function" && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
1858
2154
  action: name,
1859
2155
  level: scan.level,
1860
- disabled: opts.disabled,
1861
- onDisabledRows: opts.onDisabledRows ?? "reject",
1862
- table: opts.table
2156
+ disabled: rawOpts.disabled,
2157
+ onDisabledRows: rawOpts.onDisabledRows ?? "reject",
2158
+ table: rawOpts.table
1863
2159
  }))(target, propertyKey, descriptor);
1864
- else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: opts.table }))(target, propertyKey, descriptor);
2160
+ else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: rawOpts.table }))(target, propertyKey, descriptor);
1865
2161
  return descriptor;
1866
2162
  });
1867
2163
  }
@@ -1881,62 +2177,70 @@ function DbActionDefault() {
1881
2177
  });
1882
2178
  }
1883
2179
  //#endregion
1884
- //#region src/actions/pk-source.ts
2180
+ //#region src/actions/id-source.ts
1885
2181
  /**
1886
- * Build a parameter decorator that reads its value from the cached PK wook
2182
+ * Build a parameter decorator that reads its value from the cached ID wook
1887
2183
  * (single or multi). Validation runs inside the wook factory exactly once
1888
- * per request, regardless of how many readers consume the value (`@DbActionPK*`
2184
+ * per request, regardless of how many readers consume the value (`@DbActionID*`
1889
2185
  * resolver, gate interceptor, cached row wook, in-handler composables).
1890
2186
  *
1891
2187
  * Marks the param so {@link discoverActions} can infer the action's `level`.
1892
2188
  */
1893
- function createPkParamDecorator(kind) {
2189
+ function createIdParamDecorator(kind) {
1894
2190
  const mate = getMoostMate();
1895
- const slot = kind === "pk" ? dbActionPkSlot : dbActionPksSlot;
1896
- const resolverName = kind === "pk" ? "dbActionPk" : "dbActionPks";
1897
- return ApplyDecorators(mate.decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => current().get(slot), resolverName));
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));
1898
2194
  }
1899
2195
  //#endregion
1900
- //#region src/actions/db-action-pk.decorator.ts
2196
+ //#region src/actions/db-action-id.decorator.ts
1901
2197
  /**
1902
- * Parameter resolver that reads the primary key from the JSON request body
1903
- * and validates it against the bound table's PK schema.
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:
1904
2203
  *
1905
- * - Single-field PK → JSON-encoded scalar (`"abc"`, `42`, `true`).
1906
- * - 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: "..." }`.
1907
2208
  *
1908
- * Validation is strict no type coercion. Mismatches throw a
2209
+ * Strict unknown fields are rejected, no type coercion. Mismatches throw a
1909
2210
  * `ValidatorError` which the existing validation interceptor surfaces as
1910
2211
  * HTTP 400 with the same envelope as DTO failures.
1911
2212
  *
1912
2213
  * Marks the param so {@link discoverActions} can infer the action's `level`
1913
2214
  * as `'row'`.
1914
2215
  *
1915
- * Implementation note: the resolver is a thin reader of the cached PK wook
2216
+ * Implementation note: the resolver is a thin reader of the cached ID wook
1916
2217
  * — validation logic lives in the wook factory, which runs once per request
1917
2218
  * regardless of how many readers consume the value.
1918
2219
  */
1919
- function DbActionPK() {
1920
- return createPkParamDecorator("pk");
2220
+ function DbActionID() {
2221
+ return createIdParamDecorator("id");
1921
2222
  }
1922
2223
  //#endregion
1923
- //#region src/actions/db-action-pks.decorator.ts
2224
+ //#region src/actions/db-action-ids.decorator.ts
1924
2225
  /**
1925
- * Parameter resolver that reads a JSON array of primary keys from the request
1926
- * 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.
1927
2228
  *
1928
- * - Scalar PK JSON array of scalars (`["a","b","c"]`).
1929
- * - Composite PK JSON array of objects.
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.
1930
2234
  *
1931
- * Validation is strict no type coercion. Marks the param so
1932
- * {@link discoverActions} can infer the action's `level` as `'rows'`.
2235
+ * Strict unknown fields are rejected, no type coercion. Marks the param
2236
+ * so {@link discoverActions} can infer the action's `level` as `'rows'`.
1933
2237
  *
1934
2238
  * In `'rows'` skip mode the resolved value reflects the gate interceptor's
1935
- * filtered subset (the cached PK slot is overwritten in place); see
1936
- * {@link dbActionPksSlot} for precedence details.
2239
+ * filtered subset (the cached ID slot is overwritten in place); see
2240
+ * {@link dbActionIdsSlot} for precedence details.
1937
2241
  */
1938
- function DbActionPKs() {
1939
- return createPkParamDecorator("pks");
2242
+ function DbActionIDs() {
2243
+ return createIdParamDecorator("ids");
1940
2244
  }
1941
2245
  //#endregion
1942
2246
  //#region src/actions/db-action-row.decorator.ts
@@ -1944,9 +2248,8 @@ function createRowParamDecorator(metaKey, slot, resolverName) {
1944
2248
  return ApplyDecorators(getMoostMate().decorate(metaKey, true), Resolve(async () => current().get(slot), resolverName));
1945
2249
  }
1946
2250
  /**
1947
- * Parameter decorator that injects the row whose PK was supplied in the
1948
- * request body. Reads from the cached row wook (fetched once per request,
1949
- * shared with the gate interceptor when `disabled` is set).
2251
+ * Parameter decorator that injects the row whose identifier was supplied in
2252
+ * the request body.
1950
2253
  *
1951
2254
  * Marks the param so {@link discoverActions} infers the action's `level` as
1952
2255
  * `'row'`. Co-occurrence with `@DbActionRows()` (or any multi-cardinality
@@ -1959,8 +2262,8 @@ function DbActionRow() {
1959
2262
  return createRowParamDecorator(MOOST_DB_ACTION_ROW, dbActionRowSlot, "dbActionRow");
1960
2263
  }
1961
2264
  /**
1962
- * Parameter decorator that injects the rows-array fetched by primary keys
1963
- * from the request body. Reads from the cached row-array wook.
2265
+ * Parameter decorator that injects the rows fetched by the identifiers
2266
+ * supplied in the request body.
1964
2267
  *
1965
2268
  * Marks the param so {@link discoverActions} infers the action's `level` as
1966
2269
  * `'rows'`. In `'rows'` + `'skip'` mode the resolved value contains only the
@@ -1978,10 +2281,9 @@ function DbActionRows() {
1978
2281
  * the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
1979
2282
  * `@DbRowsActions`) to avoid repeating `level`.
1980
2283
  *
1981
- * The dictionary key serves as the action `name`. Entries do NOT bind any
1982
- * HTTP route the meta builder surfaces them in `/meta` only. For
1983
- * `processor: 'backend'`, the dev-supplied `value` MUST point to a real
1984
- * `@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.
1985
2287
  *
1986
2288
  * Multiple `@DbActions` (and shortcut) decorators on the same class
1987
2289
  * accumulate.
@@ -2022,4 +2324,19 @@ function classLevelActions(dict, forcedLevel) {
2022
2324
  });
2023
2325
  }
2024
2326
  //#endregion
2025
- export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionPK, DbActionPKs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, useDbActionPk, useDbActionPks, useDbActionRow, useDbActionRows, validationErrorTransform };
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 };