@atscript/moost-db 0.1.53 → 0.1.55

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,8 @@
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 } from "moost";
3
+ import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, TInterceptorPriority, defineInterceptor, 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
70
6
  //#region src/validation-interceptor.ts
71
7
  const dbErrorCodeToStatus = { CONFLICT: 409 };
72
8
  function transformValidationError(error, reply) {
@@ -177,18 +113,77 @@ defineAnnotatedType("object", WithFilterDto).propPattern(/./, defineAnnotatedTyp
177
113
  defineAnnotatedType("object", SortControlDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("number").value(1).$type).item(defineAnnotatedType().designType("number").value(-1).$type).$type);
178
114
  defineAnnotatedType("object", SelectControlDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("number").value(1).$type).item(defineAnnotatedType().designType("number").value(0).$type).$type);
179
115
  //#endregion
116
+ //#region src/gate-utils.ts
117
+ /**
118
+ * Walks a Uniquery filter expression and returns the first field name that
119
+ * fails the `isAllowed` predicate, or `undefined` if every leaf field is
120
+ * allowed. Logical combinators (`$and`, `$or`, `$nor`, `$not`) are traversed;
121
+ * other `$`-prefixed keys are skipped.
122
+ *
123
+ * Shared by the DB readable's `@db.column.filterable` gate and the value-help
124
+ * controller's `@ui.dict.filterable` gate — only the predicate differs.
125
+ */
126
+ function findFilterOffender(filter, isAllowed) {
127
+ if (!filter || typeof filter !== "object") return;
128
+ for (const [key, value] of Object.entries(filter)) {
129
+ if (key === "$and" || key === "$or" || key === "$nor") {
130
+ if (Array.isArray(value)) for (const sub of value) {
131
+ const inner = findFilterOffender(sub, isAllowed);
132
+ if (inner) return inner;
133
+ }
134
+ continue;
135
+ }
136
+ if (key === "$not") {
137
+ const inner = findFilterOffender(value, isAllowed);
138
+ if (inner) return inner;
139
+ continue;
140
+ }
141
+ if (key.startsWith("$")) continue;
142
+ if (!isAllowed(key)) return key;
143
+ }
144
+ }
145
+ /**
146
+ * Walks a Uniquery `$sort` control (accepts string, string[], object, or
147
+ * array-of-{field: dir}) and returns the first field name that fails the
148
+ * `isAllowed` predicate, or `undefined` if every sort key is allowed.
149
+ *
150
+ * Shared by the DB readable's `@db.column.sortable` gate and the value-help
151
+ * controller's `@ui.dict.sortable` gate.
152
+ */
153
+ function findSortOffender(sort, isAllowed) {
154
+ if (!sort) return void 0;
155
+ const check = (name) => isAllowed(name) ? void 0 : name;
156
+ if (typeof sort === "string") {
157
+ for (const part of sort.split(",")) {
158
+ const name = part.trim().replace(/^[-+]/, "").split(":")[0];
159
+ if (name) {
160
+ const bad = check(name);
161
+ if (bad) return bad;
162
+ }
163
+ }
164
+ return;
165
+ }
166
+ if (Array.isArray(sort)) {
167
+ for (const entry of sort) if (typeof entry === "string") {
168
+ const bad = check(entry.replace(/^[-+]/, ""));
169
+ if (bad) return bad;
170
+ } else if (entry && typeof entry === "object") for (const name of Object.keys(entry)) {
171
+ const bad = check(name);
172
+ if (bad) return bad;
173
+ }
174
+ return;
175
+ }
176
+ if (typeof sort === "object") for (const name of Object.keys(sort)) {
177
+ const bad = check(name);
178
+ if (bad) return bad;
179
+ }
180
+ }
181
+ //#endregion
180
182
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
181
183
  function __decorateMetadata(k, v) {
182
184
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
183
185
  }
184
186
  //#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);
189
- };
190
- }
191
- //#endregion
192
187
  //#region \0@oxc-project+runtime@0.120.0/helpers/decorate.js
193
188
  function __decorate(decorators, target, key, desc) {
194
189
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -197,24 +192,27 @@ function __decorate(decorators, target, key, desc) {
197
192
  return c > 3 && r && Object.defineProperty(target, key, r), r;
198
193
  }
199
194
  //#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;
195
+ //#region src/as-readable.controller.ts
196
+ var _ref$4;
197
+ let AsReadableController = class AsReadableController {
198
+ /** The Atscript interface this controller serves. */
199
+ boundType;
200
+ /** Short human-readable name for logging (usually the table/source name). */
201
+ controllerName;
205
202
  /** Application-scoped logger. */
206
203
  logger;
207
- /** Cached serialized type definition (lazy, computed on first access). */
208
- _serializedType;
209
204
  /** Moost application instance. */
210
205
  app;
206
+ /** Cached serialized type definition (lazy, computed on first access). */
207
+ _serializedType;
211
208
  /** Cached full meta response (computed lazily on first meta() call). */
212
209
  _metaResponse;
213
- constructor(readable, app) {
214
- this.readable = readable;
210
+ constructor(boundType, controllerName, app, kindTag = "readable") {
211
+ this.boundType = boundType;
212
+ this.controllerName = controllerName;
215
213
  this.app = app;
216
- this.logger = app.getLogger(`db [${readable.tableName}]`);
217
- this.logger.info(`Initializing ${readable.isView ? "view" : "table"} controller`);
214
+ this.logger = app.getLogger(`db [${controllerName}]`);
215
+ this.logger.info(`Initializing ${kindTag} controller`);
218
216
  this._resolveHttpPath();
219
217
  try {
220
218
  const p = this.init();
@@ -228,12 +226,19 @@ let AsDbReadableController = class AsDbReadableController {
228
226
  }
229
227
  /** Sets @db.http.path on the type metadata from the controller's computed prefix. */
230
228
  _resolveHttpPath() {
231
- const overview = this.app.getControllersOverview?.()?.find((o) => o.type === this.constructor);
232
- if (overview?.computedPrefix) this.readable.type.metadata.set("db.http.path", overview.computedPrefix);
229
+ let prefix;
230
+ try {
231
+ prefix = useControllerContext().getPrefix();
232
+ } catch {}
233
+ if (!prefix) prefix = (this.app.getControllersOverview?.()?.find((o) => o.type === this.constructor))?.computedPrefix;
234
+ if (prefix) {
235
+ if (!prefix.startsWith("/")) prefix = `/${prefix}`;
236
+ this.boundType.metadata.set("db.http.path", prefix);
237
+ }
233
238
  }
234
- /** Lazily serializes the type (after all controllers have set their @db.http.path). */
239
+ /** Lazily serializes the bound type (after all controllers have set @db.http.path). */
235
240
  getSerializedType() {
236
- if (!this._serializedType) this._serializedType = serializeAnnotatedType(this.readable.type, this.getSerializeOptions());
241
+ if (!this._serializedType) this._serializedType = serializeAnnotatedType(this.boundType, this.getSerializeOptions());
237
242
  return this._serializedType;
238
243
  }
239
244
  /**
@@ -242,13 +247,23 @@ let AsDbReadableController = class AsDbReadableController {
242
247
  init() {}
243
248
  /**
244
249
  * Returns serialization options for the `/meta` endpoint's type field.
245
- * Default: whitelist — keeps `meta.*`, `expect.*`, and `db.rel.*` annotations,
246
- * strips all other `db.*` annotations (table, column, index, default, etc.).
247
- * Override in subclass to customise what annotations are exposed to clients.
250
+ *
251
+ * `refDepth: 0.5` is intentionally static independent of `@db.depth.limit`
252
+ * (which is a security guard on nested writes, not a serialization policy).
253
+ * The shallow shape emits `{ field, type: { id, metadata } }` for every FK,
254
+ * which carries the target's `db.http.path` so clients can resolve value-help
255
+ * URLs and lazy-fetch target `/meta` when deeper structure is needed. Nav
256
+ * props (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are not `.ref` nodes
257
+ * and always expand fully regardless of `refDepth` — the write-payload shape
258
+ * clients need is unaffected.
259
+ *
260
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
261
+ * other `db.*` (table, column, index, default, etc.). Override in subclass
262
+ * to customise.
248
263
  */
249
264
  getSerializeOptions() {
250
265
  return {
251
- refDepth: 1,
266
+ refDepth: .5,
252
267
  processAnnotation: ({ key, value }) => {
253
268
  if (key.startsWith("meta.") || key.startsWith("expect.") || key.startsWith("db.rel.")) return {
254
269
  key,
@@ -268,7 +283,7 @@ let AsDbReadableController = class AsDbReadableController {
268
283
  }
269
284
  /**
270
285
  * Whether this controller is read-only (no write endpoints).
271
- * Returns `true` for readable/view controllers, overridden to `false` in AsDbController.
286
+ * Returns `true` by default; {@link AsDbController} overrides to `false`.
272
287
  */
273
288
  _isReadOnly() {
274
289
  return true;
@@ -295,7 +310,7 @@ let AsDbReadableController = class AsDbReadableController {
295
310
  validateInsights(insights) {
296
311
  for (const [key] of insights) {
297
312
  if (key === "*") continue;
298
- if (!this.readable.flatMap.has(key)) return `Unknown field "${key}"`;
313
+ if (!this.hasField(key)) return `Unknown field "${key}"`;
299
314
  }
300
315
  }
301
316
  validateParsed(parsed, type) {
@@ -305,6 +320,190 @@ let AsDbReadableController = class AsDbReadableController {
305
320
  const insightsError = this.validateInsights(parsed.insights);
306
321
  if (insightsError) return new HttpError(400, insightsError);
307
322
  }
323
+ }
324
+ /**
325
+ * Shared filter/sort/search gate check. Subclasses assemble a {@link ReadableGates}
326
+ * config per request (or once in the constructor when static) and call this to
327
+ * get a uniform HTTP 400 response for any offending field/control.
328
+ */
329
+ checkGates(filter, controls, gates) {
330
+ if (gates.filter) {
331
+ const bad = findFilterOffender(filter, gates.filter.predicate);
332
+ if (bad) return new HttpError(400, `Filtering on field "${bad}" is not permitted — add ${gates.filter.annotation} to enable.`);
333
+ }
334
+ if (gates.sort) {
335
+ const bad = findSortOffender(controls.$sort, gates.sort.predicate);
336
+ if (bad) return new HttpError(400, `Sorting on field "${bad}" is not permitted — add ${gates.sort.annotation} to enable.`);
337
+ }
338
+ if (gates.search && controls.$search && !gates.search.allowed) return new HttpError(400, gates.search.rejectionMessage);
339
+ }
340
+ parseQueryString(url) {
341
+ const idx = url.indexOf("?");
342
+ return parseUrl(idx >= 0 ? url.slice(idx + 1) : "");
343
+ }
344
+ async returnOne(result) {
345
+ const item = await result;
346
+ if (!item) return new HttpError(404);
347
+ return item;
348
+ }
349
+ /**
350
+ * **GET /meta** — returns the bound interface's metadata envelope.
351
+ *
352
+ * Base implementation delegates to {@link buildMetaResponse}, which subclasses
353
+ * override to add source-specific fields (relations, searchable flags, etc.).
354
+ * The response is cached on the instance; async overrides must cache any
355
+ * extra enrichment themselves.
356
+ */
357
+ async meta() {
358
+ if (this._metaResponse) return this._metaResponse;
359
+ const response = await this.buildMetaResponse();
360
+ this._metaResponse = response;
361
+ return response;
362
+ }
363
+ /**
364
+ * Builds the `/meta` payload. Override in subclasses to populate source-specific
365
+ * fields. Defaults return a minimal envelope with the serialized type and the
366
+ * read-only flag; value-help dicts populate their capability hints here.
367
+ */
368
+ async buildMetaResponse() {
369
+ return {
370
+ searchable: false,
371
+ vectorSearchable: false,
372
+ searchIndexes: [],
373
+ primaryKeys: [],
374
+ readOnly: this._isReadOnly(),
375
+ relations: [],
376
+ fields: {},
377
+ type: this.getSerializedType()
378
+ };
379
+ }
380
+ };
381
+ __decorate([
382
+ Get("meta"),
383
+ __decorateMetadata("design:type", Function),
384
+ __decorateMetadata("design:paramtypes", []),
385
+ __decorateMetadata("design:returntype", Promise)
386
+ ], AsReadableController.prototype, "meta", null);
387
+ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
388
+ Object,
389
+ String,
390
+ typeof (_ref$4 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$4 : Object,
391
+ Object
392
+ ])], AsReadableController);
393
+ //#endregion
394
+ //#region src/decorators.ts
395
+ /**
396
+ * DI token under which the {@link AtscriptDbReadable} instance
397
+ * is exposed to the readable controller's constructor via `@Inject`.
398
+ */
399
+ const READABLE_DEF = "__atscript_db_readable_def";
400
+ /**
401
+ * DI token under which the {@link AtscriptDbTable} instance
402
+ * is exposed to the controller's constructor via `@Inject`.
403
+ * Points to the same token as READABLE_DEF for backward compatibility.
404
+ */
405
+ const TABLE_DEF = READABLE_DEF;
406
+ /**
407
+ * Combines the boilerplate needed to turn an {@link AsDbController}
408
+ * subclass into a fully wired HTTP controller for a given `@db.table` model.
409
+ *
410
+ * Internally applies three decorators:
411
+ * 1. **Provide** — registers the table instance under {@link TABLE_DEF}.
412
+ * 2. **Controller** — registers the class as a Moost HTTP controller
413
+ * with an optional route prefix. Defaults to `table.tableName`.
414
+ * 3. **Inherit** — copies metadata (routes, guards, etc.) from the
415
+ * parent class so they stay active in the derived controller.
416
+ *
417
+ * @param table The {@link AtscriptDbTable} instance for this controller.
418
+ * @param prefix Optional route prefix. Defaults to `table.tableName`.
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * ‎@TableController(usersTable)
423
+ * export class UsersController extends AsDbController<typeof UserModel> {}
424
+ * ```
425
+ */
426
+ const TableController = (table, prefix) => {
427
+ const resolvedPath = prefix || table.type.metadata.get("db.http.path");
428
+ return ApplyDecorators(Provide(TABLE_DEF, () => table), Controller(resolvedPath || table.tableName), Inherit());
429
+ };
430
+ /**
431
+ * Combines the boilerplate needed to turn an {@link AsDbReadableController}
432
+ * subclass into a fully wired HTTP controller for a given `@db.view` or `@db.table` model.
433
+ *
434
+ * @param readable The {@link AtscriptDbReadable} instance (table or view).
435
+ * @param prefix Optional route prefix. Defaults to `readable.tableName`.
436
+ *
437
+ * @example
438
+ * ```ts
439
+ * ‎@ReadableController(activeTasksView)
440
+ * export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
441
+ * ```
442
+ */
443
+ const ReadableController = (readable, prefix) => {
444
+ const resolvedPath = prefix || readable.type.metadata.get("db.http.path");
445
+ return ApplyDecorators(Provide(READABLE_DEF, () => readable), Controller(resolvedPath || readable.tableName), Inherit());
446
+ };
447
+ /**
448
+ * Alias for {@link ReadableController} — use with view-backed controllers.
449
+ *
450
+ * @example
451
+ * ```ts
452
+ * ‎@ViewController(activeTasksView)
453
+ * export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
454
+ * ```
455
+ */
456
+ const ViewController = ReadableController;
457
+ //#endregion
458
+ //#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
459
+ function __decorateParam(paramIndex, decorator) {
460
+ return function(target, key) {
461
+ decorator(target, key, paramIndex);
462
+ };
463
+ }
464
+ //#endregion
465
+ //#region src/as-db-readable.controller.ts
466
+ var _ref$3, _ref2$2;
467
+ let AsDbReadableController = class AsDbReadableController extends AsReadableController {
468
+ /** Reference to the underlying readable (table or view). */
469
+ readable;
470
+ _gates;
471
+ constructor(readable, app) {
472
+ super(readable.type, readable.tableName, app, readable.isView ? "view" : "table");
473
+ this.readable = readable;
474
+ this._gates = this._buildGates();
475
+ }
476
+ _buildGates() {
477
+ const meta = this.readable.type.metadata;
478
+ const gates = {};
479
+ if (meta.get("db.table.filterable") === "manual") {
480
+ const allowed = this._collectAnnotated("db.column.filterable");
481
+ gates.filter = {
482
+ predicate: (f) => allowed.has(f),
483
+ annotation: "@db.column.filterable"
484
+ };
485
+ }
486
+ if (meta.get("db.table.sortable") === "manual") {
487
+ const allowed = this._collectAnnotated("db.column.sortable");
488
+ gates.sort = {
489
+ predicate: (f) => allowed.has(f),
490
+ annotation: "@db.column.sortable"
491
+ };
492
+ }
493
+ return gates;
494
+ }
495
+ _collectAnnotated(annotation) {
496
+ const out = /* @__PURE__ */ new Set();
497
+ for (const [path, entry] of this.readable.flatMap) if (entry.metadata.has(annotation)) out.add(path);
498
+ return out;
499
+ }
500
+ hasField(path) {
501
+ return this.readable.flatMap.has(path);
502
+ }
503
+ /** Validates $with relations against the readable. */
504
+ validateParsed(parsed, type) {
505
+ const baseError = super.validateParsed(parsed, type);
506
+ if (baseError) return baseError;
308
507
  const withRelations = parsed.controls.$with;
309
508
  if (withRelations?.length) {
310
509
  const relations = this.readable.relations;
@@ -340,15 +539,6 @@ let AsDbReadableController = class AsDbReadableController {
340
539
  transformProjection(projection) {
341
540
  return projection;
342
541
  }
343
- parseQueryString(url) {
344
- const idx = url.indexOf("?");
345
- return parseUrl(idx >= 0 ? url.slice(idx + 1) : "");
346
- }
347
- async returnOne(result) {
348
- const item = await result;
349
- if (!item) return new HttpError(404);
350
- return item;
351
- }
352
542
  /**
353
543
  * Extracts a composite identifier object from query params.
354
544
  * Tries composite primary key first, then compound unique indexes.
@@ -403,6 +593,8 @@ let AsDbReadableController = class AsDbReadableController {
403
593
  }
404
594
  const error = this.validateParsed(parsed, "query");
405
595
  if (error) return error;
596
+ const gateError = this.checkGates(parsed.filter, controls, this._gates);
597
+ if (gateError) return gateError;
406
598
  const [filter, select] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
407
599
  if (controls.$count) return this.readable.count({
408
600
  filter,
@@ -440,6 +632,8 @@ let AsDbReadableController = class AsDbReadableController {
440
632
  const error = this.validateParsed(parsed, "pages");
441
633
  if (error) return error;
442
634
  const controls = parsed.controls;
635
+ const gateError = this.checkGates(parsed.filter, controls, this._gates);
636
+ if (gateError) return gateError;
443
637
  const page = Math.max(Number(controls.$page || 1), 1);
444
638
  const size = Math.max(Number(controls.$size || 10), 1);
445
639
  const skip = (page - 1) * size;
@@ -506,28 +700,31 @@ let AsDbReadableController = class AsDbReadableController {
506
700
  /**
507
701
  * **GET /meta** — returns table/view metadata for UI.
508
702
  *
509
- * The return type includes `Promise<...>` so subclasses can override with an
510
- * async implementation (e.g. to enrich the payload from an external source).
511
- * The base cache only covers the base payload — async overrides must cache
512
- * their own enrichment if needed.
703
+ * Overrides the base's minimal envelope to add relations, searchable flags,
704
+ * vector-searchable flags, field-descriptor-derived filter/sort hints, and
705
+ * the configured primary keys.
513
706
  */
514
- meta() {
515
- if (this._metaResponse) return this._metaResponse;
707
+ async buildMetaResponse() {
516
708
  const relations = [];
517
709
  for (const [name, rel] of this.readable.relations) relations.push({
518
710
  name,
519
711
  direction: rel.direction,
520
712
  isArray: rel.isArray
521
713
  });
714
+ const filterableMode = this.readable.type.metadata.get("db.table.filterable") === "manual";
715
+ const sortableMode = this.readable.type.metadata.get("db.table.sortable") === "manual";
522
716
  const fields = {};
523
717
  for (const fd of this.readable.fieldDescriptors) {
524
718
  if (fd.ignored) continue;
719
+ const annotations = fd.type?.metadata;
720
+ const annotatedFilterable = annotations?.has("db.column.filterable") ?? false;
721
+ const annotatedSortable = annotations?.has("db.column.sortable") ?? false;
525
722
  fields[fd.path] = {
526
- sortable: !!fd.isIndexed,
527
- filterable: true
723
+ sortable: sortableMode ? annotatedSortable : !!fd.isIndexed,
724
+ filterable: filterableMode ? annotatedFilterable : true
528
725
  };
529
726
  }
530
- const response = {
727
+ return {
531
728
  searchable: this.readable.isSearchable(),
532
729
  vectorSearchable: this.readable.isVectorSearchable(),
533
730
  searchIndexes: this.readable.getSearchIndexes(),
@@ -537,8 +734,6 @@ let AsDbReadableController = class AsDbReadableController {
537
734
  fields,
538
735
  type: this.getSerializedType()
539
736
  };
540
- this._metaResponse = response;
541
- return response;
542
737
  }
543
738
  };
544
739
  __decorate([
@@ -568,23 +763,17 @@ __decorate([
568
763
  __decorateParam(0, Query()),
569
764
  __decorateParam(1, Url()),
570
765
  __decorateMetadata("design:type", Function),
571
- __decorateMetadata("design:paramtypes", [typeof (_ref2$1 = typeof Record !== "undefined" && Record) === "function" ? _ref2$1 : Object, String]),
766
+ __decorateMetadata("design:paramtypes", [typeof (_ref2$2 = typeof Record !== "undefined" && Record) === "function" ? _ref2$2 : Object, String]),
572
767
  __decorateMetadata("design:returntype", Promise)
573
768
  ], AsDbReadableController.prototype, "getOneComposite", null);
574
- __decorate([
575
- Get("meta"),
576
- __decorateMetadata("design:type", Function),
577
- __decorateMetadata("design:paramtypes", []),
578
- __decorateMetadata("design:returntype", Object)
579
- ], AsDbReadableController.prototype, "meta", null);
580
769
  AsDbReadableController = __decorate([
581
- UseValidationErrorTransform(),
770
+ Inherit(),
582
771
  __decorateParam(0, Inject(READABLE_DEF)),
583
- __decorateMetadata("design:paramtypes", [Object, typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object])
772
+ __decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$3 : Object])
584
773
  ], AsDbReadableController);
585
774
  //#endregion
586
775
  //#region src/as-db.controller.ts
587
- var _ref, _ref2;
776
+ var _ref$2, _ref2$1;
588
777
  let AsDbController = class AsDbController extends AsDbReadableController {
589
778
  /** Reference to the underlying table (typed for write access). */
590
779
  get table() {
@@ -705,13 +894,321 @@ __decorate([
705
894
  Delete(""),
706
895
  __decorateParam(0, Query()),
707
896
  __decorateMetadata("design:type", Function),
708
- __decorateMetadata("design:paramtypes", [typeof (_ref2 = typeof Record !== "undefined" && Record) === "function" ? _ref2 : Object]),
897
+ __decorateMetadata("design:paramtypes", [typeof (_ref2$1 = typeof Record !== "undefined" && Record) === "function" ? _ref2$1 : Object]),
709
898
  __decorateMetadata("design:returntype", Promise)
710
899
  ], AsDbController.prototype, "removeComposite", null);
711
900
  AsDbController = __decorate([
712
901
  Inherit(),
713
902
  __decorateParam(0, Inject(TABLE_DEF)),
714
- __decorateMetadata("design:paramtypes", [Object, typeof (_ref = typeof Moost !== "undefined" && Moost) === "function" ? _ref : Object])
903
+ __decorateMetadata("design:paramtypes", [Object, typeof (_ref$2 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$2 : Object])
715
904
  ], AsDbController);
716
905
  //#endregion
717
- export { AsDbController, AsDbReadableController, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, validationErrorTransform };
906
+ //#region src/as-value-help.controller.ts
907
+ var _ref$1, _ref2;
908
+ let AsValueHelpController = class AsValueHelpController extends AsReadableController {
909
+ /** Per-prop metadata map of the bound interface; eagerly built once. */
910
+ fieldMeta;
911
+ /**
912
+ * Fields that participate in `$search` by default. Populated from
913
+ * `@ui.dict.searchable`:
914
+ * - If any prop carries `@ui.dict.searchable`, only those props are here.
915
+ * - Else if the interface carries `@ui.dict.searchable`, every `string`-typed prop is here.
916
+ * - Else every `string`-typed prop is here (hint is absent — default to all strings).
917
+ */
918
+ searchableFields;
919
+ /** The `@meta.id` field name on the bound interface, if any. */
920
+ primaryKey;
921
+ constructor(boundType, controllerName, app) {
922
+ super(boundType, controllerName, app, "value-help");
923
+ const fieldMeta = /* @__PURE__ */ new Map();
924
+ const explicitlySearchable = [];
925
+ const stringProps = [];
926
+ let primaryKey;
927
+ const interfaceSearchable = boundType.metadata.has("ui.dict.searchable");
928
+ const asObj = boundType.type;
929
+ if (asObj?.props) for (const [name, prop] of asObj.props) {
930
+ const meta = prop.metadata;
931
+ fieldMeta.set(name, meta);
932
+ if (!primaryKey && meta.has("meta.id")) primaryKey = name;
933
+ if (prop.type.designType === "string") stringProps.push(name);
934
+ if (meta.has("ui.dict.searchable")) explicitlySearchable.push(name);
935
+ }
936
+ this.fieldMeta = fieldMeta;
937
+ this.primaryKey = primaryKey;
938
+ this.searchableFields = explicitlySearchable.length > 0 ? explicitlySearchable : interfaceSearchable ? stringProps : stringProps;
939
+ }
940
+ hasField(path) {
941
+ return this.fieldMeta.has(path);
942
+ }
943
+ /**
944
+ * **GET /query** — returns an array of matched rows (up to `$limit`).
945
+ */
946
+ async runQuery(url) {
947
+ const parsed = this.parseQueryString(url);
948
+ const validateError = this.validateParsed(parsed, "query");
949
+ if (validateError) return validateError;
950
+ return (await this.query({
951
+ filter: parsed.filter,
952
+ controls: parsed.controls
953
+ })).data;
954
+ }
955
+ /**
956
+ * **GET /pages** — paginated row window plus total count.
957
+ */
958
+ async runPages(url) {
959
+ const parsed = this.parseQueryString(url);
960
+ const validateError = this.validateParsed(parsed, "pages");
961
+ if (validateError) return validateError;
962
+ const controls = parsed.controls;
963
+ const page = Math.max(Number(controls.$page || 1), 1);
964
+ const size = Math.max(Number(controls.$size || 10), 1);
965
+ const skip = (page - 1) * size;
966
+ const result = await this.query({
967
+ filter: parsed.filter,
968
+ controls: {
969
+ ...controls,
970
+ $skip: skip,
971
+ $limit: size
972
+ }
973
+ });
974
+ return {
975
+ data: result.data,
976
+ page,
977
+ itemsPerPage: size,
978
+ pages: Math.ceil(result.count / size),
979
+ count: result.count
980
+ };
981
+ }
982
+ /**
983
+ * **GET /one/:id** — retrieves a single row by primary key.
984
+ */
985
+ async runGetOne(id) {
986
+ return this.returnOne(this.getOne(id));
987
+ }
988
+ /**
989
+ * **GET /one?<pk>=<val>** — retrieves a single row by PK query param (fallback).
990
+ */
991
+ async runGetOneComposite(query) {
992
+ const pk = this.primaryKey;
993
+ if (!pk) return new HttpError(400, "No primary key (@meta.id) on value-help interface");
994
+ const id = query[pk];
995
+ if (id === void 0) return new HttpError(400, `Missing PK field "${pk}"`);
996
+ return this.returnOne(this.getOne(id));
997
+ }
998
+ /**
999
+ * Meta response surfaces `@ui.dict.*` annotations as **hints** for the
1000
+ * client picker UI (which controls to render); the server does not enforce
1001
+ * these flags at request time.
1002
+ */
1003
+ async buildMetaResponse() {
1004
+ const fields = {};
1005
+ for (const [path, meta] of this.fieldMeta) fields[path] = {
1006
+ sortable: meta.has("ui.dict.sortable"),
1007
+ filterable: meta.has("ui.dict.filterable")
1008
+ };
1009
+ return {
1010
+ searchable: this.searchableFields.length > 0,
1011
+ vectorSearchable: false,
1012
+ searchIndexes: [],
1013
+ primaryKeys: this.primaryKey ? [this.primaryKey] : [],
1014
+ readOnly: this._isReadOnly(),
1015
+ relations: [],
1016
+ fields,
1017
+ type: this.getSerializedType()
1018
+ };
1019
+ }
1020
+ };
1021
+ __decorate([
1022
+ Get("query"),
1023
+ __decorateParam(0, Url()),
1024
+ __decorateMetadata("design:type", Function),
1025
+ __decorateMetadata("design:paramtypes", [String]),
1026
+ __decorateMetadata("design:returntype", Promise)
1027
+ ], AsValueHelpController.prototype, "runQuery", null);
1028
+ __decorate([
1029
+ Get("pages"),
1030
+ __decorateParam(0, Url()),
1031
+ __decorateMetadata("design:type", Function),
1032
+ __decorateMetadata("design:paramtypes", [String]),
1033
+ __decorateMetadata("design:returntype", Promise)
1034
+ ], AsValueHelpController.prototype, "runPages", null);
1035
+ __decorate([
1036
+ Get("one/:id"),
1037
+ __decorateParam(0, Param("id")),
1038
+ __decorateMetadata("design:type", Function),
1039
+ __decorateMetadata("design:paramtypes", [String]),
1040
+ __decorateMetadata("design:returntype", Promise)
1041
+ ], AsValueHelpController.prototype, "runGetOne", null);
1042
+ __decorate([
1043
+ Get("one"),
1044
+ __decorateParam(0, Query()),
1045
+ __decorateMetadata("design:type", Function),
1046
+ __decorateMetadata("design:paramtypes", [typeof (_ref2 = typeof Record !== "undefined" && Record) === "function" ? _ref2 : Object]),
1047
+ __decorateMetadata("design:returntype", Promise)
1048
+ ], AsValueHelpController.prototype, "runGetOneComposite", null);
1049
+ AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramtypes", [
1050
+ Object,
1051
+ String,
1052
+ typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object
1053
+ ])], AsValueHelpController);
1054
+ //#endregion
1055
+ //#region src/as-json-value-help.controller.ts
1056
+ var _ref;
1057
+ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueHelpController {
1058
+ rows;
1059
+ _pkIndex;
1060
+ constructor(boundType, rows, app, controllerName) {
1061
+ const name = controllerName || boundType.metadata.get("db.table") || "value-help";
1062
+ super(boundType, name, app);
1063
+ this.rows = rows;
1064
+ if (this.primaryKey) {
1065
+ const pk = this.primaryKey;
1066
+ const index = /* @__PURE__ */ new Map();
1067
+ for (const row of rows) index.set(String(row[pk]), row);
1068
+ this._pkIndex = index;
1069
+ }
1070
+ }
1071
+ async query(controls) {
1072
+ let rows = this.rows;
1073
+ if (controls.filter && Object.keys(controls.filter).length > 0) rows = rows.filter((row) => matchFilter(row, controls.filter));
1074
+ const search = controls.controls.$search;
1075
+ if (search) {
1076
+ const needle = search.toLowerCase();
1077
+ const fields = this.searchableFields;
1078
+ rows = rows.filter((row) => {
1079
+ for (const field of fields) {
1080
+ const v = row[field];
1081
+ if (typeof v === "string" && v.toLowerCase().includes(needle)) return true;
1082
+ }
1083
+ return false;
1084
+ });
1085
+ }
1086
+ if (controls.controls.$sort) rows = sortRows(rows, controls.controls.$sort);
1087
+ const total = rows.length;
1088
+ const skip = Math.max(0, Number(controls.controls.$skip ?? 0));
1089
+ const limit = Math.max(0, Number(controls.controls.$limit ?? total - skip));
1090
+ return {
1091
+ data: applySelect(rows.slice(skip, skip + limit), controls.controls.$select),
1092
+ count: total
1093
+ };
1094
+ }
1095
+ async getOne(id) {
1096
+ return this._pkIndex?.get(String(id)) ?? null;
1097
+ }
1098
+ };
1099
+ AsJsonValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramtypes", [
1100
+ Object,
1101
+ Array,
1102
+ typeof (_ref = typeof Moost !== "undefined" && Moost) === "function" ? _ref : Object,
1103
+ String
1104
+ ])], AsJsonValueHelpController);
1105
+ function matchFilter(row, filter) {
1106
+ if (!filter || typeof filter !== "object") return true;
1107
+ for (const [key, value] of Object.entries(filter)) {
1108
+ if (key === "$and") {
1109
+ if (!Array.isArray(value)) continue;
1110
+ if (!value.every((clause) => matchFilter(row, clause))) return false;
1111
+ continue;
1112
+ }
1113
+ if (key === "$or") {
1114
+ if (!Array.isArray(value)) continue;
1115
+ if (!value.some((clause) => matchFilter(row, clause))) return false;
1116
+ continue;
1117
+ }
1118
+ if (key === "$nor") {
1119
+ if (!Array.isArray(value)) continue;
1120
+ if (value.some((clause) => matchFilter(row, clause))) return false;
1121
+ continue;
1122
+ }
1123
+ if (key === "$not") {
1124
+ if (matchFilter(row, value)) return false;
1125
+ continue;
1126
+ }
1127
+ if (key.startsWith("$")) continue;
1128
+ const fieldValue = row[key];
1129
+ if (!matchFieldPredicate(fieldValue, value)) return false;
1130
+ }
1131
+ return true;
1132
+ }
1133
+ function matchFieldPredicate(fieldValue, predicate) {
1134
+ if (predicate === null || typeof predicate !== "object" || Array.isArray(predicate)) return fieldValue === predicate;
1135
+ for (const [op, operand] of Object.entries(predicate)) switch (op) {
1136
+ case "$eq":
1137
+ if (fieldValue !== operand) return false;
1138
+ break;
1139
+ case "$ne":
1140
+ if (fieldValue === operand) return false;
1141
+ break;
1142
+ case "$in":
1143
+ if (!Array.isArray(operand) || !operand.includes(fieldValue)) return false;
1144
+ break;
1145
+ case "$nin":
1146
+ if (!Array.isArray(operand) || operand.includes(fieldValue)) return false;
1147
+ break;
1148
+ case "$gt":
1149
+ if (!(fieldValue > operand)) return false;
1150
+ break;
1151
+ case "$gte":
1152
+ if (!(fieldValue >= operand)) return false;
1153
+ break;
1154
+ case "$lt":
1155
+ if (!(fieldValue < operand)) return false;
1156
+ break;
1157
+ case "$lte":
1158
+ if (!(fieldValue <= operand)) return false;
1159
+ break;
1160
+ case "$regex": {
1161
+ const re = operand instanceof RegExp ? operand : new RegExp(String(operand));
1162
+ if (typeof fieldValue !== "string" || !re.test(fieldValue)) return false;
1163
+ break;
1164
+ }
1165
+ default: if (fieldValue !== operand) return false;
1166
+ }
1167
+ return true;
1168
+ }
1169
+ function sortRows(rows, sort) {
1170
+ const keys = [];
1171
+ const push = (name, explicit) => {
1172
+ const clean = name.replace(/^[-+]/, "");
1173
+ const dir = explicit ?? (name.startsWith("-") ? -1 : 1);
1174
+ if (clean) keys.push({
1175
+ name: clean,
1176
+ dir
1177
+ });
1178
+ };
1179
+ if (typeof sort === "string") for (const part of sort.split(",")) {
1180
+ const trimmed = part.trim();
1181
+ if (!trimmed) continue;
1182
+ const [name, dir] = trimmed.split(":");
1183
+ push(name, dir === "desc" ? -1 : dir === "asc" ? 1 : void 0);
1184
+ }
1185
+ else if (Array.isArray(sort)) {
1186
+ for (const entry of sort) if (typeof entry === "string") push(entry);
1187
+ else if (entry && typeof entry === "object") for (const [name, d] of Object.entries(entry)) push(name, d === "desc" || d === -1 ? -1 : 1);
1188
+ } else if (sort && typeof sort === "object") for (const [name, d] of Object.entries(sort)) push(name, d === "desc" || d === -1 ? -1 : 1);
1189
+ if (keys.length === 0) return rows;
1190
+ const out = rows.slice();
1191
+ out.sort((a, b) => {
1192
+ for (const { name, dir } of keys) {
1193
+ const av = a[name];
1194
+ const bv = b[name];
1195
+ if (av === bv) continue;
1196
+ if (av === void 0 || av === null) return -1 * dir;
1197
+ if (bv === void 0 || bv === null) return 1 * dir;
1198
+ if (av < bv) return -1 * dir;
1199
+ if (av > bv) return 1 * dir;
1200
+ }
1201
+ return 0;
1202
+ });
1203
+ return out;
1204
+ }
1205
+ function applySelect(rows, select) {
1206
+ if (!select?.length) return rows;
1207
+ return rows.map((row) => {
1208
+ const out = {};
1209
+ for (const key of select) out[key] = row[key];
1210
+ return out;
1211
+ });
1212
+ }
1213
+ //#endregion
1214
+ export { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, validationErrorTransform };