@atscript/moost-db 0.1.127 → 0.1.129

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
@@ -8,22 +8,41 @@ let _atscript_db = require("@atscript/db");
8
8
  let _atscript_db_memory = require("@atscript/db-memory");
9
9
  let _wooksjs_event_core = require("@wooksjs/event-core");
10
10
  let _wooksjs_http_body = require("@wooksjs/http-body");
11
+ //#region src/http-errors.ts
12
+ /**
13
+ * The structured error envelope every `moost-db` rejection uses (since
14
+ * 0.1.128 all of them do): `{ message, statusCode, errors: [{ path, message }] }`
15
+ * — the same shape the validation interceptor renders for `ValidatorError` /
16
+ * `DbError`, so clients parse one format.
17
+ */
18
+ function errorEnvelope(statusCode, message, errors) {
19
+ return new _moostjs_event_http.HttpError(statusCode, {
20
+ message,
21
+ statusCode,
22
+ errors
23
+ });
24
+ }
25
+ /**
26
+ * A 400 with a single `errors` entry: `path` names the offending logical
27
+ * path / body position, `message` the reason, `top` the envelope's top-level
28
+ * message (defaults to `message`).
29
+ */
30
+ function badRequest(path, message, top = message) {
31
+ return errorEnvelope(400, top, [{
32
+ path,
33
+ message
34
+ }]);
35
+ }
36
+ //#endregion
11
37
  //#region src/validation-interceptor.ts
12
- const dbErrorCodeToStatus = { CONFLICT: 409 };
38
+ const dbErrorCodeToStatus = {
39
+ CONFLICT: 409,
40
+ CAS_MISMATCH: 409,
41
+ TX_WAIT_TIMEOUT: 503
42
+ };
13
43
  function transformValidationError(error, reply) {
14
- if (error instanceof _atscript_typescript_utils.ValidatorError) reply(new _moostjs_event_http.HttpError(400, {
15
- message: error.message,
16
- statusCode: 400,
17
- errors: error.errors
18
- }));
19
- else if (error instanceof _atscript_db.DbError) {
20
- const statusCode = dbErrorCodeToStatus[error.code] ?? 400;
21
- reply(new _moostjs_event_http.HttpError(statusCode, {
22
- message: error.message,
23
- statusCode,
24
- errors: error.errors
25
- }));
26
- }
44
+ if (error instanceof _atscript_typescript_utils.ValidatorError) reply(errorEnvelope(400, error.message, error.errors));
45
+ else if (error instanceof _atscript_db.DbError) reply(errorEnvelope(dbErrorCodeToStatus[error.code] ?? 400, error.message, error.errors));
27
46
  }
28
47
  const validationErrorTransform = () => (0, moost.defineInterceptor)({ error: transformValidationError }, moost.TInterceptorPriority.BEFORE_ALL);
29
48
  const UseValidationErrorTransform = () => (0, moost.Intercept)(validationErrorTransform());
@@ -118,72 +137,6 @@ var SelectControlDto = class {
118
137
  (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);
119
138
  (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);
120
139
  //#endregion
121
- //#region src/gate-utils.ts
122
- /**
123
- * Walks a Uniquery filter expression and returns the first field name that
124
- * fails the `isAllowed` predicate, or `undefined` if every leaf field is
125
- * allowed. Logical combinators (`$and`, `$or`, `$nor`, `$not`) are traversed;
126
- * other `$`-prefixed keys are skipped.
127
- *
128
- * Shared by the DB readable's `@db.column.filterable` gate and the value-help
129
- * controller's `@ui.dict.filterable` gate — only the predicate differs.
130
- */
131
- function findFilterOffender(filter, isAllowed) {
132
- if (!filter || typeof filter !== "object") return;
133
- for (const [key, value] of Object.entries(filter)) {
134
- if (key === "$and" || key === "$or" || key === "$nor") {
135
- if (Array.isArray(value)) for (const sub of value) {
136
- const inner = findFilterOffender(sub, isAllowed);
137
- if (inner) return inner;
138
- }
139
- continue;
140
- }
141
- if (key === "$not") {
142
- const inner = findFilterOffender(value, isAllowed);
143
- if (inner) return inner;
144
- continue;
145
- }
146
- if (key.startsWith("$")) continue;
147
- if (!isAllowed(key)) return key;
148
- }
149
- }
150
- /**
151
- * Walks a Uniquery `$sort` control (accepts string, string[], object, or
152
- * array-of-{field: dir}) and returns the first field name that fails the
153
- * `isAllowed` predicate, or `undefined` if every sort key is allowed.
154
- *
155
- * Shared by the DB readable's `@db.column.sortable` gate and the value-help
156
- * controller's `@ui.dict.sortable` gate.
157
- */
158
- function findSortOffender(sort, isAllowed) {
159
- if (!sort) return void 0;
160
- const check = (name) => isAllowed(name) ? void 0 : name;
161
- if (typeof sort === "string") {
162
- for (const part of sort.split(",")) {
163
- const name = part.trim().replace(/^[-+]/, "").split(":")[0];
164
- if (name) {
165
- const bad = check(name);
166
- if (bad) return bad;
167
- }
168
- }
169
- return;
170
- }
171
- if (Array.isArray(sort)) {
172
- for (const entry of sort) if (typeof entry === "string") {
173
- const bad = check(entry.replace(/^[-+]/, ""));
174
- if (bad) return bad;
175
- } else if (entry && typeof entry === "object") for (const name of Object.keys(entry)) {
176
- const bad = check(name);
177
- if (bad) return bad;
178
- }
179
- return;
180
- }
181
- if (typeof sort === "object") for (const name of Object.keys(sort)) {
182
- const bad = check(name);
183
- if (bad) return bad;
184
- }
185
- }
186
- //#endregion
187
140
  //#region src/mate.ts
188
141
  /**
189
142
  * Returns the shared moost-mate instance, typed against every key that
@@ -527,6 +480,122 @@ function isNonEmptyStringArray(value) {
527
480
  return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string");
528
481
  }
529
482
  //#endregion
483
+ //#region src/meta/terminal-ref.ts
484
+ const NAV_KEYS = [
485
+ "db.rel.to",
486
+ "db.rel.from",
487
+ "db.rel.via"
488
+ ];
489
+ function isNav(metadata) {
490
+ return NAV_KEYS.some((key) => metadata.has(key));
491
+ }
492
+ /**
493
+ * Resolves a (possibly dotted) prop path against an object type by walking
494
+ * `type.props` one segment at a time. Bails with `undefined` as soon as a hop
495
+ * is not an object type or the segment is missing.
496
+ */
497
+ function resolveProp(type, field) {
498
+ let current = type;
499
+ for (const segment of field.split(".")) {
500
+ if (!current || current.type.kind !== "object") return void 0;
501
+ current = current.type.props.get(segment);
502
+ }
503
+ return current;
504
+ }
505
+ /**
506
+ * Follows `def.ref` hop by hop until a prop without a `ref` (a primary key or
507
+ * a plain column) is reached. Cycle-safe (visited on `<typeId>.<field>`) and
508
+ * bounded by chain length.
509
+ */
510
+ function resolveTerminalRef(def) {
511
+ const ref = def.ref;
512
+ if (!ref) return void 0;
513
+ let type = ref.type();
514
+ let field = ref.field;
515
+ if (!type) return void 0;
516
+ let fk = def.metadata.has("db.rel.FK");
517
+ const visited = new Set([`${type.id ?? ""}.${field}`]);
518
+ for (;;) {
519
+ const prop = resolveProp(type, field);
520
+ if (!prop) break;
521
+ if (prop.metadata.has("db.rel.FK")) fk = true;
522
+ const next = prop.ref;
523
+ if (!next) break;
524
+ const nextType = next.type();
525
+ if (!nextType) break;
526
+ const key = `${nextType.id ?? ""}.${next.field}`;
527
+ if (visited.has(key)) break;
528
+ visited.add(key);
529
+ type = nextType;
530
+ field = next.field;
531
+ }
532
+ return {
533
+ type,
534
+ field,
535
+ fk
536
+ };
537
+ }
538
+ /**
539
+ * Post-pass over a serialized type in lock-step with its runtime type: every
540
+ * object prop (recursing into nested objects and array elements — never into
541
+ * `ref` bodies or navigation subtrees) whose runtime prop has a `ref` gets its
542
+ * serialized `ref` re-pointed to the terminal field (shallow `{ id, metadata }`
543
+ * target, serialized with the same annotation whitelist) and, when the chain
544
+ * passes an FK, `metadata["db.rel.FK"] = true` (never the hop's alias).
545
+ *
546
+ * Only shallow refs (`refDepth` with a `.5` step) are rewritten; a full-body
547
+ * ref target is left alone. Mutates and returns `serialized`.
548
+ */
549
+ function applyTerminalRefs(serialized, runtime, options) {
550
+ const shallowCache = /* @__PURE__ */ new Map();
551
+ const shallow = (type) => {
552
+ let target = shallowCache.get(type);
553
+ if (!target) {
554
+ target = {
555
+ id: type.id ?? "",
556
+ metadata: (0, _atscript_typescript_utils.serializeAnnotatedType)(type, {
557
+ ...options,
558
+ refDepth: 0
559
+ }).metadata
560
+ };
561
+ shallowCache.set(type, target);
562
+ }
563
+ return target;
564
+ };
565
+ walk(serialized, runtime, shallow);
566
+ return serialized;
567
+ }
568
+ function walk(node, def, shallow) {
569
+ const kind = def.type.kind;
570
+ if (kind === "object") {
571
+ const sType = node.type;
572
+ if (sType.kind !== "object" || !sType.props) return;
573
+ for (const [name, prop] of def.type.props) {
574
+ const sProp = sType.props[name];
575
+ if (!sProp) continue;
576
+ if (isNav(prop.metadata)) continue;
577
+ if (prop.ref && sProp.ref && !("type" in sProp.ref.type)) {
578
+ const terminal = resolveTerminalRef(prop);
579
+ if (terminal) {
580
+ const direct = prop.ref.type();
581
+ if (terminal.type !== direct || terminal.field !== prop.ref.field) sProp.ref = {
582
+ field: terminal.field,
583
+ type: shallow(terminal.type)
584
+ };
585
+ if (terminal.fk && sProp.metadata["db.rel.FK"] === void 0) sProp.metadata["db.rel.FK"] = true;
586
+ }
587
+ }
588
+ walk(sProp, prop, shallow);
589
+ }
590
+ return;
591
+ }
592
+ if (kind === "array") {
593
+ const sType = node.type;
594
+ const of = def.type.of;
595
+ if (sType.kind === "array" && sType.of && of) walk(sType.of, of, shallow);
596
+ }
597
+ }
598
+ //#endregion
530
599
  //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateMetadata.js
531
600
  function __decorateMetadata(k, v) {
532
601
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
@@ -594,10 +663,21 @@ let AsReadableController = class AsReadableController {
594
663
  }
595
664
  /** Lazily serializes the bound type (after all controllers have set @db.http.path). */
596
665
  getSerializedType() {
597
- if (!this._serializedType) this._serializedType = (0, _atscript_typescript_utils.serializeAnnotatedType)(this.boundType, this.getSerializeOptions());
666
+ if (!this._serializedType) this._serializedType = this.serializeForMeta(this.boundType);
598
667
  return this._serializedType;
599
668
  }
600
669
  /**
670
+ * Serializes a type for the meta surfaces (`/meta`, `/meta/form/:name`)
671
+ * with {@link getSerializeOptions}, then re-points every reference chain to
672
+ * its terminal field and inherits the `db.rel.FK` value-help marker through
673
+ * the chain (since 0.1.128; see `meta/terminal-ref.ts`). Direct references
674
+ * serialize exactly as before.
675
+ */
676
+ serializeForMeta(type) {
677
+ const options = this.getSerializeOptions();
678
+ return applyTerminalRefs((0, _atscript_typescript_utils.serializeAnnotatedType)(type, options), type, options);
679
+ }
680
+ /**
601
681
  * One-time initialization hook. Override to seed data, register watchers, etc.
602
682
  */
603
683
  init() {}
@@ -625,7 +705,7 @@ let AsReadableController = class AsReadableController {
625
705
  key,
626
706
  value
627
707
  };
628
- if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly") return {
708
+ if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version") return {
629
709
  key,
630
710
  value
631
711
  };
@@ -672,24 +752,33 @@ let AsReadableController = class AsReadableController {
672
752
  }
673
753
  }
674
754
  /**
675
- * Shared filter/sort/search gate check. Subclasses assemble a {@link ReadableGates}
676
- * config per request (or once in the constructor when static) and call this to
677
- * get a uniform HTTP 400 response for any offending field/control.
755
+ * Per-request gate hook, invoked by the DB readable controller right after
756
+ * its capability gate (`checkCapabilities`) with the parsed query. The
757
+ * default accepts everything. Return an `HttpError` to reject.
758
+ *
759
+ * @deprecated since 0.1.128 — the filter / sort gate is the
760
+ * `FieldCapabilityIndex` behind `checkCapabilities` (override that, or read
761
+ * `this.capabilities` on `AsDbReadableController`); this hook only remains
762
+ * so subclasses that overrode it keep being called.
678
763
  */
679
- checkGates(filter, controls, gates) {
680
- if (gates.filter) {
681
- const bad = findFilterOffender(filter, gates.filter.predicate);
682
- if (bad) return new _moostjs_event_http.HttpError(400, `Filtering on field "${bad}" is not permitted — add ${gates.filter.annotation} to enable.`);
683
- }
684
- if (gates.sort) {
685
- const bad = findSortOffender(controls.$sort, gates.sort.predicate);
686
- if (bad) return new _moostjs_event_http.HttpError(400, `Sorting on field "${bad}" is not permitted — add ${gates.sort.annotation} to enable.`);
687
- }
688
- if (gates.search && controls.$search && !gates.search.allowed) return new _moostjs_event_http.HttpError(400, gates.search.rejectionMessage);
689
- }
764
+ checkGates(_parsed) {}
690
765
  parseQueryString(url) {
691
766
  const idx = url.indexOf("?");
692
- return (0, _uniqu_url.parseUrl)(idx >= 0 ? url.slice(idx + 1) : "");
767
+ return this.parseUrlOr400(idx >= 0 ? url.slice(idx + 1) : "");
768
+ }
769
+ /**
770
+ * The ONE place a query string meets the `@uniqu/url` grammar. A lexer /
771
+ * parser error (e.g. an unquoted `-` in a value: `?name=json-w1`) is the
772
+ * client's fault, so since 0.1.128 it is a 400 with the validation envelope
773
+ * `{ message, statusCode: 400, errors: [{ path: "", message }] }` instead of
774
+ * an unhandled 500. Quote such values: `?name='json-w1'`.
775
+ */
776
+ parseUrlOr400(queryString) {
777
+ try {
778
+ return (0, _uniqu_url.parseUrl)(queryString);
779
+ } catch (error) {
780
+ throw badRequest("", `Malformed query string: ${error instanceof Error ? error.message : String(error)}`);
781
+ }
693
782
  }
694
783
  /**
695
784
  * Parse a URL keeping only `$*` control keywords; report whether any
@@ -704,7 +793,7 @@ let AsReadableController = class AsReadableController {
704
793
  const idx = url.indexOf("?");
705
794
  const qs = idx >= 0 ? url.slice(idx + 1) : "";
706
795
  if (!qs) return {
707
- parsed: (0, _uniqu_url.parseUrl)(""),
796
+ parsed: this.parseUrlOr400(""),
708
797
  hasNonControl: false
709
798
  };
710
799
  const kept = [];
@@ -723,7 +812,7 @@ let AsReadableController = class AsReadableController {
723
812
  else hasNonControl = true;
724
813
  }
725
814
  return {
726
- parsed: (0, _uniqu_url.parseUrl)(kept.join("&")),
815
+ parsed: this.parseUrlOr400(kept.join("&")),
727
816
  hasNonControl
728
817
  };
729
818
  }
@@ -755,7 +844,7 @@ let AsReadableController = class AsReadableController {
755
844
  if (!formType) throw new _moostjs_event_http.HttpError(404, `Unknown form "${name}"`);
756
845
  let cached = this._formSchemas.get(name);
757
846
  if (!cached) {
758
- cached = (0, _atscript_typescript_utils.serializeAnnotatedType)(formType, this.getSerializeOptions());
847
+ cached = this.serializeForMeta(formType);
759
848
  this._formSchemas.set(name, cached);
760
849
  }
761
850
  return cached;
@@ -1049,6 +1138,251 @@ function resolveBoundReadable(ctor) {
1049
1138
  throw new Error(`[moost-db] ${ctor?.name || "controller"}: no readable bound. Either pass a table/view to super(...), or decorate the class with @TableController / @ReadableController (model token, lazy factory, or instance form).`);
1050
1139
  }
1051
1140
  //#endregion
1141
+ //#region src/meta/field-capabilities.ts
1142
+ const REASON_ADAPTER_FILTER = "adapter cannot filter on this storage type.";
1143
+ const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1144
+ const REASON_WRITE_ONLY = "field is @db.writeOnly.";
1145
+ const REASON_ENCRYPTED = "field is @db.encrypted (ciphertext cannot be compared or ordered).";
1146
+ const REASON_ANNOTATION_FILTER = "add @db.column.filterable to enable.";
1147
+ const REASON_ANNOTATION_SORT = "add @db.column.sortable to enable.";
1148
+ /** Sentence subject per op ("Filtering on field …"). */
1149
+ const OP_SUBJECT = {
1150
+ filter: "Filtering on",
1151
+ sort: "Sorting on",
1152
+ select: "Selecting",
1153
+ groupBy: "Grouping by",
1154
+ having: "Filtering ($having) on",
1155
+ aggregate: "Aggregating over"
1156
+ };
1157
+ /** Lower-case verb per op ("… cannot filter JSON paths"). */
1158
+ const OP_VERB = {
1159
+ filter: "filter",
1160
+ sort: "sort by",
1161
+ select: "select",
1162
+ groupBy: "group by",
1163
+ having: "filter ($having) on",
1164
+ aggregate: "aggregate over"
1165
+ };
1166
+ function leafHint(leaves) {
1167
+ if (leaves.length === 0) return "no leaf fields";
1168
+ const shown = leaves.slice(0, 5).join(", ");
1169
+ return leaves.length > 5 ? `${shown}, …` : shown;
1170
+ }
1171
+ /**
1172
+ * Capability index of one readable, built once per controller.
1173
+ *
1174
+ * - {@link entries} feeds `/meta.fields` (listed leaves in descriptor order);
1175
+ * - {@link check} is the request gate: same inputs, same answer.
1176
+ *
1177
+ * Paths outside the index are classified by the core's `classifyQueryPath`
1178
+ * (the same rules the core backstop applies) — navigation path, nested-object
1179
+ * parent, JSON descendant (relational adapters), encrypted descendant,
1180
+ * unknown — so the 400 names the storage reason and the alternative.
1181
+ */
1182
+ var FieldCapabilityIndex = class {
1183
+ filterableManual;
1184
+ sortableManual;
1185
+ /** Navigation relations (`@db.rel.to/from/via`), incl. nested ones. */
1186
+ navFields;
1187
+ /** `@db.writeOnly` paths. */
1188
+ writeOnly;
1189
+ /** Descriptors stored as one JSON column (relational adapters). */
1190
+ jsonParents;
1191
+ /** Descriptors carrying `@db.encrypted` (the ciphertext column on relational adapters). */
1192
+ encryptedFields;
1193
+ _entries = /* @__PURE__ */ new Map();
1194
+ /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1195
+ _objectParents = /* @__PURE__ */ new Map();
1196
+ /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1197
+ get leaves() {
1198
+ return this._entries;
1199
+ }
1200
+ /** Nested-object parents — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1201
+ get objectParents() {
1202
+ return this._objectParents;
1203
+ }
1204
+ constructor(source, writeOnly) {
1205
+ const canFilter = (fd) => source.canFilterField(fd);
1206
+ const canSort = (fd) => source.canSortField(fd);
1207
+ const tableMeta = source.type.metadata;
1208
+ this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
1209
+ this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
1210
+ this.writeOnly = writeOnly;
1211
+ const nav = new Set(source.navFields);
1212
+ if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1213
+ this.navFields = nav;
1214
+ const isNavOrDescendant = (path) => nav.has(path) || (0, _atscript_db.findAncestorInSet)(path, nav) !== void 0;
1215
+ const flatMap = source.flatMap;
1216
+ const annotated = (fd, key) => {
1217
+ const fromFlat = flatMap.get(fd.path)?.metadata?.has?.(key);
1218
+ if (fromFlat !== void 0) return fromFlat;
1219
+ return fd.type?.metadata?.has?.(key) ?? false;
1220
+ };
1221
+ const jsonParents = /* @__PURE__ */ new Set();
1222
+ const encrypted = /* @__PURE__ */ new Set();
1223
+ for (const fd of source.fieldDescriptors) {
1224
+ if (fd.ignored) continue;
1225
+ if (isNavOrDescendant(fd.path)) continue;
1226
+ if (fd.storage === "json") jsonParents.add(fd.path);
1227
+ if (fd.encrypted) encrypted.add(fd.path);
1228
+ if (fd.designType === "object") {
1229
+ this._objectParents.set(fd.path, []);
1230
+ continue;
1231
+ }
1232
+ this._entries.set(fd.path, this._buildEntry(fd, canFilter, canSort, annotated));
1233
+ }
1234
+ const ignored = source.ignoredFields;
1235
+ for (const [path, entry] of flatMap) {
1236
+ if (!path || this._entries.has(path) || this._objectParents.has(path)) continue;
1237
+ if (entry?.type?.kind !== "object") continue;
1238
+ if (entry.metadata?.has?.("db.json")) continue;
1239
+ if (isNavOrDescendant(path)) continue;
1240
+ if (ignored.has(path)) continue;
1241
+ if ((0, _atscript_db.findAncestorInSet)(path, jsonParents) !== void 0) continue;
1242
+ if (encrypted.has(path) || (0, _atscript_db.findAncestorInSet)(path, encrypted) !== void 0) continue;
1243
+ this._objectParents.set(path, []);
1244
+ }
1245
+ for (const [parent, leaves] of this._objectParents) {
1246
+ const prefix = `${parent}.`;
1247
+ for (const path of this._entries.keys()) if (path.startsWith(prefix)) leaves.push(path);
1248
+ }
1249
+ this.jsonParents = jsonParents;
1250
+ this.encryptedFields = encrypted;
1251
+ }
1252
+ _buildEntry(fd, canFilter, canSort, annotated) {
1253
+ const physicalFilter = canFilter(fd);
1254
+ const physicalSort = canSort(fd);
1255
+ const isWriteOnly = this.writeOnly.has(fd.path);
1256
+ let filterable = physicalFilter;
1257
+ let sortable = physicalSort;
1258
+ let filterReason = physicalFilter ? void 0 : REASON_ADAPTER_FILTER;
1259
+ let sortReason = physicalSort ? void 0 : REASON_ADAPTER_SORT;
1260
+ let physicalReason = physicalFilter ? void 0 : REASON_ADAPTER_FILTER;
1261
+ if (fd.encrypted) {
1262
+ filterable = false;
1263
+ sortable = false;
1264
+ filterReason = REASON_ENCRYPTED;
1265
+ sortReason = REASON_ENCRYPTED;
1266
+ physicalReason = REASON_ENCRYPTED;
1267
+ }
1268
+ if (isWriteOnly) {
1269
+ filterable = false;
1270
+ sortable = false;
1271
+ filterReason = REASON_WRITE_ONLY;
1272
+ sortReason = REASON_WRITE_ONLY;
1273
+ physicalReason = REASON_WRITE_ONLY;
1274
+ }
1275
+ if (filterable && this.filterableManual && !annotated(fd, "db.column.filterable")) {
1276
+ filterable = false;
1277
+ filterReason = REASON_ANNOTATION_FILTER;
1278
+ }
1279
+ if (sortable && this.sortableManual && !annotated(fd, "db.column.sortable")) {
1280
+ sortable = false;
1281
+ sortReason = REASON_ANNOTATION_SORT;
1282
+ }
1283
+ const cap = {
1284
+ filterable,
1285
+ sortable,
1286
+ selectable: true,
1287
+ indexed: fd.isIndexed === true
1288
+ };
1289
+ if (filterReason) cap.filterReason = filterReason;
1290
+ if (sortReason) cap.sortReason = sortReason;
1291
+ return {
1292
+ fd,
1293
+ cap,
1294
+ physicalFilterable: physicalFilter && !isWriteOnly && !fd.encrypted,
1295
+ physicalReason
1296
+ };
1297
+ }
1298
+ /** Listed leaves in descriptor order — the `/meta.fields` projection source. */
1299
+ *entries() {
1300
+ for (const [path, entry] of this._entries) yield [
1301
+ path,
1302
+ entry.cap,
1303
+ entry.fd
1304
+ ];
1305
+ }
1306
+ /** Physical filter capability (adapter ∧ ¬writeOnly ∧ ¬encrypted) — ignores the manual-mode policy. */
1307
+ isPhysicallyFilterable(path) {
1308
+ return this._entries.get(path)?.physicalFilterable === true;
1309
+ }
1310
+ /**
1311
+ * Gate check for one path in one position. Returns `undefined` when the
1312
+ * path is accepted. Order: navigation paths first (a nav path "exists" on
1313
+ * the target table but is never a column here), then a listed leaf's
1314
+ * capability (no existence lookup needed — every listed leaf is a real
1315
+ * field), then `exists` (the readable's `isValidFieldPath`) and, for paths
1316
+ * that exist but are not leaves, the storage classification.
1317
+ *
1318
+ * Existence deliberately runs BEFORE the JSON / encrypted classification:
1319
+ * an untyped descendant of a JSON column (`address.nope`) is reported as
1320
+ * `Unknown field`, not as "inside JSON-stored column" — clients pin that
1321
+ * wording, so do not "align" it with the core backstop's text.
1322
+ */
1323
+ check(path, op, exists) {
1324
+ const { kind, parent } = (0, _atscript_db.classifyQueryPath)(this, path);
1325
+ if (kind === "nav") {
1326
+ if (parent === void 0) return {
1327
+ path,
1328
+ message: `"${path}" is a navigation property — use $with=${path} to load it`
1329
+ };
1330
+ return {
1331
+ path,
1332
+ message: `"${path}" is a navigation path — use $with=${parent}(...) to filter or select fields of the related rows (e.g. $with=${parent}($select=${path.slice(parent.length + 1)}))`
1333
+ };
1334
+ }
1335
+ if (kind === "leaf") {
1336
+ const entry = this._entries.get(path);
1337
+ switch (op) {
1338
+ case "select": return entry.cap.selectable ? void 0 : {
1339
+ path,
1340
+ message: `Selecting field "${path}" is not permitted.`
1341
+ };
1342
+ case "filter": return entry.cap.filterable ? void 0 : {
1343
+ path,
1344
+ message: `Filtering on field "${path}" is not permitted — ${entry.cap.filterReason}`
1345
+ };
1346
+ case "sort": return entry.cap.sortable ? void 0 : {
1347
+ path,
1348
+ message: `Sorting on field "${path}" is not permitted — ${entry.cap.sortReason}`
1349
+ };
1350
+ default: return entry.physicalFilterable ? void 0 : {
1351
+ path,
1352
+ message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
1353
+ };
1354
+ }
1355
+ }
1356
+ if (!exists(path)) return {
1357
+ path,
1358
+ message: `Unknown field "${path}"`
1359
+ };
1360
+ switch (kind) {
1361
+ case "objectParent":
1362
+ if (op === "select") return void 0;
1363
+ return {
1364
+ path,
1365
+ message: `"${path}" is a nested object — filter or sort on one of its leaves (${leafHint(this._objectParents.get(path))})`
1366
+ };
1367
+ case "jsonDescendant": return {
1368
+ path,
1369
+ message: `"${path}" is inside JSON-stored column "${parent}" — this adapter cannot ${OP_VERB[op]} JSON paths; select "${parent}" and read the value client-side.`
1370
+ };
1371
+ case "encryptedDescendant": return op === "select" ? {
1372
+ path,
1373
+ message: `"${path}" is inside encrypted field "${parent}" — select the encrypted parent "${parent}" instead.`
1374
+ } : {
1375
+ path,
1376
+ message: `Cannot ${OP_VERB[op]} encrypted field "${path}"`
1377
+ };
1378
+ default: return {
1379
+ path,
1380
+ message: `Unknown field "${path}"`
1381
+ };
1382
+ }
1383
+ }
1384
+ };
1385
+ //#endregion
1052
1386
  //#region src/permissions/crud-controls.ts
1053
1387
  /**
1054
1388
  * Static control whitelists per read op. Each list is the matching DTO's
@@ -1086,6 +1420,15 @@ const GEO_CONTROLS = [
1086
1420
  ];
1087
1421
  //#endregion
1088
1422
  //#region src/as-db-readable.controller.ts
1423
+ /** The gate positions in check order; `refs[op]` are the paths collected for each. */
1424
+ const OPS = [
1425
+ "filter",
1426
+ "sort",
1427
+ "select",
1428
+ "groupBy",
1429
+ "having",
1430
+ "aggregate"
1431
+ ];
1089
1432
  let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1090
1433
  /** Reference to the underlying readable (table or view). */
1091
1434
  readable;
@@ -1101,34 +1444,49 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1101
1444
  if (readable && (readable.isView || typeof readable.insertOne !== "function")) throw new Error(`${this.constructor.name} is bound to a ${readable.isView ? "view" : "non-table readable"} ("${readable.tableName}") — .table is only available for table-bound controllers.`);
1102
1445
  return readable;
1103
1446
  }
1104
- _gates;
1447
+ /**
1448
+ * Per-path capability index (since 0.1.128): the ONE input both `/meta.fields`
1449
+ * and the request gate ({@link checkCapabilities}) are computed from, so
1450
+ * metadata and runtime can never diverge.
1451
+ */
1452
+ capabilities;
1453
+ /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1454
+ _exists = (path) => this.hasField(path);
1105
1455
  _preferredIdSet;
1106
1456
  _overlayIsNoOp;
1107
1457
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
1108
1458
  _quantityRefByPath;
1109
- /** Paths the adapter vetoes for filtering (e.g. JSON storage on SQL). Symmetric with `/meta` `filterable: false`. */
1110
- _adapterNonFilterable;
1111
1459
  /** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
1112
1460
  _searchFallbackFields;
1113
1461
  /** `@db.writeOnly` paths — settable in writes, sealed out of every read surface. */
1114
1462
  _writeOnlySet;
1463
+ /**
1464
+ * Logical paths an exclusion `$select` inverts into: every listed leaf and
1465
+ * (on nested-object adapters) object parents — never navigation descendants,
1466
+ * which the core path guard rejects.
1467
+ */
1468
+ _invertibleFields;
1115
1469
  constructor(app, readable) {
1116
1470
  const resolved = readable ?? resolveBoundReadable(new.target);
1117
1471
  super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
1118
1472
  this.readable = resolved;
1119
- this._adapterNonFilterable = this._collectAdapterNonFilterable();
1120
1473
  this._writeOnlySet = this._collectAnnotated("db.writeOnly");
1474
+ this.capabilities = new FieldCapabilityIndex(resolved, this._writeOnlySet);
1475
+ this._invertibleFields = this._collectInvertibleFields();
1121
1476
  this._searchFallbackFields = this._collectSearchFallbackFields();
1122
- this._gates = this._buildGates();
1123
1477
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
1124
1478
  this._quantityRefByPath = this._collectQuantityRefs();
1125
1479
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1126
1480
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
1127
1481
  }
1128
- _collectAdapterNonFilterable() {
1129
- const out = /* @__PURE__ */ new Set();
1130
- if (!this.readable.fieldDescriptors || typeof this.readable.canFilterField !== "function") return out;
1131
- for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !this.readable.canFilterField(fd)) out.add(fd.path);
1482
+ _collectInvertibleFields() {
1483
+ const out = [];
1484
+ const nav = this.capabilities.navFields;
1485
+ for (const fd of this.readable.fieldDescriptors) {
1486
+ if (fd.ignored) continue;
1487
+ if (nav.has(fd.path) || (0, _atscript_db.findAncestorInSet)(fd.path, nav) !== void 0) continue;
1488
+ out.push(fd.path);
1489
+ }
1132
1490
  return out;
1133
1491
  }
1134
1492
  _collectQuantityRefs() {
@@ -1141,41 +1499,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1141
1499
  }
1142
1500
  return out;
1143
1501
  }
1144
- _buildGates() {
1145
- const meta = this.readable.type.metadata;
1146
- const gates = {};
1147
- if (meta.get("db.table.filterable") === "manual") {
1148
- const allowed = this._collectAnnotated("db.column.filterable");
1149
- gates.filter = {
1150
- predicate: (f) => allowed.has(f),
1151
- annotation: "@db.column.filterable"
1152
- };
1153
- }
1154
- if (meta.get("db.table.sortable") === "manual") {
1155
- const allowed = this._collectAnnotated("db.column.sortable");
1156
- gates.sort = {
1157
- predicate: (f) => allowed.has(f),
1158
- annotation: "@db.column.sortable"
1159
- };
1160
- }
1161
- const writeOnly = this._writeOnlySet;
1162
- if (writeOnly.size > 0) {
1163
- const prevFilter = gates.filter;
1164
- gates.filter = {
1165
- predicate: (f) => !writeOnly.has(f) && (prevFilter ? prevFilter.predicate(f) : true),
1166
- annotation: prevFilter?.annotation ?? "@db.column.filterable (field is @db.writeOnly)"
1167
- };
1168
- const prevSort = gates.sort;
1169
- gates.sort = {
1170
- predicate: (f) => !writeOnly.has(f) && (prevSort ? prevSort.predicate(f) : true),
1171
- annotation: prevSort?.annotation ?? "@db.column.sortable (field is @db.writeOnly)"
1172
- };
1173
- }
1174
- return gates;
1175
- }
1176
1502
  _collectAnnotated(annotation) {
1177
1503
  const out = /* @__PURE__ */ new Set();
1178
- if (!this.readable.flatMap) return out;
1179
1504
  for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
1180
1505
  return out;
1181
1506
  }
@@ -1184,18 +1509,49 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1184
1509
  return this.readable.flatMap.has(path);
1185
1510
  }
1186
1511
  /**
1187
- * Adds an adapter-capability veto on top of the base gate. Distinct from the
1188
- * `@db.column.filterable` rejection because the message must reference the
1189
- * adapter, not an annotation the user could add to bypass it. Sort uses
1190
- * adapter capability differently and is enforced at the SQL builder layer.
1512
+ * Structural capability gate (since 0.1.128): walks the PARSED query —
1513
+ * filter tree, `$sort`, `$select`, `$groupBy`, `$having`, aggregate
1514
+ * `$field`s — and checks every root path against {@link capabilities}, the
1515
+ * same index `/meta.fields` is projected from. Runs on the wire request,
1516
+ * before `transformFilter` / `transformProjection` and before the write-only
1517
+ * seal (a `@db.writeOnly` field is selectable; the seal strips it silently).
1518
+ *
1519
+ * Rejections use the structured envelope `{ message, statusCode: 400,
1520
+ * errors: [{ path, message }] }` — `path` is the offending logical path.
1521
+ * After the per-path checks the core `$having` rule runs (`checkHavingKeys`:
1522
+ * aliases or `$groupBy` fields only), so a readable mock and a real table
1523
+ * answer alike.
1191
1524
  */
1192
- checkGates(filter, controls, gates) {
1193
- const baseError = super.checkGates(filter, controls, gates);
1194
- if (baseError) return baseError;
1195
- if (this._adapterNonFilterable.size === 0) return void 0;
1196
- const offender = findFilterOffender(filter, (f) => !this._adapterNonFilterable.has(f));
1197
- if (!offender) return void 0;
1198
- return new _moostjs_event_http.HttpError(400, `Filtering on field "${offender}" is not permitted — adapter cannot filter on this storage type.`);
1525
+ checkCapabilities(parsed) {
1526
+ const refs = (0, _atscript_db.collectQueryPaths)(parsed);
1527
+ if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, (0, _atscript_db.unsupportedOperatorMessage)(refs.unsupportedOperator));
1528
+ for (const op of OPS) for (const path of refs[op]) {
1529
+ const verdict = this.capabilities.check(path, op, this._exists);
1530
+ if (verdict) return badRequest(verdict.path, verdict.message);
1531
+ }
1532
+ for (const path of refs.geoFilter) {
1533
+ const verdict = this.capabilities.check(path, "filter", this._exists);
1534
+ if (verdict) return badRequest(verdict.path, verdict.message);
1535
+ }
1536
+ const having = (0, _atscript_db.checkHavingKeys)(refs);
1537
+ if (having) return badRequest(having.path, having.message);
1538
+ return this.checkGates(parsed);
1539
+ }
1540
+ /**
1541
+ * Root-path existence moved into {@link checkCapabilities}; the insights map
1542
+ * only serves `$with` sub-controls here — the URL parser flattens
1543
+ * `$with=assignee($select=name)` into the insight `assignee.name`, which is
1544
+ * resolved against the target table through `isValidFieldPath`.
1545
+ */
1546
+ validateInsights(insights) {
1547
+ const nav = this.capabilities.navFields;
1548
+ for (const [key] of insights) {
1549
+ if (key === "*") continue;
1550
+ const dot = key.indexOf(".");
1551
+ if (dot === -1) continue;
1552
+ if (!nav.has(key.slice(0, dot))) continue;
1553
+ if (!this.hasField(key)) return `Unknown field "${key}"`;
1554
+ }
1199
1555
  }
1200
1556
  /** Validates $with relations against the readable. */
1201
1557
  validateParsed(parsed, type) {
@@ -1204,14 +1560,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1204
1560
  const withRelations = parsed.controls.$with;
1205
1561
  if (withRelations?.length) {
1206
1562
  const relations = this.readable.relations;
1207
- for (const rel of withRelations) if (!rel.name.includes(".") && !relations.has(rel.name)) return new _moostjs_event_http.HttpError(400, {
1208
- message: `Unknown relation "${rel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`,
1209
- statusCode: 400,
1210
- errors: [{
1211
- path: "$with",
1212
- message: `Unknown relation "${rel.name}"`
1213
- }]
1214
- });
1563
+ for (const rel of withRelations) if (!rel.name.includes(".") && !relations.has(rel.name)) return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
1215
1564
  }
1216
1565
  }
1217
1566
  /**
@@ -1286,7 +1635,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1286
1635
  return widened;
1287
1636
  }
1288
1637
  const widened = {};
1289
- for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !excluded.has(fd.path)) widened[fd.path] = 1;
1638
+ for (const path of this._invertibleFields) if (!excluded.has(path)) widened[path] = 1;
1290
1639
  for (const field of this._preferredIdSet) widened[field] = 1;
1291
1640
  return widened;
1292
1641
  }
@@ -1355,9 +1704,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1355
1704
  if (included.length > 0 && excluded.length === 0) return included;
1356
1705
  if (excluded.length > 0 && included.length === 0) {
1357
1706
  const excludedSet = new Set(excluded);
1358
- const out = [];
1359
- for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !excludedSet.has(fd.path)) out.push(fd.path);
1360
- return out;
1707
+ return this._invertibleFields.filter((path) => !excludedSet.has(path));
1361
1708
  }
1362
1709
  throw new _moostjs_event_http.HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
1363
1710
  }
@@ -1401,15 +1748,18 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1401
1748
  widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection)
1402
1749
  };
1403
1750
  }
1404
- /** `@db.column.searchable` paths, minus anything the adapter can't filter (JSON storage, encrypted). */
1751
+ /**
1752
+ * `@db.column.searchable` paths, minus anything the adapter can't filter
1753
+ * (JSON storage, encrypted), `@db.writeOnly` fields and navigation
1754
+ * descendants — physical capability only (manual-mode policy does not
1755
+ * apply to `$search`).
1756
+ */
1405
1757
  _collectSearchFallbackFields() {
1406
1758
  const out = [];
1407
- if (!this.readable.fieldDescriptors) return out;
1408
1759
  for (const fd of this.readable.fieldDescriptors) {
1409
1760
  if (fd.ignored) continue;
1410
1761
  if (!fd.type?.metadata?.has?.("db.column.searchable")) continue;
1411
- if (this._adapterNonFilterable.has(fd.path)) continue;
1412
- if (this._writeOnlySet.has(fd.path)) continue;
1762
+ if (!this.capabilities.isPhysicallyFilterable(fd.path)) continue;
1413
1763
  out.push(fd.path);
1414
1764
  }
1415
1765
  return out;
@@ -1534,11 +1884,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1534
1884
  if (groupBy?.length && controls.$with?.length) return new _moostjs_event_http.HttpError(400, "Cannot combine $with and $groupBy in the same query");
1535
1885
  const error = this.validateParsed(parsed, "query");
1536
1886
  if (error) return error;
1537
- const gateError = this.checkGates(parsed.filter, controls, this._gates);
1538
- if (gateError) return gateError;
1539
1887
  if (groupBy?.length) {
1540
1888
  const sealed = this._findWriteOnlyInAggregate(groupBy, controls.$select);
1541
1889
  if (sealed) return new _moostjs_event_http.HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
1890
+ }
1891
+ const gateError = this.checkCapabilities(parsed);
1892
+ if (gateError) return gateError;
1893
+ if (groupBy?.length) {
1542
1894
  const filter = this.applySearchFallback(await this.transformFilter(parsed.filter), controls);
1543
1895
  return this.readable.aggregate({
1544
1896
  filter,
@@ -1585,7 +1937,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1585
1937
  const error = this.validateParsed(parsed, "pages");
1586
1938
  if (error) return error;
1587
1939
  const controls = parsed.controls;
1588
- const gateError = this.checkGates(parsed.filter, controls, this._gates);
1940
+ const gateError = this.checkCapabilities(parsed);
1589
1941
  if (gateError) return gateError;
1590
1942
  const page = Math.max(Number(controls.$page || 1), 1);
1591
1943
  const size = Math.max(Number(controls.$size || 10), 1);
@@ -1647,7 +1999,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1647
1999
  const insightsError = this.validateInsights(parsed.insights);
1648
2000
  if (insightsError) return new _moostjs_event_http.HttpError(400, insightsError);
1649
2001
  }
1650
- const gateError = this.checkGates(parsed.filter, controls, this._gates);
2002
+ const gateError = this.checkCapabilities(parsed);
1651
2003
  if (gateError) return gateError;
1652
2004
  const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
1653
2005
  const select = this.widenPreferredIdProjection(this._sealProjection(transformedSelect));
@@ -1708,6 +2060,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1708
2060
  this._coerceActionsControl(parsed.controls);
1709
2061
  const error = this.validateParsed(parsed, "getOne");
1710
2062
  if (error) return error;
2063
+ const gateError = this.checkCapabilities(parsed);
2064
+ if (gateError) return gateError;
1711
2065
  const rawSelect = await this.transformProjection(parsed.controls.$select);
1712
2066
  const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
1713
2067
  if (select instanceof _moostjs_event_http.HttpError) return select;
@@ -1723,6 +2077,10 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1723
2077
  if (idObj instanceof _moostjs_event_http.HttpError) return idObj;
1724
2078
  const { parsed } = this.parseControlsOnlyFromUrl(url);
1725
2079
  this._coerceActionsControl(parsed.controls);
2080
+ const error = this.validateParsed(parsed, "getOne");
2081
+ if (error) return error;
2082
+ const gateError = this.checkCapabilities(parsed);
2083
+ if (gateError) return gateError;
1726
2084
  const rawSelect = await this.transformProjection(parsed.controls.$select);
1727
2085
  const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
1728
2086
  if (select instanceof _moostjs_event_http.HttpError) return select;
@@ -1769,32 +2127,21 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1769
2127
  direction: rel.direction,
1770
2128
  isArray: rel.isArray
1771
2129
  });
1772
- const filterableMode = this.readable.type.metadata.get("db.table.filterable") === "manual";
1773
- const sortableMode = this.readable.type.metadata.get("db.table.sortable") === "manual";
1774
2130
  const geoIndexedPhysical = /* @__PURE__ */ new Set();
1775
2131
  if (this.readable.indexes instanceof Map) {
1776
2132
  for (const index of this.readable.indexes.values()) if (index.type === "geo") for (const f of index.fields) geoIndexedPhysical.add(f.name);
1777
2133
  }
1778
2134
  const fields = {};
1779
- for (const fd of this.readable.fieldDescriptors) {
1780
- if (fd.ignored) continue;
1781
- if (fd.designType === "object") continue;
1782
- const annotations = fd.type?.metadata;
1783
- const annotatedFilterable = annotations?.has("db.column.filterable") ?? false;
1784
- const annotatedSortable = annotations?.has("db.column.sortable") ?? false;
1785
- const adapterCanFilter = this.readable.canFilterField(fd);
1786
- const adapterCanSort = this.readable.canSortField(fd);
1787
- fields[fd.path] = {
1788
- sortable: adapterCanSort && (sortableMode ? annotatedSortable : !!fd.isIndexed),
1789
- filterable: adapterCanFilter && (filterableMode ? annotatedFilterable : true)
2135
+ for (const [path, cap, fd] of this.capabilities.entries()) {
2136
+ const entry = {
2137
+ sortable: cap.sortable,
2138
+ filterable: cap.filterable
1790
2139
  };
1791
- if (fd.encrypted) fields[fd.path].encrypted = true;
1792
- if (geoIndexedPhysical.has(fd.physicalName)) fields[fd.path].geo = true;
1793
- if (this._writeOnlySet.has(fd.path)) {
1794
- fields[fd.path].writeOnly = true;
1795
- fields[fd.path].filterable = false;
1796
- fields[fd.path].sortable = false;
1797
- }
2140
+ if (cap.indexed) entry.indexed = true;
2141
+ if (fd.encrypted) entry.encrypted = true;
2142
+ if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2143
+ if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2144
+ fields[path] = entry;
1798
2145
  }
1799
2146
  return {
1800
2147
  searchable: this.readable.isSearchable() || this._searchFallbackFields.length > 0,
@@ -1874,30 +2221,27 @@ AsDbReadableController = __decorate([
1874
2221
  registerAsDbReadableController(AsDbReadableController);
1875
2222
  //#endregion
1876
2223
  //#region src/as-db.controller.ts
2224
+ var _AsDbController;
2225
+ const SHAPE_MESSAGE = "Expected an object";
1877
2226
  /**
1878
- * Strips the version field from a write body and lifts it to `$cas`, in place.
1879
- * Returns `true` iff a `$cas` predicate was actually attached — callers use
1880
- * this to gate the 404/409 disambiguation `findOne`.
1881
- *
1882
- * Per §6.2 of VERSION_PROPOSAL.md, the moost-db controller treats `version` in
1883
- * a write body as a `$cas` directive rather than a SET. No-op (returns `false`)
1884
- * when:
1885
- * - the payload isn't an object (rejected downstream by the SDK validator),
1886
- * - the version field is absent (presence-based opt-out → last-write-wins),
1887
- * - the value is not a finite number (the SDK will reject loudly via
1888
- * `$cas` validation — we don't shadow that with a controller-local 400).
2227
+ * Shape gate (since 0.1.128): a write body must be a plain object (single
2228
+ * actions) or an array of plain objects (`*Many`). Anything else — `null`, a
2229
+ * primitive, `[1, "x"]` — is rejected with the validator envelope BEFORE any
2230
+ * hook runs, so `onWrite` / `guardWrite` never see a non-object.
1889
2231
  */
1890
- function liftVersionToCas(payload, versionColumn) {
1891
- if (payload === null || typeof payload !== "object") return false;
1892
- const obj = payload;
1893
- if (!(versionColumn in obj)) return false;
1894
- const versionValue = obj[versionColumn];
1895
- if (typeof versionValue !== "number" || !Number.isFinite(versionValue)) return false;
1896
- delete obj[versionColumn];
1897
- obj.$cas = { [versionColumn]: versionValue };
1898
- return true;
2232
+ function assertWriteShape(payload) {
2233
+ if (Array.isArray(payload)) {
2234
+ for (let i = 0; i < payload.length; i++) if (!(0, _atscript_db.isPlainObject)(payload[i])) throw badRequest(`[${i}]`, SHAPE_MESSAGE);
2235
+ return;
2236
+ }
2237
+ if (!(0, _atscript_db.isPlainObject)(payload)) throw badRequest("", SHAPE_MESSAGE);
2238
+ }
2239
+ /** `true` when `data` still has the shape the endpoint received (object, or array of objects for `*Many`). */
2240
+ function hasWriteShape(data, many) {
2241
+ if (!many) return (0, _atscript_db.isPlainObject)(data);
2242
+ return Array.isArray(data) && data.every(_atscript_db.isPlainObject);
1899
2243
  }
1900
- let AsDbController = class AsDbController extends AsDbReadableController {
2244
+ let AsDbController = _AsDbController = class AsDbController extends AsDbReadableController {
1901
2245
  constructor(app, table) {
1902
2246
  super(app, table);
1903
2247
  }
@@ -1911,80 +2255,189 @@ let AsDbController = class AsDbController extends AsDbReadableController {
1911
2255
  };
1912
2256
  }
1913
2257
  /**
1914
- * Intercepts write operations. Return `undefined` to abort.
1915
- * May be async (e.g. to enrich payloads from session / permissions).
2258
+ * Intercepts write operations with the UNTRUSTED body (after the shape gate:
2259
+ * an object, or an array of objects). Runs outside any transaction. Return
2260
+ * the data in the shape received — an object, or an array of objects for
2261
+ * the `*Many` actions; anything else (including `undefined`) aborts with
2262
+ * 500 "Not saved". Return an `Error` instance to respond with that error
2263
+ * (since 0.1.128 — previously passed on as data); throw to respond with
2264
+ * the thrown error's status. May be async (e.g. to enrich payloads from
2265
+ * session / permissions).
1916
2266
  */
1917
2267
  onWrite(action, data) {
1918
2268
  return data;
1919
2269
  }
1920
2270
  /**
1921
- * Intercepts delete operations. Return `undefined` to abort.
1922
- * May be async (e.g. to resolve composite ids from external state).
2271
+ * Intercepts delete operations. Return `undefined` to abort (500 "Not
2272
+ * deleted"); return an `Error` instance to respond with that error.
2273
+ * Runs outside any transaction. May be async (e.g. to resolve composite
2274
+ * ids from external state).
1923
2275
  */
1924
2276
  onRemove(id) {
1925
2277
  return id;
1926
2278
  }
1927
2279
  /**
2280
+ * Validated-stage write guard (since 0.1.128). Overriding it is the switch:
2281
+ * the override is passed to the table as its `guard` write option and runs
2282
+ * once inside the table's own transaction, after defaults + validation and
2283
+ * before encryption / nested-relation phases. `ctx.rows` are the validated
2284
+ * rows (defaults applied on insert/replace; `$cas` removed on update) and
2285
+ * may be enriched in place — the table validates them again afterwards.
2286
+ * Reject by throwing (an `HttpError` is recommended): the transaction rolls
2287
+ * back and the error propagates unchanged. Unmodified controllers pass no
2288
+ * guard and pay nothing.
2289
+ *
2290
+ * Do not swallow `DbError`s (on PostgreSQL the transaction is aborted after
2291
+ * a failed statement), and do not await external I/O on SQLite (the guard
2292
+ * holds the connection). On MongoDB replica sets the transaction callback —
2293
+ * and therefore this guard — may run more than once on transient errors.
2294
+ */
2295
+ guardWrite(_ctx) {}
2296
+ /**
2297
+ * Validated-stage remove guard (since 0.1.128). Same switch semantics as
2298
+ * {@link guardWrite}: the override becomes `deleteOne`'s `guard` option and
2299
+ * runs inside the table's transaction once `onRemove`'s id resolved to a
2300
+ * filter. A missing row still reaches the guard — `ctx.current()` resolves
2301
+ * to `null` and the 404 comes after the guard; only an id that cannot be
2302
+ * resolved to a filter at all (malformed for the key type) is a 404 before
2303
+ * the guard.
2304
+ */
2305
+ guardRemove(_ctx) {}
2306
+ /**
2307
+ * Runs `fn` inside the bound table's adapter transaction (nested calls
2308
+ * join it). For custom actions and routes that need one transaction across
2309
+ * several table operations.
2310
+ */
2311
+ withTransaction(fn) {
2312
+ return this.table.getAdapter().withTransaction(fn);
2313
+ }
2314
+ /**
2315
+ * The table write call's trailing options: `[{ guard }]` only when
2316
+ * `guardWrite` is overridden, else nothing (the table is called exactly as
2317
+ * an unmodified controller always called it).
2318
+ */
2319
+ _writeArgs() {
2320
+ if (this.guardWrite === _AsDbController.prototype.guardWrite) return [];
2321
+ return [{ guard: (ctx) => this.guardWrite(ctx) }];
2322
+ }
2323
+ /** `deleteOne`'s trailing options: `[{ guard }]` only when `guardRemove` is overridden. */
2324
+ _removeArgs() {
2325
+ if (this.guardRemove === _AsDbController.prototype.guardRemove) return [];
2326
+ return [{ guard: (ctx) => this.guardRemove(ctx) }];
2327
+ }
2328
+ /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
2329
+ async _checkHook(pending, abortMessage) {
2330
+ const result = await pending;
2331
+ if (result === void 0) throw new _moostjs_event_http.HttpError(500, abortMessage);
2332
+ if (result instanceof Error) throw result;
2333
+ return result;
2334
+ }
2335
+ /** Runs `onWrite` and re-applies the shape gate to its output (a non-object is a 500 "Not saved"). */
2336
+ async _writeBody(action, payload, many) {
2337
+ const data = await this._checkHook(this.onWrite(action, payload), "Not saved");
2338
+ if (!hasWriteShape(data, many)) throw new _moostjs_event_http.HttpError(500, "Not saved");
2339
+ return data;
2340
+ }
2341
+ /**
2342
+ * Normalises the OCC shape of one write item in place through the shared
2343
+ * `reconcileCas` (since 0.1.128): a top-level `version` field in a write
2344
+ * body is a `$cas` directive, not a SET — it is lifted to
2345
+ * `$cas: { [versionColumn]: version }`; a raw SDK-shaped `$cas` is accepted
2346
+ * as sent; both present with different values → 400 at `$cas`; a malformed
2347
+ * `$cas` reports `separateCas`'s own message. Returns `true` iff the item is
2348
+ * CAS-bearing — callers use this to gate the 404/409 disambiguation
2349
+ * `findOne` on `matchedCount === 0`. On a non-versioned table nothing is
2350
+ * lifted and a raw `$cas` reaches the table, which rejects it.
2351
+ */
2352
+ _resolveCas(item, versionColumn, pathPrefix = "") {
2353
+ if (versionColumn === void 0) return false;
2354
+ try {
2355
+ return (0, _atscript_db.reconcileCas)(item, versionColumn, "cas") !== void 0;
2356
+ } catch (error) {
2357
+ if (error instanceof _atscript_db.DbError) throw errorEnvelope(400, error.message, error.errors.map((e) => ({
2358
+ path: `${pathPrefix}${e.path}`,
2359
+ message: e.message
2360
+ })));
2361
+ throw error;
2362
+ }
2363
+ }
2364
+ /**
2365
+ * Bulk auto-lift: each item carries its own `version` → `$cas`.
2366
+ * NOTE: per-item conflict disambiguation in the response body is deferred
2367
+ * (§6.4) — the aggregate `{ matchedCount, modifiedCount }` surfaces partial
2368
+ * application; callers can detect mismatches via `modifiedCount < N`.
2369
+ */
2370
+ _resolveBulkCas(rows, versionColumn) {
2371
+ if (versionColumn === void 0) return;
2372
+ for (let i = 0; i < rows.length; i++) this._resolveCas(rows[i], versionColumn, `[${i}].`);
2373
+ }
2374
+ /** Deletes by id (guard forwarded when overridden) and maps "nothing deleted" to 404. */
2375
+ async _deleteOrThrow(id) {
2376
+ const result = await this.table.deleteOne(id, ...this._removeArgs());
2377
+ if (result.deletedCount < 1) throw new _moostjs_event_http.HttpError(404);
2378
+ return result;
2379
+ }
2380
+ /**
1928
2381
  * **POST /** — inserts one or many records.
1929
2382
  */
1930
2383
  async insert(payload) {
2384
+ assertWriteShape(payload);
1931
2385
  if (Array.isArray(payload)) {
1932
- const data = await this.onWrite("insertMany", payload);
1933
- if (data === void 0) return new _moostjs_event_http.HttpError(500, "Not saved");
1934
- return await this.table.insertMany(data);
2386
+ const rows = await this._writeBody("insertMany", payload, true);
2387
+ return this.table.insertMany(rows, ...this._writeArgs());
1935
2388
  }
1936
- const data = await this.onWrite("insert", payload);
1937
- if (data === void 0) return new _moostjs_event_http.HttpError(500, "Not saved");
1938
- return await this.table.insertOne(data);
2389
+ const row = await this._writeBody("insert", payload, false);
2390
+ return this.table.insertOne(row, ...this._writeArgs());
1939
2391
  }
1940
2392
  /**
1941
2393
  * **PUT /** — fully replaces one or many records matched by primary key.
1942
2394
  *
1943
2395
  * When the table opts into OCC (`@db.column.version`), a top-level `version`
1944
- * field in the body is auto-lifted to `$cas` (§6.2 of VERSION_PROPOSAL.md).
1945
- * On `matchedCount === 0` for a CAS-protected write, this disambiguates
1946
- * 404 (row gone) vs 409 (version mismatch) via a single `findOne`.
2396
+ * field in the body is auto-lifted to `$cas` (§6.2 of VERSION_PROPOSAL.md);
2397
+ * a raw `$cas` is accepted as sent. On `matchedCount === 0` for a
2398
+ * CAS-bearing write, this disambiguates 404 (row gone) vs 409 (version
2399
+ * mismatch) via a single `findOne` after the table call.
1947
2400
  */
1948
2401
  async replace(payload) {
2402
+ assertWriteShape(payload);
1949
2403
  const versionColumn = this.table.versionColumn;
1950
2404
  if (Array.isArray(payload)) {
1951
- const data = await this.onWrite("replaceMany", payload);
1952
- if (data === void 0) return new _moostjs_event_http.HttpError(500, "Not saved");
1953
- if (versionColumn !== void 0) for (const item of data) liftVersionToCas(item, versionColumn);
1954
- return await this.table.bulkReplace(data);
1955
- }
1956
- const data = await this.onWrite("replace", payload);
1957
- if (data === void 0) return new _moostjs_event_http.HttpError(500, "Not saved");
1958
- const hadCasLift = versionColumn !== void 0 && liftVersionToCas(data, versionColumn);
1959
- const result = await this.table.replaceOne(data);
1960
- if (hadCasLift && result.matchedCount === 0) return await this._disambiguateMismatch(data, versionColumn);
2405
+ const rows = await this._writeBody("replaceMany", payload, true);
2406
+ this._resolveBulkCas(rows, versionColumn);
2407
+ return this.table.bulkReplace(rows, ...this._writeArgs());
2408
+ }
2409
+ const row = await this._writeBody("replace", payload, false);
2410
+ const hadCas = this._resolveCas(row, versionColumn);
2411
+ const result = await this.table.replaceOne(row, ...this._writeArgs());
2412
+ if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
1961
2413
  return result;
1962
2414
  }
1963
2415
  /**
1964
2416
  * **PATCH /** — partially updates one or many records matched by primary key.
1965
2417
  *
1966
- * Same OCC semantics as {@link replace} (§6.2 / §6.3).
2418
+ * Same OCC semantics as {@link replace} (§6.2 / §6.3). A PK-only body
2419
+ * carrying `version` (or `$cas`) is a real write — the "versioned touch":
2420
+ * the CAS statement executes and bumps the version on a hit.
1967
2421
  */
1968
2422
  async update(payload) {
2423
+ assertWriteShape(payload);
1969
2424
  const versionColumn = this.table.versionColumn;
1970
2425
  if (Array.isArray(payload)) {
1971
- const data = await this.onWrite("updateMany", payload);
1972
- if (data === void 0) return new _moostjs_event_http.HttpError(500, "Not saved");
1973
- if (versionColumn !== void 0) for (const item of data) liftVersionToCas(item, versionColumn);
1974
- return await this.table.bulkUpdate(data);
1975
- }
1976
- const data = await this.onWrite("update", payload);
1977
- if (data === void 0) return new _moostjs_event_http.HttpError(500, "Not saved");
1978
- const hadCasLift = versionColumn !== void 0 && liftVersionToCas(data, versionColumn);
1979
- const result = await this.table.updateOne(data);
1980
- if (hadCasLift && result.matchedCount === 0) return await this._disambiguateMismatch(data, versionColumn);
2426
+ const rows = await this._writeBody("updateMany", payload, true);
2427
+ this._resolveBulkCas(rows, versionColumn);
2428
+ return this.table.bulkUpdate(rows, ...this._writeArgs());
2429
+ }
2430
+ const row = await this._writeBody("update", payload, false);
2431
+ const hadCas = this._resolveCas(row, versionColumn);
2432
+ const result = await this.table.updateOne(row, ...this._writeArgs());
2433
+ if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
1981
2434
  return result;
1982
2435
  }
1983
2436
  /**
1984
2437
  * Disambiguates a `matchedCount === 0` result on a CAS-protected write:
1985
2438
  * returns 404 when the row is genuinely missing, 409 with
1986
2439
  * `{ error: "version_mismatch", currentVersion: N }` when it's present
1987
- * but the supplied version is stale (§6.3).
2440
+ * but the supplied version is stale (§6.3). Callers throw the result.
1988
2441
  */
1989
2442
  async _disambiguateMismatch(data, versionColumn) {
1990
2443
  const filter = this.table.resolveIdFilter(data);
@@ -2004,11 +2457,8 @@ let AsDbController = class AsDbController extends AsDbReadableController {
2004
2457
  * **DELETE /:id** — removes a single record by primary key.
2005
2458
  */
2006
2459
  async remove(id) {
2007
- const resolvedId = await this.onRemove(id);
2008
- if (resolvedId === void 0) return new _moostjs_event_http.HttpError(500, "Not deleted");
2009
- const result = await this.table.deleteOne(resolvedId);
2010
- if (result.deletedCount < 1) return new _moostjs_event_http.HttpError(404);
2011
- return result;
2460
+ const resolvedId = await this._checkHook(this.onRemove(id), "Not deleted");
2461
+ return this._deleteOrThrow(resolvedId);
2012
2462
  }
2013
2463
  /**
2014
2464
  * **DELETE /?field1=val1&field2=val2** — removes a record by composite key
@@ -2016,12 +2466,9 @@ let AsDbController = class AsDbController extends AsDbReadableController {
2016
2466
  */
2017
2467
  async removeComposite(query) {
2018
2468
  const idObj = this.extractIdShape(query);
2019
- if (idObj instanceof _moostjs_event_http.HttpError) return idObj;
2020
- const resolvedId = await this.onRemove(idObj);
2021
- if (resolvedId === void 0) return new _moostjs_event_http.HttpError(500, "Not deleted");
2022
- const result = await this.table.deleteOne(resolvedId);
2023
- if (result.deletedCount < 1) return new _moostjs_event_http.HttpError(404);
2024
- return result;
2469
+ if (idObj instanceof _moostjs_event_http.HttpError) throw idObj;
2470
+ const resolvedId = await this._checkHook(this.onRemove(idObj), "Not deleted");
2471
+ return this._deleteOrThrow(resolvedId);
2025
2472
  }
2026
2473
  };
2027
2474
  __decorate([
@@ -2059,7 +2506,7 @@ __decorate([
2059
2506
  __decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
2060
2507
  __decorateMetadata("design:returntype", Promise)
2061
2508
  ], AsDbController.prototype, "removeComposite", null);
2062
- AsDbController = __decorate([
2509
+ AsDbController = _AsDbController = __decorate([
2063
2510
  (0, moost.Inherit)(),
2064
2511
  __decorateParam(1, (0, moost.Inject)(TABLE_DEF)),
2065
2512
  __decorateParam(1, (0, moost.Optional)()),
@@ -3059,6 +3506,7 @@ exports.DbActions = DbActions;
3059
3506
  exports.DbRowActions = DbRowActions;
3060
3507
  exports.DbRowsActions = DbRowsActions;
3061
3508
  exports.DbTableActions = DbTableActions;
3509
+ exports.FieldCapabilityIndex = FieldCapabilityIndex;
3062
3510
  exports.InputForm = InputForm;
3063
3511
  exports.ONE_CONTROLS = ONE_CONTROLS;
3064
3512
  exports.PAGES_CONTROLS = PAGES_CONTROLS;
@@ -3069,11 +3517,20 @@ exports.TABLE_DEF = TABLE_DEF;
3069
3517
  exports.TableController = TableController;
3070
3518
  exports.UseValidationErrorTransform = UseValidationErrorTransform;
3071
3519
  exports.ViewController = ViewController;
3520
+ exports.applyTerminalRefs = applyTerminalRefs;
3072
3521
  exports.assertExposed = assertExposed;
3522
+ exports.badRequest = badRequest;
3073
3523
  exports.clearDbSpaces = require_db_space_registry.clearDbSpaces;
3524
+ Object.defineProperty(exports, "collectQueryPaths", {
3525
+ enumerable: true,
3526
+ get: function() {
3527
+ return _atscript_db.collectQueryPaths;
3528
+ }
3529
+ });
3074
3530
  exports.dbActionBodySlot = dbActionBodySlot;
3075
3531
  exports.dbActionInputSlot = dbActionInputSlot;
3076
3532
  exports.discoverActions = discoverActions;
3533
+ exports.errorEnvelope = errorEnvelope;
3077
3534
  exports.findReadableBinding = findReadableBinding;
3078
3535
  exports.getAtscriptDbMate = getAtscriptDbMate;
3079
3536
  exports.getControllerFormType = getControllerFormType;
@@ -3081,6 +3538,8 @@ exports.perRow = perRow;
3081
3538
  exports.provideDbSpace = require_db_space_registry.provideDbSpace;
3082
3539
  exports.resolveBoundReadable = resolveBoundReadable;
3083
3540
  exports.resolveDbSpace = require_db_space_registry.resolveDbSpace;
3541
+ exports.resolveProp = resolveProp;
3542
+ exports.resolveTerminalRef = resolveTerminalRef;
3084
3543
  exports.useDbActionId = useDbActionId;
3085
3544
  exports.useDbActionIds = useDbActionIds;
3086
3545
  exports.useDbActionInput = useDbActionInput;