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