@atscript/moost-db 0.1.54 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,72 +1,9 @@
1
1
  import { ValidatorError, defineAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
2
2
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
3
- import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, TInterceptorPriority, defineInterceptor, useControllerContext } from "moost";
3
+ import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, Resolve, TInterceptorPriority, defineInterceptor, getMoostMate, useControllerContext } from "moost";
4
4
  import { parseUrl } from "@uniqu/url";
5
5
  import { DbError } from "@atscript/db";
6
- //#region src/decorators.ts
7
- /**
8
- * DI token under which the {@link AtscriptDbReadable} instance
9
- * is exposed to the readable controller's constructor via `@Inject`.
10
- */
11
- const READABLE_DEF = "__atscript_db_readable_def";
12
- /**
13
- * DI token under which the {@link AtscriptDbTable} instance
14
- * is exposed to the controller's constructor via `@Inject`.
15
- * Points to the same token as READABLE_DEF for backward compatibility.
16
- */
17
- const TABLE_DEF = READABLE_DEF;
18
- /**
19
- * Combines the boilerplate needed to turn an {@link AsDbController}
20
- * subclass into a fully wired HTTP controller for a given `@db.table` model.
21
- *
22
- * Internally applies three decorators:
23
- * 1. **Provide** — registers the table instance under {@link TABLE_DEF}.
24
- * 2. **Controller** — registers the class as a Moost HTTP controller
25
- * with an optional route prefix. Defaults to `table.tableName`.
26
- * 3. **Inherit** — copies metadata (routes, guards, etc.) from the
27
- * parent class so they stay active in the derived controller.
28
- *
29
- * @param table The {@link AtscriptDbTable} instance for this controller.
30
- * @param prefix Optional route prefix. Defaults to `table.tableName`.
31
- *
32
- * @example
33
- * ```ts
34
- * ‎@TableController(usersTable)
35
- * export class UsersController extends AsDbController<typeof UserModel> {}
36
- * ```
37
- */
38
- const TableController = (table, prefix) => {
39
- const resolvedPath = prefix || table.type.metadata.get("db.http.path");
40
- return ApplyDecorators(Provide(TABLE_DEF, () => table), Controller(resolvedPath || table.tableName), Inherit());
41
- };
42
- /**
43
- * Combines the boilerplate needed to turn an {@link AsDbReadableController}
44
- * subclass into a fully wired HTTP controller for a given `@db.view` or `@db.table` model.
45
- *
46
- * @param readable The {@link AtscriptDbReadable} instance (table or view).
47
- * @param prefix Optional route prefix. Defaults to `readable.tableName`.
48
- *
49
- * @example
50
- * ```ts
51
- * ‎@ReadableController(activeTasksView)
52
- * export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
53
- * ```
54
- */
55
- const ReadableController = (readable, prefix) => {
56
- const resolvedPath = prefix || readable.type.metadata.get("db.http.path");
57
- return ApplyDecorators(Provide(READABLE_DEF, () => readable), Controller(resolvedPath || readable.tableName), Inherit());
58
- };
59
- /**
60
- * Alias for {@link ReadableController} — use with view-backed controllers.
61
- *
62
- * @example
63
- * ```ts
64
- * ‎@ViewController(activeTasksView)
65
- * export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
66
- * ```
67
- */
68
- const ViewController = ReadableController;
69
- //#endregion
6
+ import { useBody } from "@wooksjs/http-body";
70
7
  //#region src/validation-interceptor.ts
71
8
  const dbErrorCodeToStatus = { CONFLICT: 409 };
72
9
  function transformValidationError(error, reply) {
@@ -177,16 +114,265 @@ defineAnnotatedType("object", WithFilterDto).propPattern(/./, defineAnnotatedTyp
177
114
  defineAnnotatedType("object", SortControlDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("number").value(1).$type).item(defineAnnotatedType().designType("number").value(-1).$type).$type);
178
115
  defineAnnotatedType("object", SelectControlDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("number").value(1).$type).item(defineAnnotatedType().designType("number").value(0).$type).$type);
179
116
  //#endregion
180
- //#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
181
- function __decorateMetadata(k, v) {
182
- if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
117
+ //#region src/gate-utils.ts
118
+ /**
119
+ * Walks a Uniquery filter expression and returns the first field name that
120
+ * fails the `isAllowed` predicate, or `undefined` if every leaf field is
121
+ * allowed. Logical combinators (`$and`, `$or`, `$nor`, `$not`) are traversed;
122
+ * other `$`-prefixed keys are skipped.
123
+ *
124
+ * Shared by the DB readable's `@db.column.filterable` gate and the value-help
125
+ * controller's `@ui.dict.filterable` gate — only the predicate differs.
126
+ */
127
+ function findFilterOffender(filter, isAllowed) {
128
+ if (!filter || typeof filter !== "object") return;
129
+ for (const [key, value] of Object.entries(filter)) {
130
+ if (key === "$and" || key === "$or" || key === "$nor") {
131
+ if (Array.isArray(value)) for (const sub of value) {
132
+ const inner = findFilterOffender(sub, isAllowed);
133
+ if (inner) return inner;
134
+ }
135
+ continue;
136
+ }
137
+ if (key === "$not") {
138
+ const inner = findFilterOffender(value, isAllowed);
139
+ if (inner) return inner;
140
+ continue;
141
+ }
142
+ if (key.startsWith("$")) continue;
143
+ if (!isAllowed(key)) return key;
144
+ }
145
+ }
146
+ /**
147
+ * Walks a Uniquery `$sort` control (accepts string, string[], object, or
148
+ * array-of-{field: dir}) and returns the first field name that fails the
149
+ * `isAllowed` predicate, or `undefined` if every sort key is allowed.
150
+ *
151
+ * Shared by the DB readable's `@db.column.sortable` gate and the value-help
152
+ * controller's `@ui.dict.sortable` gate.
153
+ */
154
+ function findSortOffender(sort, isAllowed) {
155
+ if (!sort) return void 0;
156
+ const check = (name) => isAllowed(name) ? void 0 : name;
157
+ if (typeof sort === "string") {
158
+ for (const part of sort.split(",")) {
159
+ const name = part.trim().replace(/^[-+]/, "").split(":")[0];
160
+ if (name) {
161
+ const bad = check(name);
162
+ if (bad) return bad;
163
+ }
164
+ }
165
+ return;
166
+ }
167
+ if (Array.isArray(sort)) {
168
+ for (const entry of sort) if (typeof entry === "string") {
169
+ const bad = check(entry.replace(/^[-+]/, ""));
170
+ if (bad) return bad;
171
+ } else if (entry && typeof entry === "object") for (const name of Object.keys(entry)) {
172
+ const bad = check(name);
173
+ if (bad) return bad;
174
+ }
175
+ return;
176
+ }
177
+ if (typeof sort === "object") for (const name of Object.keys(sort)) {
178
+ const bad = check(name);
179
+ if (bad) return bad;
180
+ }
183
181
  }
184
182
  //#endregion
185
- //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
186
- function __decorateParam(paramIndex, decorator) {
187
- return function(target, key) {
188
- decorator(target, key, paramIndex);
183
+ //#region src/actions/keys.ts
184
+ /** Method-level metadata key — written by `@DbAction(name, opts)`. */
185
+ const MOOST_DB_ACTION = "atscript_db_action";
186
+ /** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
187
+ const MOOST_DB_ACTIONS = "atscript_db_actions";
188
+ /** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
189
+ const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
190
+ /**
191
+ * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
192
+ * read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
193
+ * fields win), and write it back. `name` is empty until `@DbAction` provides
194
+ * one — `discoverActions` warns and drops actions with no name.
195
+ */
196
+ function mergeActionMeta(current, patch) {
197
+ const existing = current[MOOST_DB_ACTION];
198
+ return {
199
+ name: patch.name ?? existing?.name ?? "",
200
+ opts: {
201
+ ...existing?.opts,
202
+ ...patch.opts
203
+ }
204
+ };
205
+ }
206
+ //#endregion
207
+ //#region src/actions/discover.ts
208
+ /** Optional fields shared between method opts and class-level entries. */
209
+ const OPTIONAL_FIELDS = [
210
+ "icon",
211
+ "intent",
212
+ "description",
213
+ "order",
214
+ "default",
215
+ "promptText"
216
+ ];
217
+ const WARN_PREFIX = "[moost-db actions]";
218
+ const actionsCache = /* @__PURE__ */ new WeakMap();
219
+ /**
220
+ * Discover all actions declared on a controller and produce the `/meta` array.
221
+ * Reads class + method metadata via `getMoostMate()` and resolves bound POST
222
+ * paths through the Moost controller overview.
223
+ *
224
+ * Result is memoized per controller constructor — discovery walks every
225
+ * handler entry and reads decorator metadata, which is wasted work to repeat
226
+ * across instances.
227
+ */
228
+ function discoverActions(controllerCtor, app, logger) {
229
+ const cached = actionsCache.get(controllerCtor);
230
+ if (cached) return cached;
231
+ const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
232
+ const out = [];
233
+ collectMethodActions(controllerCtor, overview, logger, out);
234
+ collectClassActions(controllerCtor, logger, out);
235
+ applyDefaultPerLevel(out, logger);
236
+ actionsCache.set(controllerCtor, out);
237
+ return out;
238
+ }
239
+ function collectMethodActions(ctor, overview, logger, out) {
240
+ if (!overview) return;
241
+ const byMethod = /* @__PURE__ */ new Map();
242
+ for (const h of overview.handlers) {
243
+ const list = byMethod.get(h.method);
244
+ if (list) list.push(h);
245
+ else byMethod.set(h.method, [h]);
246
+ }
247
+ for (const [methodName, handlers] of byMethod) {
248
+ const methodMeta = handlers[0].meta;
249
+ const action = methodMeta[MOOST_DB_ACTION];
250
+ if (!action) continue;
251
+ if (!action.name) {
252
+ logger.warn(`${WARN_PREFIX} method "${methodName}" has @DbActionDefault() but no @DbAction(name) — dropping`);
253
+ continue;
254
+ }
255
+ const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
256
+ if (!levelInfer) continue;
257
+ if (levelInfer.bodyConflict) {
258
+ logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs with @Body() — dropping`);
259
+ continue;
260
+ }
261
+ const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
262
+ if (!postEntry) {
263
+ logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
264
+ continue;
265
+ }
266
+ const path = postEntry.registeredAs[0]?.path;
267
+ if (!path) {
268
+ logger.warn(`${WARN_PREFIX} action "${action.name}" — POST handler ${methodName} has no registered path — dropping`);
269
+ continue;
270
+ }
271
+ const label = action.opts.label ?? methodMeta.label;
272
+ if (!label) {
273
+ logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
274
+ continue;
275
+ }
276
+ const info = {
277
+ name: action.name,
278
+ label,
279
+ level: levelInfer.level,
280
+ processor: "backend",
281
+ value: path
282
+ };
283
+ copyOptionalFields(info, action.opts);
284
+ out.push(info);
285
+ }
286
+ }
287
+ function inferMethodLevel(params, actionName, logger) {
288
+ let hasPk = false;
289
+ let hasPks = false;
290
+ let hasBody = false;
291
+ for (const p of params) {
292
+ const kind = p[MOOST_DB_ACTION_PARAM];
293
+ if (kind === "pk") hasPk = true;
294
+ else if (kind === "pks") hasPks = true;
295
+ if (p.paramSource === "BODY") hasBody = true;
296
+ }
297
+ if (hasPk && hasPks) {
298
+ logger.warn(`${WARN_PREFIX} action "${actionName}" has both @DbActionPK and @DbActionPKs — dropping`);
299
+ return null;
300
+ }
301
+ const level = hasPk ? "row" : hasPks ? "rows" : "table";
302
+ return {
303
+ level,
304
+ bodyConflict: hasBody && level !== "table"
305
+ };
306
+ }
307
+ function collectClassActions(ctor, logger, out) {
308
+ const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
309
+ if (!list) return;
310
+ for (const { name, entry, forcedLevel } of list) {
311
+ const built = buildClassEntry(name, entry, forcedLevel, logger);
312
+ if (built) out.push(built);
313
+ }
314
+ }
315
+ function buildClassEntry(name, entry, forcedLevel, logger) {
316
+ const level = forcedLevel ?? entry.level;
317
+ if (!level) {
318
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
319
+ return null;
320
+ }
321
+ if (!entry.label) {
322
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
323
+ return null;
324
+ }
325
+ const processor = entry.processor;
326
+ let value;
327
+ if (processor === "navigate" || processor === "backend") {
328
+ const v = entry.value;
329
+ if (typeof v !== "string" || v === "") {
330
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" with processor "${processor}" requires a non-empty "value" — dropping`);
331
+ return null;
332
+ }
333
+ value = v;
334
+ } else if (processor === "custom") {
335
+ const v = entry.value;
336
+ if (v !== void 0 && v !== null) {
337
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" with processor "custom" forbids "value" (always derived from the dict key) — dropping`);
338
+ return null;
339
+ }
340
+ value = name;
341
+ } else {
342
+ logger.warn(`${WARN_PREFIX} class-level action "${name}" has unknown processor "${String(processor)}" — dropping`);
343
+ return null;
344
+ }
345
+ const info = {
346
+ name,
347
+ label: entry.label,
348
+ level,
349
+ processor,
350
+ value
189
351
  };
352
+ copyOptionalFields(info, entry);
353
+ return info;
354
+ }
355
+ function applyDefaultPerLevel(actions, logger) {
356
+ const winners = /* @__PURE__ */ new Map();
357
+ for (const a of actions) {
358
+ if (!a.default) continue;
359
+ const existing = winners.get(a.level);
360
+ if (existing) {
361
+ a.default = false;
362
+ logger.warn(`${WARN_PREFIX} duplicate default action at level "${a.level}": "${existing}" wins, "${a.name}" demoted`);
363
+ } else winners.set(a.level, a.name);
364
+ }
365
+ }
366
+ function copyOptionalFields(info, source) {
367
+ for (const key of OPTIONAL_FIELDS) {
368
+ const value = source[key];
369
+ if (value !== void 0) info[key] = value;
370
+ }
371
+ }
372
+ //#endregion
373
+ //#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
374
+ function __decorateMetadata(k, v) {
375
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
190
376
  }
191
377
  //#endregion
192
378
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorate.js
@@ -197,24 +383,27 @@ function __decorate(decorators, target, key, desc) {
197
383
  return c > 3 && r && Object.defineProperty(target, key, r), r;
198
384
  }
199
385
  //#endregion
200
- //#region src/as-db-readable.controller.ts
201
- var _ref$1, _ref2$1;
202
- let AsDbReadableController = class AsDbReadableController {
203
- /** Reference to the underlying readable (table or view). */
204
- readable;
386
+ //#region src/as-readable.controller.ts
387
+ var _ref$4;
388
+ let AsReadableController = class AsReadableController {
389
+ /** The Atscript interface this controller serves. */
390
+ boundType;
391
+ /** Short human-readable name for logging (usually the table/source name). */
392
+ controllerName;
205
393
  /** Application-scoped logger. */
206
394
  logger;
207
- /** Cached serialized type definition (lazy, computed on first access). */
208
- _serializedType;
209
395
  /** Moost application instance. */
210
396
  app;
397
+ /** Cached serialized type definition (lazy, computed on first access). */
398
+ _serializedType;
211
399
  /** Cached full meta response (computed lazily on first meta() call). */
212
400
  _metaResponse;
213
- constructor(readable, app) {
214
- this.readable = readable;
401
+ constructor(boundType, controllerName, app, kindTag = "readable") {
402
+ this.boundType = boundType;
403
+ this.controllerName = controllerName;
215
404
  this.app = app;
216
- this.logger = app.getLogger(`db [${readable.tableName}]`);
217
- this.logger.info(`Initializing ${readable.isView ? "view" : "table"} controller`);
405
+ this.logger = app.getLogger(`db [${controllerName}]`);
406
+ this.logger.info(`Initializing ${kindTag} controller`);
218
407
  this._resolveHttpPath();
219
408
  try {
220
409
  const p = this.init();
@@ -235,12 +424,12 @@ let AsDbReadableController = class AsDbReadableController {
235
424
  if (!prefix) prefix = (this.app.getControllersOverview?.()?.find((o) => o.type === this.constructor))?.computedPrefix;
236
425
  if (prefix) {
237
426
  if (!prefix.startsWith("/")) prefix = `/${prefix}`;
238
- this.readable.type.metadata.set("db.http.path", prefix);
427
+ this.boundType.metadata.set("db.http.path", prefix);
239
428
  }
240
429
  }
241
- /** Lazily serializes the type (after all controllers have set their @db.http.path). */
430
+ /** Lazily serializes the bound type (after all controllers have set @db.http.path). */
242
431
  getSerializedType() {
243
- if (!this._serializedType) this._serializedType = serializeAnnotatedType(this.readable.type, this.getSerializeOptions());
432
+ if (!this._serializedType) this._serializedType = serializeAnnotatedType(this.boundType, this.getSerializeOptions());
244
433
  return this._serializedType;
245
434
  }
246
435
  /**
@@ -249,13 +438,23 @@ let AsDbReadableController = class AsDbReadableController {
249
438
  init() {}
250
439
  /**
251
440
  * Returns serialization options for the `/meta` endpoint's type field.
252
- * Default: whitelist — keeps `meta.*`, `expect.*`, and `db.rel.*` annotations,
253
- * strips all other `db.*` annotations (table, column, index, default, etc.).
254
- * Override in subclass to customise what annotations are exposed to clients.
441
+ *
442
+ * `refDepth: 0.5` is intentionally static independent of `@db.depth.limit`
443
+ * (which is a security guard on nested writes, not a serialization policy).
444
+ * The shallow shape emits `{ field, type: { id, metadata } }` for every FK,
445
+ * which carries the target's `db.http.path` so clients can resolve value-help
446
+ * URLs and lazy-fetch target `/meta` when deeper structure is needed. Nav
447
+ * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are not `.ref` nodes
448
+ * and always expand fully regardless of `refDepth` — the write-payload shape
449
+ * clients need is unaffected.
450
+ *
451
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
452
+ * other `db.*` (table, column, index, default, etc.). Override in subclass
453
+ * to customise.
255
454
  */
256
455
  getSerializeOptions() {
257
456
  return {
258
- refDepth: 1,
457
+ refDepth: .5,
259
458
  processAnnotation: ({ key, value }) => {
260
459
  if (key.startsWith("meta.") || key.startsWith("expect.") || key.startsWith("db.rel.")) return {
261
460
  key,
@@ -275,7 +474,7 @@ let AsDbReadableController = class AsDbReadableController {
275
474
  }
276
475
  /**
277
476
  * Whether this controller is read-only (no write endpoints).
278
- * Returns `true` for readable/view controllers, overridden to `false` in AsDbController.
477
+ * Returns `true` by default; {@link AsDbController} overrides to `false`.
279
478
  */
280
479
  _isReadOnly() {
281
480
  return true;
@@ -302,7 +501,7 @@ let AsDbReadableController = class AsDbReadableController {
302
501
  validateInsights(insights) {
303
502
  for (const [key] of insights) {
304
503
  if (key === "*") continue;
305
- if (!this.readable.flatMap.has(key)) return `Unknown field "${key}"`;
504
+ if (!this.hasField(key)) return `Unknown field "${key}"`;
306
505
  }
307
506
  }
308
507
  validateParsed(parsed, type) {
@@ -312,6 +511,201 @@ let AsDbReadableController = class AsDbReadableController {
312
511
  const insightsError = this.validateInsights(parsed.insights);
313
512
  if (insightsError) return new HttpError(400, insightsError);
314
513
  }
514
+ }
515
+ /**
516
+ * Shared filter/sort/search gate check. Subclasses assemble a {@link ReadableGates}
517
+ * config per request (or once in the constructor when static) and call this to
518
+ * get a uniform HTTP 400 response for any offending field/control.
519
+ */
520
+ checkGates(filter, controls, gates) {
521
+ if (gates.filter) {
522
+ const bad = findFilterOffender(filter, gates.filter.predicate);
523
+ if (bad) return new HttpError(400, `Filtering on field "${bad}" is not permitted — add ${gates.filter.annotation} to enable.`);
524
+ }
525
+ if (gates.sort) {
526
+ const bad = findSortOffender(controls.$sort, gates.sort.predicate);
527
+ if (bad) return new HttpError(400, `Sorting on field "${bad}" is not permitted — add ${gates.sort.annotation} to enable.`);
528
+ }
529
+ if (gates.search && controls.$search && !gates.search.allowed) return new HttpError(400, gates.search.rejectionMessage);
530
+ }
531
+ parseQueryString(url) {
532
+ const idx = url.indexOf("?");
533
+ return parseUrl(idx >= 0 ? url.slice(idx + 1) : "");
534
+ }
535
+ async returnOne(result) {
536
+ const item = await result;
537
+ if (!item) return new HttpError(404);
538
+ return item;
539
+ }
540
+ /**
541
+ * **GET /meta** — returns the bound interface's metadata envelope.
542
+ *
543
+ * Base implementation delegates to {@link buildMetaResponse}, which subclasses
544
+ * override to add source-specific fields (relations, searchable flags, etc.).
545
+ * The response is cached on the instance; async overrides must cache any
546
+ * extra enrichment themselves.
547
+ */
548
+ async meta() {
549
+ if (this._metaResponse) return this._metaResponse;
550
+ const response = await this.buildMetaResponse();
551
+ this._metaResponse = response;
552
+ return response;
553
+ }
554
+ /**
555
+ * Builds the `/meta` payload. Override in subclasses to populate source-specific
556
+ * fields. Defaults return a minimal envelope with the serialized type and the
557
+ * read-only flag; value-help dicts populate their capability hints here.
558
+ * Subclasses that fully replace the envelope must call {@link buildActions}
559
+ * directly so `@DbAction*` decorators still surface.
560
+ */
561
+ async buildMetaResponse() {
562
+ return {
563
+ searchable: false,
564
+ vectorSearchable: false,
565
+ searchIndexes: [],
566
+ primaryKeys: [],
567
+ readOnly: this._isReadOnly(),
568
+ relations: [],
569
+ fields: {},
570
+ type: this.getSerializedType(),
571
+ actions: this.buildActions()
572
+ };
573
+ }
574
+ /**
575
+ * Discovers `@DbAction*` and `@DbActions`-style class metadata on this
576
+ * controller and produces the `actions` array. Returns `[]` for value-help
577
+ * controllers — see {@link AsValueHelpController#buildMetaResponse}.
578
+ */
579
+ buildActions() {
580
+ return discoverActions(this.constructor, this.app, this.logger);
581
+ }
582
+ };
583
+ __decorate([
584
+ Get("meta"),
585
+ __decorateMetadata("design:type", Function),
586
+ __decorateMetadata("design:paramtypes", []),
587
+ __decorateMetadata("design:returntype", Promise)
588
+ ], AsReadableController.prototype, "meta", null);
589
+ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
590
+ Object,
591
+ String,
592
+ typeof (_ref$4 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$4 : Object,
593
+ Object
594
+ ])], AsReadableController);
595
+ //#endregion
596
+ //#region src/decorators.ts
597
+ /**
598
+ * DI token under which the {@link AtscriptDbReadable} instance
599
+ * is exposed to the readable controller's constructor via `@Inject`.
600
+ */
601
+ const READABLE_DEF = "__atscript_db_readable_def";
602
+ /**
603
+ * DI token under which the {@link AtscriptDbTable} instance
604
+ * is exposed to the controller's constructor via `@Inject`.
605
+ * Points to the same token as READABLE_DEF for backward compatibility.
606
+ */
607
+ const TABLE_DEF = READABLE_DEF;
608
+ /**
609
+ * Combines the boilerplate needed to turn an {@link AsDbController}
610
+ * subclass into a fully wired HTTP controller for a given `@db.table` model.
611
+ *
612
+ * Internally applies three decorators:
613
+ * 1. **Provide** — registers the table instance under {@link TABLE_DEF}.
614
+ * 2. **Controller** — registers the class as a Moost HTTP controller
615
+ * with an optional route prefix. Defaults to `table.tableName`.
616
+ * 3. **Inherit** — copies metadata (routes, guards, etc.) from the
617
+ * parent class so they stay active in the derived controller.
618
+ *
619
+ * @param table The {@link AtscriptDbTable} instance for this controller.
620
+ * @param prefix Optional route prefix. Defaults to `table.tableName`.
621
+ *
622
+ * @example
623
+ * ```ts
624
+ * ‎@TableController(usersTable)
625
+ * export class UsersController extends AsDbController<typeof UserModel> {}
626
+ * ```
627
+ */
628
+ const TableController = (table, prefix) => {
629
+ const resolvedPath = prefix || table.type.metadata.get("db.http.path");
630
+ return ApplyDecorators(Provide(TABLE_DEF, () => table), Controller(resolvedPath || table.tableName), Inherit());
631
+ };
632
+ /**
633
+ * Combines the boilerplate needed to turn an {@link AsDbReadableController}
634
+ * subclass into a fully wired HTTP controller for a given `@db.view` or `@db.table` model.
635
+ *
636
+ * @param readable The {@link AtscriptDbReadable} instance (table or view).
637
+ * @param prefix Optional route prefix. Defaults to `readable.tableName`.
638
+ *
639
+ * @example
640
+ * ```ts
641
+ * ‎@ReadableController(activeTasksView)
642
+ * export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
643
+ * ```
644
+ */
645
+ const ReadableController = (readable, prefix) => {
646
+ const resolvedPath = prefix || readable.type.metadata.get("db.http.path");
647
+ return ApplyDecorators(Provide(READABLE_DEF, () => readable), Controller(resolvedPath || readable.tableName), Inherit());
648
+ };
649
+ /**
650
+ * Alias for {@link ReadableController} — use with view-backed controllers.
651
+ *
652
+ * @example
653
+ * ```ts
654
+ * ‎@ViewController(activeTasksView)
655
+ * export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
656
+ * ```
657
+ */
658
+ const ViewController = ReadableController;
659
+ //#endregion
660
+ //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
661
+ function __decorateParam(paramIndex, decorator) {
662
+ return function(target, key) {
663
+ decorator(target, key, paramIndex);
664
+ };
665
+ }
666
+ //#endregion
667
+ //#region src/as-db-readable.controller.ts
668
+ var _ref$3, _ref2$2;
669
+ let AsDbReadableController = class AsDbReadableController extends AsReadableController {
670
+ /** Reference to the underlying readable (table or view). */
671
+ readable;
672
+ _gates;
673
+ constructor(readable, app) {
674
+ super(readable.type, readable.tableName, app, readable.isView ? "view" : "table");
675
+ this.readable = readable;
676
+ this._gates = this._buildGates();
677
+ }
678
+ _buildGates() {
679
+ const meta = this.readable.type.metadata;
680
+ const gates = {};
681
+ if (meta.get("db.table.filterable") === "manual") {
682
+ const allowed = this._collectAnnotated("db.column.filterable");
683
+ gates.filter = {
684
+ predicate: (f) => allowed.has(f),
685
+ annotation: "@db.column.filterable"
686
+ };
687
+ }
688
+ if (meta.get("db.table.sortable") === "manual") {
689
+ const allowed = this._collectAnnotated("db.column.sortable");
690
+ gates.sort = {
691
+ predicate: (f) => allowed.has(f),
692
+ annotation: "@db.column.sortable"
693
+ };
694
+ }
695
+ return gates;
696
+ }
697
+ _collectAnnotated(annotation) {
698
+ const out = /* @__PURE__ */ new Set();
699
+ for (const [path, entry] of this.readable.flatMap) if (entry.metadata.has(annotation)) out.add(path);
700
+ return out;
701
+ }
702
+ hasField(path) {
703
+ return this.readable.flatMap.has(path);
704
+ }
705
+ /** Validates $with relations against the readable. */
706
+ validateParsed(parsed, type) {
707
+ const baseError = super.validateParsed(parsed, type);
708
+ if (baseError) return baseError;
315
709
  const withRelations = parsed.controls.$with;
316
710
  if (withRelations?.length) {
317
711
  const relations = this.readable.relations;
@@ -347,15 +741,6 @@ let AsDbReadableController = class AsDbReadableController {
347
741
  transformProjection(projection) {
348
742
  return projection;
349
743
  }
350
- parseQueryString(url) {
351
- const idx = url.indexOf("?");
352
- return parseUrl(idx >= 0 ? url.slice(idx + 1) : "");
353
- }
354
- async returnOne(result) {
355
- const item = await result;
356
- if (!item) return new HttpError(404);
357
- return item;
358
- }
359
744
  /**
360
745
  * Extracts a composite identifier object from query params.
361
746
  * Tries composite primary key first, then compound unique indexes.
@@ -410,6 +795,8 @@ let AsDbReadableController = class AsDbReadableController {
410
795
  }
411
796
  const error = this.validateParsed(parsed, "query");
412
797
  if (error) return error;
798
+ const gateError = this.checkGates(parsed.filter, controls, this._gates);
799
+ if (gateError) return gateError;
413
800
  const [filter, select] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
414
801
  if (controls.$count) return this.readable.count({
415
802
  filter,
@@ -447,6 +834,8 @@ let AsDbReadableController = class AsDbReadableController {
447
834
  const error = this.validateParsed(parsed, "pages");
448
835
  if (error) return error;
449
836
  const controls = parsed.controls;
837
+ const gateError = this.checkGates(parsed.filter, controls, this._gates);
838
+ if (gateError) return gateError;
450
839
  const page = Math.max(Number(controls.$page || 1), 1);
451
840
  const size = Math.max(Number(controls.$size || 10), 1);
452
841
  const skip = (page - 1) * size;
@@ -513,28 +902,31 @@ let AsDbReadableController = class AsDbReadableController {
513
902
  /**
514
903
  * **GET /meta** — returns table/view metadata for UI.
515
904
  *
516
- * The return type includes `Promise<...>` so subclasses can override with an
517
- * async implementation (e.g. to enrich the payload from an external source).
518
- * The base cache only covers the base payload — async overrides must cache
519
- * their own enrichment if needed.
905
+ * Overrides the base's minimal envelope to add relations, searchable flags,
906
+ * vector-searchable flags, field-descriptor-derived filter/sort hints, and
907
+ * the configured primary keys.
520
908
  */
521
- meta() {
522
- if (this._metaResponse) return this._metaResponse;
909
+ async buildMetaResponse() {
523
910
  const relations = [];
524
911
  for (const [name, rel] of this.readable.relations) relations.push({
525
912
  name,
526
913
  direction: rel.direction,
527
914
  isArray: rel.isArray
528
915
  });
916
+ const filterableMode = this.readable.type.metadata.get("db.table.filterable") === "manual";
917
+ const sortableMode = this.readable.type.metadata.get("db.table.sortable") === "manual";
529
918
  const fields = {};
530
919
  for (const fd of this.readable.fieldDescriptors) {
531
920
  if (fd.ignored) continue;
921
+ const annotations = fd.type?.metadata;
922
+ const annotatedFilterable = annotations?.has("db.column.filterable") ?? false;
923
+ const annotatedSortable = annotations?.has("db.column.sortable") ?? false;
532
924
  fields[fd.path] = {
533
- sortable: !!fd.isIndexed,
534
- filterable: true
925
+ sortable: sortableMode ? annotatedSortable : !!fd.isIndexed,
926
+ filterable: filterableMode ? annotatedFilterable : true
535
927
  };
536
928
  }
537
- const response = {
929
+ return {
538
930
  searchable: this.readable.isSearchable(),
539
931
  vectorSearchable: this.readable.isVectorSearchable(),
540
932
  searchIndexes: this.readable.getSearchIndexes(),
@@ -542,10 +934,9 @@ let AsDbReadableController = class AsDbReadableController {
542
934
  readOnly: this._isReadOnly(),
543
935
  relations,
544
936
  fields,
545
- type: this.getSerializedType()
937
+ type: this.getSerializedType(),
938
+ actions: this.buildActions()
546
939
  };
547
- this._metaResponse = response;
548
- return response;
549
940
  }
550
941
  };
551
942
  __decorate([
@@ -575,23 +966,17 @@ __decorate([
575
966
  __decorateParam(0, Query()),
576
967
  __decorateParam(1, Url()),
577
968
  __decorateMetadata("design:type", Function),
578
- __decorateMetadata("design:paramtypes", [typeof (_ref2$1 = typeof Record !== "undefined" && Record) === "function" ? _ref2$1 : Object, String]),
969
+ __decorateMetadata("design:paramtypes", [typeof (_ref2$2 = typeof Record !== "undefined" && Record) === "function" ? _ref2$2 : Object, String]),
579
970
  __decorateMetadata("design:returntype", Promise)
580
971
  ], AsDbReadableController.prototype, "getOneComposite", null);
581
- __decorate([
582
- Get("meta"),
583
- __decorateMetadata("design:type", Function),
584
- __decorateMetadata("design:paramtypes", []),
585
- __decorateMetadata("design:returntype", Object)
586
- ], AsDbReadableController.prototype, "meta", null);
587
972
  AsDbReadableController = __decorate([
588
- UseValidationErrorTransform(),
973
+ Inherit(),
589
974
  __decorateParam(0, Inject(READABLE_DEF)),
590
- __decorateMetadata("design:paramtypes", [Object, typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object])
975
+ __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$3 : Object])
591
976
  ], AsDbReadableController);
592
977
  //#endregion
593
978
  //#region src/as-db.controller.ts
594
- var _ref, _ref2;
979
+ var _ref$2, _ref2$1;
595
980
  let AsDbController = class AsDbController extends AsDbReadableController {
596
981
  /** Reference to the underlying table (typed for write access). */
597
982
  get table() {
@@ -712,13 +1097,573 @@ __decorate([
712
1097
  Delete(""),
713
1098
  __decorateParam(0, Query()),
714
1099
  __decorateMetadata("design:type", Function),
715
- __decorateMetadata("design:paramtypes", [typeof (_ref2 = typeof Record !== "undefined" && Record) === "function" ? _ref2 : Object]),
1100
+ __decorateMetadata("design:paramtypes", [typeof (_ref2$1 = typeof Record !== "undefined" && Record) === "function" ? _ref2$1 : Object]),
716
1101
  __decorateMetadata("design:returntype", Promise)
717
1102
  ], AsDbController.prototype, "removeComposite", null);
718
1103
  AsDbController = __decorate([
719
1104
  Inherit(),
720
1105
  __decorateParam(0, Inject(TABLE_DEF)),
721
- __decorateMetadata("design:paramtypes", [Object, typeof (_ref = typeof Moost !== "undefined" && Moost) === "function" ? _ref : Object])
1106
+ __decorateMetadata("design:paramtypes", [Object, typeof (_ref$2 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$2 : Object])
722
1107
  ], AsDbController);
723
1108
  //#endregion
724
- export { AsDbController, AsDbReadableController, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, validationErrorTransform };
1109
+ //#region src/as-value-help.controller.ts
1110
+ var _ref$1, _ref2;
1111
+ let AsValueHelpController = class AsValueHelpController extends AsReadableController {
1112
+ /** Per-prop metadata map of the bound interface; eagerly built once. */
1113
+ fieldMeta;
1114
+ /**
1115
+ * Fields that participate in `$search` by default. Populated from
1116
+ * `@ui.dict.searchable`:
1117
+ * - If any prop carries `@ui.dict.searchable`, only those props are here.
1118
+ * - Else if the interface carries `@ui.dict.searchable`, every `string`-typed prop is here.
1119
+ * - Else every `string`-typed prop is here (hint is absent — default to all strings).
1120
+ */
1121
+ searchableFields;
1122
+ /** The `@meta.id` field name on the bound interface, if any. */
1123
+ primaryKey;
1124
+ constructor(boundType, controllerName, app) {
1125
+ super(boundType, controllerName, app, "value-help");
1126
+ const fieldMeta = /* @__PURE__ */ new Map();
1127
+ const explicitlySearchable = [];
1128
+ const stringProps = [];
1129
+ let primaryKey;
1130
+ const interfaceSearchable = boundType.metadata.has("ui.dict.searchable");
1131
+ const asObj = boundType.type;
1132
+ if (asObj?.props) for (const [name, prop] of asObj.props) {
1133
+ const meta = prop.metadata;
1134
+ fieldMeta.set(name, meta);
1135
+ if (!primaryKey && meta.has("meta.id")) primaryKey = name;
1136
+ if (prop.type.designType === "string") stringProps.push(name);
1137
+ if (meta.has("ui.dict.searchable")) explicitlySearchable.push(name);
1138
+ }
1139
+ this.fieldMeta = fieldMeta;
1140
+ this.primaryKey = primaryKey;
1141
+ this.searchableFields = explicitlySearchable.length > 0 ? explicitlySearchable : interfaceSearchable ? stringProps : stringProps;
1142
+ }
1143
+ hasField(path) {
1144
+ return this.fieldMeta.has(path);
1145
+ }
1146
+ /**
1147
+ * **GET /query** — returns an array of matched rows (up to `$limit`).
1148
+ */
1149
+ async runQuery(url) {
1150
+ const parsed = this.parseQueryString(url);
1151
+ const validateError = this.validateParsed(parsed, "query");
1152
+ if (validateError) return validateError;
1153
+ return (await this.query({
1154
+ filter: parsed.filter,
1155
+ controls: parsed.controls
1156
+ })).data;
1157
+ }
1158
+ /**
1159
+ * **GET /pages** — paginated row window plus total count.
1160
+ */
1161
+ async runPages(url) {
1162
+ const parsed = this.parseQueryString(url);
1163
+ const validateError = this.validateParsed(parsed, "pages");
1164
+ if (validateError) return validateError;
1165
+ const controls = parsed.controls;
1166
+ const page = Math.max(Number(controls.$page || 1), 1);
1167
+ const size = Math.max(Number(controls.$size || 10), 1);
1168
+ const skip = (page - 1) * size;
1169
+ const result = await this.query({
1170
+ filter: parsed.filter,
1171
+ controls: {
1172
+ ...controls,
1173
+ $skip: skip,
1174
+ $limit: size
1175
+ }
1176
+ });
1177
+ return {
1178
+ data: result.data,
1179
+ page,
1180
+ itemsPerPage: size,
1181
+ pages: Math.ceil(result.count / size),
1182
+ count: result.count
1183
+ };
1184
+ }
1185
+ /**
1186
+ * **GET /one/:id** — retrieves a single row by primary key.
1187
+ */
1188
+ async runGetOne(id) {
1189
+ return this.returnOne(this.getOne(id));
1190
+ }
1191
+ /**
1192
+ * **GET /one?<pk>=<val>** — retrieves a single row by PK query param (fallback).
1193
+ */
1194
+ async runGetOneComposite(query) {
1195
+ const pk = this.primaryKey;
1196
+ if (!pk) return new HttpError(400, "No primary key (@meta.id) on value-help interface");
1197
+ const id = query[pk];
1198
+ if (id === void 0) return new HttpError(400, `Missing PK field "${pk}"`);
1199
+ return this.returnOne(this.getOne(id));
1200
+ }
1201
+ /**
1202
+ * Meta response surfaces `@ui.dict.*` annotations as **hints** for the
1203
+ * client picker UI (which controls to render); the server does not enforce
1204
+ * these flags at request time.
1205
+ */
1206
+ async buildMetaResponse() {
1207
+ const fields = {};
1208
+ for (const [path, meta] of this.fieldMeta) fields[path] = {
1209
+ sortable: meta.has("ui.dict.sortable"),
1210
+ filterable: meta.has("ui.dict.filterable")
1211
+ };
1212
+ return {
1213
+ searchable: this.searchableFields.length > 0,
1214
+ vectorSearchable: false,
1215
+ searchIndexes: [],
1216
+ primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1217
+ readOnly: this._isReadOnly(),
1218
+ relations: [],
1219
+ fields,
1220
+ type: this.getSerializedType(),
1221
+ actions: []
1222
+ };
1223
+ }
1224
+ buildActions() {
1225
+ return [];
1226
+ }
1227
+ };
1228
+ __decorate([
1229
+ Get("query"),
1230
+ __decorateParam(0, Url()),
1231
+ __decorateMetadata("design:type", Function),
1232
+ __decorateMetadata("design:paramtypes", [String]),
1233
+ __decorateMetadata("design:returntype", Promise)
1234
+ ], AsValueHelpController.prototype, "runQuery", null);
1235
+ __decorate([
1236
+ Get("pages"),
1237
+ __decorateParam(0, Url()),
1238
+ __decorateMetadata("design:type", Function),
1239
+ __decorateMetadata("design:paramtypes", [String]),
1240
+ __decorateMetadata("design:returntype", Promise)
1241
+ ], AsValueHelpController.prototype, "runPages", null);
1242
+ __decorate([
1243
+ Get("one/:id"),
1244
+ __decorateParam(0, Param("id")),
1245
+ __decorateMetadata("design:type", Function),
1246
+ __decorateMetadata("design:paramtypes", [String]),
1247
+ __decorateMetadata("design:returntype", Promise)
1248
+ ], AsValueHelpController.prototype, "runGetOne", null);
1249
+ __decorate([
1250
+ Get("one"),
1251
+ __decorateParam(0, Query()),
1252
+ __decorateMetadata("design:type", Function),
1253
+ __decorateMetadata("design:paramtypes", [typeof (_ref2 = typeof Record !== "undefined" && Record) === "function" ? _ref2 : Object]),
1254
+ __decorateMetadata("design:returntype", Promise)
1255
+ ], AsValueHelpController.prototype, "runGetOneComposite", null);
1256
+ AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramtypes", [
1257
+ Object,
1258
+ String,
1259
+ typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object
1260
+ ])], AsValueHelpController);
1261
+ //#endregion
1262
+ //#region src/as-json-value-help.controller.ts
1263
+ var _ref;
1264
+ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueHelpController {
1265
+ rows;
1266
+ _pkIndex;
1267
+ constructor(boundType, rows, app, controllerName) {
1268
+ const name = controllerName || boundType.metadata.get("db.table") || "value-help";
1269
+ super(boundType, name, app);
1270
+ this.rows = rows;
1271
+ if (this.primaryKey) {
1272
+ const pk = this.primaryKey;
1273
+ const index = /* @__PURE__ */ new Map();
1274
+ for (const row of rows) index.set(String(row[pk]), row);
1275
+ this._pkIndex = index;
1276
+ }
1277
+ }
1278
+ async query(controls) {
1279
+ let rows = this.rows;
1280
+ if (controls.filter && Object.keys(controls.filter).length > 0) rows = rows.filter((row) => matchFilter(row, controls.filter));
1281
+ const search = controls.controls.$search;
1282
+ if (search) {
1283
+ const needle = search.toLowerCase();
1284
+ const fields = this.searchableFields;
1285
+ rows = rows.filter((row) => {
1286
+ for (const field of fields) {
1287
+ const v = row[field];
1288
+ if (typeof v === "string" && v.toLowerCase().includes(needle)) return true;
1289
+ }
1290
+ return false;
1291
+ });
1292
+ }
1293
+ if (controls.controls.$sort) rows = sortRows(rows, controls.controls.$sort);
1294
+ const total = rows.length;
1295
+ const skip = Math.max(0, Number(controls.controls.$skip ?? 0));
1296
+ const limit = Math.max(0, Number(controls.controls.$limit ?? total - skip));
1297
+ return {
1298
+ data: applySelect(rows.slice(skip, skip + limit), controls.controls.$select),
1299
+ count: total
1300
+ };
1301
+ }
1302
+ async getOne(id) {
1303
+ return this._pkIndex?.get(String(id)) ?? null;
1304
+ }
1305
+ };
1306
+ AsJsonValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramtypes", [
1307
+ Object,
1308
+ Array,
1309
+ typeof (_ref = typeof Moost !== "undefined" && Moost) === "function" ? _ref : Object,
1310
+ String
1311
+ ])], AsJsonValueHelpController);
1312
+ function matchFilter(row, filter) {
1313
+ if (!filter || typeof filter !== "object") return true;
1314
+ for (const [key, value] of Object.entries(filter)) {
1315
+ if (key === "$and") {
1316
+ if (!Array.isArray(value)) continue;
1317
+ if (!value.every((clause) => matchFilter(row, clause))) return false;
1318
+ continue;
1319
+ }
1320
+ if (key === "$or") {
1321
+ if (!Array.isArray(value)) continue;
1322
+ if (!value.some((clause) => matchFilter(row, clause))) return false;
1323
+ continue;
1324
+ }
1325
+ if (key === "$nor") {
1326
+ if (!Array.isArray(value)) continue;
1327
+ if (value.some((clause) => matchFilter(row, clause))) return false;
1328
+ continue;
1329
+ }
1330
+ if (key === "$not") {
1331
+ if (matchFilter(row, value)) return false;
1332
+ continue;
1333
+ }
1334
+ if (key.startsWith("$")) continue;
1335
+ const fieldValue = row[key];
1336
+ if (!matchFieldPredicate(fieldValue, value)) return false;
1337
+ }
1338
+ return true;
1339
+ }
1340
+ function matchFieldPredicate(fieldValue, predicate) {
1341
+ if (predicate === null || typeof predicate !== "object" || Array.isArray(predicate)) return fieldValue === predicate;
1342
+ for (const [op, operand] of Object.entries(predicate)) switch (op) {
1343
+ case "$eq":
1344
+ if (fieldValue !== operand) return false;
1345
+ break;
1346
+ case "$ne":
1347
+ if (fieldValue === operand) return false;
1348
+ break;
1349
+ case "$in":
1350
+ if (!Array.isArray(operand) || !operand.includes(fieldValue)) return false;
1351
+ break;
1352
+ case "$nin":
1353
+ if (!Array.isArray(operand) || operand.includes(fieldValue)) return false;
1354
+ break;
1355
+ case "$gt":
1356
+ if (!(fieldValue > operand)) return false;
1357
+ break;
1358
+ case "$gte":
1359
+ if (!(fieldValue >= operand)) return false;
1360
+ break;
1361
+ case "$lt":
1362
+ if (!(fieldValue < operand)) return false;
1363
+ break;
1364
+ case "$lte":
1365
+ if (!(fieldValue <= operand)) return false;
1366
+ break;
1367
+ case "$regex": {
1368
+ const re = operand instanceof RegExp ? operand : new RegExp(String(operand));
1369
+ if (typeof fieldValue !== "string" || !re.test(fieldValue)) return false;
1370
+ break;
1371
+ }
1372
+ default: if (fieldValue !== operand) return false;
1373
+ }
1374
+ return true;
1375
+ }
1376
+ function sortRows(rows, sort) {
1377
+ const keys = [];
1378
+ const push = (name, explicit) => {
1379
+ const clean = name.replace(/^[-+]/, "");
1380
+ const dir = explicit ?? (name.startsWith("-") ? -1 : 1);
1381
+ if (clean) keys.push({
1382
+ name: clean,
1383
+ dir
1384
+ });
1385
+ };
1386
+ if (typeof sort === "string") for (const part of sort.split(",")) {
1387
+ const trimmed = part.trim();
1388
+ if (!trimmed) continue;
1389
+ const [name, dir] = trimmed.split(":");
1390
+ push(name, dir === "desc" ? -1 : dir === "asc" ? 1 : void 0);
1391
+ }
1392
+ else if (Array.isArray(sort)) {
1393
+ for (const entry of sort) if (typeof entry === "string") push(entry);
1394
+ else if (entry && typeof entry === "object") for (const [name, d] of Object.entries(entry)) push(name, d === "desc" || d === -1 ? -1 : 1);
1395
+ } else if (sort && typeof sort === "object") for (const [name, d] of Object.entries(sort)) push(name, d === "desc" || d === -1 ? -1 : 1);
1396
+ if (keys.length === 0) return rows;
1397
+ const out = rows.slice();
1398
+ out.sort((a, b) => {
1399
+ for (const { name, dir } of keys) {
1400
+ const av = a[name];
1401
+ const bv = b[name];
1402
+ if (av === bv) continue;
1403
+ if (av === void 0 || av === null) return -1 * dir;
1404
+ if (bv === void 0 || bv === null) return 1 * dir;
1405
+ if (av < bv) return -1 * dir;
1406
+ if (av > bv) return 1 * dir;
1407
+ }
1408
+ return 0;
1409
+ });
1410
+ return out;
1411
+ }
1412
+ function applySelect(rows, select) {
1413
+ if (!select?.length) return rows;
1414
+ return rows.map((row) => {
1415
+ const out = {};
1416
+ for (const key of select) out[key] = row[key];
1417
+ return out;
1418
+ });
1419
+ }
1420
+ //#endregion
1421
+ //#region src/actions/db-action.decorator.ts
1422
+ /**
1423
+ * Mark a controller method as a database action surfaced via `/meta`.
1424
+ *
1425
+ * Metadata-only — pair with `@Post(...)` for Moost to bind the route. The
1426
+ * meta builder reads this metadata plus the bound POST path lazily and
1427
+ * emits the action with `processor: 'backend'`. Order vs.
1428
+ * `@DbActionDefault()` does not matter — both merge into the same slot.
1429
+ *
1430
+ * @example
1431
+ * ```ts
1432
+ * @Post('actions/block')
1433
+ * @DbAction('block', { label: 'Block', icon: 'i-as-block', intent: 'negative' })
1434
+ * async blockUser(@DbActionPK() id: string) { ... }
1435
+ * ```
1436
+ */
1437
+ function DbAction(name, opts = {}) {
1438
+ return getMoostMate().decorate((current) => {
1439
+ const meta = current;
1440
+ return {
1441
+ ...current,
1442
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, {
1443
+ name,
1444
+ opts
1445
+ })
1446
+ };
1447
+ });
1448
+ }
1449
+ //#endregion
1450
+ //#region src/actions/db-action-default.decorator.ts
1451
+ /**
1452
+ * Sugar that flips `default: true` on the same method's `@DbAction` metadata.
1453
+ * Equivalent to passing `opts.default = true`. Decorator order does not matter.
1454
+ */
1455
+ function DbActionDefault() {
1456
+ return getMoostMate().decorate((current) => {
1457
+ const meta = current;
1458
+ return {
1459
+ ...current,
1460
+ [MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
1461
+ };
1462
+ });
1463
+ }
1464
+ //#endregion
1465
+ //#region src/actions/pk-source.ts
1466
+ /**
1467
+ * Extract the PK validation source from a controller instance. Looks for
1468
+ * `readable` (set by {@link AsDbReadableController}) or `table` (set by
1469
+ * {@link AsDbController}).
1470
+ *
1471
+ * If the controller has no typed table attached (e.g. a value-help
1472
+ * controller, or a plain Moost controller without `@TableController`),
1473
+ * throws an HTTP 500 — this is a **server misconfiguration**, not a client
1474
+ * error. The body parser has nothing to validate against, so the request
1475
+ * cannot proceed. Use `@Body()` and parse the PK manually if you need to
1476
+ * accept PK-shaped bodies on a controller without an attached table.
1477
+ */
1478
+ function resolvePkSource(controller) {
1479
+ const c = controller;
1480
+ const candidate = c.readable ?? c.table;
1481
+ if (!isPkValidationSource(candidate)) throw new HttpError(500, "@DbActionPK/@DbActionPKs requires a controller with an attached table (via @TableController / @ReadableController). Use @Body() instead if your controller has no typed table.");
1482
+ return candidate;
1483
+ }
1484
+ function isPkValidationSource(value) {
1485
+ if (!value || typeof value !== "object") return false;
1486
+ const v = value;
1487
+ return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
1488
+ }
1489
+ /**
1490
+ * Build a parameter decorator that parses the JSON request body, validates
1491
+ * it against the bound table's PK schema with `validate`, and tags the param
1492
+ * so {@link discoverActions} can infer the action's `level`.
1493
+ */
1494
+ function createPkParamDecorator(kind, validate, resolverName) {
1495
+ return ApplyDecorators(getMoostMate().decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => {
1496
+ const body = await useBody().parseBody();
1497
+ validate(body, resolvePkSource(useControllerContext().getController()));
1498
+ return body;
1499
+ }, resolverName));
1500
+ }
1501
+ //#endregion
1502
+ //#region src/actions/pk-validation.ts
1503
+ /**
1504
+ * Validate a JSON-decoded body against a single-row PK shape (scalar or
1505
+ * composite). Throws {@link ValidatorError} with structured `errors` so the
1506
+ * existing validation interceptor returns HTTP 400.
1507
+ */
1508
+ function validateSinglePk(body, source, path = "") {
1509
+ const errors = collectPkErrors(body, source, path);
1510
+ if (errors.length > 0) throw new ValidatorError(errors);
1511
+ }
1512
+ /**
1513
+ * Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
1514
+ * The body MUST be an array; each element is validated against the PK schema.
1515
+ */
1516
+ function validateMultiPk(body, source) {
1517
+ if (!Array.isArray(body)) throw new ValidatorError([{
1518
+ path: "",
1519
+ message: "Expected JSON array of primary keys",
1520
+ details: []
1521
+ }]);
1522
+ const errors = [];
1523
+ for (let i = 0; i < body.length; i++) errors.push(...collectPkErrors(body[i], source, `[${i}]`));
1524
+ if (errors.length > 0) throw new ValidatorError(errors);
1525
+ }
1526
+ function collectPkErrors(value, source, pathPrefix) {
1527
+ const pkFields = source.primaryKeys;
1528
+ if (pkFields.length === 0) return [{
1529
+ path: pathPrefix,
1530
+ message: "Table has no primary key configured",
1531
+ details: []
1532
+ }];
1533
+ const errors = [];
1534
+ if (pkFields.length === 1) {
1535
+ const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
1536
+ if (err) errors.push(err);
1537
+ return errors;
1538
+ }
1539
+ if (!isPlainObject(value)) {
1540
+ errors.push({
1541
+ path: pathPrefix,
1542
+ message: "Expected JSON object for composite primary key",
1543
+ details: []
1544
+ });
1545
+ return errors;
1546
+ }
1547
+ for (const fieldName of pkFields) {
1548
+ const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
1549
+ if (!(fieldName in value)) {
1550
+ errors.push({
1551
+ path: sub,
1552
+ message: `Missing primary-key field "${fieldName}"`,
1553
+ details: []
1554
+ });
1555
+ continue;
1556
+ }
1557
+ const fd = findFieldDescriptor(source, fieldName);
1558
+ const err = checkScalar(value[fieldName], fd, sub);
1559
+ if (err) errors.push(err);
1560
+ }
1561
+ return errors;
1562
+ }
1563
+ function findFieldDescriptor(source, name) {
1564
+ for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
1565
+ }
1566
+ function checkScalar(value, fd, path) {
1567
+ const expected = fd?.designType ?? "string";
1568
+ if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
1569
+ if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
1570
+ if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
1571
+ }
1572
+ function scalarMismatch(path, expected, value) {
1573
+ return {
1574
+ path,
1575
+ message: `Expected primary-key value to be ${expected}, got ${describe(value)}`,
1576
+ details: []
1577
+ };
1578
+ }
1579
+ function describe(value) {
1580
+ if (value === null) return "null";
1581
+ if (Array.isArray(value)) return "array";
1582
+ return typeof value;
1583
+ }
1584
+ function isPlainObject(value) {
1585
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1586
+ }
1587
+ //#endregion
1588
+ //#region src/actions/db-action-pk.decorator.ts
1589
+ /**
1590
+ * Parameter resolver that reads the primary key from the JSON request body
1591
+ * and validates it against the bound table's PK schema.
1592
+ *
1593
+ * - Single-field PK → JSON-encoded scalar (`"abc"`, `42`, `true`).
1594
+ * - Composite PK → JSON object with all PK fields.
1595
+ *
1596
+ * Validation is strict — no type coercion. Mismatches throw a
1597
+ * `ValidatorError` which the existing validation interceptor surfaces as
1598
+ * HTTP 400 with the same envelope as DTO failures.
1599
+ *
1600
+ * Marks the param so {@link discoverActions} can infer the action's `level`
1601
+ * as `'row'`.
1602
+ */
1603
+ function DbActionPK() {
1604
+ return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
1605
+ }
1606
+ //#endregion
1607
+ //#region src/actions/db-action-pks.decorator.ts
1608
+ /**
1609
+ * Parameter resolver that reads a JSON array of primary keys from the request
1610
+ * body and validates each entry against the bound table's PK schema.
1611
+ *
1612
+ * - Scalar PK → JSON array of scalars (`["a","b","c"]`).
1613
+ * - Composite PK → JSON array of objects.
1614
+ *
1615
+ * Validation is strict — no type coercion. Marks the param so
1616
+ * {@link discoverActions} can infer the action's `level` as `'rows'`.
1617
+ */
1618
+ function DbActionPKs() {
1619
+ return createPkParamDecorator("pks", validateMultiPk, "dbActionPks");
1620
+ }
1621
+ //#endregion
1622
+ //#region src/actions/db-actions.decorator.ts
1623
+ /**
1624
+ * Declare class-level actions on a controller. Entries are flat dicts with
1625
+ * `processor: 'navigate' | 'custom' | 'backend'` matching the `/meta` wire
1626
+ * shape (see {@link TDbActionsEntry}). Each entry MUST specify `level`. Use
1627
+ * the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
1628
+ * `@DbRowsActions`) to avoid repeating `level`.
1629
+ *
1630
+ * The dictionary key serves as the action `name`. Entries do NOT bind any
1631
+ * HTTP route — the meta builder surfaces them in `/meta` only. For
1632
+ * `processor: 'backend'`, the dev-supplied `value` MUST point to a real
1633
+ * `@Post`-bound endpoint accepting the level-determined body shape.
1634
+ *
1635
+ * Multiple `@DbActions` (and shortcut) decorators on the same class
1636
+ * accumulate.
1637
+ */
1638
+ function DbActions(dict) {
1639
+ return classLevelActions(dict);
1640
+ }
1641
+ /** Sugar for `@DbActions` with `level: 'table'` injected into each entry. */
1642
+ function DbTableActions(dict) {
1643
+ return classLevelActions(dict, "table");
1644
+ }
1645
+ /** Sugar for `@DbActions` with `level: 'row'` injected into each entry. */
1646
+ function DbRowActions(dict) {
1647
+ return classLevelActions(dict, "row");
1648
+ }
1649
+ /** Sugar for `@DbActions` with `level: 'rows'` injected into each entry. */
1650
+ function DbRowsActions(dict) {
1651
+ return classLevelActions(dict, "rows");
1652
+ }
1653
+ function classLevelActions(dict, forcedLevel) {
1654
+ const entries = [];
1655
+ for (const [name, entry] of Object.entries(dict)) entries.push({
1656
+ name,
1657
+ entry,
1658
+ forcedLevel
1659
+ });
1660
+ return getMoostMate().decorate((current) => {
1661
+ const existing = current["atscript_db_actions"] ?? [];
1662
+ return {
1663
+ ...current,
1664
+ [MOOST_DB_ACTIONS]: [...existing, ...entries]
1665
+ };
1666
+ });
1667
+ }
1668
+ //#endregion
1669
+ export { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionPK, DbActionPKs, DbActions, DbRowActions, DbRowsActions, DbTableActions, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, validationErrorTransform };