@atscript/moost-db 0.1.134 → 0.1.136

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
@@ -251,7 +251,8 @@ const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
251
251
  /**
252
252
  * Per-controller registry of form names → compiled `.as` classes, populated
253
253
  * during {@link discoverActions} when a method param carries
254
- * `atscript_db_action_input_form`. Backs `GET /meta/form/:name`.
254
+ * `atscript_db_action_input_form` or a class-level entry passes a type as
255
+ * `inputForm`. Backs `GET /meta/form/:name`.
255
256
  *
256
257
  * Same name + same type ref across multiple actions is fine (forms can be
257
258
  * reused). Same name + *different* type refs is an ambiguity — discovery
@@ -395,7 +396,7 @@ function collectClassActions(ctor, logger, out, seen) {
395
396
  logger.warn(`${WARN_PREFIX} duplicate action name "${name}" within controller — dropping the second declaration`);
396
397
  continue;
397
398
  }
398
- const built = buildClassEntry(name, entry, logger);
399
+ const built = buildClassEntry(ctor, name, entry, logger);
399
400
  if (built) {
400
401
  seen.add(name);
401
402
  out.push({
@@ -405,7 +406,7 @@ function collectClassActions(ctor, logger, out, seen) {
405
406
  }
406
407
  }
407
408
  }
408
- function buildClassEntry(name, entry, logger) {
409
+ function buildClassEntry(ctor, name, entry, logger) {
409
410
  const level = entry.level;
410
411
  if (!level) {
411
412
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
@@ -443,6 +444,8 @@ function buildClassEntry(name, entry, logger) {
443
444
  logger.warn(`${WARN_PREFIX} class-level action "${name}" has unknown processor "${String(processor)}" — dropping`);
444
445
  return null;
445
446
  }
447
+ const form = resolveClassInputForm(name, entry, processor, logger);
448
+ if (form === null) return null;
446
449
  const info = {
447
450
  name,
448
451
  label: entry.label,
@@ -450,9 +453,45 @@ function buildClassEntry(name, entry, logger) {
450
453
  processor,
451
454
  value
452
455
  };
456
+ if (form) {
457
+ if (form.type && !registerFormType(ctor, {
458
+ type: form.type,
459
+ name: form.name
460
+ }, name, logger)) return null;
461
+ info.inputForm = form.name;
462
+ if (form.url !== void 0) info.formUrl = form.url;
463
+ }
453
464
  emitInfo(info, entry);
454
465
  return info;
455
466
  }
467
+ /**
468
+ * Resolves a class-level entry's `inputForm`. Returns `undefined` when the
469
+ * entry declares none, `null` when it must be dropped (already warned — the
470
+ * runtime check for JS callers and `processor: "navigate"`), else the form to
471
+ * emit: `type` when this controller serves it (`/meta/form/:name`), `url`
472
+ * when it lives elsewhere.
473
+ */
474
+ function resolveClassInputForm(name, entry, processor, logger) {
475
+ const inputForm = entry.inputForm;
476
+ if (inputForm === void 0) return void 0;
477
+ const drop = (reason) => {
478
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" — ${reason} — dropping`);
479
+ return null;
480
+ };
481
+ if (processor === "navigate") return drop("processor \"navigate\" cannot take an `inputForm`");
482
+ const { name: formName, url } = inputForm ?? {};
483
+ if (typeof formName === "string" && formName !== "") {
484
+ if ((0, _atscript_typescript_utils.isAnnotatedType)(inputForm)) return {
485
+ name: formName,
486
+ type: inputForm
487
+ };
488
+ if (typeof url === "string" && url !== "") return {
489
+ name: formName,
490
+ url
491
+ };
492
+ }
493
+ return drop("`inputForm` must be a compiled .as interface or `{ name, url }` (non-empty strings)");
494
+ }
456
495
  function applyDefaultPerLevel(envelopes, logger) {
457
496
  const winners = /* @__PURE__ */ new Map();
458
497
  for (const { info } of envelopes) {
@@ -977,6 +1016,11 @@ function computeStripFields(candidates, resolvedProjection) {
977
1016
  }
978
1017
  return strip;
979
1018
  }
1019
+ /**
1020
+ * Sets `$actions` on every row and strips the columns fetched only for an
1021
+ * action's `requiredFields` — IN PLACE; returns the same array, typed as
1022
+ * augmented.
1023
+ */
980
1024
  function augmentRowsWithActions(args) {
981
1025
  const { envelopes, rows, resolvedProjection } = args;
982
1026
  const candidates = collectCandidates(envelopes);
@@ -1224,16 +1268,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1224
1268
  physicalNames;
1225
1269
  /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
1226
1270
  bucketUnits;
1271
+ /** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
1272
+ aggregateFns;
1227
1273
  /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1228
1274
  signature;
1229
1275
  /**
1230
1276
  * The adapter-level capabilities that can change after construction (geo
1231
- * support, calendar-bucket units): an index whose {@link signature}
1232
- * differs from this is stale. Any new adapter-level input the index reads
1233
- * must be added here.
1277
+ * support, calendar-bucket units, aggregate functions): an index whose
1278
+ * {@link signature} differs from this is stale. Any new adapter-level input
1279
+ * the index reads must be added here.
1234
1280
  */
1235
1281
  static adapterSignature(source) {
1236
- return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}`;
1282
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}`;
1237
1283
  }
1238
1284
  _entries = /* @__PURE__ */ new Map();
1239
1285
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
@@ -1256,6 +1302,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1256
1302
  this.signature = FieldCapabilityIndex.adapterSignature(source);
1257
1303
  const units = source.calendarBucketUnits();
1258
1304
  this.bucketUnits = _uniqu_core.BUCKET_UNITS.filter((unit) => units.has(unit));
1305
+ const fns = source.aggregateFns();
1306
+ this.aggregateFns = [..._atscript_db.ALL_AGGREGATE_FNS].filter((fn) => fns.has(fn));
1259
1307
  const physicalNames = /* @__PURE__ */ new Set();
1260
1308
  const jsonValueParents = /* @__PURE__ */ new Set();
1261
1309
  for (const fd of source.fieldDescriptors) {
@@ -1552,6 +1600,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1552
1600
  _idSources = /* @__PURE__ */ new Map();
1553
1601
  _preferredIdSet;
1554
1602
  _overlayIsNoOp;
1603
+ /** `true` when a subclass implements {@link decorateRows} (the override switches the hook on). */
1604
+ _decorates;
1555
1605
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
1556
1606
  _quantityRefByPath;
1557
1607
  /** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
@@ -1575,6 +1625,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1575
1625
  this._quantityRefByPath = this._collectQuantityRefs();
1576
1626
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1577
1627
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
1628
+ this._decorates = typeof this.decorateRows === "function";
1578
1629
  this._idOpts = this.hasField === _AsDbReadableController.prototype.hasField ? void 0 : { isFieldVisible: this._exists };
1579
1630
  }
1580
1631
  /**
@@ -1681,7 +1732,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1681
1732
  }
1682
1733
  /**
1683
1734
  * The core's shared normalizer of `$select` computed entries
1684
- * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
1735
+ * (`normalizeComputedSelect`) as a 400 with the core's wording and `path`
1685
1736
  * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
1686
1737
  * week start / alias, "grouped queries only", "must also appear in
1687
1738
  * $groupBy", alias collisions with this table's fields. Runs once per
@@ -1693,7 +1744,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1693
1744
  checkComputedSelect(controls) {
1694
1745
  const capabilities = this.capabilities;
1695
1746
  try {
1696
- (0, _atscript_db.resolveCalendarBuckets)(controls, {
1747
+ (0, _atscript_db.normalizeComputedSelect)(controls, {
1697
1748
  flatMap: this.readable.flatMap,
1698
1749
  physicalNames: capabilities.physicalNames,
1699
1750
  navFields: capabilities.navFields
@@ -2050,13 +2101,27 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2050
2101
  return { kind: "plain" };
2051
2102
  }
2052
2103
  /**
2053
- * Shared `query` / `pages` pipeline: prepare actions augmentation + read
2104
+ * Finishes a read's top-level rows in place: `$actions` augmentation (when
2105
+ * the request asked for it — `prep`), then {@link decorateRows} when a
2106
+ * subclass implements it. Returns the hook's result — `undefined`, with no
2107
+ * promise or microtask, when there is no hook or it is synchronous.
2108
+ */
2109
+ _finishRows(rows, prep, ctx) {
2110
+ if (prep) augmentRowsWithActions({
2111
+ envelopes: prep.envelopes,
2112
+ rows,
2113
+ resolvedProjection: prep.resolvedProjection
2114
+ });
2115
+ return this._decorates ? this.decorateRows(rows, ctx) : void 0;
2116
+ }
2117
+ /**
2118
+ * Shared `query` / `pages` / `geo` pipeline: prepare actions augmentation + read
2054
2119
  * strategy in parallel, pre-widen $select for `requiredFields`, run
2055
2120
  * `exec`, and augment `result.data` with `$actions` when the request set
2056
- * `$actions=true`. Caller dispatches the strategy to its read-method
2057
- * family (count vs no-count).
2121
+ * `$actions=true`, then run {@link decorateRows}. Caller dispatches the
2122
+ * strategy to its read-method family (count vs no-count).
2058
2123
  */
2059
- async _runReadWithActions(queryObj, controls, select, exec) {
2124
+ async _runReadWithActions(endpoint, queryObj, controls, select, exec) {
2060
2125
  const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, select), this._resolveReadStrategy(controls)]);
2061
2126
  const result = await exec(prep?.widenedSelect ? {
2062
2127
  ...queryObj,
@@ -2065,12 +2130,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2065
2130
  $select: prep.widenedSelect
2066
2131
  }
2067
2132
  } : queryObj, strategy);
2068
- if (!prep) return result;
2069
- result.data = augmentRowsWithActions({
2070
- envelopes: prep.envelopes,
2071
- rows: result.data,
2072
- resolvedProjection: prep.resolvedProjection
2133
+ const pending = this._finishRows(result.data, prep, {
2134
+ endpoint,
2135
+ projection: select,
2136
+ controls
2073
2137
  });
2138
+ if (pending) await pending;
2074
2139
  return result;
2075
2140
  }
2076
2141
  /**
@@ -2142,7 +2207,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2142
2207
  $threshold: threshold
2143
2208
  }
2144
2209
  };
2145
- return (await this._runReadWithActions(queryObj, controls, select, async (q, strategy) => {
2210
+ return (await this._runReadWithActions("query", queryObj, controls, select, async (q, strategy) => {
2146
2211
  switch (strategy.kind) {
2147
2212
  case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
2148
2213
  case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
@@ -2180,7 +2245,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2180
2245
  $threshold: threshold
2181
2246
  }
2182
2247
  };
2183
- const result = await this._runReadWithActions(query, controls, select, async (q, strategy) => {
2248
+ const result = await this._runReadWithActions("pages", query, controls, select, async (q, strategy) => {
2184
2249
  switch (strategy.kind) {
2185
2250
  case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
2186
2251
  case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
@@ -2245,7 +2310,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2245
2310
  }
2246
2311
  };
2247
2312
  if (paginated) {
2248
- const result = await this._runReadWithActions(queryObj, controls, select, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
2313
+ const result = await this._runReadWithActions("geo", queryObj, controls, select, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
2249
2314
  return {
2250
2315
  data: result.data,
2251
2316
  page,
@@ -2254,7 +2319,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2254
2319
  count: result.count
2255
2320
  };
2256
2321
  }
2257
- return (await this._runReadWithActions(queryObj, controls, select, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
2322
+ return (await this._runReadWithActions("geo", queryObj, controls, select, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
2258
2323
  }
2259
2324
  /** Parses the `$center` control: `"lng,lat"` string (or tuple) → `[number, number]`. */
2260
2325
  _parseGeoCenter(raw) {
@@ -2329,13 +2394,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2329
2394
  }
2330
2395
  const item = await this.returnOne(Promise.resolve(row));
2331
2396
  if (item instanceof _moostjs_event_http.HttpError) return item;
2332
- if (!prep) return item;
2333
- const [augmented] = augmentRowsWithActions({
2334
- envelopes: prep.envelopes,
2335
- rows: [item],
2336
- resolvedProjection: prep.resolvedProjection
2397
+ const pending = this._finishRows([item], prep, {
2398
+ endpoint: "one",
2399
+ projection: select,
2400
+ controls: parsedControls
2337
2401
  });
2338
- return augmented;
2402
+ if (pending) await pending;
2403
+ return item;
2339
2404
  }
2340
2405
  /**
2341
2406
  * **GET /meta** — returns table/view metadata for UI.
@@ -2383,7 +2448,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2383
2448
  actions: this.buildActions(),
2384
2449
  crud: this.buildCrud(),
2385
2450
  versionColumn: this.readable.versionColumn,
2386
- ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] }
2451
+ ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] },
2452
+ aggregateFns: [...capabilities.aggregateFns]
2387
2453
  };
2388
2454
  }
2389
2455
  buildCrud() {
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { i as resolveDbSpace, n as clearDbSpaces, r as provideDbSpace, t as DEFAULT_DB_SPACE } from "./db-space-registry-CWpYwZ4R.cjs";
2
2
  import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, TSerializedAnnotatedType, TValidatorOptions, Validator } from "@atscript/typescript/utils";
3
- import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, 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, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdResolveOptions, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
3
+ import { AggregateFn, AtscriptDbReadable, AtscriptDbTable, BucketUnit, 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, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdResolveOptions, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
4
4
  import { HttpError } from "@moostjs/event-http";
5
5
  import { Mate, Moost, TConsoleBase, TMateParamMeta, TMoostMetadata } from "moost";
6
6
  import { parseUrl } from "@uniqu/url";
@@ -241,7 +241,7 @@ interface TCapabilityVerdict {
241
241
  message: string;
242
242
  }
243
243
  /** The readable members the index reads. */
244
- type TCapabilityReadable = Pick<AtscriptDbReadable, "type" | "fieldDescriptors" | "flatMap" | "navFields" | "relations" | "ignoredFields" | "canFilterField" | "canSortField" | "isGeoSearchable" | "calendarBucketUnits" | "dimensions" | "measures">;
244
+ type TCapabilityReadable = Pick<AtscriptDbReadable, "type" | "fieldDescriptors" | "flatMap" | "navFields" | "relations" | "ignoredFields" | "canFilterField" | "canSortField" | "isGeoSearchable" | "calendarBucketUnits" | "aggregateFns" | "dimensions" | "measures">;
245
245
  /**
246
246
  * Capability index of one readable.
247
247
  *
@@ -273,15 +273,17 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
273
273
  readonly physicalNames: ReadonlySet<string>;
274
274
  /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
275
275
  readonly bucketUnits: readonly BucketUnit[];
276
+ /** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
277
+ readonly aggregateFns: readonly AggregateFn[];
276
278
  /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
277
279
  readonly signature: string;
278
280
  /**
279
281
  * The adapter-level capabilities that can change after construction (geo
280
- * support, calendar-bucket units): an index whose {@link signature}
281
- * differs from this is stale. Any new adapter-level input the index reads
282
- * must be added here.
282
+ * support, calendar-bucket units, aggregate functions): an index whose
283
+ * {@link signature} differs from this is stale. Any new adapter-level input
284
+ * the index reads must be added here.
283
285
  */
284
- static adapterSignature(source: Pick<TCapabilityReadable, "isGeoSearchable" | "calendarBucketUnits">): string;
286
+ static adapterSignature(source: Pick<TCapabilityReadable, "isGeoSearchable" | "calendarBucketUnits" | "aggregateFns">): string;
285
287
  private readonly _entries;
286
288
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
287
289
  private readonly _objectParents;
@@ -324,12 +326,34 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
324
326
  }
325
327
  //#endregion
326
328
  //#region src/as-db-readable.controller.d.ts
329
+ /** Read endpoint a {@link AsDbReadableController.decorateRows} call serves. */
330
+ type TDbDecorateEndpoint = "query" | "pages" | "geo" | "one";
331
+ /**
332
+ * Context passed to {@link AsDbReadableController.decorateRows}.
333
+ *
334
+ * @since 0.1.136
335
+ */
336
+ interface TDbDecorateContext {
337
+ /** Endpoint that produced the rows. `/one` and `/one/:id` both report `"one"`. */
338
+ endpoint: TDbDecorateEndpoint;
339
+ /**
340
+ * The effective `$select` the endpoint read with — after
341
+ * `transformProjection`, the `@db.writeOnly` seal and preferred-id
342
+ * widening (`undefined` = no projection). Columns added only to feed an
343
+ * action's `requiredFields` are stripped again before the hook runs.
344
+ */
345
+ projection: UniqueryControls["$select"] | undefined;
346
+ /** The request's parsed controls (`$select`, `$with`, `$actions`, …). Read-only by convention. */
347
+ controls: Record<string, unknown>;
348
+ }
327
349
  /**
328
350
  * Read-only database controller for Moost that works with any `AtscriptDbReadable`
329
351
  * (tables or views). Provides query, pages, getOne, and meta endpoints.
330
352
  *
331
353
  * For write operations (insert, replace, update, delete), use {@link AsDbController}.
332
- * For views, use {@link AsDbViewController}.
354
+ * Views bind to this same class — `@ViewController(view)` (an alias of
355
+ * `@ReadableController`) or a constructor-passed view; there is no separate
356
+ * view controller.
333
357
  */
334
358
  declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> extends AsReadableController<T, DataType> {
335
359
  /** Reference to the underlying readable (table or view). */
@@ -370,6 +394,8 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
370
394
  private readonly _idSources;
371
395
  private readonly _preferredIdSet;
372
396
  private readonly _overlayIsNoOp;
397
+ /** `true` when a subclass implements {@link decorateRows} (the override switches the hook on). */
398
+ private readonly _decorates;
373
399
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
374
400
  private readonly _quantityRefByPath;
375
401
  /** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
@@ -435,7 +461,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
435
461
  }): HttpError | undefined;
436
462
  /**
437
463
  * The core's shared normalizer of `$select` computed entries
438
- * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
464
+ * (`normalizeComputedSelect`) as a 400 with the core's wording and `path`
439
465
  * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
440
466
  * week start / alias, "grouped queries only", "must also appear in
441
467
  * $groupBy", alias collisions with this table's fields. Runs once per
@@ -551,11 +577,45 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
551
577
  private _aggregateControls;
552
578
  private _resolveReadStrategy;
553
579
  /**
554
- * Shared `query` / `pages` pipeline: prepare actions augmentation + read
580
+ * Post-read row decoration hook. Not implemented by
581
+ * default — defining it in a subclass switches it on. Runs once per
582
+ * response on `/query`, `/pages`, `/geo` and `/one` (`/one/:id` and the
583
+ * composite form), after `$actions` augmentation, with the final top-level
584
+ * rows. Mutate the rows in place; the return value is ignored. May be async.
585
+ *
586
+ * Not called for `$count`, `$groupBy` aggregates, nested `$with` rows
587
+ * (reach them through the parent row), a `/one` 404, or value-help
588
+ * controllers.
589
+ *
590
+ * Convention (not enforced): name decoration keys with a `$` prefix, like
591
+ * `$actions` and `$distance`, so they can never collide with a field name.
592
+ * Do not overwrite `$actions`. Columns the
593
+ * hook needs but the client did not select must be added in
594
+ * {@link transformProjection} — they are then part of the response.
595
+ *
596
+ * ```ts
597
+ * protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
598
+ * const unread = await countUnread(rows.map((r) => r.id))
599
+ * for (const row of rows) row.$unread = unread.get(row.id) ?? 0
600
+ * }
601
+ * ```
602
+ *
603
+ * @since 0.1.136
604
+ */
605
+ protected decorateRows?(rows: Record<string, unknown>[], ctx: TDbDecorateContext): void | Promise<void>;
606
+ /**
607
+ * Finishes a read's top-level rows in place: `$actions` augmentation (when
608
+ * the request asked for it — `prep`), then {@link decorateRows} when a
609
+ * subclass implements it. Returns the hook's result — `undefined`, with no
610
+ * promise or microtask, when there is no hook or it is synchronous.
611
+ */
612
+ private _finishRows;
613
+ /**
614
+ * Shared `query` / `pages` / `geo` pipeline: prepare actions augmentation + read
555
615
  * strategy in parallel, pre-widen $select for `requiredFields`, run
556
616
  * `exec`, and augment `result.data` with `$actions` when the request set
557
- * `$actions=true`. Caller dispatches the strategy to its read-method
558
- * family (count vs no-count).
617
+ * `$actions=true`, then run {@link decorateRows}. Caller dispatches the
618
+ * strategy to its read-method family (count vs no-count).
559
619
  */
560
620
  private _runReadWithActions;
561
621
  /**
@@ -1006,7 +1066,7 @@ interface LooseGate {
1006
1066
  onDisabledRows?: TOnDisabledRows;
1007
1067
  }
1008
1068
  type GateOpts<TRow, R extends readonly FlatKey<TRow>[]> = unknown extends TRow ? LooseGate : NoGate | WithGate<TRow, R>;
1009
- interface BaseActionOpts extends Partial<Omit<TDbActionInfo$1, "name" | "level" | "processor" | "value" | "disabled">> {
1069
+ interface BaseActionOpts extends Partial<Omit<TDbActionInfo$1, "name" | "level" | "processor" | "value" | "disabled" | "formUrl">> {
1010
1070
  /**
1011
1071
  * Bound table reference. REQUIRED on non-`AsDbReadableController` classes
1012
1072
  * when `disabled` is set OR a `@DbActionRow*` parameter is declared.
@@ -1035,6 +1095,25 @@ interface DbActionsEntryCommonBase {
1035
1095
  promptText?: string | [string, string];
1036
1096
  /** Mirrors {@link TDbActionInfo.shortcut} — single-character UI hint. */
1037
1097
  shortcut?: string;
1098
+ /**
1099
+ * Input form the UI collects before invoking the action:
1100
+ *
1101
+ * - a compiled `.as` interface — registered on THIS controller and served
1102
+ * by its own `GET /meta/form/:name`; the wire carries `inputForm: Type.name`.
1103
+ * - `{ name, url }` — a form served elsewhere: `name` goes on the wire as
1104
+ * `inputForm`, `url` (server-absolute path of the serialized schema, e.g.
1105
+ * `"/api/shipping/meta/form/ShipForm"`) as {@link TDbActionInfo.formUrl}.
1106
+ *
1107
+ * Not allowed with `processor: 'navigate'`. Class-level entries only
1108
+ * describe the action — validating `input` is the target handler's job
1109
+ * (e.g. its own `@InputForm(Type)` param).
1110
+ *
1111
+ * @since 0.1.136
1112
+ */
1113
+ inputForm?: TAtscriptAnnotatedType | {
1114
+ name: string;
1115
+ url: string;
1116
+ };
1038
1117
  }
1039
1118
  type DbActionsEntryWithGate<TRow, R extends readonly FlatKey<TRow>[]> = DbActionsEntryCommonBase & GateOpts<TRow, R>;
1040
1119
  /**
@@ -1048,6 +1127,7 @@ type DbActionsEntryWithGate<TRow, R extends readonly FlatKey<TRow>[]> = DbAction
1048
1127
  type TDbActionsEntry<TRow = unknown, R extends readonly FlatKey<TRow>[] = []> = (DbActionsEntryWithGate<TRow, R> & {
1049
1128
  processor: "navigate";
1050
1129
  value: string;
1130
+ inputForm?: never;
1051
1131
  }) | (DbActionsEntryWithGate<TRow, R> & {
1052
1132
  processor: "custom";
1053
1133
  value?: never;
@@ -1721,4 +1801,4 @@ declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef |
1721
1801
  */
1722
1802
  declare function applyTerminalRefs(serialized: TSerializedAnnotatedType, runtime: TAtscriptAnnotatedType, options: TSerializeOptions): TSerializedAnnotatedType;
1723
1803
  //#endregion
1724
- export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
1804
+ export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, TDbDecorateContext, TDbDecorateEndpoint, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, TSerializ
3
3
  import { HttpError } from "@moostjs/event-http";
4
4
  import { Mate, Moost, TConsoleBase, TMateParamMeta, TMoostMetadata } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, 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, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdResolveOptions, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
6
+ import { AggregateFn, AtscriptDbReadable, AtscriptDbTable, BucketUnit, 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, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdResolveOptions, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
7
7
  //#region src/as-readable.controller.d.ts
8
8
  /**
9
9
  * Abstract base class for read-only HTTP controllers over an Atscript interface.
@@ -241,7 +241,7 @@ interface TCapabilityVerdict {
241
241
  message: string;
242
242
  }
243
243
  /** The readable members the index reads. */
244
- type TCapabilityReadable = Pick<AtscriptDbReadable, "type" | "fieldDescriptors" | "flatMap" | "navFields" | "relations" | "ignoredFields" | "canFilterField" | "canSortField" | "isGeoSearchable" | "calendarBucketUnits" | "dimensions" | "measures">;
244
+ type TCapabilityReadable = Pick<AtscriptDbReadable, "type" | "fieldDescriptors" | "flatMap" | "navFields" | "relations" | "ignoredFields" | "canFilterField" | "canSortField" | "isGeoSearchable" | "calendarBucketUnits" | "aggregateFns" | "dimensions" | "measures">;
245
245
  /**
246
246
  * Capability index of one readable.
247
247
  *
@@ -273,15 +273,17 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
273
273
  readonly physicalNames: ReadonlySet<string>;
274
274
  /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
275
275
  readonly bucketUnits: readonly BucketUnit[];
276
+ /** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
277
+ readonly aggregateFns: readonly AggregateFn[];
276
278
  /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
277
279
  readonly signature: string;
278
280
  /**
279
281
  * The adapter-level capabilities that can change after construction (geo
280
- * support, calendar-bucket units): an index whose {@link signature}
281
- * differs from this is stale. Any new adapter-level input the index reads
282
- * must be added here.
282
+ * support, calendar-bucket units, aggregate functions): an index whose
283
+ * {@link signature} differs from this is stale. Any new adapter-level input
284
+ * the index reads must be added here.
283
285
  */
284
- static adapterSignature(source: Pick<TCapabilityReadable, "isGeoSearchable" | "calendarBucketUnits">): string;
286
+ static adapterSignature(source: Pick<TCapabilityReadable, "isGeoSearchable" | "calendarBucketUnits" | "aggregateFns">): string;
285
287
  private readonly _entries;
286
288
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
287
289
  private readonly _objectParents;
@@ -324,12 +326,34 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
324
326
  }
325
327
  //#endregion
326
328
  //#region src/as-db-readable.controller.d.ts
329
+ /** Read endpoint a {@link AsDbReadableController.decorateRows} call serves. */
330
+ type TDbDecorateEndpoint = "query" | "pages" | "geo" | "one";
331
+ /**
332
+ * Context passed to {@link AsDbReadableController.decorateRows}.
333
+ *
334
+ * @since 0.1.136
335
+ */
336
+ interface TDbDecorateContext {
337
+ /** Endpoint that produced the rows. `/one` and `/one/:id` both report `"one"`. */
338
+ endpoint: TDbDecorateEndpoint;
339
+ /**
340
+ * The effective `$select` the endpoint read with — after
341
+ * `transformProjection`, the `@db.writeOnly` seal and preferred-id
342
+ * widening (`undefined` = no projection). Columns added only to feed an
343
+ * action's `requiredFields` are stripped again before the hook runs.
344
+ */
345
+ projection: UniqueryControls["$select"] | undefined;
346
+ /** The request's parsed controls (`$select`, `$with`, `$actions`, …). Read-only by convention. */
347
+ controls: Record<string, unknown>;
348
+ }
327
349
  /**
328
350
  * Read-only database controller for Moost that works with any `AtscriptDbReadable`
329
351
  * (tables or views). Provides query, pages, getOne, and meta endpoints.
330
352
  *
331
353
  * For write operations (insert, replace, update, delete), use {@link AsDbController}.
332
- * For views, use {@link AsDbViewController}.
354
+ * Views bind to this same class — `@ViewController(view)` (an alias of
355
+ * `@ReadableController`) or a constructor-passed view; there is no separate
356
+ * view controller.
333
357
  */
334
358
  declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> extends AsReadableController<T, DataType> {
335
359
  /** Reference to the underlying readable (table or view). */
@@ -370,6 +394,8 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
370
394
  private readonly _idSources;
371
395
  private readonly _preferredIdSet;
372
396
  private readonly _overlayIsNoOp;
397
+ /** `true` when a subclass implements {@link decorateRows} (the override switches the hook on). */
398
+ private readonly _decorates;
373
399
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
374
400
  private readonly _quantityRefByPath;
375
401
  /** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
@@ -435,7 +461,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
435
461
  }): HttpError | undefined;
436
462
  /**
437
463
  * The core's shared normalizer of `$select` computed entries
438
- * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
464
+ * (`normalizeComputedSelect`) as a 400 with the core's wording and `path`
439
465
  * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
440
466
  * week start / alias, "grouped queries only", "must also appear in
441
467
  * $groupBy", alias collisions with this table's fields. Runs once per
@@ -551,11 +577,45 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
551
577
  private _aggregateControls;
552
578
  private _resolveReadStrategy;
553
579
  /**
554
- * Shared `query` / `pages` pipeline: prepare actions augmentation + read
580
+ * Post-read row decoration hook. Not implemented by
581
+ * default — defining it in a subclass switches it on. Runs once per
582
+ * response on `/query`, `/pages`, `/geo` and `/one` (`/one/:id` and the
583
+ * composite form), after `$actions` augmentation, with the final top-level
584
+ * rows. Mutate the rows in place; the return value is ignored. May be async.
585
+ *
586
+ * Not called for `$count`, `$groupBy` aggregates, nested `$with` rows
587
+ * (reach them through the parent row), a `/one` 404, or value-help
588
+ * controllers.
589
+ *
590
+ * Convention (not enforced): name decoration keys with a `$` prefix, like
591
+ * `$actions` and `$distance`, so they can never collide with a field name.
592
+ * Do not overwrite `$actions`. Columns the
593
+ * hook needs but the client did not select must be added in
594
+ * {@link transformProjection} — they are then part of the response.
595
+ *
596
+ * ```ts
597
+ * protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
598
+ * const unread = await countUnread(rows.map((r) => r.id))
599
+ * for (const row of rows) row.$unread = unread.get(row.id) ?? 0
600
+ * }
601
+ * ```
602
+ *
603
+ * @since 0.1.136
604
+ */
605
+ protected decorateRows?(rows: Record<string, unknown>[], ctx: TDbDecorateContext): void | Promise<void>;
606
+ /**
607
+ * Finishes a read's top-level rows in place: `$actions` augmentation (when
608
+ * the request asked for it — `prep`), then {@link decorateRows} when a
609
+ * subclass implements it. Returns the hook's result — `undefined`, with no
610
+ * promise or microtask, when there is no hook or it is synchronous.
611
+ */
612
+ private _finishRows;
613
+ /**
614
+ * Shared `query` / `pages` / `geo` pipeline: prepare actions augmentation + read
555
615
  * strategy in parallel, pre-widen $select for `requiredFields`, run
556
616
  * `exec`, and augment `result.data` with `$actions` when the request set
557
- * `$actions=true`. Caller dispatches the strategy to its read-method
558
- * family (count vs no-count).
617
+ * `$actions=true`, then run {@link decorateRows}. Caller dispatches the
618
+ * strategy to its read-method family (count vs no-count).
559
619
  */
560
620
  private _runReadWithActions;
561
621
  /**
@@ -1006,7 +1066,7 @@ interface LooseGate {
1006
1066
  onDisabledRows?: TOnDisabledRows;
1007
1067
  }
1008
1068
  type GateOpts<TRow, R extends readonly FlatKey<TRow>[]> = unknown extends TRow ? LooseGate : NoGate | WithGate<TRow, R>;
1009
- interface BaseActionOpts extends Partial<Omit<TDbActionInfo$1, "name" | "level" | "processor" | "value" | "disabled">> {
1069
+ interface BaseActionOpts extends Partial<Omit<TDbActionInfo$1, "name" | "level" | "processor" | "value" | "disabled" | "formUrl">> {
1010
1070
  /**
1011
1071
  * Bound table reference. REQUIRED on non-`AsDbReadableController` classes
1012
1072
  * when `disabled` is set OR a `@DbActionRow*` parameter is declared.
@@ -1035,6 +1095,25 @@ interface DbActionsEntryCommonBase {
1035
1095
  promptText?: string | [string, string];
1036
1096
  /** Mirrors {@link TDbActionInfo.shortcut} — single-character UI hint. */
1037
1097
  shortcut?: string;
1098
+ /**
1099
+ * Input form the UI collects before invoking the action:
1100
+ *
1101
+ * - a compiled `.as` interface — registered on THIS controller and served
1102
+ * by its own `GET /meta/form/:name`; the wire carries `inputForm: Type.name`.
1103
+ * - `{ name, url }` — a form served elsewhere: `name` goes on the wire as
1104
+ * `inputForm`, `url` (server-absolute path of the serialized schema, e.g.
1105
+ * `"/api/shipping/meta/form/ShipForm"`) as {@link TDbActionInfo.formUrl}.
1106
+ *
1107
+ * Not allowed with `processor: 'navigate'`. Class-level entries only
1108
+ * describe the action — validating `input` is the target handler's job
1109
+ * (e.g. its own `@InputForm(Type)` param).
1110
+ *
1111
+ * @since 0.1.136
1112
+ */
1113
+ inputForm?: TAtscriptAnnotatedType | {
1114
+ name: string;
1115
+ url: string;
1116
+ };
1038
1117
  }
1039
1118
  type DbActionsEntryWithGate<TRow, R extends readonly FlatKey<TRow>[]> = DbActionsEntryCommonBase & GateOpts<TRow, R>;
1040
1119
  /**
@@ -1048,6 +1127,7 @@ type DbActionsEntryWithGate<TRow, R extends readonly FlatKey<TRow>[]> = DbAction
1048
1127
  type TDbActionsEntry<TRow = unknown, R extends readonly FlatKey<TRow>[] = []> = (DbActionsEntryWithGate<TRow, R> & {
1049
1128
  processor: "navigate";
1050
1129
  value: string;
1130
+ inputForm?: never;
1051
1131
  }) | (DbActionsEntryWithGate<TRow, R> & {
1052
1132
  processor: "custom";
1053
1133
  value?: never;
@@ -1721,4 +1801,4 @@ declare function resolveTerminalRef(def: TAtscriptAnnotatedType): TTerminalRef |
1721
1801
  */
1722
1802
  declare function applyTerminalRefs(serialized: TSerializedAnnotatedType, runtime: TAtscriptAnnotatedType, options: TSerializeOptions): TSerializedAnnotatedType;
1723
1803
  //#endregion
1724
- export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
1804
+ export { ActionDisabledError, type ActionDisabledErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbClassActionMeta, TDbDecorateContext, TDbDecorateEndpoint, type TDbRemoveGuardContext, type TDbWriteAction, type TDbWriteGuardContext, type TFieldCapability, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, ValueHelpQuery, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotate
3
3
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
4
4
  import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Provide, Resolve, TInterceptorPriority, defineBeforeInterceptor, defineInterceptor, getMoostMate, useControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { ADAPTER_FILTER_REASON, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isJsonValueField, isPlainObject, narrowerFilterOps, reconcileCas, resolveCalendarBuckets, unsupportedOperatorMessage } from "@atscript/db";
6
+ import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isJsonValueField, isPlainObject, narrowerFilterOps, normalizeComputedSelect, reconcileCas, unsupportedOperatorMessage } from "@atscript/db";
7
7
  import { BUCKET_UNITS } from "@uniqu/core";
8
8
  import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
9
9
  import { cached, current, defineWook, key } from "@wooksjs/event-core";
@@ -250,7 +250,8 @@ const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
250
250
  /**
251
251
  * Per-controller registry of form names → compiled `.as` classes, populated
252
252
  * during {@link discoverActions} when a method param carries
253
- * `atscript_db_action_input_form`. Backs `GET /meta/form/:name`.
253
+ * `atscript_db_action_input_form` or a class-level entry passes a type as
254
+ * `inputForm`. Backs `GET /meta/form/:name`.
254
255
  *
255
256
  * Same name + same type ref across multiple actions is fine (forms can be
256
257
  * reused). Same name + *different* type refs is an ambiguity — discovery
@@ -394,7 +395,7 @@ function collectClassActions(ctor, logger, out, seen) {
394
395
  logger.warn(`${WARN_PREFIX} duplicate action name "${name}" within controller — dropping the second declaration`);
395
396
  continue;
396
397
  }
397
- const built = buildClassEntry(name, entry, logger);
398
+ const built = buildClassEntry(ctor, name, entry, logger);
398
399
  if (built) {
399
400
  seen.add(name);
400
401
  out.push({
@@ -404,7 +405,7 @@ function collectClassActions(ctor, logger, out, seen) {
404
405
  }
405
406
  }
406
407
  }
407
- function buildClassEntry(name, entry, logger) {
408
+ function buildClassEntry(ctor, name, entry, logger) {
408
409
  const level = entry.level;
409
410
  if (!level) {
410
411
  logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
@@ -442,6 +443,8 @@ function buildClassEntry(name, entry, logger) {
442
443
  logger.warn(`${WARN_PREFIX} class-level action "${name}" has unknown processor "${String(processor)}" — dropping`);
443
444
  return null;
444
445
  }
446
+ const form = resolveClassInputForm(name, entry, processor, logger);
447
+ if (form === null) return null;
445
448
  const info = {
446
449
  name,
447
450
  label: entry.label,
@@ -449,9 +452,45 @@ function buildClassEntry(name, entry, logger) {
449
452
  processor,
450
453
  value
451
454
  };
455
+ if (form) {
456
+ if (form.type && !registerFormType(ctor, {
457
+ type: form.type,
458
+ name: form.name
459
+ }, name, logger)) return null;
460
+ info.inputForm = form.name;
461
+ if (form.url !== void 0) info.formUrl = form.url;
462
+ }
452
463
  emitInfo(info, entry);
453
464
  return info;
454
465
  }
466
+ /**
467
+ * Resolves a class-level entry's `inputForm`. Returns `undefined` when the
468
+ * entry declares none, `null` when it must be dropped (already warned — the
469
+ * runtime check for JS callers and `processor: "navigate"`), else the form to
470
+ * emit: `type` when this controller serves it (`/meta/form/:name`), `url`
471
+ * when it lives elsewhere.
472
+ */
473
+ function resolveClassInputForm(name, entry, processor, logger) {
474
+ const inputForm = entry.inputForm;
475
+ if (inputForm === void 0) return void 0;
476
+ const drop = (reason) => {
477
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" — ${reason} — dropping`);
478
+ return null;
479
+ };
480
+ if (processor === "navigate") return drop("processor \"navigate\" cannot take an `inputForm`");
481
+ const { name: formName, url } = inputForm ?? {};
482
+ if (typeof formName === "string" && formName !== "") {
483
+ if (isAnnotatedType(inputForm)) return {
484
+ name: formName,
485
+ type: inputForm
486
+ };
487
+ if (typeof url === "string" && url !== "") return {
488
+ name: formName,
489
+ url
490
+ };
491
+ }
492
+ return drop("`inputForm` must be a compiled .as interface or `{ name, url }` (non-empty strings)");
493
+ }
455
494
  function applyDefaultPerLevel(envelopes, logger) {
456
495
  const winners = /* @__PURE__ */ new Map();
457
496
  for (const { info } of envelopes) {
@@ -976,6 +1015,11 @@ function computeStripFields(candidates, resolvedProjection) {
976
1015
  }
977
1016
  return strip;
978
1017
  }
1018
+ /**
1019
+ * Sets `$actions` on every row and strips the columns fetched only for an
1020
+ * action's `requiredFields` — IN PLACE; returns the same array, typed as
1021
+ * augmented.
1022
+ */
979
1023
  function augmentRowsWithActions(args) {
980
1024
  const { envelopes, rows, resolvedProjection } = args;
981
1025
  const candidates = collectCandidates(envelopes);
@@ -1223,16 +1267,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1223
1267
  physicalNames;
1224
1268
  /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
1225
1269
  bucketUnits;
1270
+ /** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
1271
+ aggregateFns;
1226
1272
  /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1227
1273
  signature;
1228
1274
  /**
1229
1275
  * The adapter-level capabilities that can change after construction (geo
1230
- * support, calendar-bucket units): an index whose {@link signature}
1231
- * differs from this is stale. Any new adapter-level input the index reads
1232
- * must be added here.
1276
+ * support, calendar-bucket units, aggregate functions): an index whose
1277
+ * {@link signature} differs from this is stale. Any new adapter-level input
1278
+ * the index reads must be added here.
1233
1279
  */
1234
1280
  static adapterSignature(source) {
1235
- return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}`;
1281
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}`;
1236
1282
  }
1237
1283
  _entries = /* @__PURE__ */ new Map();
1238
1284
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
@@ -1255,6 +1301,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1255
1301
  this.signature = FieldCapabilityIndex.adapterSignature(source);
1256
1302
  const units = source.calendarBucketUnits();
1257
1303
  this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
1304
+ const fns = source.aggregateFns();
1305
+ this.aggregateFns = [...ALL_AGGREGATE_FNS].filter((fn) => fns.has(fn));
1258
1306
  const physicalNames = /* @__PURE__ */ new Set();
1259
1307
  const jsonValueParents = /* @__PURE__ */ new Set();
1260
1308
  for (const fd of source.fieldDescriptors) {
@@ -1551,6 +1599,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1551
1599
  _idSources = /* @__PURE__ */ new Map();
1552
1600
  _preferredIdSet;
1553
1601
  _overlayIsNoOp;
1602
+ /** `true` when a subclass implements {@link decorateRows} (the override switches the hook on). */
1603
+ _decorates;
1554
1604
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
1555
1605
  _quantityRefByPath;
1556
1606
  /** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
@@ -1574,6 +1624,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1574
1624
  this._quantityRefByPath = this._collectQuantityRefs();
1575
1625
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1576
1626
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
1627
+ this._decorates = typeof this.decorateRows === "function";
1577
1628
  this._idOpts = this.hasField === _AsDbReadableController.prototype.hasField ? void 0 : { isFieldVisible: this._exists };
1578
1629
  }
1579
1630
  /**
@@ -1680,7 +1731,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1680
1731
  }
1681
1732
  /**
1682
1733
  * The core's shared normalizer of `$select` computed entries
1683
- * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
1734
+ * (`normalizeComputedSelect`) as a 400 with the core's wording and `path`
1684
1735
  * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
1685
1736
  * week start / alias, "grouped queries only", "must also appear in
1686
1737
  * $groupBy", alias collisions with this table's fields. Runs once per
@@ -1692,7 +1743,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1692
1743
  checkComputedSelect(controls) {
1693
1744
  const capabilities = this.capabilities;
1694
1745
  try {
1695
- resolveCalendarBuckets(controls, {
1746
+ normalizeComputedSelect(controls, {
1696
1747
  flatMap: this.readable.flatMap,
1697
1748
  physicalNames: capabilities.physicalNames,
1698
1749
  navFields: capabilities.navFields
@@ -2049,13 +2100,27 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2049
2100
  return { kind: "plain" };
2050
2101
  }
2051
2102
  /**
2052
- * Shared `query` / `pages` pipeline: prepare actions augmentation + read
2103
+ * Finishes a read's top-level rows in place: `$actions` augmentation (when
2104
+ * the request asked for it — `prep`), then {@link decorateRows} when a
2105
+ * subclass implements it. Returns the hook's result — `undefined`, with no
2106
+ * promise or microtask, when there is no hook or it is synchronous.
2107
+ */
2108
+ _finishRows(rows, prep, ctx) {
2109
+ if (prep) augmentRowsWithActions({
2110
+ envelopes: prep.envelopes,
2111
+ rows,
2112
+ resolvedProjection: prep.resolvedProjection
2113
+ });
2114
+ return this._decorates ? this.decorateRows(rows, ctx) : void 0;
2115
+ }
2116
+ /**
2117
+ * Shared `query` / `pages` / `geo` pipeline: prepare actions augmentation + read
2053
2118
  * strategy in parallel, pre-widen $select for `requiredFields`, run
2054
2119
  * `exec`, and augment `result.data` with `$actions` when the request set
2055
- * `$actions=true`. Caller dispatches the strategy to its read-method
2056
- * family (count vs no-count).
2120
+ * `$actions=true`, then run {@link decorateRows}. Caller dispatches the
2121
+ * strategy to its read-method family (count vs no-count).
2057
2122
  */
2058
- async _runReadWithActions(queryObj, controls, select, exec) {
2123
+ async _runReadWithActions(endpoint, queryObj, controls, select, exec) {
2059
2124
  const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, select), this._resolveReadStrategy(controls)]);
2060
2125
  const result = await exec(prep?.widenedSelect ? {
2061
2126
  ...queryObj,
@@ -2064,12 +2129,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2064
2129
  $select: prep.widenedSelect
2065
2130
  }
2066
2131
  } : queryObj, strategy);
2067
- if (!prep) return result;
2068
- result.data = augmentRowsWithActions({
2069
- envelopes: prep.envelopes,
2070
- rows: result.data,
2071
- resolvedProjection: prep.resolvedProjection
2132
+ const pending = this._finishRows(result.data, prep, {
2133
+ endpoint,
2134
+ projection: select,
2135
+ controls
2072
2136
  });
2137
+ if (pending) await pending;
2073
2138
  return result;
2074
2139
  }
2075
2140
  /**
@@ -2141,7 +2206,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2141
2206
  $threshold: threshold
2142
2207
  }
2143
2208
  };
2144
- return (await this._runReadWithActions(queryObj, controls, select, async (q, strategy) => {
2209
+ return (await this._runReadWithActions("query", queryObj, controls, select, async (q, strategy) => {
2145
2210
  switch (strategy.kind) {
2146
2211
  case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
2147
2212
  case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
@@ -2179,7 +2244,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2179
2244
  $threshold: threshold
2180
2245
  }
2181
2246
  };
2182
- const result = await this._runReadWithActions(query, controls, select, async (q, strategy) => {
2247
+ const result = await this._runReadWithActions("pages", query, controls, select, async (q, strategy) => {
2183
2248
  switch (strategy.kind) {
2184
2249
  case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
2185
2250
  case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
@@ -2244,7 +2309,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2244
2309
  }
2245
2310
  };
2246
2311
  if (paginated) {
2247
- const result = await this._runReadWithActions(queryObj, controls, select, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
2312
+ const result = await this._runReadWithActions("geo", queryObj, controls, select, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
2248
2313
  return {
2249
2314
  data: result.data,
2250
2315
  page,
@@ -2253,7 +2318,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2253
2318
  count: result.count
2254
2319
  };
2255
2320
  }
2256
- return (await this._runReadWithActions(queryObj, controls, select, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
2321
+ return (await this._runReadWithActions("geo", queryObj, controls, select, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
2257
2322
  }
2258
2323
  /** Parses the `$center` control: `"lng,lat"` string (or tuple) → `[number, number]`. */
2259
2324
  _parseGeoCenter(raw) {
@@ -2328,13 +2393,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2328
2393
  }
2329
2394
  const item = await this.returnOne(Promise.resolve(row));
2330
2395
  if (item instanceof HttpError) return item;
2331
- if (!prep) return item;
2332
- const [augmented] = augmentRowsWithActions({
2333
- envelopes: prep.envelopes,
2334
- rows: [item],
2335
- resolvedProjection: prep.resolvedProjection
2396
+ const pending = this._finishRows([item], prep, {
2397
+ endpoint: "one",
2398
+ projection: select,
2399
+ controls: parsedControls
2336
2400
  });
2337
- return augmented;
2401
+ if (pending) await pending;
2402
+ return item;
2338
2403
  }
2339
2404
  /**
2340
2405
  * **GET /meta** — returns table/view metadata for UI.
@@ -2382,7 +2447,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2382
2447
  actions: this.buildActions(),
2383
2448
  crud: this.buildCrud(),
2384
2449
  versionColumn: this.readable.versionColumn,
2385
- ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] }
2450
+ ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] },
2451
+ aggregateFns: [...capabilities.aggregateFns]
2386
2452
  };
2387
2453
  }
2388
2454
  buildCrud() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.134",
3
+ "version": "0.1.136",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -43,14 +43,14 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@uniqu/url": "^0.1.10",
47
- "@atscript/db-memory": "^0.1.134"
46
+ "@uniqu/url": "^0.1.11",
47
+ "@atscript/db-memory": "^0.1.136"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@atscript/core": "^0.1.92",
51
51
  "@atscript/typescript": "^0.1.92",
52
52
  "@moostjs/event-http": "^0.6.37",
53
- "@uniqu/core": "^0.1.10",
53
+ "@uniqu/core": "^0.1.11",
54
54
  "@wooksjs/event-core": "^0.7.23",
55
55
  "@wooksjs/event-http": "^0.7.23",
56
56
  "@wooksjs/http-body": "^0.7.23",
@@ -60,11 +60,11 @@
60
60
  "peerDependencies": {
61
61
  "@atscript/typescript": "^0.1.92",
62
62
  "@moostjs/event-http": "^0.6.37",
63
- "@uniqu/core": "^0.1.10",
63
+ "@uniqu/core": "^0.1.11",
64
64
  "@wooksjs/event-core": "^0.7.23",
65
65
  "@wooksjs/http-body": "^0.7.23",
66
66
  "moost": "^0.6.37",
67
- "@atscript/db": "^0.1.134"
67
+ "@atscript/db": "^0.1.136"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",