@atscript/moost-db 0.1.141 → 0.1.143

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,11 +3,11 @@ 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 { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isJsonValueField, isPlainObject, narrowerFilterOps, normalizeComputedSelect, reconcileCas, unsupportedOperatorMessage } from "@atscript/db";
7
- import { BUCKET_UNITS } from "@uniqu/core";
8
- import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
6
+ import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, geoIndexNotFoundMessage, isEmptyObject, isJsonValueField, isPlainObject, narrowerFilterOps, normalizeComputedSelect, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
9
7
  import { cached, current, defineWook, key } from "@wooksjs/event-core";
10
8
  import { useBody } from "@wooksjs/http-body";
9
+ import { BUCKET_UNITS } from "@uniqu/core";
10
+ import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
11
11
  //#region src/http-errors.ts
12
12
  /**
13
13
  * The structured error envelope every `moost-db` rejection uses (since
@@ -33,6 +33,18 @@ function badRequest(path, message, top = message) {
33
33
  message
34
34
  }]);
35
35
  }
36
+ /**
37
+ * The 400 of a `$with` relation the request cannot reach — nonexistent, or
38
+ * hidden by `hasField` (the two answer alike): `Unknown relation "<name>"`,
39
+ * the envelope message listing `visible` (the relations the caller CAN
40
+ * load at that level). The single source of this wording — a permission
41
+ * layer that rejects a relation itself should throw this.
42
+ *
43
+ * @since 0.1.143
44
+ */
45
+ function unknownRelationError(name, visible) {
46
+ return badRequest("$with", `Unknown relation "${name}"`, `Unknown relation "${name}" in $with. Available relations: ${visible.join(", ") || "(none)"}`);
47
+ }
36
48
  //#endregion
37
49
  //#region src/validation-interceptor.ts
38
50
  const dbErrorCodeToStatus = {
@@ -76,6 +88,15 @@ var GetOneControlsDto = class {
76
88
  throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
77
89
  }
78
90
  };
91
+ var GeoControlsDto = class {
92
+ static __is_atscript_annotated_type = true;
93
+ static type = {};
94
+ static metadata = /* @__PURE__ */ new Map();
95
+ static id = "GeoControlsDto";
96
+ static toJsonSchema() {
97
+ throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
98
+ }
99
+ };
79
100
  var WithRelationDto = class {
80
101
  static __is_atscript_annotated_type = true;
81
102
  static type = {};
@@ -132,6 +153,15 @@ defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedTyp
132
153
  message: "Expected positive number"
133
154
  }, true).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$fuzzy", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
134
155
  defineAnnotatedType("object", GetOneControlsDto).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
156
+ defineAnnotatedType("object", GeoControlsDto).prop("$center", defineAnnotatedType("union").item(defineAnnotatedType().designType("string").tags("string").$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("number").tags("number").$type).$type).optional().$type).prop("$maxDistance", defineAnnotatedType().designType("number").tags("number").optional().$type).prop("$minDistance", defineAnnotatedType().designType("number").tags("number").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$page", defineAnnotatedType().designType("string").tags("string").annotate("expect.pattern", {
157
+ pattern: "^\\d+$",
158
+ flags: "u",
159
+ message: "Expected positive number"
160
+ }, true).optional().$type).prop("$size", defineAnnotatedType().designType("string").tags("string").annotate("expect.pattern", {
161
+ pattern: "^\\d+$",
162
+ flags: "u",
163
+ message: "Expected positive number"
164
+ }, true).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
135
165
  defineAnnotatedType("object", WithRelationDto).prop("name", defineAnnotatedType().designType("string").tags("string").$type).prop("filter", defineAnnotatedType().refTo(WithFilterDto).optional().$type).prop("controls", defineAnnotatedType().refTo(WithRelationControlsDto).optional().$type).prop("insights", defineAnnotatedType().refTo(WithFilterDto).optional().$type);
136
166
  defineAnnotatedType("object", WithRelationControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
137
167
  defineAnnotatedType("object", WithFilterDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("string").tags("string").$type).item(defineAnnotatedType().designType("number").tags("number").$type).item(defineAnnotatedType().designType("boolean").tags("boolean").$type).item(defineAnnotatedType().designType("null").tags("null").$type).item(defineAnnotatedType().refTo(WithFilterDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithFilterDto).$type).$type).$type);
@@ -162,6 +192,26 @@ function getAtscriptDbMate() {
162
192
  return getMoostMate();
163
193
  }
164
194
  //#endregion
195
+ //#region src/actions/keys.ts
196
+ /** Log-message prefix for warnings emitted from the actions subsystem. */
197
+ const WARN_PREFIX = "[moost-db actions]";
198
+ /**
199
+ * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
200
+ * read the existing `atscript_db_action` slot, merge the patch (later-applied
201
+ * fields win), and write it back. `name` is empty until `@DbAction` provides
202
+ * one — `discoverActions` warns and drops actions with no name.
203
+ */
204
+ function mergeActionMeta(current, patch) {
205
+ const existing = current.atscript_db_action;
206
+ return {
207
+ name: patch.name ?? existing?.name ?? "",
208
+ opts: {
209
+ ...existing?.opts,
210
+ ...patch.opts
211
+ }
212
+ };
213
+ }
214
+ //#endregion
165
215
  //#region src/actions/controller-registry.ts
166
216
  let asDbReadableCtor = null;
167
217
  let asValueHelpCtor = null;
@@ -179,25 +229,33 @@ function isAsValueHelpControllerSubclass(ctor) {
179
229
  if (!asValueHelpCtor) return false;
180
230
  return asValueHelpCtor.prototype.isPrototypeOf(ctor.prototype);
181
231
  }
182
- //#endregion
183
- //#region src/actions/keys.ts
184
- /** Log-message prefix for warnings emitted from the actions subsystem. */
185
- const WARN_PREFIX = "[moost-db actions]";
232
+ /** The error every `@DbAction*` on a value-help controller fails with (since 0.1.143). */
233
+ function valueHelpActionError(ctorName, actions) {
234
+ return /* @__PURE__ */ new Error(`${WARN_PREFIX} ${ctorName} is a value-help controller — @DbAction / @DbActions are not supported there (found: ${actions.map((a) => `"${a}"`).join(", ")}). Move the action(s) to an AsDbReadableController / AsDbController.`);
235
+ }
236
+ const checkedValueHelp = /* @__PURE__ */ new WeakSet();
186
237
  /**
187
- * Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
188
- * read the existing `atscript_db_action` slot, merge the patch (later-applied
189
- * fields win), and write it back. `name` is empty until `@DbAction` provides
190
- * one — `discoverActions` warns and drops actions with no name.
238
+ * Bind-time check for value-help controllers (since 0.1.143): throws when the
239
+ * class — or anything it inherits — carries `@DbAction` / `@DbActions*`
240
+ * metadata. The decorators throw on their own when applied to a value-help
241
+ * subclass directly; this catches actions inherited from a non-value-help
242
+ * base. Memoized per class.
191
243
  */
192
- function mergeActionMeta(current, patch) {
193
- const existing = current.atscript_db_action;
194
- return {
195
- name: patch.name ?? existing?.name ?? "",
196
- opts: {
197
- ...existing?.opts,
198
- ...patch.opts
244
+ function assertNoValueHelpActions(ctor) {
245
+ if (checkedValueHelp.has(ctor)) return;
246
+ const mate = getAtscriptDbMate();
247
+ const found = /* @__PURE__ */ new Set();
248
+ for (let proto = ctor.prototype; proto && proto !== Object.prototype;) {
249
+ for (const entry of mate.read(proto.constructor)?.atscript_db_actions ?? []) found.add(entry.name);
250
+ for (const key of Object.getOwnPropertyNames(proto)) {
251
+ if (key === "constructor") continue;
252
+ const action = mate.read(proto, key)?.atscript_db_action;
253
+ if (action) found.add(action.name || key);
199
254
  }
200
- };
255
+ proto = Object.getPrototypeOf(proto);
256
+ }
257
+ if (found.size > 0) throw valueHelpActionError(ctor.name, [...found]);
258
+ checkedValueHelp.add(ctor);
201
259
  }
202
260
  //#endregion
203
261
  //#region src/actions/param-level.ts
@@ -740,9 +798,13 @@ let AsReadableController = class AsReadableController {
740
798
  * `ref` (`field: ""`) and their bodies always expand fully regardless of
741
799
  * `refDepth` — the write-payload shape clients need is unaffected.
742
800
  *
743
- * Annotation whitelist: keeps `meta.*`, `expect.*`, and `db.rel.*`; strips
744
- * other `db.*` (table, column, index, default, etc.). Override in subclass
745
- * to customise.
801
+ * Annotation whitelist: keeps `meta.*`, `expect.*`, `db.rel.*`, the `db.*`
802
+ * keys the shared db validator plugin reads in db-client (`db.json`,
803
+ * `db.patch.strategy`, `db.default*`, `db.column.version`,
804
+ * `db.column.derived`) and the client-facing `db.http.path` /
805
+ * `db.writeOnly`; strips every other `db.*` (table, column, index, etc.).
806
+ * Override in subclass to customise — keep the validator keys, or client
807
+ * preflight diverges from the server.
746
808
  */
747
809
  getSerializeOptions() {
748
810
  return {
@@ -752,7 +814,7 @@ let AsReadableController = class AsReadableController {
752
814
  key,
753
815
  value
754
816
  };
755
- if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version") return {
817
+ if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly" || key === "db.column.version" || key === "db.column.derived") return {
756
818
  key,
757
819
  value
758
820
  };
@@ -767,6 +829,7 @@ let AsReadableController = class AsReadableController {
767
829
  _queryControlsValidator;
768
830
  _pagesControlsValidator;
769
831
  _getOneControlsValidator;
832
+ _geoControlsValidator;
770
833
  get queryControlsValidator() {
771
834
  if (!this._queryControlsValidator) this._queryControlsValidator = QueryControlsDto.validator();
772
835
  return this._queryControlsValidator;
@@ -779,9 +842,52 @@ let AsReadableController = class AsReadableController {
779
842
  if (!this._getOneControlsValidator) this._getOneControlsValidator = GetOneControlsDto.validator();
780
843
  return this._getOneControlsValidator;
781
844
  }
845
+ /** `/geo` controls validator (since 0.1.143 — `/geo` runs {@link validateParsed} like `/query`). */
846
+ get geoControlsValidator() {
847
+ if (!this._geoControlsValidator) this._geoControlsValidator = GeoControlsDto.validator();
848
+ return this._geoControlsValidator;
849
+ }
850
+ async parseRequest(endpoint, url) {
851
+ let request;
852
+ if (url !== void 0) {
853
+ const { parsed, hasNonControl } = endpoint === "one" ? this.parseControlsOnlyFromUrl(url) : {
854
+ parsed: this.parseQueryString(url),
855
+ hasNonControl: false
856
+ };
857
+ const controls = parsed.controls;
858
+ if (typeof controls.$actions === "string") controls.$actions = controls.$actions === "true" || controls.$actions === "1" || controls.$actions === "";
859
+ request = {
860
+ parsed,
861
+ controls,
862
+ hasNonControl
863
+ };
864
+ }
865
+ if (typeof this.prepareRequest === "function") await this.prepareRequest(request ? {
866
+ endpoint,
867
+ controls: request.controls
868
+ } : { endpoint });
869
+ return request;
870
+ }
871
+ /**
872
+ * Validates the parsed controls against the endpoint's DTO — the hook for
873
+ * per-control authorization (override, call `super`, add rules). `"geo"`
874
+ * since 0.1.143 (`/geo` used to skip it).
875
+ */
782
876
  validateControls(controls, type) {
783
877
  if (type === "query" && controls.$groupBy !== void 0) return;
784
- const v = type === "query" ? this.queryControlsValidator : type === "pages" ? this.pagesControlsValidator : this.getOneControlsValidator;
878
+ let v;
879
+ switch (type) {
880
+ case "query":
881
+ v = this.queryControlsValidator;
882
+ break;
883
+ case "pages":
884
+ v = this.pagesControlsValidator;
885
+ break;
886
+ case "geo":
887
+ v = this.geoControlsValidator;
888
+ break;
889
+ default: v = this.getOneControlsValidator;
890
+ }
785
891
  if (!v.validate(controls, true)) return v.errors[0]?.message || "Invalid controls";
786
892
  }
787
893
  validateInsights(insights) {
@@ -875,6 +981,19 @@ let AsReadableController = class AsReadableController {
875
981
  * response by principal.
876
982
  */
877
983
  async meta() {
984
+ await this.parseRequest("meta");
985
+ return this.resolveMeta();
986
+ }
987
+ /**
988
+ * The `/meta` payload for the current request — the cached envelope
989
+ * through {@link applyMetaOverlay} — WITHOUT the `/meta` route's
990
+ * {@link prepareRequest} call. Internal consumers (e.g. `$actions`
991
+ * filtering on a read) use this, so the hook runs once per request with
992
+ * the endpoint actually being served.
993
+ *
994
+ * @since 0.1.143
995
+ */
996
+ resolveMeta() {
878
997
  const key = this.metaCacheKey();
879
998
  if (!this._metaResponse || key !== this._metaResponseKey) {
880
999
  this._metaResponse = this.buildMetaResponse();
@@ -895,12 +1014,14 @@ let AsReadableController = class AsReadableController {
895
1014
  * compiled `.as` class's `.name`, registered when an action's parameter is
896
1015
  * decorated with `@InputForm(FormType)`. Schemas are serialized once and
897
1016
  * cached per controller; the response uses the same annotation-allowlist
898
- * policy as {@link getSerializeOptions}.
1017
+ * policy as {@link getSerializeOptions}. Since 0.1.143 the form must pass
1018
+ * {@link authorizeForm} — a refused form answers exactly like an unknown one.
899
1019
  */
900
1020
  async metaForm(name) {
901
- discoverActions(this.constructor, this.app, this.logger);
1021
+ await this.parseRequest("metaForm");
1022
+ const envelopes = discoverActions(this.constructor, this.app, this.logger);
902
1023
  const formType = getControllerFormType(this.constructor, name);
903
- if (!formType) throw new HttpError(404, `Unknown form "${name}"`);
1024
+ if (!formType || !await this.authorizeForm(name, envelopes.filter((e) => e.info.inputForm === name).map((e) => e.info.name))) throw new HttpError(404, `Unknown form "${name}"`);
904
1025
  let cached = this._formSchemas.get(name);
905
1026
  if (!cached) {
906
1027
  cached = this.serializeForMeta(formType);
@@ -909,6 +1030,18 @@ let AsReadableController = class AsReadableController {
909
1030
  return cached;
910
1031
  }
911
1032
  /**
1033
+ * Per-request gate for `GET /meta/form/:name`: return `false` to refuse the
1034
+ * form — the response is then the same 404 an unknown form gets, so a
1035
+ * refused form's existence does not leak. `actionNames` are the discovered
1036
+ * actions whose input form is `name` (a permission layer typically allows
1037
+ * the form iff the caller may run at least one of them). Default: `true`.
1038
+ *
1039
+ * @since 0.1.143
1040
+ */
1041
+ authorizeForm(_name, _actionNames) {
1042
+ return true;
1043
+ }
1044
+ /**
912
1045
  * Builds the `/meta` payload. Override in subclasses to populate source-specific
913
1046
  * fields. Subclasses that fully replace the envelope must call
914
1047
  * {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
@@ -1064,67 +1197,346 @@ function augmentRowsWithActions(args) {
1064
1197
  return rows;
1065
1198
  }
1066
1199
  //#endregion
1067
- //#region src/decorators.ts
1200
+ //#region src/actions/current-action.ts
1201
+ /** Read the current action's `TDbActionMeta` from the wook context. Returns undefined outside a controller (e.g. direct-wook test paths). */
1202
+ function readCurrentActionMeta(ctx) {
1203
+ let ctrl;
1204
+ let methodName;
1205
+ try {
1206
+ const cc = useControllerContext(ctx);
1207
+ ctrl = cc.getController();
1208
+ methodName = cc.getMethod();
1209
+ } catch {
1210
+ return;
1211
+ }
1212
+ if (!ctrl || !methodName) return void 0;
1213
+ return getAtscriptDbMate().read(ctrl.constructor, methodName)?.atscript_db_action;
1214
+ }
1215
+ //#endregion
1216
+ //#region src/actions/input-form-cache.ts
1068
1217
  /**
1069
- * DI token under which the {@link AtscriptDbReadable} instance
1070
- * is exposed to the readable controller's constructor via `@Inject`.
1218
+ * Cached parse of the action request body. Centralises the shape check so
1219
+ * every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
1220
+ * reads through the same gate. An array or scalar root is rejected with the
1221
+ * same `ValidatorError` envelope as today's strict-shape ID failures.
1071
1222
  */
1072
- const READABLE_DEF = "__atscript_db_readable_def";
1223
+ const dbActionBodySlot = cached(async (ctx) => {
1224
+ const raw = await useBody(ctx).parseBody();
1225
+ if (raw == null) return {};
1226
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new ValidatorError([{
1227
+ path: "",
1228
+ message: "Action body must be an object of shape { ids?, input? }"
1229
+ }]);
1230
+ return raw;
1231
+ });
1232
+ /** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
1233
+ const dbActionInputSlot = cached(async (ctx) => {
1234
+ return (await ctx.get(dbActionBodySlot)).input;
1235
+ });
1236
+ /** Composable for in-handler reads of the form input. */
1237
+ const useDbActionInput = defineWook((ctx) => ({ load: () => ctx.get(dbActionInputSlot) }));
1238
+ //#endregion
1239
+ //#region src/actions/prepare-request.ts
1073
1240
  /**
1074
- * DI token under which the {@link AtscriptDbTable} instance
1075
- * is exposed to the controller's constructor via `@Inject`.
1076
- * Points to the same token as READABLE_DEF for backward compatibility.
1241
+ * The controller's `prepareRequest({ endpoint: "action", action })` for this
1242
+ * `@DbAction` event (since 0.1.143) — started once per event, before the
1243
+ * action's ids are validated, its rows loaded or its row overlay built (each
1244
+ * of those awaits it first). `undefined` when the controller defines no
1245
+ * `prepareRequest` (nothing to await) or outside an action handler.
1077
1246
  */
1078
- const TABLE_DEF = READABLE_DEF;
1079
- function normalizeOptions(prefixOrOptions) {
1080
- if (typeof prefixOrOptions === "string") return { prefix: prefixOrOptions };
1081
- return prefixOrOptions ?? {};
1247
+ const dbActionPreparedSlot = cached((ctx) => {
1248
+ let ctrl;
1249
+ try {
1250
+ ctrl = useControllerContext(ctx).getController();
1251
+ } catch {
1252
+ return;
1253
+ }
1254
+ const prepare = ctrl?.prepareRequest;
1255
+ if (typeof prepare !== "function") return void 0;
1256
+ const action = readCurrentActionMeta(ctx)?.name;
1257
+ if (action === void 0) return void 0;
1258
+ return (async () => prepare.call(ctrl, {
1259
+ endpoint: "action",
1260
+ action
1261
+ }))();
1262
+ });
1263
+ /** Awaits the controller's `prepareRequest` for this action event (no-op without one). */
1264
+ async function awaitActionPrepared(ctx) {
1265
+ const pending = ctx.get(dbActionPreparedSlot);
1266
+ if (pending) await pending;
1082
1267
  }
1268
+ /** Same priority as the action gate: after the guards (auth) ran, before argument resolution. */
1269
+ const ACTION_GATE_PRIORITY = TInterceptorPriority.AFTER_GUARD;
1083
1270
  /**
1084
- * Builds the shared binding metadata for a decorator invocation: classifies
1085
- * the binding form, computes the static route prefix, and packages a uniform
1086
- * `resolve()` used by both the DI provide factory and the base controller's
1087
- * `super(app)` fallback.
1271
+ * Interceptor for `'table'`-level actions of an `AsReadableController`
1272
+ * subclass (the gate / thin interceptor cover `'row'` / `'rows'`): runs the
1273
+ * controller's `prepareRequest` before the handler. No `prepareRequest` →
1274
+ * returns without awaiting.
1088
1275
  */
1089
- function buildBinding(binding, options, decoratorName) {
1090
- if (isAnnotatedType(binding)) {
1091
- const model = binding;
1092
- const space = options.space ?? model.metadata.get("db.space");
1093
- const prefix = options.prefix || model.metadata.get("db.http.path") || model.metadata.get("db.table") || model.metadata.get("db.view") || model.id || "";
1094
- if (!prefix) throw new Error(`[moost-db] @${decoratorName}: cannot derive a route prefix from the model token (no @db.http.path / @db.table / @db.view and no type id). Pass an explicit prefix.`);
1095
- return {
1096
- meta: {
1097
- model,
1098
- resolve: () => resolveDbSpace(space).get(model)
1099
- },
1100
- prefix
1101
- };
1102
- }
1103
- if (typeof binding === "function") {
1104
- if (!options.prefix) throw new Error(`[moost-db] @${decoratorName}: the lazy factory form needs an explicit route prefix (the readable is not created until app.init()). Pass a prefix, or use the model token form which derives it from @db.http.path / @db.table.`);
1105
- return {
1106
- meta: { resolve: binding },
1107
- prefix: options.prefix
1108
- };
1276
+ const actionPrepareInterceptor = defineBeforeInterceptor(() => current().get(dbActionPreparedSlot), ACTION_GATE_PRIORITY);
1277
+ //#endregion
1278
+ //#region src/actions/id-validation.ts
1279
+ const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
1280
+ function getSourceCache(source) {
1281
+ let cache = SOURCE_CACHE.get(source);
1282
+ if (cache) return cache;
1283
+ const identifications = source.identifications;
1284
+ const byKeySig = /* @__PURE__ */ new Map();
1285
+ for (const ident of identifications) byKeySig.set(fieldsSig(ident.fields), ident);
1286
+ const fieldByName = /* @__PURE__ */ new Map();
1287
+ for (const fd of source.fieldDescriptors) fieldByName.set(fd.path, fd);
1288
+ cache = {
1289
+ byKeySig,
1290
+ fieldByName,
1291
+ formatted: identifications.map((id) => `[${id.fields.join(", ")}]`).join(", ")
1292
+ };
1293
+ SOURCE_CACHE.set(source, cache);
1294
+ return cache;
1295
+ }
1296
+ function fieldsSig(fields) {
1297
+ return fields.toSorted().join("");
1298
+ }
1299
+ function isIdValidationSource(value) {
1300
+ if (!value || typeof value !== "object") return false;
1301
+ const v = value;
1302
+ return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
1303
+ }
1304
+ function validateSingleId(body, source, path = "") {
1305
+ const errors = collectIdErrors(body, source, path);
1306
+ if (errors.length > 0) throw new ValidatorError(errors);
1307
+ return body;
1308
+ }
1309
+ /** `maxIds`: an array longer than it is rejected (400) before any per-id work. */
1310
+ function validateMultiId(body, source, maxIds = Infinity) {
1311
+ if (!Array.isArray(body)) throw new ValidatorError([{
1312
+ path: "",
1313
+ message: "Expected JSON array of identifier objects",
1314
+ details: []
1315
+ }]);
1316
+ if (body.length > maxIds) throw new ValidatorError([{
1317
+ path: "",
1318
+ message: `Too many identifiers: ${body.length} (at most ${maxIds} per request)`,
1319
+ details: []
1320
+ }]);
1321
+ const errors = [];
1322
+ for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
1323
+ if (errors.length > 0) throw new ValidatorError(errors);
1324
+ return body;
1325
+ }
1326
+ function collectIdErrors(value, source, pathPrefix) {
1327
+ if (!isPlainObject$1(value)) return [{
1328
+ path: pathPrefix,
1329
+ message: "Expected JSON object for row identifier",
1330
+ details: []
1331
+ }];
1332
+ const cache = getSourceCache(source);
1333
+ if (cache.byKeySig.size === 0) return [{
1334
+ path: pathPrefix,
1335
+ message: "Table has no identifier configured",
1336
+ details: []
1337
+ }];
1338
+ const match = cache.byKeySig.get(fieldsSig(Object.keys(value)));
1339
+ if (!match) return [{
1340
+ path: pathPrefix,
1341
+ message: `Identifier fields must exactly match one of: ${cache.formatted}`,
1342
+ details: []
1343
+ }];
1344
+ const errors = [];
1345
+ for (const fieldName of match.fields) {
1346
+ const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
1347
+ const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
1348
+ if (err) errors.push(err);
1109
1349
  }
1110
- const readable = binding;
1111
- const prefix = options.prefix || readable.type.metadata.get("db.http.path") || readable.tableName;
1350
+ return errors;
1351
+ }
1352
+ function checkScalar(value, fd, path) {
1353
+ const expected = fd?.designType ?? "string";
1354
+ if (typeof value === "object" && value !== null) return scalarMismatch(path, "a scalar", value);
1355
+ if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
1356
+ if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
1357
+ if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
1358
+ }
1359
+ function scalarMismatch(path, expected, value) {
1112
1360
  return {
1113
- meta: {
1114
- model: readable.type,
1115
- resolve: () => readable
1116
- },
1117
- prefix
1361
+ path,
1362
+ message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
1363
+ details: []
1118
1364
  };
1119
1365
  }
1120
- function bindReadableController(binding, prefixOrOptions, decoratorName) {
1121
- const { meta, prefix } = buildBinding(binding, normalizeOptions(prefixOrOptions), decoratorName);
1122
- return ApplyDecorators(getAtscriptDbMate().decorate((classMeta) => {
1123
- classMeta.atscript_db_readable_binding = meta;
1124
- return classMeta;
1125
- }), Provide(READABLE_DEF, () => meta.resolve()), Controller(prefix), Inherit());
1366
+ function describe(value) {
1367
+ if (value === null) return "null";
1368
+ if (Array.isArray(value)) return "array";
1369
+ return typeof value;
1126
1370
  }
1127
- /**
1371
+ function isPlainObject$1(value) {
1372
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1373
+ }
1374
+ //#endregion
1375
+ //#region src/actions/id-cache.ts
1376
+ const boundTableKey = key("atscript_db_action_bound_table");
1377
+ function controllerOf(ctx) {
1378
+ return useControllerContext(ctx).getController();
1379
+ }
1380
+ function controllerTable(ctx) {
1381
+ const ctrl = controllerOf(ctx);
1382
+ return ctrl?.readable ?? ctrl?.table ?? null;
1383
+ }
1384
+ function getActionTable(ctx) {
1385
+ return (ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0) ?? controllerTable(ctx);
1386
+ }
1387
+ const warnedTags = /* @__PURE__ */ new Set();
1388
+ function noTableError(ctx) {
1389
+ const actionName = readCurrentActionMeta(ctx)?.name;
1390
+ const tag = actionName ? `"${actionName}"` : "<unknown>";
1391
+ if (!warnedTags.has(tag)) {
1392
+ warnedTags.add(tag);
1393
+ console.warn(`${WARN_PREFIX} ${tag}: controller has no readable/table property and the action declares no opts.table. Either expose readable/table on the controller, extend AsDbReadableController, or pass opts.table on @DbAction.`);
1394
+ }
1395
+ return new HttpError(500, {
1396
+ statusCode: 500,
1397
+ error: "Internal Server Error",
1398
+ message: "Internal server error",
1399
+ code: "ACTION_TABLE_NOT_BOUND"
1400
+ });
1401
+ }
1402
+ /**
1403
+ * Validates the body's `ids` against the action table's identifications. For
1404
+ * the controller's own table that is its `idSource` (since 0.1.134): a unique
1405
+ * index over a field `hasField` hides neither addresses a row nor appears in
1406
+ * the "must exactly match one of" message. An `opts.table` binding has no
1407
+ * visibility hook. The controller's `prepareRequest` (since 0.1.143) runs
1408
+ * first, so the visibility the ids are validated against is the request's.
1409
+ */
1410
+ async function resolveValidatedId(ctx, validate) {
1411
+ await awaitActionPrepared(ctx);
1412
+ let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
1413
+ if (!source) {
1414
+ const ctrl = controllerOf(ctx);
1415
+ source = ctrl?.idSource ?? ctrl?.readable ?? ctrl?.table ?? null;
1416
+ }
1417
+ if (!isIdValidationSource(source)) throw noTableError(ctx);
1418
+ const env = await ctx.get(dbActionBodySlot);
1419
+ validate(env.ids, source);
1420
+ return env.ids;
1421
+ }
1422
+ const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
1423
+ /** Default cap on the identifiers one `'rows'`-level request may carry — see `DbActionOpts.maxIds`. */
1424
+ const DEFAULT_MAX_ACTION_IDS = 1e3;
1425
+ function maxIdsOf(ctx) {
1426
+ const max = (readCurrentActionMeta(ctx)?.opts)?.maxIds;
1427
+ return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_MAX_ACTION_IDS;
1428
+ }
1429
+ const dbActionIdsSlot = cached(async (ctx) => {
1430
+ return await resolveValidatedId(ctx, (body, src) => validateMultiId(body, src, maxIdsOf(ctx)));
1431
+ });
1432
+ const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
1433
+ const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
1434
+ //#endregion
1435
+ //#region src/actions/row-scope.ts
1436
+ /**
1437
+ * The controller whose hooks govern this action's rows: only when the action
1438
+ * runs against the controller's OWN readable — an `opts.table` binding on a
1439
+ * plain controller has no row overlay or visibility hook. Once per event.
1440
+ */
1441
+ const scopedControllerSlot = cached((ctx) => {
1442
+ let ctrl;
1443
+ try {
1444
+ ctrl = controllerOf(ctx);
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ const table = controllerTable(ctx);
1449
+ if (!ctrl || table == null || getActionTable(ctx) !== table) return null;
1450
+ return ctrl;
1451
+ });
1452
+ /**
1453
+ * The controller's row overlay for action ids / rows — its `rowOverlay()`,
1454
+ * the same overlay `/one/:id` ANDs in (no hook call, no extra query when the
1455
+ * controller overrides neither `transformOne` nor `transformFilter`), `null`
1456
+ * when there is none. Evaluated once per request, after the controller's
1457
+ * `prepareRequest` (since 0.1.143).
1458
+ */
1459
+ const dbActionOverlaySlot = cached(async (ctx) => {
1460
+ const ctrl = ctx.get(scopedControllerSlot);
1461
+ if (!ctrl?.rowOverlay) return null;
1462
+ await awaitActionPrepared(ctx);
1463
+ return await ctrl.rowOverlay() ?? null;
1464
+ });
1465
+ /** `filter` AND the overlay (identity without one). */
1466
+ function withOverlay(filter, overlay) {
1467
+ return overlay ? { $and: [filter, overlay] } : filter;
1468
+ }
1469
+ /**
1470
+ * The controller's field visibility when request-scoped (`hasField`
1471
+ * overridden), else `undefined` — `requiredFields` it hides (a derived field
1472
+ * over a hidden source included) are never selected.
1473
+ */
1474
+ function actionFieldVisibility(ctx) {
1475
+ const visibility = ctx.get(scopedControllerSlot)?.fieldVisibility;
1476
+ return visibility?.scoped ? visibility.isVisible : void 0;
1477
+ }
1478
+ //#endregion
1479
+ //#region src/decorators.ts
1480
+ /**
1481
+ * DI token under which the {@link AtscriptDbReadable} instance
1482
+ * is exposed to the readable controller's constructor via `@Inject`.
1483
+ */
1484
+ const READABLE_DEF = "__atscript_db_readable_def";
1485
+ /**
1486
+ * DI token under which the {@link AtscriptDbTable} instance
1487
+ * is exposed to the controller's constructor via `@Inject`.
1488
+ * Points to the same token as READABLE_DEF for backward compatibility.
1489
+ */
1490
+ const TABLE_DEF = READABLE_DEF;
1491
+ function normalizeOptions(prefixOrOptions) {
1492
+ if (typeof prefixOrOptions === "string") return { prefix: prefixOrOptions };
1493
+ return prefixOrOptions ?? {};
1494
+ }
1495
+ /**
1496
+ * Builds the shared binding metadata for a decorator invocation: classifies
1497
+ * the binding form, computes the static route prefix, and packages a uniform
1498
+ * `resolve()` used by both the DI provide factory and the base controller's
1499
+ * `super(app)` fallback.
1500
+ */
1501
+ function buildBinding(binding, options, decoratorName) {
1502
+ if (isAnnotatedType(binding)) {
1503
+ const model = binding;
1504
+ const space = options.space ?? model.metadata.get("db.space");
1505
+ const prefix = options.prefix || model.metadata.get("db.http.path") || model.metadata.get("db.table") || model.metadata.get("db.view") || model.id || "";
1506
+ if (!prefix) throw new Error(`[moost-db] @${decoratorName}: cannot derive a route prefix from the model token (no @db.http.path / @db.table / @db.view and no type id). Pass an explicit prefix.`);
1507
+ return {
1508
+ meta: {
1509
+ model,
1510
+ resolve: () => resolveDbSpace(space).get(model)
1511
+ },
1512
+ prefix
1513
+ };
1514
+ }
1515
+ if (typeof binding === "function") {
1516
+ if (!options.prefix) throw new Error(`[moost-db] @${decoratorName}: the lazy factory form needs an explicit route prefix (the readable is not created until app.init()). Pass a prefix, or use the model token form which derives it from @db.http.path / @db.table.`);
1517
+ return {
1518
+ meta: { resolve: binding },
1519
+ prefix: options.prefix
1520
+ };
1521
+ }
1522
+ const readable = binding;
1523
+ const prefix = options.prefix || readable.type.metadata.get("db.http.path") || readable.tableName;
1524
+ return {
1525
+ meta: {
1526
+ model: readable.type,
1527
+ resolve: () => readable
1528
+ },
1529
+ prefix
1530
+ };
1531
+ }
1532
+ function bindReadableController(binding, prefixOrOptions, decoratorName) {
1533
+ const { meta, prefix } = buildBinding(binding, normalizeOptions(prefixOrOptions), decoratorName);
1534
+ return ApplyDecorators(getAtscriptDbMate().decorate((classMeta) => {
1535
+ classMeta.atscript_db_readable_binding = meta;
1536
+ return classMeta;
1537
+ }), Provide(READABLE_DEF, () => meta.resolve()), Controller(prefix), Inherit());
1538
+ }
1539
+ /**
1128
1540
  * Combines the boilerplate needed to turn an {@link AsDbController}
1129
1541
  * subclass into a fully wired HTTP controller for a given `@db.table` model.
1130
1542
  *
@@ -1238,6 +1650,17 @@ const OP_VERB = {
1238
1650
  aggregate: "aggregate over",
1239
1651
  bucket: "bucket"
1240
1652
  };
1653
+ /**
1654
+ * The verdict of a filter / sort on a `@db.writeOnly` path — the same
1655
+ * sentence {@link FieldCapabilityIndex.check} answers for this table's own
1656
+ * fields; used for `$with` sub-queries on a joined table's.
1657
+ */
1658
+ function writeOnlyVerdict(path, op) {
1659
+ return {
1660
+ path,
1661
+ message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${REASON_WRITE_ONLY}`
1662
+ };
1663
+ }
1241
1664
  /** The one "nonexistent path" verdict — hidden paths answer with it byte for byte. */
1242
1665
  function unknownField(path) {
1243
1666
  return {
@@ -1332,7 +1755,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1332
1755
  const nav = new Set(source.navFields);
1333
1756
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1334
1757
  this.navFields = nav;
1335
- const isNavOrDescendant = (path) => nav.has(path) || findAncestorInSet(path, nav) !== void 0;
1758
+ const isNavOrDescendant = (path) => selfOrAncestor(path, nav) !== void 0;
1336
1759
  const flatMap = source.flatMap;
1337
1760
  const annotated = (fd, key) => {
1338
1761
  const fromFlat = flatMap.get(fd.path)?.metadata?.has?.(key);
@@ -1360,7 +1783,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1360
1783
  if (isNavOrDescendant(path)) continue;
1361
1784
  if (ignored.has(path)) continue;
1362
1785
  if (findAncestorInSet(path, jsonParents) !== void 0) continue;
1363
- if (encrypted.has(path) || findAncestorInSet(path, encrypted) !== void 0) continue;
1786
+ if (selfOrAncestor(path, encrypted) !== void 0) continue;
1364
1787
  this._objectParents.set(path, []);
1365
1788
  }
1366
1789
  for (const [parent, leaves] of this._objectParents) {
@@ -1532,23 +1955,14 @@ const QUERY_CONTROLS = [
1532
1955
  const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
1533
1956
  const ONE_CONTROLS = dtoControls(GetOneControlsDto);
1534
1957
  /**
1535
- * Controls accepted by the `/geo` endpoint. No backing DTO — `$center` /
1536
- * `$maxDistance` / `$minDistance` are parsed and validated by the handler.
1958
+ * Controls accepted by the `/geo` endpoint — `GeoControlsDto` (since 0.1.143;
1959
+ * `$center` / `$maxDistance` / `$minDistance` are parsed by the handler
1960
+ * before the DTO check) plus the URL-grammar `filter` / `insights`.
1537
1961
  */
1538
1962
  const GEO_CONTROLS = [
1539
1963
  "filter",
1540
1964
  "insights",
1541
- "center",
1542
- "maxDistance",
1543
- "minDistance",
1544
- "index",
1545
- "select",
1546
- "skip",
1547
- "limit",
1548
- "page",
1549
- "size",
1550
- "with",
1551
- "actions"
1965
+ ...dtoControls(GeoControlsDto)
1552
1966
  ];
1553
1967
  //#endregion
1554
1968
  //#region src/as-db-readable.controller.ts
@@ -1562,6 +1976,11 @@ const PATH_OPS = [
1562
1976
  "aggregate",
1563
1977
  "bucket"
1564
1978
  ];
1979
+ /** The 400 of a filter / sort on a `@db.writeOnly` field. */
1980
+ function writeOnlyError(path, op) {
1981
+ const verdict = writeOnlyVerdict(path, op);
1982
+ return badRequest(verdict.path, verdict.message);
1983
+ }
1565
1984
  let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
1566
1985
  /** Reference to the underlying readable (table or view). */
1567
1986
  readable;
@@ -1600,13 +2019,31 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1600
2019
  metaCacheKey() {
1601
2020
  return this.capabilities;
1602
2021
  }
1603
- /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1604
- _exists = (path) => this.hasField(path);
1605
2022
  /**
1606
- * Id-resolution options (since 0.1.134): `{ isFieldVisible: hasField }`
1607
- * when a subclass overrides {@link hasField}, else `undefined` (the default
1608
- * accepts every real path, so resolution stays unfiltered). A unique index
1609
- * over a hidden field is never an identification.
2023
+ * THE field-visibility answer (since 0.1.143) every read surface consults:
2024
+ * the capability gate, the index gate and `$search` fallback, id
2025
+ * resolution, the `@db.writeOnly` / derived seals of the projection and of
2026
+ * every `$with` level, `$actions` widening and action `requiredFields`
2027
+ * (the actions module reaches it duck-typed, like {@link idSource}).
2028
+ */
2029
+ fieldVisibility;
2030
+ /** A subclass overrides {@link hasField}: visibility is request-scoped (derived rule, index gate, id options). */
2031
+ _hasFieldOverridden;
2032
+ /** `@db.column.derived` path → its source's logical path, per readable (bound + `$with` targets). */
2033
+ _derivedSources = /* @__PURE__ */ new WeakMap();
2034
+ /** The bound readable's entry of {@link _derivedSources}. */
2035
+ _derivedSource;
2036
+ /** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
2037
+ _targetWriteOnly = /* @__PURE__ */ new WeakMap();
2038
+ _indexFieldPathsCache;
2039
+ /** {@link _nativeSearch} per request, keyed by the request's parsed controls. */
2040
+ _nativeSearchByRequest = /* @__PURE__ */ new WeakMap();
2041
+ /**
2042
+ * Id-resolution options (since 0.1.134): `{ isFieldVisible }` (the
2043
+ * {@link fieldVisibility} check) when a subclass overrides {@link hasField},
2044
+ * else `undefined` (the default accepts every real path, so resolution
2045
+ * stays unfiltered). A unique index over a hidden field is never an
2046
+ * identification.
1610
2047
  */
1611
2048
  _idOpts;
1612
2049
  /** Narrowed id sources, one stable object per distinct visible-identification set. */
@@ -1615,6 +2052,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1615
2052
  _overlayIsNoOp;
1616
2053
  /** `true` when a subclass implements {@link decorateRows} (the override switches the hook on). */
1617
2054
  _decorates;
2055
+ /** `true` when a subclass overrides {@link transformOne} or {@link transformFilter} (a row overlay may exist). */
2056
+ _hasRowOverlay;
1618
2057
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
1619
2058
  _quantityRefByPath;
1620
2059
  /** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
@@ -1632,6 +2071,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1632
2071
  super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
1633
2072
  this.readable = resolved;
1634
2073
  this._writeOnlySet = this._collectAnnotated("db.writeOnly");
2074
+ this._derivedSource = this._derivedSourcesOf(resolved);
1635
2075
  this._invertibleFields = this._collectInvertibleFields();
1636
2076
  this._searchFallbackFields = this._collectSearchFallbackFields();
1637
2077
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
@@ -1639,7 +2079,21 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1639
2079
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1640
2080
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
1641
2081
  this._decorates = typeof this.decorateRows === "function";
1642
- this._idOpts = this.hasField === _AsDbReadableController.prototype.hasField ? void 0 : { isFieldVisible: this._exists };
2082
+ const proto = _AsDbReadableController.prototype;
2083
+ this._hasRowOverlay = this.transformOne !== proto.transformOne || this.transformFilter !== proto.transformFilter;
2084
+ const scoped = this.hasField !== proto.hasField;
2085
+ this._hasFieldOverridden = scoped;
2086
+ const isVisible = (path) => {
2087
+ if (!this.hasField(path)) return false;
2088
+ const source = scoped ? this._derivedSource.get(path) : void 0;
2089
+ return source === void 0 || this.hasField(source);
2090
+ };
2091
+ this.fieldVisibility = {
2092
+ scoped,
2093
+ isVisible,
2094
+ sealedFor: (readable, prefix = "") => this._sealedFor(readable, prefix)
2095
+ };
2096
+ this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
1643
2097
  }
1644
2098
  /**
1645
2099
  * The identifications this request may address rows through (since
@@ -1668,7 +2122,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1668
2122
  const nav = this.capabilities.navFields;
1669
2123
  for (const fd of this.readable.fieldDescriptors) {
1670
2124
  if (fd.ignored) continue;
1671
- if (nav.has(fd.path) || findAncestorInSet(fd.path, nav) !== void 0) continue;
2125
+ if (selfOrAncestor(fd.path, nav) !== void 0) continue;
1672
2126
  out.push(fd.path);
1673
2127
  }
1674
2128
  return out;
@@ -1683,6 +2137,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1683
2137
  }
1684
2138
  return out;
1685
2139
  }
2140
+ /** `readable`'s `@db.column.derived` path → source path map, collected once per readable. */
2141
+ _derivedSourcesOf(readable) {
2142
+ let map = this._derivedSources.get(readable);
2143
+ if (!map) {
2144
+ const out = /* @__PURE__ */ new Map();
2145
+ for (const fd of readable.fieldDescriptors ?? []) if (fd.derived?.sourcePath) out.set(fd.path, fd.derived.sourcePath);
2146
+ map = out;
2147
+ this._derivedSources.set(readable, map);
2148
+ }
2149
+ return map;
2150
+ }
1686
2151
  _collectAnnotated(annotation) {
1687
2152
  const out = /* @__PURE__ */ new Set();
1688
2153
  for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
@@ -1702,9 +2167,18 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1702
2167
  * an action id (primary key and `preferredId` always are) — and the
1703
2168
  * nested-object 400 hint lists visible leaves only. The default accepts every real path
1704
2169
  * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1705
- * there with `applyMetaOverlay`. Native text search and vector search
1706
- * (`$vector` names an index) run inside the engine over its indexes, out of
1707
- * this hook's reach — keep hidden fields out of those indexes.
2170
+ * there with `applyMetaOverlay` ({@link indexFieldPaths} names what each
2171
+ * search / geo index reads).
2172
+ *
2173
+ * Since 0.1.143 an override also gates the engine's indexes: a native
2174
+ * text-search index (`$index`, or the default one), a vector index
2175
+ * (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
2176
+ * answers exactly like a nonexistent index (400); a hidden DEFAULT text
2177
+ * index falls back to the `@db.column.searchable` substring search over
2178
+ * visible fields (or ignores the term when there are none). A
2179
+ * `@db.column.derived` field is visible only while its source path is,
2180
+ * and one whose source is hidden is sealed out of every read projection
2181
+ * for the request, like a `@db.writeOnly` field.
1708
2182
  */
1709
2183
  hasField(path) {
1710
2184
  if (typeof this.readable.isValidFieldPath === "function") return this.readable.isValidFieldPath(path);
@@ -1725,23 +2199,26 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1725
2199
  * ran first); a bucket's source is checked like any other path (op
1726
2200
  * `bucket`). After the per-path checks the core `$having` rule runs
1727
2201
  * (`checkHavingKeys`: aliases or `$groupBy` fields only), so a readable mock
1728
- * and a real table answer alike.
2202
+ * and a real table answer alike. Last (since 0.1.143, when {@link hasField}
2203
+ * is overridden) the index gate: a text / vector / geo index the request
2204
+ * uses must read only visible paths — see {@link indexFieldPaths}.
1729
2205
  */
1730
2206
  checkCapabilities(parsed) {
1731
2207
  const capabilities = this.capabilities;
2208
+ const isVisible = this.fieldVisibility.isVisible;
1732
2209
  const refs = collectQueryPaths$1(parsed);
1733
2210
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
1734
2211
  for (const { path, predicate } of refs.filter) {
1735
- const verdict = capabilities.check(path, "filter", this._exists, predicate);
2212
+ const verdict = capabilities.check(path, "filter", isVisible, predicate);
1736
2213
  if (verdict) return badRequest(verdict.path, verdict.message);
1737
2214
  }
1738
2215
  for (const op of PATH_OPS) for (const path of refs[op]) {
1739
- const verdict = capabilities.check(path, op, this._exists);
2216
+ const verdict = capabilities.check(path, op, isVisible);
1740
2217
  if (verdict) return badRequest(verdict.path, verdict.message);
1741
2218
  }
1742
2219
  const having = checkHavingKeys(refs);
1743
2220
  if (having) return badRequest(having.path, having.message);
1744
- return this.checkGates(parsed);
2221
+ return this.checkGates(parsed) ?? this._checkIndexGate(parsed.controls ?? {});
1745
2222
  }
1746
2223
  /**
1747
2224
  * The core's shared normalizer of `$select` computed entries
@@ -1784,22 +2261,278 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1784
2261
  if (!this.hasField(key)) return `Unknown field "${key}"`;
1785
2262
  }
1786
2263
  }
1787
- /** {@link checkComputedSelect} (before the controls DTO), then $with relations against the readable. */
2264
+ /**
2265
+ * {@link checkComputedSelect} (before the controls DTO), the controls DTO
2266
+ * ({@link validateControls}), then the `$with` relation names at every
2267
+ * level — BEFORE the `$with` sub-query paths ({@link validateInsights}),
2268
+ * so a hidden or nonexistent nested relation answers `Unknown relation`,
2269
+ * never `Unknown field "rel.sub"` (since 0.1.143) — then the insights and
2270
+ * the joined-row write-only veto.
2271
+ */
1788
2272
  validateParsed(parsed, type) {
1789
2273
  const computedError = this.checkComputedSelect(parsed.controls);
1790
2274
  if (computedError) return computedError;
1791
- const baseError = super.validateParsed(parsed, type);
1792
- if (baseError) return baseError;
1793
- const withRelations = parsed.controls.$with;
2275
+ const controls = parsed.controls;
2276
+ const controlsError = this.validateControls(controls, type);
2277
+ if (controlsError) return new HttpError(400, controlsError);
2278
+ const withRelations = controls.$with;
2279
+ const unknown = withRelations?.length ? this._checkWithRelations(withRelations, this.readable, "") : void 0;
2280
+ if (unknown) return unknown;
2281
+ if (parsed.insights) {
2282
+ const insightsError = this.validateInsights(parsed.insights);
2283
+ if (insightsError) return new HttpError(400, insightsError);
2284
+ }
1794
2285
  if (withRelations?.length) {
1795
- const relations = this.readable.relations;
1796
- for (const rel of withRelations) {
1797
- const dot = rel.name.indexOf(".");
1798
- if (!(dot === -1 ? relations.has(rel.name) && this.hasField(rel.name) : this.hasField(rel.name.slice(0, dot)))) {
1799
- const visible = [...relations.keys()].filter((name) => this.hasField(name));
1800
- return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${visible.join(", ") || "(none)"}`);
2286
+ const vetoed = this._walkWith(withRelations, this.readable, "", (rel, target, path, nested) => {
2287
+ const sealed = this._writeOnlyOf(target);
2288
+ if (sealed.size === 0) return void 0;
2289
+ const refs = collectQueryPaths$1({
2290
+ filter: rel.filter,
2291
+ controls: { $sort: nested.$sort ?? rel.$sort }
2292
+ });
2293
+ const filtered = refs.filter.find((ref) => selfOrAncestor(ref.path, sealed) !== void 0)?.path;
2294
+ if (filtered !== void 0) return writeOnlyError(`${path}.${filtered}`, "filter");
2295
+ const sorted = refs.sort.find((p) => selfOrAncestor(p, sealed) !== void 0);
2296
+ return sorted === void 0 ? void 0 : writeOnlyError(`${path}.${sorted}`, "sort");
2297
+ });
2298
+ if (vetoed instanceof HttpError) return vetoed;
2299
+ }
2300
+ }
2301
+ /**
2302
+ * `$with` relation names at every level (nested `$with` since 0.1.143):
2303
+ * each segment of an entry's (dotted) name must be a relation of its
2304
+ * level's readable that {@link hasField} accepts at its full path from
2305
+ * this controller (`rel`, then `rel.sub` for a nested / dotted one) —
2306
+ * hidden answers exactly like nonexistent: {@link unknownRelationError}
2307
+ * with the entry's name and the relations visible at the level it failed
2308
+ * at. A level whose target readable cannot be resolved is not descended.
2309
+ */
2310
+ _checkWithRelations(withRels, readable, prefix) {
2311
+ if (!Array.isArray(withRels)) return void 0;
2312
+ for (const rel of withRels) {
2313
+ if (typeof rel?.name !== "string") continue;
2314
+ let level = readable;
2315
+ let path = prefix;
2316
+ for (const segment of rel.name.split(".")) {
2317
+ if (!level) break;
2318
+ const relations = level.relations ?? /* @__PURE__ */ new Map();
2319
+ if (!relations.has(segment) || !this.hasField(path + segment)) {
2320
+ const visible = [...relations.keys()].filter((name) => this.hasField(path + name));
2321
+ return unknownRelationError(rel.name, visible);
1801
2322
  }
2323
+ path += `${segment}.`;
2324
+ level = typeof level.relatedTable === "function" ? level.relatedTable(segment) : void 0;
1802
2325
  }
2326
+ if (!level) continue;
2327
+ const nested = this._checkWithRelations(rel.controls?.$with ?? rel.$with, level, path);
2328
+ if (nested) return nested;
2329
+ }
2330
+ }
2331
+ /**
2332
+ * `@db.writeOnly` paths of a `$with` target — its own fields only (its
2333
+ * navigation descendants are sealed one level down, by their own target).
2334
+ */
2335
+ _writeOnlyOf(readable) {
2336
+ let set = this._targetWriteOnly.get(readable);
2337
+ if (!set) {
2338
+ const own = /* @__PURE__ */ new Set();
2339
+ const nav = readable.navFields ?? /* @__PURE__ */ new Set();
2340
+ for (const [path, entry] of readable.flatMap ?? []) {
2341
+ if (!entry?.metadata?.has?.("db.writeOnly")) continue;
2342
+ if (selfOrAncestor(path, nav) !== void 0) continue;
2343
+ own.add(path);
2344
+ }
2345
+ set = own;
2346
+ this._targetWriteOnly.set(readable, set);
2347
+ }
2348
+ return set;
2349
+ }
2350
+ /** {@link TDbFieldVisibility.sealedFor}. */
2351
+ _sealedFor(readable, prefix) {
2352
+ const writeOnly = readable === this.readable ? this._writeOnlySet : this._writeOnlyOf(readable);
2353
+ if (!this._hasFieldOverridden) return writeOnly;
2354
+ let out;
2355
+ for (const [path, source] of this._derivedSourcesOf(readable)) {
2356
+ if (writeOnly.has(path) || this.hasField(prefix + source)) continue;
2357
+ (out ??= new Set(writeOnly)).add(path);
2358
+ }
2359
+ return out ?? writeOnly;
2360
+ }
2361
+ /** The readable a `$with` entry name (`rel` or dotted `rel.sub`) loads from, if resolvable. */
2362
+ _relTarget(readable, name) {
2363
+ let current = readable;
2364
+ for (const segment of name.split(".")) {
2365
+ if (typeof current?.relatedTable !== "function") return void 0;
2366
+ current = current.relatedTable(segment);
2367
+ }
2368
+ return current;
2369
+ }
2370
+ /**
2371
+ * Walks a `$with` tree pre-order: `visit(rel, target, path, controls)` for
2372
+ * every entry whose target readable resolves (`path` = the entry's dotted
2373
+ * path from this controller, `controls` = its sub-controls). The visitor
2374
+ * returns replacement sub-controls, an `HttpError` to stop the walk (it is
2375
+ * returned as-is), or `undefined` to keep the entry. Returns the rebuilt
2376
+ * tree — the same array when nothing changed.
2377
+ */
2378
+ _walkWith(withRels, readable, prefix, visit) {
2379
+ if (!Array.isArray(withRels) || withRels.length === 0) return withRels;
2380
+ let out;
2381
+ for (let i = 0; i < withRels.length; i++) {
2382
+ const rel = withRels[i];
2383
+ const target = this._relTarget(readable, rel.name);
2384
+ if (!target) continue;
2385
+ const path = `${prefix}${rel.name}`;
2386
+ const nested = rel.controls ?? {};
2387
+ const visited = visit(rel, target, path, nested);
2388
+ if (visited instanceof HttpError) return visited;
2389
+ const children = nested.$with ?? rel.$with;
2390
+ const walked = this._walkWith(children, target, `${path}.`, visit);
2391
+ if (walked instanceof HttpError) return walked;
2392
+ if (visited === void 0 && walked === children) continue;
2393
+ const controls = { ...visited ?? nested };
2394
+ if (walked !== void 0) controls.$with = walked;
2395
+ out ??= [...withRels];
2396
+ out[i] = {
2397
+ ...rel,
2398
+ controls
2399
+ };
2400
+ }
2401
+ return out ?? withRels;
2402
+ }
2403
+ /**
2404
+ * The read controls with every level sealed — the root `$select` (`select`,
2405
+ * the {@link transformProjection} result) and each `$with` entry's
2406
+ * `$select` lose the paths {@link TDbFieldVisibility.sealedFor} names for
2407
+ * their readable (an exclusion is forced when there is no projection), so
2408
+ * sealed values never leave the database. Runs AFTER `transformProjection`
2409
+ * so permission overlays compose: they see the wire `$select`, this
2410
+ * guarantees the seal on whatever they return.
2411
+ */
2412
+ _sealControls(controls, select) {
2413
+ const vis = this.fieldVisibility;
2414
+ const $with = this._walkWith(controls.$with, this.readable, "", (rel, target, path, nested) => {
2415
+ const sealed = vis.sealedFor(target, `${path}.`);
2416
+ if (sealed.size === 0) return void 0;
2417
+ const sub = nested.$select ?? rel.$select;
2418
+ return {
2419
+ ...nested,
2420
+ $select: this._sealSelect(sub, sealed)
2421
+ };
2422
+ });
2423
+ const out = {
2424
+ ...controls,
2425
+ $select: this._sealSelect(select, vis.sealedFor(this.readable))
2426
+ };
2427
+ if ($with !== controls.$with) out.$with = $with;
2428
+ return out;
2429
+ }
2430
+ /**
2431
+ * The text / vector / geo indexes of the bound readable with the LOGICAL
2432
+ * field paths each reads, and which one answers when a request names none.
2433
+ * Text and vector entries are the adapter's `getSearchIndexes()` (the names
2434
+ * `$index` / `$vector` address, their `fields` and `isDefault`; an entry
2435
+ * without `fields` lists every field — fail-closed); geo entries are the
2436
+ * `@db.index.geo` indexes. The request gate checks every listed path against
2437
+ * {@link hasField} (only when `hasField` is overridden); permission
2438
+ * overlays use it to prune `/meta` (`searchIndexes`, `searchable`,
2439
+ * `vectorSearchable`, `geoSearchable`). Computed once. Override to describe
2440
+ * an index the model cannot express.
2441
+ *
2442
+ * @since 0.1.143
2443
+ */
2444
+ indexFieldPaths() {
2445
+ return this._indexFieldPathsCache ??= [...this._searchIndexFieldPaths(), ...this._geoIndexFieldPaths()];
2446
+ }
2447
+ _searchIndexFieldPaths() {
2448
+ const out = (typeof this.readable.getSearchIndexes === "function" ? this.readable.getSearchIndexes() : []).map((info) => ({
2449
+ name: info.name,
2450
+ type: info.type === "vector" ? "vector" : "text",
2451
+ fields: info.fields ?? this._invertibleFields,
2452
+ isDefault: info.isDefault === true
2453
+ }));
2454
+ for (const type of ["text", "vector"]) {
2455
+ const ofType = out.filter((entry) => entry.type === type);
2456
+ if (ofType.some((entry) => entry.isDefault)) continue;
2457
+ const fallback = ofType.find((entry) => entry.name === "DEFAULT") ?? ofType[0];
2458
+ if (fallback) fallback.isDefault = true;
2459
+ }
2460
+ return out;
2461
+ }
2462
+ /** `@db.index.geo` indexes — their fields carry physical names, mapped back to logical paths. */
2463
+ _geoIndexFieldPaths() {
2464
+ const readable = this.readable;
2465
+ if (!(readable.indexes instanceof Map)) return [];
2466
+ const logical = /* @__PURE__ */ new Map();
2467
+ for (const fd of readable.fieldDescriptors) {
2468
+ if (fd.ignored) continue;
2469
+ const prev = logical.get(fd.physicalName);
2470
+ if (prev === void 0 || this._derivedSource.has(prev)) logical.set(fd.physicalName, fd.path);
2471
+ }
2472
+ const out = [];
2473
+ for (const index of readable.indexes.values()) {
2474
+ if (index.type !== "geo") continue;
2475
+ out.push({
2476
+ name: index.name,
2477
+ type: "geo",
2478
+ fields: index.fields.map((field) => logical.get(field.name) ?? field.name),
2479
+ isDefault: out.length === 0
2480
+ });
2481
+ }
2482
+ return out;
2483
+ }
2484
+ /** Every path `entry` reads is visible to this request. */
2485
+ _indexVisible(entry) {
2486
+ return entry.fields.every(this.fieldVisibility.isVisible);
2487
+ }
2488
+ /**
2489
+ * Native text search serves this request: the adapter searches natively
2490
+ * and — under an overridden {@link hasField} — the default index (when the
2491
+ * request names none) reads only visible fields. A named index is gated by
2492
+ * {@link checkCapabilities}. Answered once per request (keyed by its
2493
+ * parsed controls).
2494
+ */
2495
+ _nativeSearch(controls) {
2496
+ let native = this._nativeSearchByRequest.get(controls);
2497
+ if (native === void 0) {
2498
+ native = this._resolveNativeSearch(controls);
2499
+ this._nativeSearchByRequest.set(controls, native);
2500
+ }
2501
+ return native;
2502
+ }
2503
+ _resolveNativeSearch(controls) {
2504
+ if (!this.readable.isSearchable()) return false;
2505
+ if (!this._hasFieldOverridden) return true;
2506
+ if (typeof controls.$index === "string" && controls.$index) return true;
2507
+ const def = this.indexFieldPaths().find((e) => e.type === "text" && e.isDefault);
2508
+ return def === void 0 || this._indexVisible(def);
2509
+ }
2510
+ /**
2511
+ * Index visibility gate (only when {@link hasField} is overridden), run by
2512
+ * {@link checkCapabilities} on every read: the geo index `/geo` reads
2513
+ * (`$index`, or the default one), the vector index `$vector` names (or the
2514
+ * default one) and the text index `$index` names must read only visible
2515
+ * paths; otherwise the request is answered exactly like one naming a
2516
+ * nonexistent index (the core's wording).
2517
+ */
2518
+ _checkIndexGate(controls) {
2519
+ if (!this._hasFieldOverridden) return void 0;
2520
+ const name = typeof controls.$index === "string" ? controls.$index : void 0;
2521
+ if (controls.$center !== void 0) {
2522
+ const geoIndexes = this.indexFieldPaths().filter((e) => e.type === "geo");
2523
+ const entry = name === void 0 ? geoIndexes.find((e) => e.isDefault) : geoIndexes.find((e) => e.name === name);
2524
+ if (entry && !this._indexVisible(entry)) return badRequest(name ?? "", geoIndexNotFoundMessage(this.readable.tableName, name));
2525
+ }
2526
+ if (!controls.$search) return void 0;
2527
+ if (controls.$vector !== void 0) {
2528
+ const vectorName = typeof controls.$vector === "string" ? controls.$vector : "";
2529
+ const entry = this.indexFieldPaths().find((e) => e.type === "vector" && (vectorName ? e.name === vectorName : e.isDefault));
2530
+ if (entry && this._indexVisible(entry)) return void 0;
2531
+ return badRequest("$vector", vectorIndexNotFoundMessage(vectorName || void 0));
2532
+ }
2533
+ if (name && this.readable.isSearchable()) {
2534
+ const entry = this.indexFieldPaths().find((e) => e.type === "text" && e.name === name);
2535
+ if (!entry || !this._indexVisible(entry)) return badRequest("$index", searchIndexNotFoundMessage(name));
1803
2536
  }
1804
2537
  }
1805
2538
  /**
@@ -1887,7 +2620,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1887
2620
  */
1888
2621
  _invertExclusion(excluded) {
1889
2622
  return this._invertibleFields.filter((path) => {
1890
- if (excluded.has(path) || findAncestorInSet(path, excluded) !== void 0) return false;
2623
+ if (selfOrAncestor(path, excluded) !== void 0) return false;
1891
2624
  const prefix = `${path}.`;
1892
2625
  for (const key of excluded) if (key.startsWith(prefix)) return false;
1893
2626
  return true;
@@ -1933,11 +2666,6 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1933
2666
  }
1934
2667
  return projection;
1935
2668
  }
1936
- /** WHY: the URL parser only auto-coerces `$count`; every other boolean control reaches us as `"true"`/`"1"` and would fail DTO validation. */
1937
- _coerceActionsControl(controls) {
1938
- const v = controls.$actions;
1939
- if (typeof v === "string") controls.$actions = v === "true" || v === "1" || v === "";
1940
- }
1941
2669
  /** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
1942
2670
  _resolveProjectionForAugmenter(select) {
1943
2671
  if (select === void 0) return null;
@@ -1964,12 +2692,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1964
2692
  const rowLevelEnvelopes = discoverRowLevelActions(this.constructor, this.app, this.logger);
1965
2693
  if (rowLevelEnvelopes.length === 0) return null;
1966
2694
  if (this._overlayIsNoOp) return rowLevelEnvelopes;
1967
- const overlayMeta = await this.meta();
2695
+ const overlayMeta = await this.resolveMeta();
1968
2696
  const allowedNames = new Set(overlayMeta.actions.map((a) => a.name));
1969
2697
  const filtered = rowLevelEnvelopes.filter((e) => allowedNames.has(e.info.name));
1970
2698
  return filtered.length === 0 ? null : filtered;
1971
2699
  }
1972
- /** Returns a widened `$select` only when at least one `requiredFields` entry is missing; `null` means "no widening needed". */
2700
+ /**
2701
+ * Returns a widened `$select` only when at least one `requiredFields` entry
2702
+ * is missing; `null` means "no widening needed". A field the request may
2703
+ * not see (`hasField`, derived source) is never added (since 0.1.143) —
2704
+ * the action predicate sees it as `undefined`.
2705
+ */
1973
2706
  _widenSelectForActions(envelopes, baseSelect) {
1974
2707
  let resultSet = null;
1975
2708
  let result = null;
@@ -1977,7 +2710,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
1977
2710
  const raw = e.raw;
1978
2711
  if (!Array.isArray(raw.requiredFields)) continue;
1979
2712
  for (const f of raw.requiredFields) {
1980
- if (resultSet ? resultSet.has(f) : baseSelect.includes(f)) continue;
2713
+ if ((resultSet ? resultSet.has(f) : baseSelect.includes(f)) || !this.fieldVisibility.isVisible(f)) continue;
1981
2714
  if (resultSet === null) {
1982
2715
  resultSet = new Set(baseSelect);
1983
2716
  result = [...baseSelect];
@@ -2016,14 +2749,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2016
2749
  return out;
2017
2750
  }
2018
2751
  /**
2019
- * Removes `@db.writeOnly` fields from any `$select` shape — and forces an
2020
- * exclusion when no projection was requested — so sealed values never leave
2021
- * the database on a read. Runs AFTER `transformProjection` so permission
2022
- * overlays compose (they see the wire `$select`; this guarantees the seal on
2023
- * whatever they return).
2752
+ * `select` without the `sealed` paths (see {@link _sealControls}); an
2753
+ * exclusion of them is forced when there is no projection, or when every
2754
+ * requested path was sealed.
2024
2755
  */
2025
- _sealProjection(select) {
2026
- const writeOnly = this._writeOnlySet;
2756
+ _sealSelect(select, writeOnly) {
2027
2757
  if (writeOnly.size === 0) return select;
2028
2758
  const exclusion = () => {
2029
2759
  const out = {};
@@ -2048,7 +2778,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2048
2778
  /** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
2049
2779
  _findWriteOnlyInAggregate(groupBy, select) {
2050
2780
  if (this._writeOnlySet.size === 0) return void 0;
2051
- const sealed = (f) => this._writeOnlySet.has(f) && this.hasField(f);
2781
+ const sealed = (f) => this._writeOnlySet.has(f) && this.fieldVisibility.isVisible(f);
2052
2782
  for (const f of groupBy) if (sealed(f)) return f;
2053
2783
  if (Array.isArray(select)) for (const item of select) {
2054
2784
  const field = typeof item === "string" ? item : item.$field;
@@ -2058,18 +2788,20 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2058
2788
  /**
2059
2789
  * Merges the `$search` fallback into the filter: a case-insensitive literal
2060
2790
  * substring match OR'd across the `@db.column.searchable` fields, `$and`-combined
2061
- * with the existing filter. Applies only when the adapter has no native search
2062
- * (native wins) and the request isn't a vector search (`$vector` consumes the term).
2791
+ * with the existing filter. Applies only when native search does not serve
2792
+ * the request (no native search, or — since 0.1.143 — its default index
2793
+ * reads a field {@link hasField} hides) and the request isn't a vector
2794
+ * search (`$vector` consumes the term).
2063
2795
  */
2064
2796
  applySearchFallback(filter, controls) {
2065
2797
  const term = controls.$search;
2066
2798
  if (!term || controls.$vector !== void 0) return filter;
2067
- if (this.readable.isSearchable()) return filter;
2068
- const fields = this._searchFallbackFields.filter((f) => this.hasField(f));
2799
+ if (this._nativeSearch(controls)) return filter;
2800
+ const fields = this._searchFallbackFields.filter((f) => this.fieldVisibility.isVisible(f));
2069
2801
  if (fields.length === 0) return filter;
2070
2802
  const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
2071
2803
  const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
2072
- return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
2804
+ return filter && !isEmptyObject(filter) ? { $and: [filter, fragment] } : fragment;
2073
2805
  }
2074
2806
  /**
2075
2807
  * The controls a grouped query hands to the adapter. `$search` has two
@@ -2091,7 +2823,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2091
2823
  * the same shape as the one this whole path exists to remove.
2092
2824
  */
2093
2825
  _aggregateControls(controls) {
2094
- if (controls.$search === void 0 || this.readable.isSearchable()) return controls;
2826
+ if (controls.$search === void 0 || this._nativeSearch(controls)) return controls;
2095
2827
  const rest = { ...controls };
2096
2828
  delete rest.$search;
2097
2829
  delete rest.$index;
@@ -2106,7 +2838,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2106
2838
  vector: await this.computeEmbedding(searchTerm, vectorField || void 0),
2107
2839
  vectorField
2108
2840
  };
2109
- if (searchTerm && this.readable.isSearchable()) return {
2841
+ if (searchTerm && this._nativeSearch(controls)) return {
2110
2842
  kind: "search",
2111
2843
  term: searchTerm,
2112
2844
  index: indexName
@@ -2152,6 +2884,56 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2152
2884
  return result;
2153
2885
  }
2154
2886
  /**
2887
+ * The filter addressing exactly the ONE row `id` means — the readable's
2888
+ * PK-first `resolveRowFilter` (since 0.1.143) under this request's
2889
+ * identifications (`_idOpts`). `scope` (the row overlay) restricts which
2890
+ * rows count while the id is pinned, so a row outside it never shadows one
2891
+ * inside it. Readables without it (partial mocks) fall back to
2892
+ * `resolveIdFilter`.
2893
+ */
2894
+ resolveRowFilter(id, scope) {
2895
+ const readable = this.readable;
2896
+ if (typeof readable.resolveRowFilter === "function") return readable.resolveRowFilter(id, scope ? {
2897
+ ...this._idOpts,
2898
+ scope
2899
+ } : this._idOpts);
2900
+ return Promise.resolve(readable.resolveIdFilter(id, this._idOpts));
2901
+ }
2902
+ /**
2903
+ * The ONE row `id` addresses, read with `controls`. A hidden unique key is
2904
+ * not an identification (since 0.1.134): the id resolves as if that index
2905
+ * did not exist. Since 0.1.143 it resolves primary key first, counting
2906
+ * only rows inside the overlay — an out-of-scope row never shadows an
2907
+ * in-scope one, so the answer is the same as if it did not exist — in one
2908
+ * step (`findOneByRow`). Readables without it (partial mocks) pin the row
2909
+ * with {@link resolveRowFilter}, then read it.
2910
+ */
2911
+ async _findRow(id, overlay, controls) {
2912
+ const readable = this.readable;
2913
+ if (typeof readable.findOneByRow === "function") return await readable.findOneByRow(id, {
2914
+ ...this._idOpts,
2915
+ scope: overlay,
2916
+ controls
2917
+ });
2918
+ const idFilter = await this.resolveRowFilter(id, overlay);
2919
+ if (!idFilter) return null;
2920
+ return await readable.findOne({
2921
+ filter: withOverlay(idFilter, overlay),
2922
+ controls
2923
+ });
2924
+ }
2925
+ /**
2926
+ * The row overlay id-addressed endpoints (`/one`, `DELETE`) apply:
2927
+ * `transformOne({})` when non-empty, and only when a subclass overrides
2928
+ * {@link transformOne} / {@link transformFilter} — `undefined` otherwise,
2929
+ * at no cost (since 0.1.143).
2930
+ */
2931
+ async rowOverlay() {
2932
+ if (!this._hasRowOverlay) return void 0;
2933
+ const overlay = await this.transformOne({});
2934
+ return overlay && !isEmptyObject(overlay) ? overlay : void 0;
2935
+ }
2936
+ /**
2155
2937
  * Pick the first identification (PK or unique index) whose fields are all
2156
2938
  * present in the query. A unique index over a field {@link hasField} hides
2157
2939
  * is not a candidate (since 0.1.134) — `?hidden=x` answers exactly like
@@ -2176,9 +2958,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2176
2958
  * **GET /query** — returns an array of records or a count.
2177
2959
  */
2178
2960
  async query(url) {
2179
- const parsed = this.parseQueryString(url);
2180
- const controls = parsed.controls;
2181
- this._coerceActionsControl(controls);
2961
+ const { parsed, controls } = await this.parseRequest("query", url);
2182
2962
  const groupBy = controls.$groupBy;
2183
2963
  if (groupBy?.length && controls.$with?.length) return new HttpError(400, "Cannot combine $with and $groupBy in the same query");
2184
2964
  if (groupBy?.length && controls.$vector !== void 0) return new HttpError(400, "Cannot combine $vector and $groupBy in the same query");
@@ -2200,21 +2980,21 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2200
2980
  }
2201
2981
  const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
2202
2982
  const filter = this.applySearchFallback(transformedFilter, controls);
2203
- const rawSelect = this._sealProjection(transformedSelect);
2983
+ const sealed = this._sealControls(controls, transformedSelect);
2204
2984
  if (controls.$count) return this.readable.count({
2205
2985
  filter,
2206
2986
  controls: {
2207
2987
  ...controls,
2208
- $select: rawSelect
2988
+ $select: sealed.$select
2209
2989
  }
2210
2990
  });
2211
- const select = this.widenPreferredIdProjection(rawSelect);
2991
+ const select = this.widenPreferredIdProjection(sealed.$select);
2212
2992
  if (select instanceof HttpError) return select;
2213
2993
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
2214
2994
  const queryObj = {
2215
2995
  filter,
2216
2996
  controls: {
2217
- ...controls,
2997
+ ...sealed,
2218
2998
  $select: select,
2219
2999
  $limit: controls.$limit || 1e3,
2220
3000
  $threshold: threshold
@@ -2232,11 +3012,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2232
3012
  * **GET /pages** — returns paginated records with metadata.
2233
3013
  */
2234
3014
  async pages(url) {
2235
- const parsed = this.parseQueryString(url);
2236
- this._coerceActionsControl(parsed.controls);
3015
+ const { parsed, controls } = await this.parseRequest("pages", url);
2237
3016
  const error = this.validateParsed(parsed, "pages");
2238
3017
  if (error) return error;
2239
- const controls = parsed.controls;
2240
3018
  const gateError = this.checkCapabilities(parsed);
2241
3019
  if (gateError) return gateError;
2242
3020
  const page = Math.max(Number(controls.$page || 1), 1);
@@ -2244,14 +3022,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2244
3022
  const skip = (page - 1) * size;
2245
3023
  const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
2246
3024
  const filter = this.applySearchFallback(transformedFilter, controls);
2247
- const rawSelect = this._sealProjection(transformedSelect);
2248
- const select = this.widenPreferredIdProjection(rawSelect);
3025
+ const sealed = this._sealControls(controls, transformedSelect);
3026
+ const select = this.widenPreferredIdProjection(sealed.$select);
2249
3027
  if (select instanceof HttpError) return select;
2250
3028
  const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
2251
3029
  const query = {
2252
3030
  filter,
2253
3031
  controls: {
2254
- ...controls,
3032
+ ...sealed,
2255
3033
  $select: select,
2256
3034
  $skip: skip,
2257
3035
  $limit: size,
@@ -2281,12 +3059,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2281
3059
  * (meters), `$index` (geo index name), plus the standard filter / `$select` /
2282
3060
  * `$with` / pagination syntax. Each row carries a computed `$distance`
2283
3061
  * (meters). With `$page` / `$size` the response is the `/pages` envelope;
2284
- * otherwise a plain row array (`$skip` / `$limit` compose).
3062
+ * otherwise a plain row array (`$skip` / `$limit` compose). Since 0.1.143
3063
+ * the controls pass {@link validateParsed} (type `"geo"`) like `/query`'s,
3064
+ * and the geo index must read only fields {@link hasField} shows.
2285
3065
  */
2286
3066
  async geo(url) {
2287
- const parsed = this.parseQueryString(url);
2288
- const controls = parsed.controls;
2289
- this._coerceActionsControl(controls);
3067
+ const { parsed, controls } = await this.parseRequest("geo", url);
2290
3068
  const point = this._parseGeoCenter(controls.$center);
2291
3069
  if (point instanceof HttpError) return point;
2292
3070
  for (const key of ["$maxDistance", "$minDistance"]) if (controls[key] !== void 0) {
@@ -2295,16 +3073,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2295
3073
  controls[key] = num;
2296
3074
  }
2297
3075
  const indexName = typeof controls.$index === "string" ? controls.$index : void 0;
2298
- if (parsed.insights) {
2299
- const insightsError = this.validateInsights(parsed.insights);
2300
- if (insightsError) return new HttpError(400, insightsError);
2301
- }
2302
- const computedError = this.checkComputedSelect(controls);
2303
- if (computedError) return computedError;
3076
+ const error = this.validateParsed(parsed, "geo");
3077
+ if (error) return error;
2304
3078
  const gateError = this.checkCapabilities(parsed);
2305
3079
  if (gateError) return gateError;
2306
3080
  const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
2307
- const select = this.widenPreferredIdProjection(this._sealProjection(transformedSelect));
3081
+ const sealed = this._sealControls(controls, transformedSelect);
3082
+ const select = this.widenPreferredIdProjection(sealed.$select);
2308
3083
  if (select instanceof HttpError) return select;
2309
3084
  const paginated = controls.$page !== void 0 || controls.$size !== void 0;
2310
3085
  const page = Math.max(Number(controls.$page || 1), 1);
@@ -2312,7 +3087,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2312
3087
  const queryObj = {
2313
3088
  filter,
2314
3089
  controls: {
2315
- ...controls,
3090
+ ...sealed,
2316
3091
  $center: void 0,
2317
3092
  $index: void 0,
2318
3093
  $select: select,
@@ -2357,17 +3132,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2357
3132
  * read overlays gate `/one` symmetrically with `/query` / `/pages`.
2358
3133
  */
2359
3134
  async getOne(id, url) {
2360
- const { parsed, hasNonControl } = this.parseControlsOnlyFromUrl(url);
3135
+ const { parsed, controls, hasNonControl } = await this.parseRequest("one", url);
2361
3136
  if (hasNonControl) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
2362
- this._coerceActionsControl(parsed.controls);
2363
- const error = this.validateParsed(parsed, "getOne");
2364
- if (error) return error;
2365
- const gateError = this.checkCapabilities(parsed);
2366
- if (gateError) return gateError;
2367
- const rawSelect = await this.transformProjection(parsed.controls.$select);
2368
- const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
2369
- if (select instanceof HttpError) return select;
2370
- return this._findByIdAndAugment(id, parsed.controls, select);
3137
+ return this._readOne(id, parsed, controls);
2371
3138
  }
2372
3139
  /**
2373
3140
  * **GET /one?field1=val1&field2=val2** — retrieves a single record by composite key
@@ -2375,42 +3142,33 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2375
3142
  * gating as {@link getOne}.
2376
3143
  */
2377
3144
  async getOneComposite(query, url) {
3145
+ const { parsed, controls } = await this.parseRequest("one", url);
2378
3146
  const idObj = this.extractIdShape(query);
2379
3147
  if (idObj instanceof HttpError) return idObj;
2380
- const { parsed } = this.parseControlsOnlyFromUrl(url);
2381
- this._coerceActionsControl(parsed.controls);
2382
- const error = this.validateParsed(parsed, "getOne");
3148
+ return this._readOne(idObj, parsed, controls);
3149
+ }
3150
+ /**
3151
+ * The shared `/one` pipeline: validation + capability gate (since 0.1.128
3152
+ * on the composite form too — an unknown `$select` path used to reach the
3153
+ * driver there), the sealed projection, the row read and its augmentation.
3154
+ */
3155
+ async _readOne(id, parsed, controls) {
3156
+ const error = this.validateParsed(parsed, "getOne") ?? this.checkCapabilities(parsed);
2383
3157
  if (error) return error;
2384
- const gateError = this.checkCapabilities(parsed);
2385
- if (gateError) return gateError;
2386
- const rawSelect = await this.transformProjection(parsed.controls.$select);
2387
- const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
3158
+ const sealed = this._sealControls(controls, await this.transformProjection(controls.$select));
3159
+ const select = this.widenPreferredIdProjection(sealed.$select);
2388
3160
  if (select instanceof HttpError) return select;
2389
- return this._findByIdAndAugment(idObj, parsed.controls, select);
2390
- }
2391
- async _findByIdAndAugment(id, parsedControls, select) {
2392
- const prep = await this._prepareAugmentation(parsedControls, select);
2393
- const initialSelect = prep?.widenedSelect ?? select;
2394
- const controls = {
2395
- ...parsedControls,
2396
- $select: initialSelect
3161
+ const [prep, overlay] = await Promise.all([this._prepareAugmentation(controls, select), this.rowOverlay()]);
3162
+ const readControls = {
3163
+ ...sealed,
3164
+ $select: prep?.widenedSelect ?? select
2397
3165
  };
2398
- const idFilter = this.readable.resolveIdFilter(id, this._idOpts);
2399
- let row = null;
2400
- if (idFilter) {
2401
- const overlay = await this.transformOne({});
2402
- const filter = overlay && Object.keys(overlay).length > 0 ? { $and: [idFilter, overlay] } : idFilter;
2403
- row = await this.readable.findOne({
2404
- filter,
2405
- controls
2406
- });
2407
- }
2408
- const item = await this.returnOne(Promise.resolve(row));
3166
+ const item = await this.returnOne(this._findRow(id, overlay, readControls));
2409
3167
  if (item instanceof HttpError) return item;
2410
3168
  const pending = this._finishRows([item], prep, {
2411
3169
  endpoint: "one",
2412
3170
  projection: select,
2413
- controls: parsedControls
3171
+ controls
2414
3172
  });
2415
3173
  if (pending) await pending;
2416
3174
  return item;
@@ -2453,7 +3211,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
2453
3211
  searchable: this.readable.isSearchable() || this._searchFallbackFields.length > 0,
2454
3212
  vectorSearchable: this.readable.isVectorSearchable(),
2455
3213
  geoSearchable: this._isGeoSearchable(),
2456
- searchIndexes: this.readable.getSearchIndexes(),
3214
+ searchIndexes: this.readable.getSearchIndexes().map(({ fields: _fields, ...index }) => index),
2457
3215
  primaryKeys: [...this.readable.primaryKeys],
2458
3216
  preferredId: [...this.readable.preferredId],
2459
3217
  relations,
@@ -2553,7 +3311,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2553
3311
  constructor(app, table) {
2554
3312
  super(app, table);
2555
3313
  const proto = _AsDbController.prototype;
2556
- this._writeArgs = this._hookArgs(this.guardWrite !== proto.guardWrite ? (ctx) => this.guardWrite(ctx) : void 0);
3314
+ this._writeArgs = this._hookArgs(this.guardWrite !== proto.guardWrite ? (ctx) => this.guardWrite(ctx) : void 0, this.checkWrite !== proto.checkWrite ? (ctx) => this.checkWrite(ctx) : void 0);
2557
3315
  this._removeArgs = this._hookArgs(this.guardRemove !== proto.guardRemove ? (ctx) => this.guardRemove(ctx) : void 0);
2558
3316
  }
2559
3317
  buildCrud() {
@@ -2615,6 +3373,30 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2615
3373
  */
2616
3374
  guardRemove(_ctx) {}
2617
3375
  /**
3376
+ * Post-write check (since 0.1.143) — a row-level "WITH CHECK". Overriding it
3377
+ * is the switch (as with {@link guardWrite}): the override is passed to the
3378
+ * table as its `check` write option and runs once per insert / replace /
3379
+ * update call (bulk forms included), inside the table's transaction, AFTER
3380
+ * the main write and every nested-relation phase. `ctx.filters` holds one
3381
+ * exact primary-key filter per written row; `ctx.count(filter)` counts
3382
+ * inside the same transaction — e.g. verify every written row still matches
3383
+ * a policy filter:
3384
+ *
3385
+ * ```ts
3386
+ * protected async checkWrite(ctx: TDbWriteCheckContext) {
3387
+ * const n = await ctx.count({ $and: [{ $or: ctx.filters }, tenantFilter()] })
3388
+ * if (n !== ctx.filters.length) throw new HttpError(403)
3389
+ * }
3390
+ * ```
3391
+ *
3392
+ * Throw to reject: the transaction rolls back and the error propagates
3393
+ * unchanged. When `ctx.transactional` is `false` (the adapter's
3394
+ * transaction is a pass-through, e.g. a standalone MongoDB) the write is
3395
+ * already durable — validate before the write instead (`guardWrite`).
3396
+ * Removes have no post-image and never call it.
3397
+ */
3398
+ checkWrite(_ctx) {}
3399
+ /**
2618
3400
  * Runs `fn` inside the bound table's adapter transaction (nested calls
2619
3401
  * join it). For custom actions and routes that need one transaction across
2620
3402
  * several table operations.
@@ -2627,15 +3409,17 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2627
3409
  /** `deleteOne`'s trailing options — see {@link _hookArgs}. */
2628
3410
  _removeArgs;
2629
3411
  /**
2630
- * A table call's trailing options, built once: `guard` only when the guard
2631
- * hook is overridden, `isFieldVisible` only when `hasField` is (an id or a
2632
- * PK-less payload never resolves through a hidden unique key) — else
2633
- * nothing, so an unmodified controller calls the table exactly as before.
3412
+ * A table call's trailing options, built once: `guard` / `check` only when
3413
+ * the matching hook is overridden, `isFieldVisible` only when `hasField`
3414
+ * is (an id or a PK-less payload never resolves through a hidden unique
3415
+ * key) — else nothing, so an unmodified controller calls the table exactly
3416
+ * as before.
2634
3417
  */
2635
- _hookArgs(guard) {
3418
+ _hookArgs(guard, check) {
2636
3419
  const opts = { ...this._idOpts };
2637
3420
  if (guard) opts.guard = guard;
2638
- return Object.keys(opts).length > 0 ? [opts] : [];
3421
+ if (check) opts.check = check;
3422
+ return isEmptyObject(opts) ? [] : [opts];
2639
3423
  }
2640
3424
  /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
2641
3425
  async _checkHook(pending, abortMessage) {
@@ -2683,9 +3467,20 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2683
3467
  if (versionColumn === void 0) return;
2684
3468
  for (let i = 0; i < rows.length; i++) this._resolveCas(rows[i], versionColumn, `[${i}].`);
2685
3469
  }
2686
- /** Deletes by id (guard forwarded when overridden) and maps "nothing deleted" to 404. */
3470
+ /**
3471
+ * Deletes by id (guard forwarded when overridden) and maps "nothing
3472
+ * deleted" to 404. Since 0.1.143 the row overlay ({@link rowOverlay}) is
3473
+ * the delete's `scope`: the id is pinned among in-scope rows inside the
3474
+ * table's transaction and an out-of-scope row is not deleted — a 404,
3475
+ * exactly like a missing one.
3476
+ */
2687
3477
  async _deleteOrThrow(id) {
2688
- const result = await this.table.deleteOne(id, ...this._removeArgs);
3478
+ const scope = await this.rowOverlay();
3479
+ const args = scope ? [{
3480
+ ...this._removeArgs[0],
3481
+ scope
3482
+ }] : this._removeArgs;
3483
+ const result = await this.table.deleteOne(id, ...args);
2689
3484
  if (result.deletedCount < 1) throw new HttpError(404);
2690
3485
  return result;
2691
3486
  }
@@ -2693,6 +3488,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2693
3488
  * **POST /** — inserts one or many records.
2694
3489
  */
2695
3490
  async insert(payload) {
3491
+ await this.parseRequest("insert");
2696
3492
  assertWriteShape(payload);
2697
3493
  if (Array.isArray(payload)) {
2698
3494
  const rows = await this._writeBody("insertMany", payload, true);
@@ -2711,6 +3507,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2711
3507
  * mismatch) via a single `findOne` after the table call.
2712
3508
  */
2713
3509
  async replace(payload) {
3510
+ await this.parseRequest("replace");
2714
3511
  assertWriteShape(payload);
2715
3512
  const versionColumn = this.table.versionColumn;
2716
3513
  if (Array.isArray(payload)) {
@@ -2732,6 +3529,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2732
3529
  * the CAS statement executes and bumps the version on a hit.
2733
3530
  */
2734
3531
  async update(payload) {
3532
+ await this.parseRequest("update");
2735
3533
  assertWriteShape(payload);
2736
3534
  const versionColumn = this.table.versionColumn;
2737
3535
  if (Array.isArray(payload)) {
@@ -2750,10 +3548,24 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2750
3548
  * returns 404 when the row is genuinely missing, 409 with
2751
3549
  * `{ error: "version_mismatch", currentVersion: N }` when it's present
2752
3550
  * but the supplied version is stale (§6.3). Callers throw the result.
3551
+ *
3552
+ * The row is the one the write targeted (since 0.1.143): the table's
3553
+ * `recordFilter` — the write's own resolution, primary key first — so a
3554
+ * payload carrying the full primary key addresses that row only, never a
3555
+ * different row that merely shares a unique value with the payload.
3556
+ * Tables without it (partial mocks) resolve through {@link resolveRowFilter}.
2753
3557
  */
2754
3558
  async _disambiguateMismatch(data, versionColumn) {
2755
- const filter = this.table.resolveIdFilter(data, this._idOpts);
2756
- const row = filter ? await this.table.findOne({
3559
+ const table = this.table;
3560
+ let filter;
3561
+ if (typeof table.recordFilter === "function") try {
3562
+ filter = table.recordFilter(data, this._idOpts);
3563
+ } catch (error) {
3564
+ if (!(error instanceof DbError)) throw error;
3565
+ filter = null;
3566
+ }
3567
+ else filter = await this.resolveRowFilter(data);
3568
+ const row = filter ? await table.findOne({
2757
3569
  filter,
2758
3570
  controls: {}
2759
3571
  }) : null;
@@ -2769,6 +3581,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2769
3581
  * **DELETE /:id** — removes a single record by primary key.
2770
3582
  */
2771
3583
  async remove(id) {
3584
+ await this.parseRequest("remove");
2772
3585
  const resolvedId = await this._checkHook(this.onRemove(id), "Not deleted");
2773
3586
  return this._deleteOrThrow(resolvedId);
2774
3587
  }
@@ -2777,6 +3590,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2777
3590
  * (composite primary key or compound unique index).
2778
3591
  */
2779
3592
  async removeComposite(query) {
3593
+ await this.parseRequest("remove");
2780
3594
  const idObj = this.extractIdShape(query);
2781
3595
  if (idObj instanceof HttpError) throw idObj;
2782
3596
  const resolvedId = await this._checkHook(this.onRemove(idObj), "Not deleted");
@@ -2841,6 +3655,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
2841
3655
  primaryKey;
2842
3656
  constructor(boundType, controllerName, app) {
2843
3657
  super(boundType, controllerName, app, "value-help");
3658
+ assertNoValueHelpActions(this.constructor);
2844
3659
  const fieldMeta = /* @__PURE__ */ new Map();
2845
3660
  const explicitlySearchable = [];
2846
3661
  const stringProps = [];
@@ -2858,40 +3673,110 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
2858
3673
  this.primaryKey = primaryKey;
2859
3674
  this.searchableFields = explicitlySearchable.length > 0 ? explicitlySearchable : interfaceSearchable ? stringProps : stringProps;
2860
3675
  }
3676
+ /**
3677
+ * THE field-visibility hook: `true` when `path` is a field of the bound
3678
+ * interface visible to this request. A path it rejects in the request's
3679
+ * filter / `$sort` / `$select` gets the same `Unknown field "x"` 400 as a
3680
+ * nonexistent one, and a hidden field never takes part in `$search`
3681
+ * (`AsJsonValueHelpController`). Override to hide fields per request — it
3682
+ * gates the request only; strip the column from responses with
3683
+ * {@link transformProjection}.
3684
+ */
2861
3685
  hasField(path) {
2862
3686
  return this.fieldMeta.has(path);
2863
3687
  }
2864
3688
  /**
3689
+ * Row overlay — the value-help counterpart of the DB controllers'
3690
+ * `transformFilter`. Receives the request filter of `/query` / `/pages`
3691
+ * and returns the one to run (AND your scope in: `{ $and: [filter, scope] }`).
3692
+ * `/one` evaluates `transformFilter({})` against the found row in memory: a
3693
+ * row outside it answers 404, exactly like a missing one. Default: identity.
3694
+ * May be async.
3695
+ *
3696
+ * @since 0.1.143
3697
+ */
3698
+ transformFilter(filter) {
3699
+ return filter;
3700
+ }
3701
+ /**
3702
+ * Projection hook — receives the request `$select` (`undefined` when
3703
+ * absent; `/one` always passes `undefined`) and returns the projection to
3704
+ * apply: an inclusion list / `{ path: 1 }` map, or an exclusion
3705
+ * `{ path: 0 }` map. Default: identity. May be async.
3706
+ *
3707
+ * @since 0.1.143
3708
+ */
3709
+ transformProjection(select) {
3710
+ return select;
3711
+ }
3712
+ /**
3713
+ * Normalizes a value-help `$select` (the raw `parseUrl` form or a
3714
+ * {@link transformProjection} result) to the `@atscript/db-memory`
3715
+ * `{ path: 0 | 1 }` projection map:
3716
+ * - `string[]` (e.g. from `?$select=a,b`) → inclusion map `{ a: 1, b: 1 }`,
3717
+ * - a plain `{ path: 0 | 1 }` object → passed through (0 / falsy → exclude),
3718
+ * - anything else / empty → `undefined` (no projection; whole rows returned).
3719
+ */
3720
+ normalizeSelect(select) {
3721
+ const out = {};
3722
+ if (Array.isArray(select)) {
3723
+ for (const field of select) if (typeof field === "string" && field) out[field] = 1;
3724
+ } else if (select && typeof select === "object") for (const [path, v] of Object.entries(select)) out[path] = v === 0 || v === false ? 0 : 1;
3725
+ return Object.keys(out).length > 0 ? out : void 0;
3726
+ }
3727
+ /** `filter` through {@link transformFilter} and `controls.$select` through {@link transformProjection}. */
3728
+ async _scopedQuery(filter, controls) {
3729
+ const [scopedFilter, select] = await Promise.all([this.transformFilter(filter), this.transformProjection(controls.$select)]);
3730
+ const out = { ...controls };
3731
+ if (select === void 0) delete out.$select;
3732
+ else out.$select = select;
3733
+ return {
3734
+ filter: scopedFilter,
3735
+ controls: out
3736
+ };
3737
+ }
3738
+ /**
3739
+ * `getOne` + the row overlay (miss → 404) + the projection, applied in
3740
+ * memory — `getOne` is the subclass's own lookup (id coercion included),
3741
+ * so it is not re-expressed as a {@link query} filter.
3742
+ */
3743
+ async _scopedOne(id) {
3744
+ const [item, overlay, select] = await Promise.all([
3745
+ this.returnOne(this.getOne(id)),
3746
+ this.transformFilter({}),
3747
+ this.transformProjection(void 0)
3748
+ ]);
3749
+ if (item instanceof HttpError) return item;
3750
+ if (overlay && !isEmptyObject(overlay)) {
3751
+ if (!buildMemoryPredicate(overlay)(item)) return new HttpError(404);
3752
+ }
3753
+ const projection = this.normalizeSelect(select);
3754
+ return projection ? projectRow(item, projection, { clone: false }) : item;
3755
+ }
3756
+ /**
2865
3757
  * **GET /query** — returns an array of matched rows (up to `$limit`).
2866
3758
  */
2867
3759
  async runQuery(url) {
2868
- const parsed = this.parseQueryString(url);
3760
+ const { parsed, controls } = await this.parseRequest("query", url);
2869
3761
  const validateError = this.validateParsed(parsed, "query");
2870
3762
  if (validateError) return validateError;
2871
- return (await this.query({
2872
- filter: parsed.filter,
2873
- controls: parsed.controls
2874
- })).data;
3763
+ return (await this.query(await this._scopedQuery(parsed.filter, controls))).data;
2875
3764
  }
2876
3765
  /**
2877
3766
  * **GET /pages** — paginated row window plus total count.
2878
3767
  */
2879
3768
  async runPages(url) {
2880
- const parsed = this.parseQueryString(url);
3769
+ const { parsed, controls } = await this.parseRequest("pages", url);
2881
3770
  const validateError = this.validateParsed(parsed, "pages");
2882
3771
  if (validateError) return validateError;
2883
- const controls = parsed.controls;
2884
3772
  const page = Math.max(Number(controls.$page || 1), 1);
2885
3773
  const size = Math.max(Number(controls.$size || 10), 1);
2886
3774
  const skip = (page - 1) * size;
2887
- const result = await this.query({
2888
- filter: parsed.filter,
2889
- controls: {
2890
- ...controls,
2891
- $skip: skip,
2892
- $limit: size
2893
- }
2894
- });
3775
+ const result = await this.query(await this._scopedQuery(parsed.filter, {
3776
+ ...controls,
3777
+ $skip: skip,
3778
+ $limit: size
3779
+ }));
2895
3780
  return {
2896
3781
  data: result.data,
2897
3782
  page,
@@ -2904,17 +3789,19 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
2904
3789
  * **GET /one/:id** — retrieves a single row by primary key.
2905
3790
  */
2906
3791
  async runGetOne(id) {
2907
- return this.returnOne(this.getOne(id));
3792
+ await this.parseRequest("one", "");
3793
+ return this._scopedOne(id);
2908
3794
  }
2909
3795
  /**
2910
3796
  * **GET /one?<pk>=<val>** — retrieves a single row by PK query param (fallback).
2911
3797
  */
2912
3798
  async runGetOneComposite(query) {
3799
+ await this.parseRequest("one", "");
2913
3800
  const pk = this.primaryKey;
2914
3801
  if (!pk) return new HttpError(400, "No primary key (@meta.id) on value-help interface");
2915
3802
  const id = query[pk];
2916
3803
  if (id === void 0) return new HttpError(400, `Missing PK field "${pk}"`);
2917
- return this.returnOne(this.getOne(id));
3804
+ return this._scopedOne(id);
2918
3805
  }
2919
3806
  /**
2920
3807
  * Meta response surfaces `@ui.dict.*` annotations as **hints** for the
@@ -3011,7 +3898,7 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
3011
3898
  const search = controls.controls.$search;
3012
3899
  if (search) {
3013
3900
  const needle = search.toLowerCase();
3014
- const fields = this.searchableFields;
3901
+ const fields = this.searchableFields.filter((field) => this.hasField(field));
3015
3902
  rows = rows.filter((row) => {
3016
3903
  for (const field of fields) {
3017
3904
  const v = row[field];
@@ -3063,25 +3950,6 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
3063
3950
  }
3064
3951
  return Object.keys(out).length > 0 ? out : void 0;
3065
3952
  }
3066
- /**
3067
- * Normalizes the raw `parseUrl` `$select` form to the engine's `{ path: 0 |
3068
- * 1 }` projection map:
3069
- * - `string[]` (e.g. from `?$select=a,b`) → inclusion map `{ a: 1, b: 1 }`,
3070
- * - a plain `{ path: 0 | 1 }` object → passed through (0 / falsy → exclude),
3071
- * - anything else / empty → `undefined` (no projection; whole rows returned).
3072
- */
3073
- normalizeSelect(select) {
3074
- if (Array.isArray(select)) {
3075
- const out = {};
3076
- for (const field of select) if (typeof field === "string" && field) out[field] = 1;
3077
- return Object.keys(out).length > 0 ? out : void 0;
3078
- }
3079
- if (select && typeof select === "object") {
3080
- const out = {};
3081
- for (const [path, v] of Object.entries(select)) out[path] = v === 0 || v === false ? 0 : 1;
3082
- return Object.keys(out).length > 0 ? out : void 0;
3083
- }
3084
- }
3085
3953
  };
3086
3954
  AsJsonValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramtypes", [
3087
3955
  Object,
@@ -3198,187 +4066,6 @@ var ActionDisabledError = class extends HttpError {
3198
4066
  }
3199
4067
  };
3200
4068
  //#endregion
3201
- //#region src/actions/current-action.ts
3202
- /** Read the current action's `TDbActionMeta` from the wook context. Returns undefined outside a controller (e.g. direct-wook test paths). */
3203
- function readCurrentActionMeta(ctx) {
3204
- let ctrl;
3205
- let methodName;
3206
- try {
3207
- const cc = useControllerContext(ctx);
3208
- ctrl = cc.getController();
3209
- methodName = cc.getMethod();
3210
- } catch {
3211
- return;
3212
- }
3213
- if (!ctrl || !methodName) return void 0;
3214
- return getAtscriptDbMate().read(ctrl.constructor, methodName)?.atscript_db_action;
3215
- }
3216
- //#endregion
3217
- //#region src/actions/input-form-cache.ts
3218
- /**
3219
- * Cached parse of the action request body. Centralises the shape check so
3220
- * every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
3221
- * reads through the same gate. An array or scalar root is rejected with the
3222
- * same `ValidatorError` envelope as today's strict-shape ID failures.
3223
- */
3224
- const dbActionBodySlot = cached(async (ctx) => {
3225
- const raw = await useBody(ctx).parseBody();
3226
- if (raw == null) return {};
3227
- if (typeof raw !== "object" || Array.isArray(raw)) throw new ValidatorError([{
3228
- path: "",
3229
- message: "Action body must be an object of shape { ids?, input? }"
3230
- }]);
3231
- return raw;
3232
- });
3233
- /** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
3234
- const dbActionInputSlot = cached(async (ctx) => {
3235
- return (await ctx.get(dbActionBodySlot)).input;
3236
- });
3237
- /** Composable for in-handler reads of the form input. */
3238
- const useDbActionInput = defineWook((ctx) => ({ load: () => ctx.get(dbActionInputSlot) }));
3239
- //#endregion
3240
- //#region src/actions/id-validation.ts
3241
- const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
3242
- function getSourceCache(source) {
3243
- let cache = SOURCE_CACHE.get(source);
3244
- if (cache) return cache;
3245
- const identifications = source.identifications;
3246
- const byKeySig = /* @__PURE__ */ new Map();
3247
- for (const ident of identifications) byKeySig.set(fieldsSig(ident.fields), ident);
3248
- const fieldByName = /* @__PURE__ */ new Map();
3249
- for (const fd of source.fieldDescriptors) fieldByName.set(fd.path, fd);
3250
- cache = {
3251
- byKeySig,
3252
- fieldByName,
3253
- formatted: identifications.map((id) => `[${id.fields.join(", ")}]`).join(", ")
3254
- };
3255
- SOURCE_CACHE.set(source, cache);
3256
- return cache;
3257
- }
3258
- function fieldsSig(fields) {
3259
- return fields.toSorted().join("");
3260
- }
3261
- function isIdValidationSource(value) {
3262
- if (!value || typeof value !== "object") return false;
3263
- const v = value;
3264
- return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
3265
- }
3266
- function validateSingleId(body, source, path = "") {
3267
- const errors = collectIdErrors(body, source, path);
3268
- if (errors.length > 0) throw new ValidatorError(errors);
3269
- return body;
3270
- }
3271
- function validateMultiId(body, source) {
3272
- if (!Array.isArray(body)) throw new ValidatorError([{
3273
- path: "",
3274
- message: "Expected JSON array of identifier objects",
3275
- details: []
3276
- }]);
3277
- const errors = [];
3278
- for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
3279
- if (errors.length > 0) throw new ValidatorError(errors);
3280
- return body;
3281
- }
3282
- function collectIdErrors(value, source, pathPrefix) {
3283
- if (!isPlainObject$1(value)) return [{
3284
- path: pathPrefix,
3285
- message: "Expected JSON object for row identifier",
3286
- details: []
3287
- }];
3288
- const cache = getSourceCache(source);
3289
- if (cache.byKeySig.size === 0) return [{
3290
- path: pathPrefix,
3291
- message: "Table has no identifier configured",
3292
- details: []
3293
- }];
3294
- const match = cache.byKeySig.get(fieldsSig(Object.keys(value)));
3295
- if (!match) return [{
3296
- path: pathPrefix,
3297
- message: `Identifier fields must exactly match one of: ${cache.formatted}`,
3298
- details: []
3299
- }];
3300
- const errors = [];
3301
- for (const fieldName of match.fields) {
3302
- const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
3303
- const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
3304
- if (err) errors.push(err);
3305
- }
3306
- return errors;
3307
- }
3308
- function checkScalar(value, fd, path) {
3309
- const expected = fd?.designType ?? "string";
3310
- if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
3311
- if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
3312
- if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
3313
- }
3314
- function scalarMismatch(path, expected, value) {
3315
- return {
3316
- path,
3317
- message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
3318
- details: []
3319
- };
3320
- }
3321
- function describe(value) {
3322
- if (value === null) return "null";
3323
- if (Array.isArray(value)) return "array";
3324
- return typeof value;
3325
- }
3326
- function isPlainObject$1(value) {
3327
- return typeof value === "object" && value !== null && !Array.isArray(value);
3328
- }
3329
- //#endregion
3330
- //#region src/actions/id-cache.ts
3331
- const boundTableKey = key("atscript_db_action_bound_table");
3332
- function controllerOf(ctx) {
3333
- return useControllerContext(ctx).getController();
3334
- }
3335
- function controllerTable(ctx) {
3336
- const ctrl = controllerOf(ctx);
3337
- return ctrl?.readable ?? ctrl?.table ?? null;
3338
- }
3339
- function getActionTable(ctx) {
3340
- return (ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0) ?? controllerTable(ctx);
3341
- }
3342
- const warnedTags = /* @__PURE__ */ new Set();
3343
- function noTableError(ctx) {
3344
- const actionName = readCurrentActionMeta(ctx)?.name;
3345
- const tag = actionName ? `"${actionName}"` : "<unknown>";
3346
- if (!warnedTags.has(tag)) {
3347
- warnedTags.add(tag);
3348
- console.warn(`${WARN_PREFIX} ${tag}: controller has no readable/table property and the action declares no opts.table. Either expose readable/table on the controller, extend AsDbReadableController, or pass opts.table on @DbAction.`);
3349
- }
3350
- return new HttpError(500, {
3351
- statusCode: 500,
3352
- error: "Internal Server Error",
3353
- message: "Internal server error",
3354
- code: "ACTION_TABLE_NOT_BOUND"
3355
- });
3356
- }
3357
- /**
3358
- * Validates the body's `ids` against the action table's identifications. For
3359
- * the controller's own table that is its `idSource` (since 0.1.134): a unique
3360
- * index over a field `hasField` hides neither addresses a row nor appears in
3361
- * the "must exactly match one of" message. An `opts.table` binding has no
3362
- * visibility hook.
3363
- */
3364
- async function resolveValidatedId(ctx, validate) {
3365
- let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
3366
- if (!source) {
3367
- const ctrl = controllerOf(ctx);
3368
- source = ctrl?.idSource ?? ctrl?.readable ?? ctrl?.table ?? null;
3369
- }
3370
- if (!isIdValidationSource(source)) throw noTableError(ctx);
3371
- const env = await ctx.get(dbActionBodySlot);
3372
- validate(env.ids, source);
3373
- return env.ids;
3374
- }
3375
- const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
3376
- const dbActionIdsSlot = cached(async (ctx) => {
3377
- return await resolveValidatedId(ctx, validateMultiId);
3378
- });
3379
- const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
3380
- const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
3381
- //#endregion
3382
4069
  //#region src/actions/row-cache.ts
3383
4070
  function asFetchTable(value) {
3384
4071
  if (!value || typeof value !== "object") return null;
@@ -3398,13 +4085,28 @@ function readActionFieldSet(ctx) {
3398
4085
  const opts = action.opts;
3399
4086
  return Array.isArray(opts.requiredFields) ? opts.requiredFields : null;
3400
4087
  }
4088
+ /**
4089
+ * Id columns plus the action's `requiredFields` — minus any the controller's
4090
+ * field visibility hides (since 0.1.143; `hasField`, and a derived field over
4091
+ * a hidden source): a hidden column is never loaded, so a `disabled`
4092
+ * predicate sees it as `undefined`.
4093
+ */
3401
4094
  function seedActionFields(ctx, table) {
3402
4095
  const fields = /* @__PURE__ */ new Set();
3403
4096
  for (const f of table.preferredId ?? table.primaryKeys) fields.add(f);
3404
4097
  const action = readActionFieldSet(ctx);
3405
- if (action) for (const f of action) fields.add(f);
4098
+ if (action) {
4099
+ const visible = actionFieldVisibility(ctx);
4100
+ for (const f of action) if (!visible || visible(f)) fields.add(f);
4101
+ }
3406
4102
  return fields;
3407
4103
  }
4104
+ /**
4105
+ * Loaded row / rows are ANDed with the controller's row overlay (see
4106
+ * `dbActionOverlaySlot`, since 0.1.143): an out-of-scope id loads nothing —
4107
+ * exactly like a missing one (the same 404 on `'row'` actions, so the two
4108
+ * can't be told apart).
4109
+ */
3408
4110
  async function loadRow(ctx) {
3409
4111
  const id = await ctx.get(dbActionIdSlot);
3410
4112
  const table = asFetchTable(getActionTable(ctx));
@@ -3412,7 +4114,7 @@ async function loadRow(ctx) {
3412
4114
  const fields = seedActionFields(ctx, table);
3413
4115
  for (const k of Object.keys(id)) fields.add(k);
3414
4116
  const row = await table.findOne({
3415
- filter: id,
4117
+ filter: withOverlay(id, await ctx.get(dbActionOverlaySlot)),
3416
4118
  controls: { $select: [...fields] }
3417
4119
  });
3418
4120
  if (row == null) throw new HttpError(404, "Row not found for action identifier");
@@ -3444,7 +4146,7 @@ async function loadRows(ctx) {
3444
4146
  }
3445
4147
  }
3446
4148
  const rows = await table.findMany({
3447
- filter: { $or: dedupedIds },
4149
+ filter: withOverlay({ $or: dedupedIds }, await ctx.get(dbActionOverlaySlot)),
3448
4150
  controls: { $select: [...fields] }
3449
4151
  });
3450
4152
  const rowByKey = /* @__PURE__ */ new Map();
@@ -3469,7 +4171,6 @@ const useDbActionRow = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowSlo
3469
4171
  const useDbActionRows = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowsSlot) }));
3470
4172
  //#endregion
3471
4173
  //#region src/actions/gate-interceptor.ts
3472
- const GATE_PRIORITY = TInterceptorPriority.AFTER_GUARD;
3473
4174
  function injectBoundTable(fallback) {
3474
4175
  const ctx = current();
3475
4176
  if (ctx.has(boundTableKey)) return;
@@ -3479,62 +4180,98 @@ function injectBoundTable(fallback) {
3479
4180
  function buildGateInterceptor(opts) {
3480
4181
  const { action, level, disabled, onDisabledRows, table } = opts;
3481
4182
  return defineBeforeInterceptor(async () => {
3482
- injectBoundTable(table);
3483
4183
  const ctx = current();
4184
+ await awaitActionPrepared(ctx);
4185
+ injectBoundTable(table);
3484
4186
  if (level === "row") {
3485
4187
  const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
3486
4188
  assertVerdictLength(action, verdicts, 1);
3487
4189
  if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
3488
4190
  return;
3489
4191
  }
3490
- const ids = await ctx.get(dbActionIdsSlot);
3491
- const rows = await ctx.get(dbActionRowsSlot);
3492
- const existingRows = [];
3493
- for (const row of rows) if (row !== void 0) existingRows.push(row);
3494
- const verdicts = disabled(existingRows);
4192
+ await gateRows(ctx, action, disabled, onDisabledRows);
4193
+ }, ACTION_GATE_PRIORITY);
4194
+ }
4195
+ /**
4196
+ * `'rows'` level: a request id without a loaded row (missing, or outside the
4197
+ * row overlay — indistinguishable) fails like a disabled row with no reason;
4198
+ * `disabled` (when given) judges the loaded rows. Then `onDisabledRows`
4199
+ * applies: `'reject'` → 409 listing every failing id; `'skip'` → the cached
4200
+ * ids / rows narrow to the survivors (zero survivors → 409 with every id).
4201
+ */
4202
+ async function gateRows(ctx, action, disabled, onDisabledRows) {
4203
+ const ids = await ctx.get(dbActionIdsSlot);
4204
+ const rows = await ctx.get(dbActionRowsSlot);
4205
+ const existingRows = [];
4206
+ for (const row of rows) if (row !== void 0) existingRows.push(row);
4207
+ let verdicts;
4208
+ if (disabled) {
4209
+ verdicts = disabled(existingRows);
3495
4210
  assertVerdictLength(action, verdicts, existingRows.length);
3496
- const failingIds = [];
3497
- const failingReasons = [];
3498
- const passingRows = [];
3499
- const passingIds = [];
3500
- let verdictIndex = 0;
3501
- for (let i = 0; i < ids.length; i++) {
3502
- const row = rows[i];
3503
- const verdict = row === void 0 ? void 0 : verdicts[verdictIndex++];
3504
- if (row === void 0 || verdict) {
3505
- failingIds.push(ids[i]);
3506
- failingReasons.push(verdictReason(verdict));
3507
- } else {
3508
- passingRows.push(row);
3509
- passingIds.push(ids[i]);
3510
- }
4211
+ }
4212
+ const failingIds = [];
4213
+ const failingReasons = [];
4214
+ const passingRows = [];
4215
+ const passingIds = [];
4216
+ let verdictIndex = 0;
4217
+ for (let i = 0; i < ids.length; i++) {
4218
+ const row = rows[i];
4219
+ const verdict = row === void 0 ? void 0 : verdicts?.[verdictIndex++];
4220
+ if (row === void 0 || verdict) {
4221
+ failingIds.push(ids[i]);
4222
+ failingReasons.push(verdictReason(verdict));
4223
+ } else {
4224
+ passingRows.push(row);
4225
+ passingIds.push(ids[i]);
3511
4226
  }
3512
- if (onDisabledRows === "skip") {
3513
- if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
3514
- if (failingIds.length > 0) {
3515
- ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
3516
- ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
3517
- }
3518
- return;
4227
+ }
4228
+ if (onDisabledRows === "skip") {
4229
+ if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids], failingReasons);
4230
+ if (failingIds.length > 0) {
4231
+ ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
4232
+ ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
3519
4233
  }
3520
- if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
3521
- }, GATE_PRIORITY);
4234
+ return;
4235
+ }
4236
+ if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
3522
4237
  }
3523
- /** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
4238
+ /**
4239
+ * Interceptor for `'row'` / `'rows'` actions without `disabled` (and for a
4240
+ * `@DbActionRow*` handler of any other level: bound-table injection only):
4241
+ * runs the controller's `prepareRequest` (when defined, since 0.1.143),
4242
+ * injects the bound table and — only when the controller has a row overlay
4243
+ * (`transformOne` / `transformFilter` overridden, non-empty) — verifies the
4244
+ * requested ids against it before the handler runs by loading the row(s)
4245
+ * the handler would get: `'row'` → the 404 of a missing row; `'rows'` →
4246
+ * out-of-scope and missing ids fail like disabled rows with no reason
4247
+ * (`onDisabledRows`). No overlay → no query.
4248
+ */
3524
4249
  function buildThinInterceptor(opts) {
3525
- const { table } = opts;
3526
- return defineBeforeInterceptor(() => {
4250
+ const { table, scope } = opts;
4251
+ return defineBeforeInterceptor(async () => {
4252
+ const ctx = current();
4253
+ await awaitActionPrepared(ctx);
3527
4254
  injectBoundTable(table);
3528
- }, GATE_PRIORITY);
4255
+ if (!scope) return;
4256
+ if (!await ctx.get(dbActionOverlaySlot)) return;
4257
+ if (scope.level === "rows") await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
4258
+ else await ctx.get(dbActionRowSlot);
4259
+ }, ACTION_GATE_PRIORITY);
3529
4260
  }
3530
4261
  //#endregion
3531
4262
  //#region src/actions/db-action.decorator.ts
3532
4263
  /**
3533
4264
  * Mark a controller method as a database action surfaced via `/meta`. Writes
3534
- * `atscript_db_action` metadata and registers a Moost interceptor when needed
3535
- * (gate when `disabled` is set, thin bound-table injector when only
3536
- * `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
3537
- * is undefined and emits a warning.
4265
+ * `atscript_db_action` metadata and, for every `'row'` / `'rows'` action,
4266
+ * registers a Moost interceptor: the gate when `disabled` is set, else the
4267
+ * bound-table injector that also verifies the ids against the controller's
4268
+ * row overlay (since 0.1.143). Either first awaits the controller's
4269
+ * `prepareRequest({ endpoint: "action", action })` when it defines one —
4270
+ * before any id is validated or row loaded; a `'table'`-level action on an
4271
+ * `AsReadableController` subclass gets an interceptor for that alone
4272
+ * (since 0.1.143). Stacking two `@DbAction` on the same method
4273
+ * is undefined and emits a warning. Throws on a value-help controller
4274
+ * (since 0.1.143 — value-help controllers do not support actions).
3538
4275
  *
3539
4276
  * Generic over `TRow` (annotate at the call site: `@DbAction<Order>(...)`)
3540
4277
  * and `R` (the literal `requiredFields` tuple, inferred via `const R`).
@@ -3553,7 +4290,8 @@ function DbAction(name, opts = {}) {
3553
4290
  opts
3554
4291
  })
3555
4292
  }))(target, propertyKey, descriptor);
3556
- if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
4293
+ const ctor = typeof target === "function" ? target : target.constructor;
4294
+ if (isAsValueHelpControllerSubclass(ctor)) throw valueHelpActionError(ctor.name, [name]);
3557
4295
  const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
3558
4296
  const rawOpts = opts;
3559
4297
  if (typeof rawOpts.disabled === "function" && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
@@ -3563,7 +4301,17 @@ function DbAction(name, opts = {}) {
3563
4301
  onDisabledRows: rawOpts.onDisabledRows ?? "reject",
3564
4302
  table: rawOpts.table
3565
4303
  }))(target, propertyKey, descriptor);
3566
- else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: rawOpts.table }))(target, propertyKey, descriptor);
4304
+ else if (scan.level !== "table" || scan.hasRowParam) {
4305
+ const scope = scan.level === "table" ? void 0 : {
4306
+ action: name,
4307
+ level: scan.level,
4308
+ onDisabledRows: rawOpts.onDisabledRows ?? "reject"
4309
+ };
4310
+ Intercept(buildThinInterceptor({
4311
+ table: rawOpts.table,
4312
+ scope
4313
+ }))(target, propertyKey, descriptor);
4314
+ } else if (typeof target.parseRequest === "function") Intercept(actionPrepareInterceptor)(target, propertyKey, descriptor);
3567
4315
  return descriptor;
3568
4316
  });
3569
4317
  }
@@ -3688,7 +4436,7 @@ function DbActionRows() {
3688
4436
  * `disabled` predicate is type-narrowed by its own `requiredFields` literal.
3689
4437
  *
3690
4438
  * Multiple `@DbActions` (and shortcut) decorators on the same class
3691
- * accumulate.
4439
+ * accumulate. Throws on a value-help controller (since 0.1.143).
3692
4440
  */
3693
4441
  function DbActions(dict) {
3694
4442
  return classLevelActions(dict);
@@ -3717,10 +4465,14 @@ function classLevelActions(dict, forcedLevel) {
3717
4465
  entry: merged
3718
4466
  });
3719
4467
  }
3720
- return getAtscriptDbMate().decorate((current) => ({
4468
+ const decorate = getAtscriptDbMate().decorate((current) => ({
3721
4469
  ...current,
3722
4470
  atscript_db_actions: [...current.atscript_db_actions ?? [], ...entries]
3723
4471
  }));
4472
+ return (target) => {
4473
+ if (isAsValueHelpControllerSubclass(target)) throw valueHelpActionError(target.name, entries.map((e) => e.name));
4474
+ return decorate(target);
4475
+ };
3724
4476
  }
3725
4477
  //#endregion
3726
4478
  //#region src/actions/db-action-input-form.decorator.ts
@@ -3818,4 +4570,37 @@ function InputForm(formType, validatorOpts) {
3818
4570
  */
3819
4571
  const perRow = (fn) => (rows) => rows.map(fn);
3820
4572
  //#endregion
3821
- 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 };
4573
+ //#region src/permissions/crud-handlers.ts
4574
+ /**
4575
+ * The handler method(s) serving each CRUD op on `AsDbReadableController` /
4576
+ * `AsDbController` — what a permission layer authorizes a `/meta` `crud`
4577
+ * entry through (an op is allowed when ANY of its handlers is). `one` is
4578
+ * served by `/one/:id` and `/one?…`, `remove` by `DELETE /:id` and
4579
+ * `DELETE /?…`. Readables (no writes) serve only the read ops.
4580
+ *
4581
+ * @since 0.1.143
4582
+ */
4583
+ const DB_CRUD_HANDLERS = Object.freeze({
4584
+ query: ["query"],
4585
+ pages: ["pages"],
4586
+ one: ["getOne", "getOneComposite"],
4587
+ geo: ["geo"],
4588
+ insert: ["insert"],
4589
+ update: ["update"],
4590
+ replace: ["replace"],
4591
+ remove: ["remove", "removeComposite"]
4592
+ });
4593
+ /**
4594
+ * The handler method(s) serving each CRUD op on the value-help controllers
4595
+ * (`AsValueHelpController` / `AsJsonValueHelpController` — read ops only,
4596
+ * no `geo`). Same contract as {@link DB_CRUD_HANDLERS}.
4597
+ *
4598
+ * @since 0.1.143
4599
+ */
4600
+ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
4601
+ query: ["runQuery"],
4602
+ pages: ["runPages"],
4603
+ one: ["runGetOne", "runGetOneComposite"]
4604
+ });
4605
+ //#endregion
4606
+ export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, 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, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };