@atscript/moost-db 0.1.126 → 0.1.128

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