@atscript/moost-db 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { ValidatorError, defineAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
2
2
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
3
- import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, TInterceptorPriority, defineInterceptor, useControllerContext } from "moost";
3
+ import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, Resolve, TInterceptorPriority, defineInterceptor, getMoostMate, useControllerContext } from "moost";
4
4
  import { parseUrl } from "@uniqu/url";
5
5
  import { DbError } from "@atscript/db";
6
+ import { useBody } from "@wooksjs/http-body";
6
7
  //#region src/validation-interceptor.ts
7
8
  const dbErrorCodeToStatus = { CONFLICT: 409 };
8
9
  function transformValidationError(error, reply) {
@@ -179,6 +180,196 @@ function findSortOffender(sort, isAllowed) {
179
180
  }
180
181
  }
181
182
  //#endregion
183
+ //#region src/actions/keys.ts
184
+ /** Method-level metadata key — written by `@DbAction(name, opts)`. */
185
+ const MOOST_DB_ACTION = "atscript_db_action";
186
+ /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
187
+ const MOOST_DB_ACTIONS = "atscript_db_actions";
188
+ /** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
189
+ const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
190
+ /**
191
+ * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
192
+ * read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
193
+ * fields win), and write it back. `name` is empty until `@DbAction` provides
194
+ * one — `discoverActions` warns and drops actions with no name.
195
+ */
196
+ function mergeActionMeta(current, patch) {
197
+ const existing = current[MOOST_DB_ACTION];
198
+ return {
199
+ name: patch.name ?? existing?.name ?? "",
200
+ opts: {
201
+ ...existing?.opts,
202
+ ...patch.opts
203
+ }
204
+ };
205
+ }
206
+ //#endregion
207
+ //#region src/actions/discover.ts
208
+ /** Optional fields shared between method opts and class-level entries. */
209
+ const OPTIONAL_FIELDS = [
210
+ "icon",
211
+ "intent",
212
+ "description",
213
+ "order",
214
+ "default",
215
+ "promptText"
216
+ ];
217
+ const WARN_PREFIX = "[moost-db actions]";
218
+ const actionsCache = /* @__PURE__ */ new WeakMap();
219
+ /**
220
+ * Discover all actions declared on a controller and produce the `/meta` array.
221
+ * Reads class + method metadata via `getMoostMate()` and resolves bound POST
222
+ * paths through the Moost controller overview.
223
+ *
224
+ * Result is memoized per controller constructor — discovery walks every
225
+ * handler entry and reads decorator metadata, which is wasted work to repeat
226
+ * across instances.
227
+ */
228
+ function discoverActions(controllerCtor, app, logger) {
229
+ const cached = actionsCache.get(controllerCtor);
230
+ if (cached) return cached;
231
+ const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
232
+ const out = [];
233
+ collectMethodActions(controllerCtor, overview, logger, out);
234
+ collectClassActions(controllerCtor, logger, out);
235
+ applyDefaultPerLevel(out, logger);
236
+ actionsCache.set(controllerCtor, out);
237
+ return out;
238
+ }
239
+ function collectMethodActions(ctor, overview, logger, out) {
240
+ if (!overview) return;
241
+ const byMethod = /* @__PURE__ */ new Map();
242
+ for (const h of overview.handlers) {
243
+ const list = byMethod.get(h.method);
244
+ if (list) list.push(h);
245
+ else byMethod.set(h.method, [h]);
246
+ }
247
+ for (const [methodName, handlers] of byMethod) {
248
+ const methodMeta = handlers[0].meta;
249
+ const action = methodMeta[MOOST_DB_ACTION];
250
+ if (!action) continue;
251
+ if (!action.name) {
252
+ logger.warn(`${WARN_PREFIX} method "${methodName}" has @DbActionDefault() but no @DbAction(name) — dropping`);
253
+ continue;
254
+ }
255
+ const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
256
+ if (!levelInfer) continue;
257
+ if (levelInfer.bodyConflict) {
258
+ logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs with @Body() — dropping`);
259
+ continue;
260
+ }
261
+ const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
262
+ if (!postEntry) {
263
+ logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
264
+ continue;
265
+ }
266
+ const path = postEntry.registeredAs[0]?.path;
267
+ if (!path) {
268
+ logger.warn(`${WARN_PREFIX} action "${action.name}" — POST handler ${methodName} has no registered path — dropping`);
269
+ continue;
270
+ }
271
+ const label = action.opts.label ?? methodMeta.label;
272
+ if (!label) {
273
+ logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
274
+ continue;
275
+ }
276
+ const info = {
277
+ name: action.name,
278
+ label,
279
+ level: levelInfer.level,
280
+ processor: "backend",
281
+ value: path
282
+ };
283
+ copyOptionalFields(info, action.opts);
284
+ out.push(info);
285
+ }
286
+ }
287
+ function inferMethodLevel(params, actionName, logger) {
288
+ let hasPk = false;
289
+ let hasPks = false;
290
+ let hasBody = false;
291
+ for (const p of params) {
292
+ const kind = p[MOOST_DB_ACTION_PARAM];
293
+ if (kind === "pk") hasPk = true;
294
+ else if (kind === "pks") hasPks = true;
295
+ if (p.paramSource === "BODY") hasBody = true;
296
+ }
297
+ if (hasPk && hasPks) {
298
+ logger.warn(`${WARN_PREFIX} action "${actionName}" has both @DbActionPK and @DbActionPKs — dropping`);
299
+ return null;
300
+ }
301
+ const level = hasPk ? "row" : hasPks ? "rows" : "table";
302
+ return {
303
+ level,
304
+ bodyConflict: hasBody && level !== "table"
305
+ };
306
+ }
307
+ function collectClassActions(ctor, logger, out) {
308
+ const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
309
+ if (!list) return;
310
+ for (const { name, entry, forcedLevel } of list) {
311
+ const built = buildClassEntry(name, entry, forcedLevel, logger);
312
+ if (built) out.push(built);
313
+ }
314
+ }
315
+ function buildClassEntry(name, entry, forcedLevel, logger) {
316
+ const level = forcedLevel ?? entry.level;
317
+ if (!level) {
318
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
319
+ return null;
320
+ }
321
+ if (!entry.label) {
322
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
323
+ return null;
324
+ }
325
+ const processor = entry.processor;
326
+ let value;
327
+ if (processor === "navigate" || processor === "backend") {
328
+ const v = entry.value;
329
+ if (typeof v !== "string" || v === "") {
330
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" with processor "${processor}" requires a non-empty "value" — dropping`);
331
+ return null;
332
+ }
333
+ value = v;
334
+ } else if (processor === "custom") {
335
+ const v = entry.value;
336
+ if (v !== void 0 && v !== null) {
337
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" with processor "custom" forbids "value" (always derived from the dict key) — dropping`);
338
+ return null;
339
+ }
340
+ value = name;
341
+ } else {
342
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" has unknown processor "${String(processor)}" — dropping`);
343
+ return null;
344
+ }
345
+ const info = {
346
+ name,
347
+ label: entry.label,
348
+ level,
349
+ processor,
350
+ value
351
+ };
352
+ copyOptionalFields(info, entry);
353
+ return info;
354
+ }
355
+ function applyDefaultPerLevel(actions, logger) {
356
+ const winners = /* @__PURE__ */ new Map();
357
+ for (const a of actions) {
358
+ if (!a.default) continue;
359
+ const existing = winners.get(a.level);
360
+ if (existing) {
361
+ a.default = false;
362
+ logger.warn(`${WARN_PREFIX} duplicate default action at level "${a.level}": "${existing}" wins, "${a.name}" demoted`);
363
+ } else winners.set(a.level, a.name);
364
+ }
365
+ }
366
+ function copyOptionalFields(info, source) {
367
+ for (const key of OPTIONAL_FIELDS) {
368
+ const value = source[key];
369
+ if (value !== void 0) info[key] = value;
370
+ }
371
+ }
372
+ //#endregion
182
373
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
183
374
  function __decorateMetadata(k, v) {
184
375
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
@@ -281,13 +472,6 @@ let AsReadableController = class AsReadableController {
281
472
  }
282
473
  };
283
474
  }
284
- /**
285
- * Whether this controller is read-only (no write endpoints).
286
- * Returns `true` by default; {@link AsDbController} overrides to `false`.
287
- */
288
- _isReadOnly() {
289
- return true;
290
- }
291
475
  _queryControlsValidator;
292
476
  _pagesControlsValidator;
293
477
  _getOneControlsValidator;
@@ -347,36 +531,60 @@ let AsReadableController = class AsReadableController {
347
531
  return item;
348
532
  }
349
533
  /**
350
- * **GET /meta** — returns the bound interface's metadata envelope.
351
- *
352
- * Base implementation delegates to {@link buildMetaResponse}, which subclasses
353
- * override to add source-specific fields (relations, searchable flags, etc.).
354
- * The response is cached on the instance; async overrides must cache any
355
- * extra enrichment themselves.
534
+ * **GET /meta** — returns the bound interface's metadata envelope. The
535
+ * static envelope is cached; {@link applyMetaOverlay} runs per request so
536
+ * subclasses can prune the response by principal.
356
537
  */
357
538
  async meta() {
358
- if (this._metaResponse) return this._metaResponse;
359
- const response = await this.buildMetaResponse();
360
- this._metaResponse = response;
361
- return response;
539
+ if (!this._metaResponse) this._metaResponse = this.buildMetaResponse();
540
+ return this.applyMetaOverlay(this._metaResponse);
362
541
  }
363
542
  /**
364
543
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
365
- * fields. Defaults return a minimal envelope with the serialized type and the
366
- * read-only flag; value-help dicts populate their capability hints here.
544
+ * fields. Subclasses that fully replace the envelope must call
545
+ * {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
546
+ * decorators and CRUD permissions still surface.
367
547
  */
368
- async buildMetaResponse() {
548
+ buildMetaResponse() {
369
549
  return {
370
550
  searchable: false,
371
551
  vectorSearchable: false,
372
552
  searchIndexes: [],
373
553
  primaryKeys: [],
374
- readOnly: this._isReadOnly(),
375
554
  relations: [],
376
555
  fields: {},
377
- type: this.getSerializedType()
556
+ type: this.getSerializedType(),
557
+ actions: this.buildActions(),
558
+ crud: this.buildCrud()
378
559
  };
379
560
  }
561
+ /**
562
+ * Discovers `@DbAction*` and `@DbActions`-style class metadata on this
563
+ * controller and produces the `actions` array. Returns `[]` for value-help
564
+ * controllers — see {@link AsValueHelpController#buildMetaResponse}.
565
+ */
566
+ buildActions() {
567
+ return discoverActions(this.constructor, this.app, this.logger);
568
+ }
569
+ /**
570
+ * Declares the built-in CRUD operations this controller exposes. Subclasses
571
+ * override to add their keys; the bare base only exposes `/meta`. See
572
+ * `docs/http/permissions.md` for the wire shape and overlay rules.
573
+ */
574
+ buildCrud() {
575
+ return {};
576
+ }
577
+ /**
578
+ * Per-request overlay applied to the cached `/meta` envelope. Default no-op.
579
+ * Subclasses may shallow-clone and prune `crud` keys, `crud[op]` arrays, or
580
+ * `actions[]` based on the current request principal (read via Moost
581
+ * composables). The cached envelope MUST NOT be mutated — see
582
+ * `docs/http/permissions.md` for the full contract, including the
583
+ * "discoverability only" caveat.
584
+ */
585
+ applyMetaOverlay(meta) {
586
+ return meta;
587
+ }
380
588
  };
381
589
  __decorate([
382
590
  Get("meta"),
@@ -455,6 +663,23 @@ const ReadableController = (readable, prefix) => {
455
663
  */
456
664
  const ViewController = ReadableController;
457
665
  //#endregion
666
+ //#region src/permissions/crud-controls.ts
667
+ /**
668
+ * Static control whitelists per read op. Each list is the matching DTO's
669
+ * `$`-properties (stripped) plus the URL-grammar extras (`filter`,
670
+ * `insights`, `groupBy`) that bypass the DTO. The DTO is the single source of
671
+ * truth — adding a `$control` there auto-extends the whitelist here.
672
+ */
673
+ const dtoControls = (Dto) => [...Dto.type.props.keys()].map((k) => k.startsWith("$") ? k.slice(1) : k);
674
+ const QUERY_CONTROLS = [
675
+ "filter",
676
+ "insights",
677
+ ...dtoControls(QueryControlsDto),
678
+ "groupBy"
679
+ ];
680
+ const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
681
+ const ONE_CONTROLS = dtoControls(GetOneControlsDto);
682
+ //#endregion
458
683
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
459
684
  function __decorateParam(paramIndex, decorator) {
460
685
  return function(target, key) {
@@ -704,7 +929,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
704
929
  * vector-searchable flags, field-descriptor-derived filter/sort hints, and
705
930
  * the configured primary keys.
706
931
  */
707
- async buildMetaResponse() {
932
+ buildMetaResponse() {
708
933
  const relations = [];
709
934
  for (const [name, rel] of this.readable.relations) relations.push({
710
935
  name,
@@ -729,10 +954,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
729
954
  vectorSearchable: this.readable.isVectorSearchable(),
730
955
  searchIndexes: this.readable.getSearchIndexes(),
731
956
  primaryKeys: [...this.readable.primaryKeys],
732
- readOnly: this._isReadOnly(),
733
957
  relations,
734
958
  fields,
735
- type: this.getSerializedType()
959
+ type: this.getSerializedType(),
960
+ actions: this.buildActions(),
961
+ crud: this.buildCrud()
962
+ };
963
+ }
964
+ buildCrud() {
965
+ return {
966
+ ...super.buildCrud(),
967
+ query: [...QUERY_CONTROLS],
968
+ pages: [...PAGES_CONTROLS],
969
+ one: [...ONE_CONTROLS]
736
970
  };
737
971
  }
738
972
  };
@@ -782,8 +1016,14 @@ let AsDbController = class AsDbController extends AsDbReadableController {
782
1016
  constructor(table, app) {
783
1017
  super(table, app);
784
1018
  }
785
- _isReadOnly() {
786
- return false;
1019
+ buildCrud() {
1020
+ return {
1021
+ ...super.buildCrud(),
1022
+ insert: [],
1023
+ update: [],
1024
+ replace: [],
1025
+ remove: []
1026
+ };
787
1027
  }
788
1028
  /**
789
1029
  * Intercepts write operations. Return `undefined` to abort.
@@ -1000,7 +1240,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1000
1240
  * client picker UI (which controls to render); the server does not enforce
1001
1241
  * these flags at request time.
1002
1242
  */
1003
- async buildMetaResponse() {
1243
+ buildMetaResponse() {
1004
1244
  const fields = {};
1005
1245
  for (const [path, meta] of this.fieldMeta) fields[path] = {
1006
1246
  sortable: meta.has("ui.dict.sortable"),
@@ -1011,10 +1251,22 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
1011
1251
  vectorSearchable: false,
1012
1252
  searchIndexes: [],
1013
1253
  primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1014
- readOnly: this._isReadOnly(),
1015
1254
  relations: [],
1016
1255
  fields,
1017
- type: this.getSerializedType()
1256
+ type: this.getSerializedType(),
1257
+ actions: [],
1258
+ crud: this.buildCrud()
1259
+ };
1260
+ }
1261
+ buildActions() {
1262
+ return [];
1263
+ }
1264
+ buildCrud() {
1265
+ return {
1266
+ ...super.buildCrud(),
1267
+ query: [...QUERY_CONTROLS],
1268
+ pages: [...PAGES_CONTROLS],
1269
+ one: [...ONE_CONTROLS]
1018
1270
  };
1019
1271
  }
1020
1272
  };
@@ -1211,4 +1463,252 @@ function applySelect(rows, select) {
1211
1463
  });
1212
1464
  }
1213
1465
  //#endregion
1214
- export { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, validationErrorTransform };
1466
+ //#region src/actions/db-action.decorator.ts
1467
+ /**
1468
+ * Mark a controller method as a database action surfaced via `/meta`.
1469
+ *
1470
+ * Metadata-only — pair with `@Post(...)` for Moost to bind the route. The
1471
+ * meta builder reads this metadata plus the bound POST path lazily and
1472
+ * emits the action with `processor: 'backend'`. Order vs.
1473
+ * `@DbActionDefault()` does not matter — both merge into the same slot.
1474
+ *
1475
+ * @example
1476
+ * ```ts
1477
+ * @Post('actions/block')
1478
+ * @DbAction('block', { label: 'Block', icon: 'i-as-block', intent: 'negative' })
1479
+ * async blockUser(@DbActionPK() id: string) { ... }
1480
+ * ```
1481
+ */
1482
+ function DbAction(name, opts = {}) {
1483
+ return getMoostMate().decorate((current) => {
1484
+ const meta = current;
1485
+ return {
1486
+ ...current,
1487
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1488
+ name,
1489
+ opts
1490
+ })
1491
+ };
1492
+ });
1493
+ }
1494
+ //#endregion
1495
+ //#region src/actions/db-action-default.decorator.ts
1496
+ /**
1497
+ * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1498
+ * Equivalent to passing `opts.default = true`. Decorator order does not matter.
1499
+ */
1500
+ function DbActionDefault() {
1501
+ return getMoostMate().decorate((current) => {
1502
+ const meta = current;
1503
+ return {
1504
+ ...current,
1505
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
1506
+ };
1507
+ });
1508
+ }
1509
+ //#endregion
1510
+ //#region src/actions/pk-source.ts
1511
+ /**
1512
+ * Extract the PK validation source from a controller instance. Looks for
1513
+ * `readable` (set by {@link AsDbReadableController}) or `table` (set by
1514
+ * {@link AsDbController}).
1515
+ *
1516
+ * If the controller has no typed table attached (e.g. a value-help
1517
+ * controller, or a plain Moost controller without `@TableController`),
1518
+ * throws an HTTP 500 — this is a **server misconfiguration**, not a client
1519
+ * error. The body parser has nothing to validate against, so the request
1520
+ * cannot proceed. Use `@Body()` and parse the PK manually if you need to
1521
+ * accept PK-shaped bodies on a controller without an attached table.
1522
+ */
1523
+ function resolvePkSource(controller) {
1524
+ const c = controller;
1525
+ const candidate = c.readable ?? c.table;
1526
+ if (!isPkValidationSource(candidate)) throw new HttpError(500, "@DbActionPK/@DbActionPKs requires a controller with an attached table (via @TableController / @ReadableController). Use @Body() instead if your controller has no typed table.");
1527
+ return candidate;
1528
+ }
1529
+ function isPkValidationSource(value) {
1530
+ if (!value || typeof value !== "object") return false;
1531
+ const v = value;
1532
+ return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1533
+ }
1534
+ /**
1535
+ * Build a parameter decorator that parses the JSON request body, validates
1536
+ * it against the bound table's PK schema with `validate`, and tags the param
1537
+ * so {@link discoverActions} can infer the action's `level`.
1538
+ */
1539
+ function createPkParamDecorator(kind, validate, resolverName) {
1540
+ return ApplyDecorators(getMoostMate().decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => {
1541
+ const body = await useBody().parseBody();
1542
+ validate(body, resolvePkSource(useControllerContext().getController()));
1543
+ return body;
1544
+ }, resolverName));
1545
+ }
1546
+ //#endregion
1547
+ //#region src/actions/pk-validation.ts
1548
+ /**
1549
+ * Validate a JSON-decoded body against a single-row PK shape (scalar or
1550
+ * composite). Throws {@link ValidatorError} with structured `errors` so the
1551
+ * existing validation interceptor returns HTTP 400.
1552
+ */
1553
+ function validateSinglePk(body, source, path = "") {
1554
+ const errors = collectPkErrors(body, source, path);
1555
+ if (errors.length > 0) throw new ValidatorError(errors);
1556
+ }
1557
+ /**
1558
+ * Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
1559
+ * The body MUST be an array; each element is validated against the PK schema.
1560
+ */
1561
+ function validateMultiPk(body, source) {
1562
+ if (!Array.isArray(body)) throw new ValidatorError([{
1563
+ path: "",
1564
+ message: "Expected JSON array of primary keys",
1565
+ details: []
1566
+ }]);
1567
+ const errors = [];
1568
+ for (let i = 0; i < body.length; i++) errors.push(...collectPkErrors(body[i], source, `[${i}]`));
1569
+ if (errors.length > 0) throw new ValidatorError(errors);
1570
+ }
1571
+ function collectPkErrors(value, source, pathPrefix) {
1572
+ const pkFields = source.primaryKeys;
1573
+ if (pkFields.length === 0) return [{
1574
+ path: pathPrefix,
1575
+ message: "Table has no primary key configured",
1576
+ details: []
1577
+ }];
1578
+ const errors = [];
1579
+ if (pkFields.length === 1) {
1580
+ const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
1581
+ if (err) errors.push(err);
1582
+ return errors;
1583
+ }
1584
+ if (!isPlainObject(value)) {
1585
+ errors.push({
1586
+ path: pathPrefix,
1587
+ message: "Expected JSON object for composite primary key",
1588
+ details: []
1589
+ });
1590
+ return errors;
1591
+ }
1592
+ for (const fieldName of pkFields) {
1593
+ const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
1594
+ if (!(fieldName in value)) {
1595
+ errors.push({
1596
+ path: sub,
1597
+ message: `Missing primary-key field "${fieldName}"`,
1598
+ details: []
1599
+ });
1600
+ continue;
1601
+ }
1602
+ const fd = findFieldDescriptor(source, fieldName);
1603
+ const err = checkScalar(value[fieldName], fd, sub);
1604
+ if (err) errors.push(err);
1605
+ }
1606
+ return errors;
1607
+ }
1608
+ function findFieldDescriptor(source, name) {
1609
+ for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
1610
+ }
1611
+ function checkScalar(value, fd, path) {
1612
+ const expected = fd?.designType ?? "string";
1613
+ if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
1614
+ if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
1615
+ if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
1616
+ }
1617
+ function scalarMismatch(path, expected, value) {
1618
+ return {
1619
+ path,
1620
+ message: `Expected primary-key value to be ${expected}, got ${describe(value)}`,
1621
+ details: []
1622
+ };
1623
+ }
1624
+ function describe(value) {
1625
+ if (value === null) return "null";
1626
+ if (Array.isArray(value)) return "array";
1627
+ return typeof value;
1628
+ }
1629
+ function isPlainObject(value) {
1630
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1631
+ }
1632
+ //#endregion
1633
+ //#region src/actions/db-action-pk.decorator.ts
1634
+ /**
1635
+ * Parameter resolver that reads the primary key from the JSON request body
1636
+ * and validates it against the bound table's PK schema.
1637
+ *
1638
+ * - Single-field PK → JSON-encoded scalar (`"abc"`, `42`, `true`).
1639
+ * - Composite PK → JSON object with all PK fields.
1640
+ *
1641
+ * Validation is strict — no type coercion. Mismatches throw a
1642
+ * `ValidatorError` which the existing validation interceptor surfaces as
1643
+ * HTTP 400 with the same envelope as DTO failures.
1644
+ *
1645
+ * Marks the param so {@link discoverActions} can infer the action's `level`
1646
+ * as `'row'`.
1647
+ */
1648
+ function DbActionPK() {
1649
+ return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
1650
+ }
1651
+ //#endregion
1652
+ //#region src/actions/db-action-pks.decorator.ts
1653
+ /**
1654
+ * Parameter resolver that reads a JSON array of primary keys from the request
1655
+ * body and validates each entry against the bound table's PK schema.
1656
+ *
1657
+ * - Scalar PK → JSON array of scalars (`["a","b","c"]`).
1658
+ * - Composite PK → JSON array of objects.
1659
+ *
1660
+ * Validation is strict — no type coercion. Marks the param so
1661
+ * {@link discoverActions} can infer the action's `level` as `'rows'`.
1662
+ */
1663
+ function DbActionPKs() {
1664
+ return createPkParamDecorator("pks", validateMultiPk, "dbActionPks");
1665
+ }
1666
+ //#endregion
1667
+ //#region src/actions/db-actions.decorator.ts
1668
+ /**
1669
+ * Declare class-level actions on a controller. Entries are flat dicts with
1670
+ * `processor: 'navigate' | 'custom' | 'backend'` matching the `/meta` wire
1671
+ * shape (see {@link TDbActionsEntry}). Each entry MUST specify `level`. Use
1672
+ * the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
1673
+ * `@DbRowsActions`) to avoid repeating `level`.
1674
+ *
1675
+ * The dictionary key serves as the action `name`. Entries do NOT bind any
1676
+ * HTTP route — the meta builder surfaces them in `/meta` only. For
1677
+ * `processor: 'backend'`, the dev-supplied `value` MUST point to a real
1678
+ * `@Post`-bound endpoint accepting the level-determined body shape.
1679
+ *
1680
+ * Multiple `@DbActions` (and shortcut) decorators on the same class
1681
+ * accumulate.
1682
+ */
1683
+ function DbActions(dict) {
1684
+ return classLevelActions(dict);
1685
+ }
1686
+ /** Sugar for `@DbActions` with `level: 'table'` injected into each entry. */
1687
+ function DbTableActions(dict) {
1688
+ return classLevelActions(dict, "table");
1689
+ }
1690
+ /** Sugar for `@DbActions` with `level: 'row'` injected into each entry. */
1691
+ function DbRowActions(dict) {
1692
+ return classLevelActions(dict, "row");
1693
+ }
1694
+ /** Sugar for `@DbActions` with `level: 'rows'` injected into each entry. */
1695
+ function DbRowsActions(dict) {
1696
+ return classLevelActions(dict, "rows");
1697
+ }
1698
+ function classLevelActions(dict, forcedLevel) {
1699
+ const entries = [];
1700
+ for (const [name, entry] of Object.entries(dict)) entries.push({
1701
+ name,
1702
+ entry,
1703
+ forcedLevel
1704
+ });
1705
+ return getMoostMate().decorate((current) => {
1706
+ const existing = current["atscript_db_actions"] ?? [];
1707
+ return {
1708
+ ...current,
1709
+ [MOOST_DB_ACTIONS]: [...existing, ...entries]
1710
+ };
1711
+ });
1712
+ }
1713
+ //#endregion
1714
+ export { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionPK, DbActionPKs, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, validationErrorTransform };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -45,6 +45,7 @@
45
45
  "@atscript/typescript": "^0.1.50",
46
46
  "@moostjs/event-http": "^0.6.8",
47
47
  "@uniqu/core": "^0.1.5",
48
+ "@wooksjs/http-body": "^0.7.10",
48
49
  "moost": "^0.6.8",
49
50
  "unplugin-atscript": "^0.1.50"
50
51
  },
@@ -52,8 +53,9 @@
52
53
  "@atscript/typescript": "^0.1.50",
53
54
  "@moostjs/event-http": "^0.6.8",
54
55
  "@uniqu/core": "^0.1.5",
56
+ "@wooksjs/http-body": "^0.7.10",
55
57
  "moost": "^0.6.8",
56
- "@atscript/db": "^0.1.55"
58
+ "@atscript/db": "^0.1.57"
57
59
  },
58
60
  "scripts": {
59
61
  "postinstall": "asc -f dts",