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