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