@atscript/moost-db 0.1.61 → 0.1.63

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
@@ -213,6 +213,21 @@ const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
213
213
  const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
214
214
  const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
215
215
  /**
216
+ * Param-level metadata key — written by `@InputForm(FormType)`. Carries the
217
+ * compiled `.as` class plus its `.name` so {@link discoverActions} can both
218
+ * emit `inputForm` on `/meta` and register the type in the controller's form
219
+ * registry for `GET /meta/form/:name`.
220
+ */
221
+ const MOOST_DB_ACTION_INPUT_FORM = "atscript_db_action_input_form";
222
+ /**
223
+ * Generic param-level metadata key — written by `@InputForm(FormType)`
224
+ * alongside {@link MOOST_DB_ACTION_INPUT_FORM}. Holds just the type ref so a
225
+ * generic atscript-aware Moost pipe (installed globally via
226
+ * `app.applyGlobalPipes(...)` or scoped via `@Pipe(...)`) can validate the
227
+ * resolved value without knowing about the moost-db-specific key.
228
+ */
229
+ const MOOST_ATSCRIPT_TYPE = "atscript_type";
230
+ /**
216
231
  * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
217
232
  * read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
218
233
  * fields win), and write it back. `name` is empty until `@DbAction` provides
@@ -235,6 +250,8 @@ function scanParamLevel(params) {
235
250
  let multi = false;
236
251
  let hasRowParam = false;
237
252
  let hasBody = false;
253
+ let inputForm;
254
+ let hasDuplicateInputForm = false;
238
255
  for (const p of params) {
239
256
  const kind = p[MOOST_DB_ACTION_PARAM];
240
257
  if (kind === "id") single = true;
@@ -248,13 +265,18 @@ function scanParamLevel(params) {
248
265
  hasRowParam = true;
249
266
  }
250
267
  if (p.paramSource === "BODY") hasBody = true;
268
+ const form = p[MOOST_DB_ACTION_INPUT_FORM];
269
+ if (form) if (inputForm) hasDuplicateInputForm = true;
270
+ else inputForm = form;
251
271
  }
252
272
  return {
253
273
  level: single && multi ? "table" : single ? "row" : multi ? "rows" : "table",
254
274
  single,
255
275
  multi,
256
276
  hasRowParam,
257
- hasBody
277
+ hasBody,
278
+ inputForm,
279
+ hasDuplicateInputForm
258
280
  };
259
281
  }
260
282
  //#endregion
@@ -271,6 +293,34 @@ const OPTIONAL_FIELDS = [
271
293
  ];
272
294
  const actionsCache = /* @__PURE__ */ new WeakMap();
273
295
  const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
296
+ /**
297
+ * Per-controller registry of form names → compiled `.as` classes, populated
298
+ * during {@link discoverActions} when a method param carries
299
+ * {@link MOOST_DB_ACTION_INPUT_FORM}. Backs `GET /meta/form/:name`.
300
+ *
301
+ * Same name + same type ref across multiple actions is fine (forms can be
302
+ * reused). Same name + *different* type refs is an ambiguity — discovery
303
+ * warns and drops the second action.
304
+ */
305
+ const formRegistry = /* @__PURE__ */ new WeakMap();
306
+ /** Lookup helper for `AsReadableController.metaForm()`. */
307
+ function getControllerFormType(ctor, name) {
308
+ return formRegistry.get(ctor)?.get(name);
309
+ }
310
+ function registerFormType(ctor, meta, actionName, logger) {
311
+ let map = formRegistry.get(ctor);
312
+ if (!map) {
313
+ map = /* @__PURE__ */ new Map();
314
+ formRegistry.set(ctor, map);
315
+ }
316
+ const existing = map.get(meta.name);
317
+ if (existing && existing !== meta.type) {
318
+ logger.warn(`${WARN_PREFIX} action "${actionName}" — form name "${meta.name}" already registered on this controller with a different type. Reusing the same FormType across actions is fine; clashing names are not — dropping`);
319
+ return false;
320
+ }
321
+ if (!existing) map.set(meta.name, meta.type);
322
+ return true;
323
+ }
274
324
  /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
275
325
  function discoverActions(controllerCtor, app, logger) {
276
326
  const cached = actionsCache.get(controllerCtor);
@@ -356,6 +406,10 @@ function collectMethodActions(ctor, overview, logger, out, seen) {
356
406
  processor: "backend",
357
407
  value: path
358
408
  };
409
+ if (levelInfer.inputForm) {
410
+ if (!registerFormType(ctor, levelInfer.inputForm, action.name, logger)) continue;
411
+ info.inputForm = levelInfer.inputForm.name;
412
+ }
359
413
  emitInfo(info, action.opts);
360
414
  seen.add(action.name);
361
415
  out.push({
@@ -370,10 +424,12 @@ function inferMethodLevel(params, actionName, logger) {
370
424
  logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionID / @DbActionRow vs @DbActionIDs / @DbActionRows) — dropping`);
371
425
  return null;
372
426
  }
427
+ if (scan.hasDuplicateInputForm) logger.warn(`${WARN_PREFIX} action "${actionName}" has more than one @InputForm() param — only the first is honored. Compose multiple inputs into a single form interface.`);
373
428
  return {
374
429
  level: scan.level,
375
430
  bodyConflict: scan.hasBody && scan.level !== "table",
376
- hasRowParam: scan.hasRowParam
431
+ hasRowParam: scan.hasRowParam,
432
+ inputForm: scan.inputForm
377
433
  };
378
434
  }
379
435
  function collectClassActions(ctor, logger, out, seen) {
@@ -484,6 +540,13 @@ function __decorate(decorators, target, key, desc) {
484
540
  return c > 3 && r && Object.defineProperty(target, key, r), r;
485
541
  }
486
542
  //#endregion
543
+ //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
544
+ function __decorateParam(paramIndex, decorator) {
545
+ return function(target, key) {
546
+ decorator(target, key, paramIndex);
547
+ };
548
+ }
549
+ //#endregion
487
550
  //#region src/as-readable.controller.ts
488
551
  var _ref$4;
489
552
  let AsReadableController = class AsReadableController {
@@ -499,6 +562,8 @@ let AsReadableController = class AsReadableController {
499
562
  _serializedType;
500
563
  /** Cached full meta response (computed lazily on first meta() call). */
501
564
  _metaResponse;
565
+ /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
566
+ _formSchemas = /* @__PURE__ */ new Map();
502
567
  constructor(boundType, controllerName, app, kindTag = "readable") {
503
568
  this.boundType = boundType;
504
569
  this.controllerName = controllerName;
@@ -626,6 +691,42 @@ let AsReadableController = class AsReadableController {
626
691
  const idx = url.indexOf("?");
627
692
  return (0, _uniqu_url.parseUrl)(idx >= 0 ? url.slice(idx + 1) : "");
628
693
  }
694
+ /**
695
+ * Parse a URL keeping only `$*` control keywords; report whether any
696
+ * non-control parts were present. Used by `/one` routes where the
697
+ * uniquery lexer cannot tokenise PK values containing `-` and other
698
+ * reserved chars, so non-control parts must be stripped before lexing.
699
+ * `/one/:id` rejects stray filter params with 400 via `hasNonControl`;
700
+ * `/one` (composite) ignores it because the composite-key params have
701
+ * already been extracted via `@Query()`.
702
+ */
703
+ parseControlsOnlyFromUrl(url) {
704
+ const idx = url.indexOf("?");
705
+ const qs = idx >= 0 ? url.slice(idx + 1) : "";
706
+ if (!qs) return {
707
+ parsed: (0, _uniqu_url.parseUrl)(""),
708
+ hasNonControl: false
709
+ };
710
+ const kept = [];
711
+ let hasNonControl = false;
712
+ for (const part of qs.split("&")) {
713
+ if (!part) continue;
714
+ const eq = part.indexOf("=");
715
+ const rawKey = eq === -1 ? part : part.slice(0, eq);
716
+ let key;
717
+ try {
718
+ key = decodeURIComponent(rawKey);
719
+ } catch {
720
+ key = rawKey;
721
+ }
722
+ if (key.startsWith("$")) kept.push(part);
723
+ else hasNonControl = true;
724
+ }
725
+ return {
726
+ parsed: (0, _uniqu_url.parseUrl)(kept.join("&")),
727
+ hasNonControl
728
+ };
729
+ }
629
730
  async returnOne(result) {
630
731
  const item = await result;
631
732
  if (!item) return new _moostjs_event_http.HttpError(404);
@@ -641,6 +742,25 @@ let AsReadableController = class AsReadableController {
641
742
  return this.applyMetaOverlay(this._metaResponse);
642
743
  }
643
744
  /**
745
+ * **GET /meta/form/:name** — returns the serialized schema of a form
746
+ * referenced by an action's `inputForm` field. The form name is the
747
+ * compiled `.as` class's `.name`, registered when an action's parameter is
748
+ * decorated with `@InputForm(FormType)`. Schemas are serialized once and
749
+ * cached per controller; the response uses the same annotation-allowlist
750
+ * policy as {@link getSerializeOptions}.
751
+ */
752
+ async metaForm(name) {
753
+ discoverActions(this.constructor, this.app, this.logger);
754
+ const formType = getControllerFormType(this.constructor, name);
755
+ if (!formType) throw new _moostjs_event_http.HttpError(404, `Unknown form "${name}"`);
756
+ let cached = this._formSchemas.get(name);
757
+ if (!cached) {
758
+ cached = (0, _atscript_typescript_utils.serializeAnnotatedType)(formType, this.getSerializeOptions());
759
+ this._formSchemas.set(name, cached);
760
+ }
761
+ return cached;
762
+ }
763
+ /**
644
764
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
645
765
  * fields. Subclasses that fully replace the envelope must call
646
766
  * {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
@@ -694,6 +814,13 @@ __decorate([
694
814
  __decorateMetadata("design:paramtypes", []),
695
815
  __decorateMetadata("design:returntype", Promise)
696
816
  ], AsReadableController.prototype, "meta", null);
817
+ __decorate([
818
+ (0, _moostjs_event_http.Get)("meta/form/:name"),
819
+ __decorateParam(0, (0, moost.Param)("name")),
820
+ __decorateMetadata("design:type", Function),
821
+ __decorateMetadata("design:paramtypes", [String]),
822
+ __decorateMetadata("design:returntype", Promise)
823
+ ], AsReadableController.prototype, "metaForm", null);
697
824
  AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
698
825
  Object,
699
826
  String,
@@ -856,13 +983,6 @@ const QUERY_CONTROLS = [
856
983
  const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
857
984
  const ONE_CONTROLS = dtoControls(GetOneControlsDto);
858
985
  //#endregion
859
- //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
860
- function __decorateParam(paramIndex, decorator) {
861
- return function(target, key) {
862
- decorator(target, key, paramIndex);
863
- };
864
- }
865
- //#endregion
866
986
  //#region src/as-db-readable.controller.ts
867
987
  var _ref$3, _ref2$2;
868
988
  let AsDbReadableController = class AsDbReadableController extends AsReadableController {
@@ -1214,9 +1334,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1214
1334
  * **GET /one/:id** — retrieves a single record by ID or unique property.
1215
1335
  */
1216
1336
  async getOne(id, url) {
1217
- const parsed = this.parseQueryString(url);
1337
+ const { parsed, hasNonControl } = this.parseControlsOnlyFromUrl(url);
1338
+ if (hasNonControl) return new _moostjs_event_http.HttpError(400, "Filtering is not allowed for \"one\" endpoint");
1218
1339
  this._coerceActionsControl(parsed.controls);
1219
- if (Object.keys(parsed.filter).length > 0) return new _moostjs_event_http.HttpError(400, "Filtering is not allowed for \"one\" endpoint");
1220
1340
  const error = this.validateParsed(parsed, "getOne");
1221
1341
  if (error) return error;
1222
1342
  const rawSelect = await this.transformProjection(parsed.controls.$select);
@@ -1231,7 +1351,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1231
1351
  async getOneComposite(query, url) {
1232
1352
  const idObj = this.extractIdShape(query);
1233
1353
  if (idObj instanceof _moostjs_event_http.HttpError) return idObj;
1234
- const parsed = this.parseQueryString(url);
1354
+ const { parsed } = this.parseControlsOnlyFromUrl(url);
1235
1355
  this._coerceActionsControl(parsed.controls);
1236
1356
  const rawSelect = await this.transformProjection(parsed.controls.$select);
1237
1357
  const select = this.widenPreferredIdProjection(rawSelect);
@@ -1847,6 +1967,29 @@ function readCurrentActionMeta(ctx) {
1847
1967
  return (0, moost.getMoostMate)().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION];
1848
1968
  }
1849
1969
  //#endregion
1970
+ //#region src/actions/input-form-cache.ts
1971
+ /**
1972
+ * Cached parse of the action request body. Centralises the shape check so
1973
+ * every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
1974
+ * reads through the same gate. An array or scalar root is rejected with the
1975
+ * same `ValidatorError` envelope as today's strict-shape ID failures.
1976
+ */
1977
+ const dbActionBodySlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
1978
+ const raw = await (0, _wooksjs_http_body.useBody)(ctx).parseBody();
1979
+ if (raw == null) return {};
1980
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new _atscript_typescript_utils.ValidatorError([{
1981
+ path: "",
1982
+ message: "Action body must be an object of shape { ids?, input? }"
1983
+ }]);
1984
+ return raw;
1985
+ });
1986
+ /** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
1987
+ const dbActionInputSlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
1988
+ return (await ctx.get(dbActionBodySlot)).input;
1989
+ });
1990
+ /** Composable for in-handler reads of the form input. */
1991
+ const useDbActionInput = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionInputSlot) }));
1992
+ //#endregion
1850
1993
  //#region src/actions/id-validation.ts
1851
1994
  const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
1852
1995
  function getSourceCache(source) {
@@ -1964,9 +2107,9 @@ function noTableError(ctx) {
1964
2107
  async function resolveValidatedId(ctx, validate) {
1965
2108
  const table = getActionTable(ctx);
1966
2109
  if (!isIdValidationSource(table)) throw noTableError(ctx);
1967
- const body = await (0, _wooksjs_http_body.useBody)(ctx).parseBody();
1968
- validate(body, table);
1969
- return body;
2110
+ const env = await ctx.get(dbActionBodySlot);
2111
+ validate(env.ids, table);
2112
+ return env.ids;
1970
2113
  }
1971
2114
  const dbActionIdSlot = (0, _wooksjs_event_core.cached)((ctx) => resolveValidatedId(ctx, validateSingleId));
1972
2115
  const dbActionIdsSlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
@@ -2325,6 +2468,43 @@ function classLevelActions(dict, forcedLevel) {
2325
2468
  });
2326
2469
  }
2327
2470
  //#endregion
2471
+ //#region src/actions/db-action-input-form.decorator.ts
2472
+ /**
2473
+ * Parameter decorator that injects the `input` field of the action request
2474
+ * envelope (`{ ids?, input? }`) into the handler.
2475
+ *
2476
+ * Pairs the resolved value with two pieces of param-level metadata:
2477
+ *
2478
+ * 1. {@link MOOST_DB_ACTION_INPUT_FORM} — the compiled `.as` class plus its
2479
+ * name, consumed by {@link discoverActions} to:
2480
+ * - emit `inputForm: FormType.name` on the action's `/meta` entry, and
2481
+ * - register the type in the controller's form registry so
2482
+ * `GET /meta/form/:name` can serve the serialized schema.
2483
+ * 2. {@link MOOST_ATSCRIPT_TYPE} — just the type ref, providing a generic
2484
+ * hook any atscript-aware Moost pipe can read without knowing about the
2485
+ * moost-db-specific key.
2486
+ *
2487
+ * Validation is intentionally *not* performed here. To validate `input`
2488
+ * against `FormType`, install an atscript validator pipe globally
2489
+ * (`app.applyGlobalPipes(...)`) or scope it via `@Pipe(...)`. The pipe reads
2490
+ * `MOOST_ATSCRIPT_TYPE` off the param and runs `FormType.validator()`.
2491
+ *
2492
+ * Only one `@InputForm()` per action is supported. To collect multiple
2493
+ * structured inputs, compose them into a single `.as` interface and pass an
2494
+ * array form on the field whose user-facing intent is "list of items".
2495
+ *
2496
+ * @param formType A compiled `.as` interface class (carries `.validator()`,
2497
+ * `.metadata`, etc.).
2498
+ */
2499
+ function InputForm(formType) {
2500
+ const mate = (0, moost.getMoostMate)();
2501
+ const meta = {
2502
+ type: formType,
2503
+ name: formType.name
2504
+ };
2505
+ return (0, moost.ApplyDecorators)(mate.decorate(MOOST_DB_ACTION_INPUT_FORM, meta), mate.decorate(MOOST_ATSCRIPT_TYPE, formType), (0, moost.Resolve)(async () => (0, _wooksjs_event_core.current)().get(dbActionInputSlot), "dbActionInputForm"));
2506
+ }
2507
+ //#endregion
2328
2508
  //#region src/actions/per-row.ts
2329
2509
  /**
2330
2510
  * Lift a per-row predicate into the batch shape required by
@@ -2381,6 +2561,9 @@ exports.DbActions = DbActions;
2381
2561
  exports.DbRowActions = DbRowActions;
2382
2562
  exports.DbRowsActions = DbRowsActions;
2383
2563
  exports.DbTableActions = DbTableActions;
2564
+ exports.InputForm = InputForm;
2565
+ exports.MOOST_ATSCRIPT_TYPE = MOOST_ATSCRIPT_TYPE;
2566
+ exports.MOOST_DB_ACTION_INPUT_FORM = MOOST_DB_ACTION_INPUT_FORM;
2384
2567
  exports.ONE_CONTROLS = ONE_CONTROLS;
2385
2568
  exports.PAGES_CONTROLS = PAGES_CONTROLS;
2386
2569
  exports.QUERY_CONTROLS = QUERY_CONTROLS;
@@ -2390,10 +2573,14 @@ exports.TABLE_DEF = TABLE_DEF;
2390
2573
  exports.TableController = TableController;
2391
2574
  exports.UseValidationErrorTransform = UseValidationErrorTransform;
2392
2575
  exports.ViewController = ViewController;
2576
+ exports.dbActionBodySlot = dbActionBodySlot;
2577
+ exports.dbActionInputSlot = dbActionInputSlot;
2393
2578
  exports.discoverActions = discoverActions;
2579
+ exports.getControllerFormType = getControllerFormType;
2394
2580
  exports.perRow = perRow;
2395
2581
  exports.useDbActionId = useDbActionId;
2396
2582
  exports.useDbActionIds = useDbActionIds;
2583
+ exports.useDbActionInput = useDbActionInput;
2397
2584
  exports.useDbActionRow = useDbActionRow;
2398
2585
  exports.useDbActionRows = useDbActionRows;
2399
2586
  exports.validationErrorTransform = validationErrorTransform;
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import * as _atscript_typescript_utils0 from "@atscript/typescript/utils";
2
- import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, Validator } from "@atscript/typescript/utils";
3
1
  import * as _uniqu_url0 from "@uniqu/url";
2
+ import { parseUrl } from "@uniqu/url";
3
+ import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, TSerializedAnnotatedType, Validator } from "@atscript/typescript/utils";
4
4
  import { AtscriptDbReadable, AtscriptDbTable, FilterExpr, FlatOf, TCrudOp, TCrudPermissions, TCrudPermissions as TCrudPermissions$1, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionIntent as TDbActionIntent$1, TDbActionLevel, TDbActionLevel as TDbActionLevel$1, TDbActionProcessor, TDbFieldMeta, TIdentification, TMetaResponse, Uniquery, UniqueryControls } from "@atscript/db";
5
5
  import { HttpError } from "@moostjs/event-http";
6
6
  import * as moost from "moost";
@@ -61,13 +61,15 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
61
61
  private _serializedType?;
62
62
  /** Cached full meta response (computed lazily on first meta() call). */
63
63
  private _metaResponse?;
64
+ /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
65
+ private _formSchemas;
64
66
  constructor(boundType: T, controllerName: string, app: Moost, kindTag?: string);
65
67
  /** Subclass contract: return `true` if `path` addresses a valid field on the bound source. */
66
68
  protected abstract hasField(path: string): boolean;
67
69
  /** Sets @db.http.path on the type metadata from the controller's computed prefix. */
68
70
  private _resolveHttpPath;
69
71
  /** Lazily serializes the bound type (after all controllers have set @db.http.path). */
70
- protected getSerializedType(): _atscript_typescript_utils0.TSerializedAnnotatedType;
72
+ protected getSerializedType(): TSerializedAnnotatedType;
71
73
  /**
72
74
  * One-time initialization hook. Override to seed data, register watchers, etc.
73
75
  */
@@ -105,6 +107,19 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
105
107
  */
106
108
  protected checkGates(filter: FilterExpr | undefined, controls: Record<string, unknown>, gates: ReadableGates): HttpError | undefined;
107
109
  protected parseQueryString(url: string): _uniqu_url0.UrlQuery;
110
+ /**
111
+ * Parse a URL keeping only `$*` control keywords; report whether any
112
+ * non-control parts were present. Used by `/one` routes where the
113
+ * uniquery lexer cannot tokenise PK values containing `-` and other
114
+ * reserved chars, so non-control parts must be stripped before lexing.
115
+ * `/one/:id` rejects stray filter params with 400 via `hasNonControl`;
116
+ * `/one` (composite) ignores it because the composite-key params have
117
+ * already been extracted via `@Query()`.
118
+ */
119
+ protected parseControlsOnlyFromUrl(url: string): {
120
+ parsed: ReturnType<typeof parseUrl>;
121
+ hasNonControl: boolean;
122
+ };
108
123
  protected returnOne(result: Promise<DataType | null>): Promise<DataType | HttpError>;
109
124
  /**
110
125
  * **GET /meta** — returns the bound interface's metadata envelope. The
@@ -112,6 +127,15 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
112
127
  * subclasses can prune the response by principal.
113
128
  */
114
129
  meta(): Promise<TMetaResponse>;
130
+ /**
131
+ * **GET /meta/form/:name** — returns the serialized schema of a form
132
+ * referenced by an action's `inputForm` field. The form name is the
133
+ * compiled `.as` class's `.name`, registered when an action's parameter is
134
+ * decorated with `@InputForm(FormType)`. Schemas are serialized once and
135
+ * cached per controller; the response uses the same annotation-allowlist
136
+ * policy as {@link getSerializeOptions}.
137
+ */
138
+ metaForm(name: string): Promise<TSerializedAnnotatedType>;
115
139
  /**
116
140
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
117
141
  * fields. Subclasses that fully replace the envelope must call
@@ -716,6 +740,38 @@ declare function DbRowActions<TRow = unknown, const D extends Record<string, unk
716
740
  /** Sugar for `@DbActions` with `level: 'rows'` injected into each entry. */
717
741
  declare function DbRowsActions<TRow = unknown, const D extends Record<string, unknown> = {}>(dict: D & ValidatedUnpinnedDict<TRow, D>): ClassDecorator;
718
742
  //#endregion
743
+ //#region src/actions/db-action-input-form.decorator.d.ts
744
+ /**
745
+ * Parameter decorator that injects the `input` field of the action request
746
+ * envelope (`{ ids?, input? }`) into the handler.
747
+ *
748
+ * Pairs the resolved value with two pieces of param-level metadata:
749
+ *
750
+ * 1. {@link MOOST_DB_ACTION_INPUT_FORM} — the compiled `.as` class plus its
751
+ * name, consumed by {@link discoverActions} to:
752
+ * - emit `inputForm: FormType.name` on the action's `/meta` entry, and
753
+ * - register the type in the controller's form registry so
754
+ * `GET /meta/form/:name` can serve the serialized schema.
755
+ * 2. {@link MOOST_ATSCRIPT_TYPE} — just the type ref, providing a generic
756
+ * hook any atscript-aware Moost pipe can read without knowing about the
757
+ * moost-db-specific key.
758
+ *
759
+ * Validation is intentionally *not* performed here. To validate `input`
760
+ * against `FormType`, install an atscript validator pipe globally
761
+ * (`app.applyGlobalPipes(...)`) or scope it via `@Pipe(...)`. The pipe reads
762
+ * `MOOST_ATSCRIPT_TYPE` off the param and runs `FormType.validator()`.
763
+ *
764
+ * Only one `@InputForm()` per action is supported. To collect multiple
765
+ * structured inputs, compose them into a single `.as` interface and pass an
766
+ * array form on the field whose user-facing intent is "list of items".
767
+ *
768
+ * @param formType A compiled `.as` interface class (carries `.validator()`,
769
+ * `.metadata`, etc.).
770
+ */
771
+ declare function InputForm<T extends TAtscriptAnnotatedType & {
772
+ readonly name: string;
773
+ }>(formType: T): ParameterDecorator;
774
+ //#endregion
719
775
  //#region src/actions/discover.d.ts
720
776
  /**
721
777
  * Pairs the wire-shaped `info` with the original decorator opts / dict entry,
@@ -726,6 +782,8 @@ interface TDbActionEnvelope {
726
782
  info: TDbActionInfo$1;
727
783
  raw: DbActionOpts | TDbActionsEntry;
728
784
  }
785
+ /** Lookup helper for `AsReadableController.metaForm()`. */
786
+ declare function getControllerFormType(ctor: Function, name: string): TAtscriptAnnotatedType | undefined;
729
787
  /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
730
788
  declare function discoverActions(controllerCtor: Function, app: Moost, logger: TConsoleBase): TDbActionEnvelope[];
731
789
  //#endregion
@@ -752,6 +810,94 @@ declare const useDbActionRows: _wooksjs_event_core0.WookComposable<{
752
810
  load: () => Promise<(Record<string, unknown> | undefined)[]>;
753
811
  }>;
754
812
  //#endregion
813
+ //#region src/actions/input-form-cache.d.ts
814
+ /**
815
+ * Wire-shape of an action request body. Both fields are optional:
816
+ *
817
+ * - `ids` — what previously sat at the body root: a single identifier object
818
+ * (`'row'`-level), an array of identifier objects (`'rows'`-level), or
819
+ * absent (`'table'`-level).
820
+ * - `input` — present only when the action declares an `@InputForm()`
821
+ * parameter; carries the form payload the user filled out.
822
+ */
823
+ interface DbActionEnvelope {
824
+ ids?: unknown;
825
+ input?: unknown;
826
+ }
827
+ /**
828
+ * Cached parse of the action request body. Centralises the shape check so
829
+ * every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
830
+ * reads through the same gate. An array or scalar root is rejected with the
831
+ * same `ValidatorError` envelope as today's strict-shape ID failures.
832
+ */
833
+ declare const dbActionBodySlot: moost.Cached<Promise<DbActionEnvelope>>;
834
+ /** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
835
+ declare const dbActionInputSlot: moost.Cached<Promise<unknown>>;
836
+ /** Composable for in-handler reads of the form input. */
837
+ declare const useDbActionInput: _wooksjs_event_core0.WookComposable<{
838
+ load: () => Promise<unknown>;
839
+ }>;
840
+ //#endregion
841
+ //#region src/actions/keys.d.ts
842
+ /** Method-level metadata key — written by `@DbAction(name, opts)`. */
843
+ declare const MOOST_DB_ACTION = "atscript_db_action";
844
+ /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
845
+ declare const MOOST_DB_ACTIONS = "atscript_db_actions";
846
+ /** Param-level metadata key — written by `@DbActionID()` / `@DbActionIDs()`. Drives level inference. */
847
+ declare const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
848
+ /** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
849
+ declare const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
850
+ declare const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
851
+ /**
852
+ * Param-level metadata key — written by `@InputForm(FormType)`. Carries the
853
+ * compiled `.as` class plus its `.name` so {@link discoverActions} can both
854
+ * emit `inputForm` on `/meta` and register the type in the controller's form
855
+ * registry for `GET /meta/form/:name`.
856
+ */
857
+ declare const MOOST_DB_ACTION_INPUT_FORM = "atscript_db_action_input_form";
858
+ /**
859
+ * Generic param-level metadata key — written by `@InputForm(FormType)`
860
+ * alongside {@link MOOST_DB_ACTION_INPUT_FORM}. Holds just the type ref so a
861
+ * generic atscript-aware Moost pipe (installed globally via
862
+ * `app.applyGlobalPipes(...)` or scoped via `@Pipe(...)`) can validate the
863
+ * resolved value without knowing about the moost-db-specific key.
864
+ */
865
+ declare const MOOST_ATSCRIPT_TYPE = "atscript_type";
866
+ type TDbActionRowMarker = true;
867
+ /** Stamped by `@InputForm(FormType)` — the compiled `.as` class + the wire name (`FormType.name`). */
868
+ interface TDbActionInputFormMeta {
869
+ type: TAtscriptAnnotatedType;
870
+ name: string;
871
+ }
872
+ /** Method-level action metadata written by `@DbAction(name, opts)`. */
873
+ interface TDbActionMeta {
874
+ name: string;
875
+ opts: DbActionOpts;
876
+ }
877
+ /** Class-level entry — a `TDbActionsEntry` plus its dictionary key. */
878
+ interface TDbClassActionMeta {
879
+ name: string;
880
+ entry: TDbActionsEntry;
881
+ }
882
+ /** Param marker kind — informs level inference and ID-resolution shape. */
883
+ type TDbActionParamKind = "id" | "ids";
884
+ declare module "moost" {
885
+ interface TMoostMetadata {
886
+ [MOOST_DB_ACTION]?: TDbActionMeta;
887
+ [MOOST_DB_ACTIONS]?: TDbClassActionMeta[];
888
+ [MOOST_DB_ACTION_PARAM]?: TDbActionParamKind;
889
+ [MOOST_DB_ACTION_ROW]?: TDbActionRowMarker;
890
+ [MOOST_DB_ACTION_ROWS]?: TDbActionRowMarker;
891
+ }
892
+ interface TMoostParamsMetadata {
893
+ [MOOST_DB_ACTION_PARAM]?: TDbActionParamKind;
894
+ [MOOST_DB_ACTION_ROW]?: TDbActionRowMarker;
895
+ [MOOST_DB_ACTION_ROWS]?: TDbActionRowMarker;
896
+ [MOOST_DB_ACTION_INPUT_FORM]?: TDbActionInputFormMeta;
897
+ [MOOST_ATSCRIPT_TYPE]?: TAtscriptAnnotatedType;
898
+ }
899
+ }
900
+ //#endregion
755
901
  //#region src/actions/action-disabled-error.d.ts
756
902
  /**
757
903
  * Wire-body shape for server-side gate rejections. The `name` discriminator
@@ -812,4 +958,4 @@ declare const QUERY_CONTROLS: readonly string[];
812
958
  declare const PAGES_CONTROLS: readonly string[];
813
959
  declare const ONE_CONTROLS: readonly string[];
814
960
  //#endregion
815
- export { ActionDisabledError, ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, IdValidationSource, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, ReadableGates, TABLE_DEF, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, TDbActionsEntry, TDbActionsEntryUnpinned, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, discoverActions, perRow, useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows, validationErrorTransform };
961
+ export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, type IdValidationSource, InputForm, MOOST_ATSCRIPT_TYPE, MOOST_DB_ACTION_INPUT_FORM, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, ReadableGates, TABLE_DEF, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, dbActionBodySlot, dbActionInputSlot, discoverActions, getControllerFormType, perRow, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
1
- import * as _atscript_typescript_utils0 from "@atscript/typescript/utils";
2
- import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, Validator } from "@atscript/typescript/utils";
1
+ import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, TSerializedAnnotatedType, Validator } from "@atscript/typescript/utils";
3
2
  import { HttpError } from "@moostjs/event-http";
4
3
  import * as moost from "moost";
5
4
  import { Moost, TConsoleBase } from "moost";
6
5
  import * as _uniqu_url0 from "@uniqu/url";
6
+ import { parseUrl } from "@uniqu/url";
7
7
  import { AtscriptDbReadable, AtscriptDbTable, FilterExpr, FlatOf, TCrudOp, TCrudPermissions, TCrudPermissions as TCrudPermissions$1, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionIntent as TDbActionIntent$1, TDbActionLevel, TDbActionLevel as TDbActionLevel$1, TDbActionProcessor, TDbFieldMeta, TIdentification, TMetaResponse, Uniquery, UniqueryControls } from "@atscript/db";
8
8
  import * as _wooksjs_event_core0 from "@wooksjs/event-core";
9
9
 
@@ -61,13 +61,15 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
61
61
  private _serializedType?;
62
62
  /** Cached full meta response (computed lazily on first meta() call). */
63
63
  private _metaResponse?;
64
+ /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
65
+ private _formSchemas;
64
66
  constructor(boundType: T, controllerName: string, app: Moost, kindTag?: string);
65
67
  /** Subclass contract: return `true` if `path` addresses a valid field on the bound source. */
66
68
  protected abstract hasField(path: string): boolean;
67
69
  /** Sets @db.http.path on the type metadata from the controller's computed prefix. */
68
70
  private _resolveHttpPath;
69
71
  /** Lazily serializes the bound type (after all controllers have set @db.http.path). */
70
- protected getSerializedType(): _atscript_typescript_utils0.TSerializedAnnotatedType;
72
+ protected getSerializedType(): TSerializedAnnotatedType;
71
73
  /**
72
74
  * One-time initialization hook. Override to seed data, register watchers, etc.
73
75
  */
@@ -105,6 +107,19 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
105
107
  */
106
108
  protected checkGates(filter: FilterExpr | undefined, controls: Record<string, unknown>, gates: ReadableGates): HttpError | undefined;
107
109
  protected parseQueryString(url: string): _uniqu_url0.UrlQuery;
110
+ /**
111
+ * Parse a URL keeping only `$*` control keywords; report whether any
112
+ * non-control parts were present. Used by `/one` routes where the
113
+ * uniquery lexer cannot tokenise PK values containing `-` and other
114
+ * reserved chars, so non-control parts must be stripped before lexing.
115
+ * `/one/:id` rejects stray filter params with 400 via `hasNonControl`;
116
+ * `/one` (composite) ignores it because the composite-key params have
117
+ * already been extracted via `@Query()`.
118
+ */
119
+ protected parseControlsOnlyFromUrl(url: string): {
120
+ parsed: ReturnType<typeof parseUrl>;
121
+ hasNonControl: boolean;
122
+ };
108
123
  protected returnOne(result: Promise<DataType | null>): Promise<DataType | HttpError>;
109
124
  /**
110
125
  * **GET /meta** — returns the bound interface's metadata envelope. The
@@ -112,6 +127,15 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
112
127
  * subclasses can prune the response by principal.
113
128
  */
114
129
  meta(): Promise<TMetaResponse>;
130
+ /**
131
+ * **GET /meta/form/:name** — returns the serialized schema of a form
132
+ * referenced by an action's `inputForm` field. The form name is the
133
+ * compiled `.as` class's `.name`, registered when an action's parameter is
134
+ * decorated with `@InputForm(FormType)`. Schemas are serialized once and
135
+ * cached per controller; the response uses the same annotation-allowlist
136
+ * policy as {@link getSerializeOptions}.
137
+ */
138
+ metaForm(name: string): Promise<TSerializedAnnotatedType>;
115
139
  /**
116
140
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
117
141
  * fields. Subclasses that fully replace the envelope must call
@@ -716,6 +740,38 @@ declare function DbRowActions<TRow = unknown, const D extends Record<string, unk
716
740
  /** Sugar for `@DbActions` with `level: 'rows'` injected into each entry. */
717
741
  declare function DbRowsActions<TRow = unknown, const D extends Record<string, unknown> = {}>(dict: D & ValidatedUnpinnedDict<TRow, D>): ClassDecorator;
718
742
  //#endregion
743
+ //#region src/actions/db-action-input-form.decorator.d.ts
744
+ /**
745
+ * Parameter decorator that injects the `input` field of the action request
746
+ * envelope (`{ ids?, input? }`) into the handler.
747
+ *
748
+ * Pairs the resolved value with two pieces of param-level metadata:
749
+ *
750
+ * 1. {@link MOOST_DB_ACTION_INPUT_FORM} — the compiled `.as` class plus its
751
+ * name, consumed by {@link discoverActions} to:
752
+ * - emit `inputForm: FormType.name` on the action's `/meta` entry, and
753
+ * - register the type in the controller's form registry so
754
+ * `GET /meta/form/:name` can serve the serialized schema.
755
+ * 2. {@link MOOST_ATSCRIPT_TYPE} — just the type ref, providing a generic
756
+ * hook any atscript-aware Moost pipe can read without knowing about the
757
+ * moost-db-specific key.
758
+ *
759
+ * Validation is intentionally *not* performed here. To validate `input`
760
+ * against `FormType`, install an atscript validator pipe globally
761
+ * (`app.applyGlobalPipes(...)`) or scope it via `@Pipe(...)`. The pipe reads
762
+ * `MOOST_ATSCRIPT_TYPE` off the param and runs `FormType.validator()`.
763
+ *
764
+ * Only one `@InputForm()` per action is supported. To collect multiple
765
+ * structured inputs, compose them into a single `.as` interface and pass an
766
+ * array form on the field whose user-facing intent is "list of items".
767
+ *
768
+ * @param formType A compiled `.as` interface class (carries `.validator()`,
769
+ * `.metadata`, etc.).
770
+ */
771
+ declare function InputForm<T extends TAtscriptAnnotatedType & {
772
+ readonly name: string;
773
+ }>(formType: T): ParameterDecorator;
774
+ //#endregion
719
775
  //#region src/actions/discover.d.ts
720
776
  /**
721
777
  * Pairs the wire-shaped `info` with the original decorator opts / dict entry,
@@ -726,6 +782,8 @@ interface TDbActionEnvelope {
726
782
  info: TDbActionInfo$1;
727
783
  raw: DbActionOpts | TDbActionsEntry;
728
784
  }
785
+ /** Lookup helper for `AsReadableController.metaForm()`. */
786
+ declare function getControllerFormType(ctor: Function, name: string): TAtscriptAnnotatedType | undefined;
729
787
  /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
730
788
  declare function discoverActions(controllerCtor: Function, app: Moost, logger: TConsoleBase): TDbActionEnvelope[];
731
789
  //#endregion
@@ -752,6 +810,94 @@ declare const useDbActionRows: _wooksjs_event_core0.WookComposable<{
752
810
  load: () => Promise<(Record<string, unknown> | undefined)[]>;
753
811
  }>;
754
812
  //#endregion
813
+ //#region src/actions/input-form-cache.d.ts
814
+ /**
815
+ * Wire-shape of an action request body. Both fields are optional:
816
+ *
817
+ * - `ids` — what previously sat at the body root: a single identifier object
818
+ * (`'row'`-level), an array of identifier objects (`'rows'`-level), or
819
+ * absent (`'table'`-level).
820
+ * - `input` — present only when the action declares an `@InputForm()`
821
+ * parameter; carries the form payload the user filled out.
822
+ */
823
+ interface DbActionEnvelope {
824
+ ids?: unknown;
825
+ input?: unknown;
826
+ }
827
+ /**
828
+ * Cached parse of the action request body. Centralises the shape check so
829
+ * every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
830
+ * reads through the same gate. An array or scalar root is rejected with the
831
+ * same `ValidatorError` envelope as today's strict-shape ID failures.
832
+ */
833
+ declare const dbActionBodySlot: moost.Cached<Promise<DbActionEnvelope>>;
834
+ /** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
835
+ declare const dbActionInputSlot: moost.Cached<Promise<unknown>>;
836
+ /** Composable for in-handler reads of the form input. */
837
+ declare const useDbActionInput: _wooksjs_event_core0.WookComposable<{
838
+ load: () => Promise<unknown>;
839
+ }>;
840
+ //#endregion
841
+ //#region src/actions/keys.d.ts
842
+ /** Method-level metadata key — written by `@DbAction(name, opts)`. */
843
+ declare const MOOST_DB_ACTION = "atscript_db_action";
844
+ /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
845
+ declare const MOOST_DB_ACTIONS = "atscript_db_actions";
846
+ /** Param-level metadata key — written by `@DbActionID()` / `@DbActionIDs()`. Drives level inference. */
847
+ declare const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
848
+ /** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
849
+ declare const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
850
+ declare const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
851
+ /**
852
+ * Param-level metadata key — written by `@InputForm(FormType)`. Carries the
853
+ * compiled `.as` class plus its `.name` so {@link discoverActions} can both
854
+ * emit `inputForm` on `/meta` and register the type in the controller's form
855
+ * registry for `GET /meta/form/:name`.
856
+ */
857
+ declare const MOOST_DB_ACTION_INPUT_FORM = "atscript_db_action_input_form";
858
+ /**
859
+ * Generic param-level metadata key — written by `@InputForm(FormType)`
860
+ * alongside {@link MOOST_DB_ACTION_INPUT_FORM}. Holds just the type ref so a
861
+ * generic atscript-aware Moost pipe (installed globally via
862
+ * `app.applyGlobalPipes(...)` or scoped via `@Pipe(...)`) can validate the
863
+ * resolved value without knowing about the moost-db-specific key.
864
+ */
865
+ declare const MOOST_ATSCRIPT_TYPE = "atscript_type";
866
+ type TDbActionRowMarker = true;
867
+ /** Stamped by `@InputForm(FormType)` — the compiled `.as` class + the wire name (`FormType.name`). */
868
+ interface TDbActionInputFormMeta {
869
+ type: TAtscriptAnnotatedType;
870
+ name: string;
871
+ }
872
+ /** Method-level action metadata written by `@DbAction(name, opts)`. */
873
+ interface TDbActionMeta {
874
+ name: string;
875
+ opts: DbActionOpts;
876
+ }
877
+ /** Class-level entry — a `TDbActionsEntry` plus its dictionary key. */
878
+ interface TDbClassActionMeta {
879
+ name: string;
880
+ entry: TDbActionsEntry;
881
+ }
882
+ /** Param marker kind — informs level inference and ID-resolution shape. */
883
+ type TDbActionParamKind = "id" | "ids";
884
+ declare module "moost" {
885
+ interface TMoostMetadata {
886
+ [MOOST_DB_ACTION]?: TDbActionMeta;
887
+ [MOOST_DB_ACTIONS]?: TDbClassActionMeta[];
888
+ [MOOST_DB_ACTION_PARAM]?: TDbActionParamKind;
889
+ [MOOST_DB_ACTION_ROW]?: TDbActionRowMarker;
890
+ [MOOST_DB_ACTION_ROWS]?: TDbActionRowMarker;
891
+ }
892
+ interface TMoostParamsMetadata {
893
+ [MOOST_DB_ACTION_PARAM]?: TDbActionParamKind;
894
+ [MOOST_DB_ACTION_ROW]?: TDbActionRowMarker;
895
+ [MOOST_DB_ACTION_ROWS]?: TDbActionRowMarker;
896
+ [MOOST_DB_ACTION_INPUT_FORM]?: TDbActionInputFormMeta;
897
+ [MOOST_ATSCRIPT_TYPE]?: TAtscriptAnnotatedType;
898
+ }
899
+ }
900
+ //#endregion
755
901
  //#region src/actions/action-disabled-error.d.ts
756
902
  /**
757
903
  * Wire-body shape for server-side gate rejections. The `name` discriminator
@@ -812,4 +958,4 @@ declare const QUERY_CONTROLS: readonly string[];
812
958
  declare const PAGES_CONTROLS: readonly string[];
813
959
  declare const ONE_CONTROLS: readonly string[];
814
960
  //#endregion
815
- export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, type IdValidationSource, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, ReadableGates, TABLE_DEF, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, discoverActions, perRow, useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows, validationErrorTransform };
961
+ export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, type IdValidationSource, InputForm, MOOST_ATSCRIPT_TYPE, MOOST_DB_ACTION_INPUT_FORM, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, ReadableGates, TABLE_DEF, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, dbActionBodySlot, dbActionInputSlot, discoverActions, getControllerFormType, perRow, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/dist/index.mjs CHANGED
@@ -212,6 +212,21 @@ const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
212
212
  const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
213
213
  const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
214
214
  /**
215
+ * Param-level metadata key — written by `@InputForm(FormType)`. Carries the
216
+ * compiled `.as` class plus its `.name` so {@link discoverActions} can both
217
+ * emit `inputForm` on `/meta` and register the type in the controller's form
218
+ * registry for `GET /meta/form/:name`.
219
+ */
220
+ const MOOST_DB_ACTION_INPUT_FORM = "atscript_db_action_input_form";
221
+ /**
222
+ * Generic param-level metadata key — written by `@InputForm(FormType)`
223
+ * alongside {@link MOOST_DB_ACTION_INPUT_FORM}. Holds just the type ref so a
224
+ * generic atscript-aware Moost pipe (installed globally via
225
+ * `app.applyGlobalPipes(...)` or scoped via `@Pipe(...)`) can validate the
226
+ * resolved value without knowing about the moost-db-specific key.
227
+ */
228
+ const MOOST_ATSCRIPT_TYPE = "atscript_type";
229
+ /**
215
230
  * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
216
231
  * read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
217
232
  * fields win), and write it back. `name` is empty until `@DbAction` provides
@@ -234,6 +249,8 @@ function scanParamLevel(params) {
234
249
  let multi = false;
235
250
  let hasRowParam = false;
236
251
  let hasBody = false;
252
+ let inputForm;
253
+ let hasDuplicateInputForm = false;
237
254
  for (const p of params) {
238
255
  const kind = p[MOOST_DB_ACTION_PARAM];
239
256
  if (kind === "id") single = true;
@@ -247,13 +264,18 @@ function scanParamLevel(params) {
247
264
  hasRowParam = true;
248
265
  }
249
266
  if (p.paramSource === "BODY") hasBody = true;
267
+ const form = p[MOOST_DB_ACTION_INPUT_FORM];
268
+ if (form) if (inputForm) hasDuplicateInputForm = true;
269
+ else inputForm = form;
250
270
  }
251
271
  return {
252
272
  level: single && multi ? "table" : single ? "row" : multi ? "rows" : "table",
253
273
  single,
254
274
  multi,
255
275
  hasRowParam,
256
- hasBody
276
+ hasBody,
277
+ inputForm,
278
+ hasDuplicateInputForm
257
279
  };
258
280
  }
259
281
  //#endregion
@@ -270,6 +292,34 @@ const OPTIONAL_FIELDS = [
270
292
  ];
271
293
  const actionsCache = /* @__PURE__ */ new WeakMap();
272
294
  const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
295
+ /**
296
+ * Per-controller registry of form names → compiled `.as` classes, populated
297
+ * during {@link discoverActions} when a method param carries
298
+ * {@link MOOST_DB_ACTION_INPUT_FORM}. Backs `GET /meta/form/:name`.
299
+ *
300
+ * Same name + same type ref across multiple actions is fine (forms can be
301
+ * reused). Same name + *different* type refs is an ambiguity — discovery
302
+ * warns and drops the second action.
303
+ */
304
+ const formRegistry = /* @__PURE__ */ new WeakMap();
305
+ /** Lookup helper for `AsReadableController.metaForm()`. */
306
+ function getControllerFormType(ctor, name) {
307
+ return formRegistry.get(ctor)?.get(name);
308
+ }
309
+ function registerFormType(ctor, meta, actionName, logger) {
310
+ let map = formRegistry.get(ctor);
311
+ if (!map) {
312
+ map = /* @__PURE__ */ new Map();
313
+ formRegistry.set(ctor, map);
314
+ }
315
+ const existing = map.get(meta.name);
316
+ if (existing && existing !== meta.type) {
317
+ logger.warn(`${WARN_PREFIX} action "${actionName}" — form name "${meta.name}" already registered on this controller with a different type. Reusing the same FormType across actions is fine; clashing names are not — dropping`);
318
+ return false;
319
+ }
320
+ if (!existing) map.set(meta.name, meta.type);
321
+ return true;
322
+ }
273
323
  /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
274
324
  function discoverActions(controllerCtor, app, logger) {
275
325
  const cached = actionsCache.get(controllerCtor);
@@ -355,6 +405,10 @@ function collectMethodActions(ctor, overview, logger, out, seen) {
355
405
  processor: "backend",
356
406
  value: path
357
407
  };
408
+ if (levelInfer.inputForm) {
409
+ if (!registerFormType(ctor, levelInfer.inputForm, action.name, logger)) continue;
410
+ info.inputForm = levelInfer.inputForm.name;
411
+ }
358
412
  emitInfo(info, action.opts);
359
413
  seen.add(action.name);
360
414
  out.push({
@@ -369,10 +423,12 @@ function inferMethodLevel(params, actionName, logger) {
369
423
  logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionID / @DbActionRow vs @DbActionIDs / @DbActionRows) — dropping`);
370
424
  return null;
371
425
  }
426
+ if (scan.hasDuplicateInputForm) logger.warn(`${WARN_PREFIX} action "${actionName}" has more than one @InputForm() param — only the first is honored. Compose multiple inputs into a single form interface.`);
372
427
  return {
373
428
  level: scan.level,
374
429
  bodyConflict: scan.hasBody && scan.level !== "table",
375
- hasRowParam: scan.hasRowParam
430
+ hasRowParam: scan.hasRowParam,
431
+ inputForm: scan.inputForm
376
432
  };
377
433
  }
378
434
  function collectClassActions(ctor, logger, out, seen) {
@@ -483,6 +539,13 @@ function __decorate(decorators, target, key, desc) {
483
539
  return c > 3 && r && Object.defineProperty(target, key, r), r;
484
540
  }
485
541
  //#endregion
542
+ //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
543
+ function __decorateParam(paramIndex, decorator) {
544
+ return function(target, key) {
545
+ decorator(target, key, paramIndex);
546
+ };
547
+ }
548
+ //#endregion
486
549
  //#region src/as-readable.controller.ts
487
550
  var _ref$4;
488
551
  let AsReadableController = class AsReadableController {
@@ -498,6 +561,8 @@ let AsReadableController = class AsReadableController {
498
561
  _serializedType;
499
562
  /** Cached full meta response (computed lazily on first meta() call). */
500
563
  _metaResponse;
564
+ /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
565
+ _formSchemas = /* @__PURE__ */ new Map();
501
566
  constructor(boundType, controllerName, app, kindTag = "readable") {
502
567
  this.boundType = boundType;
503
568
  this.controllerName = controllerName;
@@ -625,6 +690,42 @@ let AsReadableController = class AsReadableController {
625
690
  const idx = url.indexOf("?");
626
691
  return parseUrl(idx >= 0 ? url.slice(idx + 1) : "");
627
692
  }
693
+ /**
694
+ * Parse a URL keeping only `$*` control keywords; report whether any
695
+ * non-control parts were present. Used by `/one` routes where the
696
+ * uniquery lexer cannot tokenise PK values containing `-` and other
697
+ * reserved chars, so non-control parts must be stripped before lexing.
698
+ * `/one/:id` rejects stray filter params with 400 via `hasNonControl`;
699
+ * `/one` (composite) ignores it because the composite-key params have
700
+ * already been extracted via `@Query()`.
701
+ */
702
+ parseControlsOnlyFromUrl(url) {
703
+ const idx = url.indexOf("?");
704
+ const qs = idx >= 0 ? url.slice(idx + 1) : "";
705
+ if (!qs) return {
706
+ parsed: parseUrl(""),
707
+ hasNonControl: false
708
+ };
709
+ const kept = [];
710
+ let hasNonControl = false;
711
+ for (const part of qs.split("&")) {
712
+ if (!part) continue;
713
+ const eq = part.indexOf("=");
714
+ const rawKey = eq === -1 ? part : part.slice(0, eq);
715
+ let key;
716
+ try {
717
+ key = decodeURIComponent(rawKey);
718
+ } catch {
719
+ key = rawKey;
720
+ }
721
+ if (key.startsWith("$")) kept.push(part);
722
+ else hasNonControl = true;
723
+ }
724
+ return {
725
+ parsed: parseUrl(kept.join("&")),
726
+ hasNonControl
727
+ };
728
+ }
628
729
  async returnOne(result) {
629
730
  const item = await result;
630
731
  if (!item) return new HttpError(404);
@@ -640,6 +741,25 @@ let AsReadableController = class AsReadableController {
640
741
  return this.applyMetaOverlay(this._metaResponse);
641
742
  }
642
743
  /**
744
+ * **GET /meta/form/:name** — returns the serialized schema of a form
745
+ * referenced by an action's `inputForm` field. The form name is the
746
+ * compiled `.as` class's `.name`, registered when an action's parameter is
747
+ * decorated with `@InputForm(FormType)`. Schemas are serialized once and
748
+ * cached per controller; the response uses the same annotation-allowlist
749
+ * policy as {@link getSerializeOptions}.
750
+ */
751
+ async metaForm(name) {
752
+ discoverActions(this.constructor, this.app, this.logger);
753
+ const formType = getControllerFormType(this.constructor, name);
754
+ if (!formType) throw new HttpError(404, `Unknown form "${name}"`);
755
+ let cached = this._formSchemas.get(name);
756
+ if (!cached) {
757
+ cached = serializeAnnotatedType(formType, this.getSerializeOptions());
758
+ this._formSchemas.set(name, cached);
759
+ }
760
+ return cached;
761
+ }
762
+ /**
643
763
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
644
764
  * fields. Subclasses that fully replace the envelope must call
645
765
  * {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
@@ -693,6 +813,13 @@ __decorate([
693
813
  __decorateMetadata("design:paramtypes", []),
694
814
  __decorateMetadata("design:returntype", Promise)
695
815
  ], AsReadableController.prototype, "meta", null);
816
+ __decorate([
817
+ Get("meta/form/:name"),
818
+ __decorateParam(0, Param("name")),
819
+ __decorateMetadata("design:type", Function),
820
+ __decorateMetadata("design:paramtypes", [String]),
821
+ __decorateMetadata("design:returntype", Promise)
822
+ ], AsReadableController.prototype, "metaForm", null);
696
823
  AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
697
824
  Object,
698
825
  String,
@@ -855,13 +982,6 @@ const QUERY_CONTROLS = [
855
982
  const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
856
983
  const ONE_CONTROLS = dtoControls(GetOneControlsDto);
857
984
  //#endregion
858
- //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
859
- function __decorateParam(paramIndex, decorator) {
860
- return function(target, key) {
861
- decorator(target, key, paramIndex);
862
- };
863
- }
864
- //#endregion
865
985
  //#region src/as-db-readable.controller.ts
866
986
  var _ref$3, _ref2$2;
867
987
  let AsDbReadableController = class AsDbReadableController extends AsReadableController {
@@ -1213,9 +1333,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1213
1333
  * **GET /one/:id** — retrieves a single record by ID or unique property.
1214
1334
  */
1215
1335
  async getOne(id, url) {
1216
- const parsed = this.parseQueryString(url);
1336
+ const { parsed, hasNonControl } = this.parseControlsOnlyFromUrl(url);
1337
+ if (hasNonControl) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
1217
1338
  this._coerceActionsControl(parsed.controls);
1218
- if (Object.keys(parsed.filter).length > 0) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
1219
1339
  const error = this.validateParsed(parsed, "getOne");
1220
1340
  if (error) return error;
1221
1341
  const rawSelect = await this.transformProjection(parsed.controls.$select);
@@ -1230,7 +1350,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1230
1350
  async getOneComposite(query, url) {
1231
1351
  const idObj = this.extractIdShape(query);
1232
1352
  if (idObj instanceof HttpError) return idObj;
1233
- const parsed = this.parseQueryString(url);
1353
+ const { parsed } = this.parseControlsOnlyFromUrl(url);
1234
1354
  this._coerceActionsControl(parsed.controls);
1235
1355
  const rawSelect = await this.transformProjection(parsed.controls.$select);
1236
1356
  const select = this.widenPreferredIdProjection(rawSelect);
@@ -1846,6 +1966,29 @@ function readCurrentActionMeta(ctx) {
1846
1966
  return getMoostMate().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION];
1847
1967
  }
1848
1968
  //#endregion
1969
+ //#region src/actions/input-form-cache.ts
1970
+ /**
1971
+ * Cached parse of the action request body. Centralises the shape check so
1972
+ * every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
1973
+ * reads through the same gate. An array or scalar root is rejected with the
1974
+ * same `ValidatorError` envelope as today's strict-shape ID failures.
1975
+ */
1976
+ const dbActionBodySlot = cached(async (ctx) => {
1977
+ const raw = await useBody(ctx).parseBody();
1978
+ if (raw == null) return {};
1979
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new ValidatorError([{
1980
+ path: "",
1981
+ message: "Action body must be an object of shape { ids?, input? }"
1982
+ }]);
1983
+ return raw;
1984
+ });
1985
+ /** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
1986
+ const dbActionInputSlot = cached(async (ctx) => {
1987
+ return (await ctx.get(dbActionBodySlot)).input;
1988
+ });
1989
+ /** Composable for in-handler reads of the form input. */
1990
+ const useDbActionInput = defineWook((ctx) => ({ load: () => ctx.get(dbActionInputSlot) }));
1991
+ //#endregion
1849
1992
  //#region src/actions/id-validation.ts
1850
1993
  const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
1851
1994
  function getSourceCache(source) {
@@ -1963,9 +2106,9 @@ function noTableError(ctx) {
1963
2106
  async function resolveValidatedId(ctx, validate) {
1964
2107
  const table = getActionTable(ctx);
1965
2108
  if (!isIdValidationSource(table)) throw noTableError(ctx);
1966
- const body = await useBody(ctx).parseBody();
1967
- validate(body, table);
1968
- return body;
2109
+ const env = await ctx.get(dbActionBodySlot);
2110
+ validate(env.ids, table);
2111
+ return env.ids;
1969
2112
  }
1970
2113
  const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
1971
2114
  const dbActionIdsSlot = cached(async (ctx) => {
@@ -2324,6 +2467,43 @@ function classLevelActions(dict, forcedLevel) {
2324
2467
  });
2325
2468
  }
2326
2469
  //#endregion
2470
+ //#region src/actions/db-action-input-form.decorator.ts
2471
+ /**
2472
+ * Parameter decorator that injects the `input` field of the action request
2473
+ * envelope (`{ ids?, input? }`) into the handler.
2474
+ *
2475
+ * Pairs the resolved value with two pieces of param-level metadata:
2476
+ *
2477
+ * 1. {@link MOOST_DB_ACTION_INPUT_FORM} — the compiled `.as` class plus its
2478
+ * name, consumed by {@link discoverActions} to:
2479
+ * - emit `inputForm: FormType.name` on the action's `/meta` entry, and
2480
+ * - register the type in the controller's form registry so
2481
+ * `GET /meta/form/:name` can serve the serialized schema.
2482
+ * 2. {@link MOOST_ATSCRIPT_TYPE} — just the type ref, providing a generic
2483
+ * hook any atscript-aware Moost pipe can read without knowing about the
2484
+ * moost-db-specific key.
2485
+ *
2486
+ * Validation is intentionally *not* performed here. To validate `input`
2487
+ * against `FormType`, install an atscript validator pipe globally
2488
+ * (`app.applyGlobalPipes(...)`) or scope it via `@Pipe(...)`. The pipe reads
2489
+ * `MOOST_ATSCRIPT_TYPE` off the param and runs `FormType.validator()`.
2490
+ *
2491
+ * Only one `@InputForm()` per action is supported. To collect multiple
2492
+ * structured inputs, compose them into a single `.as` interface and pass an
2493
+ * array form on the field whose user-facing intent is "list of items".
2494
+ *
2495
+ * @param formType A compiled `.as` interface class (carries `.validator()`,
2496
+ * `.metadata`, etc.).
2497
+ */
2498
+ function InputForm(formType) {
2499
+ const mate = getMoostMate();
2500
+ const meta = {
2501
+ type: formType,
2502
+ name: formType.name
2503
+ };
2504
+ return ApplyDecorators(mate.decorate(MOOST_DB_ACTION_INPUT_FORM, meta), mate.decorate(MOOST_ATSCRIPT_TYPE, formType), Resolve(async () => current().get(dbActionInputSlot), "dbActionInputForm"));
2505
+ }
2506
+ //#endregion
2327
2507
  //#region src/actions/per-row.ts
2328
2508
  /**
2329
2509
  * Lift a per-row predicate into the batch shape required by
@@ -2339,4 +2519,4 @@ function classLevelActions(dict, forcedLevel) {
2339
2519
  */
2340
2520
  const perRow = (fn) => (rows) => rows.map(fn);
2341
2521
  //#endregion
2342
- export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, perRow, useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows, validationErrorTransform };
2522
+ export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, InputForm, MOOST_ATSCRIPT_TYPE, MOOST_DB_ACTION_INPUT_FORM, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, dbActionBodySlot, dbActionInputSlot, discoverActions, getControllerFormType, perRow, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -58,7 +58,7 @@
58
58
  "@wooksjs/event-core": "^0.7.10",
59
59
  "@wooksjs/http-body": "^0.7.10",
60
60
  "moost": "^0.6.8",
61
- "@atscript/db": "^0.1.61"
61
+ "@atscript/db": "^0.1.63"
62
62
  },
63
63
  "scripts": {
64
64
  "postinstall": "asc -f dts",