@atscript/moost-db 0.1.142 → 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 +1258 -474
- package/dist/index.d.cts +518 -59
- package/dist/index.d.mts +518 -59
- package/dist/index.mjs +1256 -475
- package/package.json +3 -3
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
@@ -772,6 +830,7 @@ let AsReadableController = class AsReadableController {
|
|
|
772
830
|
_queryControlsValidator;
|
|
773
831
|
_pagesControlsValidator;
|
|
774
832
|
_getOneControlsValidator;
|
|
833
|
+
_geoControlsValidator;
|
|
775
834
|
get queryControlsValidator() {
|
|
776
835
|
if (!this._queryControlsValidator) this._queryControlsValidator = QueryControlsDto.validator();
|
|
777
836
|
return this._queryControlsValidator;
|
|
@@ -784,9 +843,52 @@ let AsReadableController = class AsReadableController {
|
|
|
784
843
|
if (!this._getOneControlsValidator) this._getOneControlsValidator = GetOneControlsDto.validator();
|
|
785
844
|
return this._getOneControlsValidator;
|
|
786
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
|
+
*/
|
|
787
877
|
validateControls(controls, type) {
|
|
788
878
|
if (type === "query" && controls.$groupBy !== void 0) return;
|
|
789
|
-
|
|
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
|
+
}
|
|
790
892
|
if (!v.validate(controls, true)) return v.errors[0]?.message || "Invalid controls";
|
|
791
893
|
}
|
|
792
894
|
validateInsights(insights) {
|
|
@@ -880,6 +982,19 @@ let AsReadableController = class AsReadableController {
|
|
|
880
982
|
* response by principal.
|
|
881
983
|
*/
|
|
882
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() {
|
|
883
998
|
const key = this.metaCacheKey();
|
|
884
999
|
if (!this._metaResponse || key !== this._metaResponseKey) {
|
|
885
1000
|
this._metaResponse = this.buildMetaResponse();
|
|
@@ -900,12 +1015,14 @@ let AsReadableController = class AsReadableController {
|
|
|
900
1015
|
* compiled `.as` class's `.name`, registered when an action's parameter is
|
|
901
1016
|
* decorated with `@InputForm(FormType)`. Schemas are serialized once and
|
|
902
1017
|
* cached per controller; the response uses the same annotation-allowlist
|
|
903
|
-
* 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.
|
|
904
1020
|
*/
|
|
905
1021
|
async metaForm(name) {
|
|
906
|
-
|
|
1022
|
+
await this.parseRequest("metaForm");
|
|
1023
|
+
const envelopes = discoverActions(this.constructor, this.app, this.logger);
|
|
907
1024
|
const formType = getControllerFormType(this.constructor, name);
|
|
908
|
-
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}"`);
|
|
909
1026
|
let cached = this._formSchemas.get(name);
|
|
910
1027
|
if (!cached) {
|
|
911
1028
|
cached = this.serializeForMeta(formType);
|
|
@@ -914,6 +1031,18 @@ let AsReadableController = class AsReadableController {
|
|
|
914
1031
|
return cached;
|
|
915
1032
|
}
|
|
916
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
|
+
/**
|
|
917
1046
|
* Builds the `/meta` payload. Override in subclasses to populate source-specific
|
|
918
1047
|
* fields. Subclasses that fully replace the envelope must call
|
|
919
1048
|
* {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
|
|
@@ -1069,68 +1198,347 @@ function augmentRowsWithActions(args) {
|
|
|
1069
1198
|
return rows;
|
|
1070
1199
|
}
|
|
1071
1200
|
//#endregion
|
|
1072
|
-
//#region src/
|
|
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
|
|
1073
1218
|
/**
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
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.
|
|
1076
1223
|
*/
|
|
1077
|
-
const
|
|
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
|
|
1078
1241
|
/**
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
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.
|
|
1082
1247
|
*/
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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;
|
|
1087
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;
|
|
1088
1271
|
/**
|
|
1089
|
-
*
|
|
1090
|
-
* the
|
|
1091
|
-
* `
|
|
1092
|
-
*
|
|
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.
|
|
1093
1276
|
*/
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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);
|
|
1114
1350
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
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) {
|
|
1117
1361
|
return {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
},
|
|
1122
|
-
prefix
|
|
1362
|
+
path,
|
|
1363
|
+
message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
|
|
1364
|
+
details: []
|
|
1123
1365
|
};
|
|
1124
1366
|
}
|
|
1125
|
-
function
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
return classMeta;
|
|
1130
|
-
}), (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;
|
|
1131
1371
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
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}
|
|
1134
1542
|
* subclass into a fully wired HTTP controller for a given `@db.table` model.
|
|
1135
1543
|
*
|
|
1136
1544
|
* Internally applies three decorators:
|
|
@@ -1243,6 +1651,17 @@ const OP_VERB = {
|
|
|
1243
1651
|
aggregate: "aggregate over",
|
|
1244
1652
|
bucket: "bucket"
|
|
1245
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
|
+
}
|
|
1246
1665
|
/** The one "nonexistent path" verdict — hidden paths answer with it byte for byte. */
|
|
1247
1666
|
function unknownField(path) {
|
|
1248
1667
|
return {
|
|
@@ -1337,7 +1756,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1337
1756
|
const nav = new Set(source.navFields);
|
|
1338
1757
|
if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
|
|
1339
1758
|
this.navFields = nav;
|
|
1340
|
-
const isNavOrDescendant = (path) =>
|
|
1759
|
+
const isNavOrDescendant = (path) => (0, _atscript_db.selfOrAncestor)(path, nav) !== void 0;
|
|
1341
1760
|
const flatMap = source.flatMap;
|
|
1342
1761
|
const annotated = (fd, key) => {
|
|
1343
1762
|
const fromFlat = flatMap.get(fd.path)?.metadata?.has?.(key);
|
|
@@ -1365,7 +1784,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1365
1784
|
if (isNavOrDescendant(path)) continue;
|
|
1366
1785
|
if (ignored.has(path)) continue;
|
|
1367
1786
|
if ((0, _atscript_db.findAncestorInSet)(path, jsonParents) !== void 0) continue;
|
|
1368
|
-
if (
|
|
1787
|
+
if ((0, _atscript_db.selfOrAncestor)(path, encrypted) !== void 0) continue;
|
|
1369
1788
|
this._objectParents.set(path, []);
|
|
1370
1789
|
}
|
|
1371
1790
|
for (const [parent, leaves] of this._objectParents) {
|
|
@@ -1537,23 +1956,14 @@ const QUERY_CONTROLS = [
|
|
|
1537
1956
|
const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
|
|
1538
1957
|
const ONE_CONTROLS = dtoControls(GetOneControlsDto);
|
|
1539
1958
|
/**
|
|
1540
|
-
* Controls accepted by the `/geo` endpoint
|
|
1541
|
-
* `$maxDistance` / `$minDistance` are parsed
|
|
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`.
|
|
1542
1962
|
*/
|
|
1543
1963
|
const GEO_CONTROLS = [
|
|
1544
1964
|
"filter",
|
|
1545
1965
|
"insights",
|
|
1546
|
-
|
|
1547
|
-
"maxDistance",
|
|
1548
|
-
"minDistance",
|
|
1549
|
-
"index",
|
|
1550
|
-
"select",
|
|
1551
|
-
"skip",
|
|
1552
|
-
"limit",
|
|
1553
|
-
"page",
|
|
1554
|
-
"size",
|
|
1555
|
-
"with",
|
|
1556
|
-
"actions"
|
|
1966
|
+
...dtoControls(GeoControlsDto)
|
|
1557
1967
|
];
|
|
1558
1968
|
//#endregion
|
|
1559
1969
|
//#region src/as-db-readable.controller.ts
|
|
@@ -1567,6 +1977,11 @@ const PATH_OPS = [
|
|
|
1567
1977
|
"aggregate",
|
|
1568
1978
|
"bucket"
|
|
1569
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
|
+
}
|
|
1570
1985
|
let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
|
|
1571
1986
|
/** Reference to the underlying readable (table or view). */
|
|
1572
1987
|
readable;
|
|
@@ -1605,13 +2020,31 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1605
2020
|
metaCacheKey() {
|
|
1606
2021
|
return this.capabilities;
|
|
1607
2022
|
}
|
|
1608
|
-
/** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
|
|
1609
|
-
_exists = (path) => this.hasField(path);
|
|
1610
2023
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
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.
|
|
1615
2048
|
*/
|
|
1616
2049
|
_idOpts;
|
|
1617
2050
|
/** Narrowed id sources, one stable object per distinct visible-identification set. */
|
|
@@ -1620,6 +2053,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1620
2053
|
_overlayIsNoOp;
|
|
1621
2054
|
/** `true` when a subclass implements {@link decorateRows} (the override switches the hook on). */
|
|
1622
2055
|
_decorates;
|
|
2056
|
+
/** `true` when a subclass overrides {@link transformOne} or {@link transformFilter} (a row overlay may exist). */
|
|
2057
|
+
_hasRowOverlay;
|
|
1623
2058
|
/** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
|
|
1624
2059
|
_quantityRefByPath;
|
|
1625
2060
|
/** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
|
|
@@ -1637,6 +2072,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1637
2072
|
super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
|
|
1638
2073
|
this.readable = resolved;
|
|
1639
2074
|
this._writeOnlySet = this._collectAnnotated("db.writeOnly");
|
|
2075
|
+
this._derivedSource = this._derivedSourcesOf(resolved);
|
|
1640
2076
|
this._invertibleFields = this._collectInvertibleFields();
|
|
1641
2077
|
this._searchFallbackFields = this._collectSearchFallbackFields();
|
|
1642
2078
|
this._preferredIdSet = new Set(resolved.preferredId ?? []);
|
|
@@ -1644,7 +2080,21 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1644
2080
|
const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
|
|
1645
2081
|
this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
|
|
1646
2082
|
this._decorates = typeof this.decorateRows === "function";
|
|
1647
|
-
|
|
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;
|
|
1648
2098
|
}
|
|
1649
2099
|
/**
|
|
1650
2100
|
* The identifications this request may address rows through (since
|
|
@@ -1673,7 +2123,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1673
2123
|
const nav = this.capabilities.navFields;
|
|
1674
2124
|
for (const fd of this.readable.fieldDescriptors) {
|
|
1675
2125
|
if (fd.ignored) continue;
|
|
1676
|
-
if (
|
|
2126
|
+
if ((0, _atscript_db.selfOrAncestor)(fd.path, nav) !== void 0) continue;
|
|
1677
2127
|
out.push(fd.path);
|
|
1678
2128
|
}
|
|
1679
2129
|
return out;
|
|
@@ -1688,6 +2138,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1688
2138
|
}
|
|
1689
2139
|
return out;
|
|
1690
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
|
+
}
|
|
1691
2152
|
_collectAnnotated(annotation) {
|
|
1692
2153
|
const out = /* @__PURE__ */ new Set();
|
|
1693
2154
|
for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
|
|
@@ -1707,9 +2168,18 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1707
2168
|
* an action id (primary key and `preferredId` always are) — and the
|
|
1708
2169
|
* nested-object 400 hint lists visible leaves only. The default accepts every real path
|
|
1709
2170
|
* (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
|
|
1710
|
-
* there with `applyMetaOverlay
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
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.
|
|
1713
2183
|
*/
|
|
1714
2184
|
hasField(path) {
|
|
1715
2185
|
if (typeof this.readable.isValidFieldPath === "function") return this.readable.isValidFieldPath(path);
|
|
@@ -1730,23 +2200,26 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1730
2200
|
* ran first); a bucket's source is checked like any other path (op
|
|
1731
2201
|
* `bucket`). After the per-path checks the core `$having` rule runs
|
|
1732
2202
|
* (`checkHavingKeys`: aliases or `$groupBy` fields only), so a readable mock
|
|
1733
|
-
* 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}.
|
|
1734
2206
|
*/
|
|
1735
2207
|
checkCapabilities(parsed) {
|
|
1736
2208
|
const capabilities = this.capabilities;
|
|
2209
|
+
const isVisible = this.fieldVisibility.isVisible;
|
|
1737
2210
|
const refs = (0, _atscript_db.collectQueryPaths)(parsed);
|
|
1738
2211
|
if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, (0, _atscript_db.unsupportedOperatorMessage)(refs.unsupportedOperator));
|
|
1739
2212
|
for (const { path, predicate } of refs.filter) {
|
|
1740
|
-
const verdict = capabilities.check(path, "filter",
|
|
2213
|
+
const verdict = capabilities.check(path, "filter", isVisible, predicate);
|
|
1741
2214
|
if (verdict) return badRequest(verdict.path, verdict.message);
|
|
1742
2215
|
}
|
|
1743
2216
|
for (const op of PATH_OPS) for (const path of refs[op]) {
|
|
1744
|
-
const verdict = capabilities.check(path, op,
|
|
2217
|
+
const verdict = capabilities.check(path, op, isVisible);
|
|
1745
2218
|
if (verdict) return badRequest(verdict.path, verdict.message);
|
|
1746
2219
|
}
|
|
1747
2220
|
const having = (0, _atscript_db.checkHavingKeys)(refs);
|
|
1748
2221
|
if (having) return badRequest(having.path, having.message);
|
|
1749
|
-
return this.checkGates(parsed);
|
|
2222
|
+
return this.checkGates(parsed) ?? this._checkIndexGate(parsed.controls ?? {});
|
|
1750
2223
|
}
|
|
1751
2224
|
/**
|
|
1752
2225
|
* The core's shared normalizer of `$select` computed entries
|
|
@@ -1789,22 +2262,278 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1789
2262
|
if (!this.hasField(key)) return `Unknown field "${key}"`;
|
|
1790
2263
|
}
|
|
1791
2264
|
}
|
|
1792
|
-
/**
|
|
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
|
+
*/
|
|
1793
2273
|
validateParsed(parsed, type) {
|
|
1794
2274
|
const computedError = this.checkComputedSelect(parsed.controls);
|
|
1795
2275
|
if (computedError) return computedError;
|
|
1796
|
-
const
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
+
}
|
|
1799
2286
|
if (withRelations?.length) {
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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);
|
|
1806
2323
|
}
|
|
2324
|
+
path += `${segment}.`;
|
|
2325
|
+
level = typeof level.relatedTable === "function" ? level.relatedTable(segment) : void 0;
|
|
1807
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));
|
|
1808
2537
|
}
|
|
1809
2538
|
}
|
|
1810
2539
|
/**
|
|
@@ -1892,7 +2621,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1892
2621
|
*/
|
|
1893
2622
|
_invertExclusion(excluded) {
|
|
1894
2623
|
return this._invertibleFields.filter((path) => {
|
|
1895
|
-
if (
|
|
2624
|
+
if ((0, _atscript_db.selfOrAncestor)(path, excluded) !== void 0) return false;
|
|
1896
2625
|
const prefix = `${path}.`;
|
|
1897
2626
|
for (const key of excluded) if (key.startsWith(prefix)) return false;
|
|
1898
2627
|
return true;
|
|
@@ -1938,11 +2667,6 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1938
2667
|
}
|
|
1939
2668
|
return projection;
|
|
1940
2669
|
}
|
|
1941
|
-
/** WHY: the URL parser only auto-coerces `$count`; every other boolean control reaches us as `"true"`/`"1"` and would fail DTO validation. */
|
|
1942
|
-
_coerceActionsControl(controls) {
|
|
1943
|
-
const v = controls.$actions;
|
|
1944
|
-
if (typeof v === "string") controls.$actions = v === "true" || v === "1" || v === "";
|
|
1945
|
-
}
|
|
1946
2670
|
/** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
|
|
1947
2671
|
_resolveProjectionForAugmenter(select) {
|
|
1948
2672
|
if (select === void 0) return null;
|
|
@@ -1969,12 +2693,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1969
2693
|
const rowLevelEnvelopes = discoverRowLevelActions(this.constructor, this.app, this.logger);
|
|
1970
2694
|
if (rowLevelEnvelopes.length === 0) return null;
|
|
1971
2695
|
if (this._overlayIsNoOp) return rowLevelEnvelopes;
|
|
1972
|
-
const overlayMeta = await this.
|
|
2696
|
+
const overlayMeta = await this.resolveMeta();
|
|
1973
2697
|
const allowedNames = new Set(overlayMeta.actions.map((a) => a.name));
|
|
1974
2698
|
const filtered = rowLevelEnvelopes.filter((e) => allowedNames.has(e.info.name));
|
|
1975
2699
|
return filtered.length === 0 ? null : filtered;
|
|
1976
2700
|
}
|
|
1977
|
-
/**
|
|
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
|
+
*/
|
|
1978
2707
|
_widenSelectForActions(envelopes, baseSelect) {
|
|
1979
2708
|
let resultSet = null;
|
|
1980
2709
|
let result = null;
|
|
@@ -1982,7 +2711,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
1982
2711
|
const raw = e.raw;
|
|
1983
2712
|
if (!Array.isArray(raw.requiredFields)) continue;
|
|
1984
2713
|
for (const f of raw.requiredFields) {
|
|
1985
|
-
if (resultSet ? resultSet.has(f) : baseSelect.includes(f)) continue;
|
|
2714
|
+
if ((resultSet ? resultSet.has(f) : baseSelect.includes(f)) || !this.fieldVisibility.isVisible(f)) continue;
|
|
1986
2715
|
if (resultSet === null) {
|
|
1987
2716
|
resultSet = new Set(baseSelect);
|
|
1988
2717
|
result = [...baseSelect];
|
|
@@ -2021,14 +2750,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2021
2750
|
return out;
|
|
2022
2751
|
}
|
|
2023
2752
|
/**
|
|
2024
|
-
*
|
|
2025
|
-
* exclusion
|
|
2026
|
-
*
|
|
2027
|
-
* overlays compose (they see the wire `$select`; this guarantees the seal on
|
|
2028
|
-
* 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.
|
|
2029
2756
|
*/
|
|
2030
|
-
|
|
2031
|
-
const writeOnly = this._writeOnlySet;
|
|
2757
|
+
_sealSelect(select, writeOnly) {
|
|
2032
2758
|
if (writeOnly.size === 0) return select;
|
|
2033
2759
|
const exclusion = () => {
|
|
2034
2760
|
const out = {};
|
|
@@ -2053,7 +2779,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2053
2779
|
/** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
|
|
2054
2780
|
_findWriteOnlyInAggregate(groupBy, select) {
|
|
2055
2781
|
if (this._writeOnlySet.size === 0) return void 0;
|
|
2056
|
-
const sealed = (f) => this._writeOnlySet.has(f) && this.
|
|
2782
|
+
const sealed = (f) => this._writeOnlySet.has(f) && this.fieldVisibility.isVisible(f);
|
|
2057
2783
|
for (const f of groupBy) if (sealed(f)) return f;
|
|
2058
2784
|
if (Array.isArray(select)) for (const item of select) {
|
|
2059
2785
|
const field = typeof item === "string" ? item : item.$field;
|
|
@@ -2063,18 +2789,20 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2063
2789
|
/**
|
|
2064
2790
|
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
2065
2791
|
* substring match OR'd across the `@db.column.searchable` fields, `$and`-combined
|
|
2066
|
-
* with the existing filter. Applies only when
|
|
2067
|
-
*
|
|
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).
|
|
2068
2796
|
*/
|
|
2069
2797
|
applySearchFallback(filter, controls) {
|
|
2070
2798
|
const term = controls.$search;
|
|
2071
2799
|
if (!term || controls.$vector !== void 0) return filter;
|
|
2072
|
-
if (this.
|
|
2073
|
-
const fields = this._searchFallbackFields.filter((f) => this.
|
|
2800
|
+
if (this._nativeSearch(controls)) return filter;
|
|
2801
|
+
const fields = this._searchFallbackFields.filter((f) => this.fieldVisibility.isVisible(f));
|
|
2074
2802
|
if (fields.length === 0) return filter;
|
|
2075
2803
|
const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
|
|
2076
2804
|
const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
|
|
2077
|
-
return filter &&
|
|
2805
|
+
return filter && !(0, _atscript_db.isEmptyObject)(filter) ? { $and: [filter, fragment] } : fragment;
|
|
2078
2806
|
}
|
|
2079
2807
|
/**
|
|
2080
2808
|
* The controls a grouped query hands to the adapter. `$search` has two
|
|
@@ -2096,7 +2824,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2096
2824
|
* the same shape as the one this whole path exists to remove.
|
|
2097
2825
|
*/
|
|
2098
2826
|
_aggregateControls(controls) {
|
|
2099
|
-
if (controls.$search === void 0 || this.
|
|
2827
|
+
if (controls.$search === void 0 || this._nativeSearch(controls)) return controls;
|
|
2100
2828
|
const rest = { ...controls };
|
|
2101
2829
|
delete rest.$search;
|
|
2102
2830
|
delete rest.$index;
|
|
@@ -2111,7 +2839,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2111
2839
|
vector: await this.computeEmbedding(searchTerm, vectorField || void 0),
|
|
2112
2840
|
vectorField
|
|
2113
2841
|
};
|
|
2114
|
-
if (searchTerm && this.
|
|
2842
|
+
if (searchTerm && this._nativeSearch(controls)) return {
|
|
2115
2843
|
kind: "search",
|
|
2116
2844
|
term: searchTerm,
|
|
2117
2845
|
index: indexName
|
|
@@ -2157,6 +2885,56 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2157
2885
|
return result;
|
|
2158
2886
|
}
|
|
2159
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
|
+
/**
|
|
2160
2938
|
* Pick the first identification (PK or unique index) whose fields are all
|
|
2161
2939
|
* present in the query. A unique index over a field {@link hasField} hides
|
|
2162
2940
|
* is not a candidate (since 0.1.134) — `?hidden=x` answers exactly like
|
|
@@ -2181,9 +2959,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2181
2959
|
* **GET /query** — returns an array of records or a count.
|
|
2182
2960
|
*/
|
|
2183
2961
|
async query(url) {
|
|
2184
|
-
const parsed = this.
|
|
2185
|
-
const controls = parsed.controls;
|
|
2186
|
-
this._coerceActionsControl(controls);
|
|
2962
|
+
const { parsed, controls } = await this.parseRequest("query", url);
|
|
2187
2963
|
const groupBy = controls.$groupBy;
|
|
2188
2964
|
if (groupBy?.length && controls.$with?.length) return new _moostjs_event_http.HttpError(400, "Cannot combine $with and $groupBy in the same query");
|
|
2189
2965
|
if (groupBy?.length && controls.$vector !== void 0) return new _moostjs_event_http.HttpError(400, "Cannot combine $vector and $groupBy in the same query");
|
|
@@ -2205,21 +2981,21 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2205
2981
|
}
|
|
2206
2982
|
const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
2207
2983
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
2208
|
-
const
|
|
2984
|
+
const sealed = this._sealControls(controls, transformedSelect);
|
|
2209
2985
|
if (controls.$count) return this.readable.count({
|
|
2210
2986
|
filter,
|
|
2211
2987
|
controls: {
|
|
2212
2988
|
...controls,
|
|
2213
|
-
$select:
|
|
2989
|
+
$select: sealed.$select
|
|
2214
2990
|
}
|
|
2215
2991
|
});
|
|
2216
|
-
const select = this.widenPreferredIdProjection(
|
|
2992
|
+
const select = this.widenPreferredIdProjection(sealed.$select);
|
|
2217
2993
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
2218
2994
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
2219
2995
|
const queryObj = {
|
|
2220
2996
|
filter,
|
|
2221
2997
|
controls: {
|
|
2222
|
-
...
|
|
2998
|
+
...sealed,
|
|
2223
2999
|
$select: select,
|
|
2224
3000
|
$limit: controls.$limit || 1e3,
|
|
2225
3001
|
$threshold: threshold
|
|
@@ -2237,11 +3013,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2237
3013
|
* **GET /pages** — returns paginated records with metadata.
|
|
2238
3014
|
*/
|
|
2239
3015
|
async pages(url) {
|
|
2240
|
-
const parsed = this.
|
|
2241
|
-
this._coerceActionsControl(parsed.controls);
|
|
3016
|
+
const { parsed, controls } = await this.parseRequest("pages", url);
|
|
2242
3017
|
const error = this.validateParsed(parsed, "pages");
|
|
2243
3018
|
if (error) return error;
|
|
2244
|
-
const controls = parsed.controls;
|
|
2245
3019
|
const gateError = this.checkCapabilities(parsed);
|
|
2246
3020
|
if (gateError) return gateError;
|
|
2247
3021
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
@@ -2249,14 +3023,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2249
3023
|
const skip = (page - 1) * size;
|
|
2250
3024
|
const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
2251
3025
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
2252
|
-
const
|
|
2253
|
-
const select = this.widenPreferredIdProjection(
|
|
3026
|
+
const sealed = this._sealControls(controls, transformedSelect);
|
|
3027
|
+
const select = this.widenPreferredIdProjection(sealed.$select);
|
|
2254
3028
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
2255
3029
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
2256
3030
|
const query = {
|
|
2257
3031
|
filter,
|
|
2258
3032
|
controls: {
|
|
2259
|
-
...
|
|
3033
|
+
...sealed,
|
|
2260
3034
|
$select: select,
|
|
2261
3035
|
$skip: skip,
|
|
2262
3036
|
$limit: size,
|
|
@@ -2286,12 +3060,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2286
3060
|
* (meters), `$index` (geo index name), plus the standard filter / `$select` /
|
|
2287
3061
|
* `$with` / pagination syntax. Each row carries a computed `$distance`
|
|
2288
3062
|
* (meters). With `$page` / `$size` the response is the `/pages` envelope;
|
|
2289
|
-
* 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.
|
|
2290
3066
|
*/
|
|
2291
3067
|
async geo(url) {
|
|
2292
|
-
const parsed = this.
|
|
2293
|
-
const controls = parsed.controls;
|
|
2294
|
-
this._coerceActionsControl(controls);
|
|
3068
|
+
const { parsed, controls } = await this.parseRequest("geo", url);
|
|
2295
3069
|
const point = this._parseGeoCenter(controls.$center);
|
|
2296
3070
|
if (point instanceof _moostjs_event_http.HttpError) return point;
|
|
2297
3071
|
for (const key of ["$maxDistance", "$minDistance"]) if (controls[key] !== void 0) {
|
|
@@ -2300,16 +3074,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2300
3074
|
controls[key] = num;
|
|
2301
3075
|
}
|
|
2302
3076
|
const indexName = typeof controls.$index === "string" ? controls.$index : void 0;
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
if (insightsError) return new _moostjs_event_http.HttpError(400, insightsError);
|
|
2306
|
-
}
|
|
2307
|
-
const computedError = this.checkComputedSelect(controls);
|
|
2308
|
-
if (computedError) return computedError;
|
|
3077
|
+
const error = this.validateParsed(parsed, "geo");
|
|
3078
|
+
if (error) return error;
|
|
2309
3079
|
const gateError = this.checkCapabilities(parsed);
|
|
2310
3080
|
if (gateError) return gateError;
|
|
2311
3081
|
const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
2312
|
-
const
|
|
3082
|
+
const sealed = this._sealControls(controls, transformedSelect);
|
|
3083
|
+
const select = this.widenPreferredIdProjection(sealed.$select);
|
|
2313
3084
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
2314
3085
|
const paginated = controls.$page !== void 0 || controls.$size !== void 0;
|
|
2315
3086
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
@@ -2317,7 +3088,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2317
3088
|
const queryObj = {
|
|
2318
3089
|
filter,
|
|
2319
3090
|
controls: {
|
|
2320
|
-
...
|
|
3091
|
+
...sealed,
|
|
2321
3092
|
$center: void 0,
|
|
2322
3093
|
$index: void 0,
|
|
2323
3094
|
$select: select,
|
|
@@ -2362,17 +3133,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2362
3133
|
* read overlays gate `/one` symmetrically with `/query` / `/pages`.
|
|
2363
3134
|
*/
|
|
2364
3135
|
async getOne(id, url) {
|
|
2365
|
-
const { parsed, hasNonControl } = this.
|
|
3136
|
+
const { parsed, controls, hasNonControl } = await this.parseRequest("one", url);
|
|
2366
3137
|
if (hasNonControl) return new _moostjs_event_http.HttpError(400, "Filtering is not allowed for \"one\" endpoint");
|
|
2367
|
-
this.
|
|
2368
|
-
const error = this.validateParsed(parsed, "getOne");
|
|
2369
|
-
if (error) return error;
|
|
2370
|
-
const gateError = this.checkCapabilities(parsed);
|
|
2371
|
-
if (gateError) return gateError;
|
|
2372
|
-
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
2373
|
-
const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
|
|
2374
|
-
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
2375
|
-
return this._findByIdAndAugment(id, parsed.controls, select);
|
|
3138
|
+
return this._readOne(id, parsed, controls);
|
|
2376
3139
|
}
|
|
2377
3140
|
/**
|
|
2378
3141
|
* **GET /one?field1=val1&field2=val2** — retrieves a single record by composite key
|
|
@@ -2380,42 +3143,33 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2380
3143
|
* gating as {@link getOne}.
|
|
2381
3144
|
*/
|
|
2382
3145
|
async getOneComposite(query, url) {
|
|
3146
|
+
const { parsed, controls } = await this.parseRequest("one", url);
|
|
2383
3147
|
const idObj = this.extractIdShape(query);
|
|
2384
3148
|
if (idObj instanceof _moostjs_event_http.HttpError) return idObj;
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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);
|
|
2388
3158
|
if (error) return error;
|
|
2389
|
-
const
|
|
2390
|
-
|
|
2391
|
-
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
2392
|
-
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);
|
|
2393
3161
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const initialSelect = prep?.widenedSelect ?? select;
|
|
2399
|
-
const controls = {
|
|
2400
|
-
...parsedControls,
|
|
2401
|
-
$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
|
|
2402
3166
|
};
|
|
2403
|
-
const
|
|
2404
|
-
let row = null;
|
|
2405
|
-
if (idFilter) {
|
|
2406
|
-
const overlay = await this.transformOne({});
|
|
2407
|
-
const filter = overlay && Object.keys(overlay).length > 0 ? { $and: [idFilter, overlay] } : idFilter;
|
|
2408
|
-
row = await this.readable.findOne({
|
|
2409
|
-
filter,
|
|
2410
|
-
controls
|
|
2411
|
-
});
|
|
2412
|
-
}
|
|
2413
|
-
const item = await this.returnOne(Promise.resolve(row));
|
|
3167
|
+
const item = await this.returnOne(this._findRow(id, overlay, readControls));
|
|
2414
3168
|
if (item instanceof _moostjs_event_http.HttpError) return item;
|
|
2415
3169
|
const pending = this._finishRows([item], prep, {
|
|
2416
3170
|
endpoint: "one",
|
|
2417
3171
|
projection: select,
|
|
2418
|
-
controls
|
|
3172
|
+
controls
|
|
2419
3173
|
});
|
|
2420
3174
|
if (pending) await pending;
|
|
2421
3175
|
return item;
|
|
@@ -2458,7 +3212,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2458
3212
|
searchable: this.readable.isSearchable() || this._searchFallbackFields.length > 0,
|
|
2459
3213
|
vectorSearchable: this.readable.isVectorSearchable(),
|
|
2460
3214
|
geoSearchable: this._isGeoSearchable(),
|
|
2461
|
-
searchIndexes: this.readable.getSearchIndexes(),
|
|
3215
|
+
searchIndexes: this.readable.getSearchIndexes().map(({ fields: _fields, ...index }) => index),
|
|
2462
3216
|
primaryKeys: [...this.readable.primaryKeys],
|
|
2463
3217
|
preferredId: [...this.readable.preferredId],
|
|
2464
3218
|
relations,
|
|
@@ -2558,7 +3312,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2558
3312
|
constructor(app, table) {
|
|
2559
3313
|
super(app, table);
|
|
2560
3314
|
const proto = _AsDbController.prototype;
|
|
2561
|
-
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);
|
|
2562
3316
|
this._removeArgs = this._hookArgs(this.guardRemove !== proto.guardRemove ? (ctx) => this.guardRemove(ctx) : void 0);
|
|
2563
3317
|
}
|
|
2564
3318
|
buildCrud() {
|
|
@@ -2620,6 +3374,30 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2620
3374
|
*/
|
|
2621
3375
|
guardRemove(_ctx) {}
|
|
2622
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
|
+
/**
|
|
2623
3401
|
* Runs `fn` inside the bound table's adapter transaction (nested calls
|
|
2624
3402
|
* join it). For custom actions and routes that need one transaction across
|
|
2625
3403
|
* several table operations.
|
|
@@ -2632,15 +3410,17 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2632
3410
|
/** `deleteOne`'s trailing options — see {@link _hookArgs}. */
|
|
2633
3411
|
_removeArgs;
|
|
2634
3412
|
/**
|
|
2635
|
-
* A table call's trailing options, built once: `guard` only when
|
|
2636
|
-
* hook is overridden, `isFieldVisible` only when `hasField`
|
|
2637
|
-
* PK-less payload never resolves through a hidden unique
|
|
2638
|
-
* nothing, so an unmodified controller calls the table exactly
|
|
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.
|
|
2639
3418
|
*/
|
|
2640
|
-
_hookArgs(guard) {
|
|
3419
|
+
_hookArgs(guard, check) {
|
|
2641
3420
|
const opts = { ...this._idOpts };
|
|
2642
3421
|
if (guard) opts.guard = guard;
|
|
2643
|
-
|
|
3422
|
+
if (check) opts.check = check;
|
|
3423
|
+
return (0, _atscript_db.isEmptyObject)(opts) ? [] : [opts];
|
|
2644
3424
|
}
|
|
2645
3425
|
/** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
|
|
2646
3426
|
async _checkHook(pending, abortMessage) {
|
|
@@ -2688,9 +3468,20 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2688
3468
|
if (versionColumn === void 0) return;
|
|
2689
3469
|
for (let i = 0; i < rows.length; i++) this._resolveCas(rows[i], versionColumn, `[${i}].`);
|
|
2690
3470
|
}
|
|
2691
|
-
/**
|
|
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
|
+
*/
|
|
2692
3478
|
async _deleteOrThrow(id) {
|
|
2693
|
-
const
|
|
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);
|
|
2694
3485
|
if (result.deletedCount < 1) throw new _moostjs_event_http.HttpError(404);
|
|
2695
3486
|
return result;
|
|
2696
3487
|
}
|
|
@@ -2698,6 +3489,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2698
3489
|
* **POST /** — inserts one or many records.
|
|
2699
3490
|
*/
|
|
2700
3491
|
async insert(payload) {
|
|
3492
|
+
await this.parseRequest("insert");
|
|
2701
3493
|
assertWriteShape(payload);
|
|
2702
3494
|
if (Array.isArray(payload)) {
|
|
2703
3495
|
const rows = await this._writeBody("insertMany", payload, true);
|
|
@@ -2716,6 +3508,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2716
3508
|
* mismatch) via a single `findOne` after the table call.
|
|
2717
3509
|
*/
|
|
2718
3510
|
async replace(payload) {
|
|
3511
|
+
await this.parseRequest("replace");
|
|
2719
3512
|
assertWriteShape(payload);
|
|
2720
3513
|
const versionColumn = this.table.versionColumn;
|
|
2721
3514
|
if (Array.isArray(payload)) {
|
|
@@ -2737,6 +3530,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2737
3530
|
* the CAS statement executes and bumps the version on a hit.
|
|
2738
3531
|
*/
|
|
2739
3532
|
async update(payload) {
|
|
3533
|
+
await this.parseRequest("update");
|
|
2740
3534
|
assertWriteShape(payload);
|
|
2741
3535
|
const versionColumn = this.table.versionColumn;
|
|
2742
3536
|
if (Array.isArray(payload)) {
|
|
@@ -2755,10 +3549,24 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2755
3549
|
* returns 404 when the row is genuinely missing, 409 with
|
|
2756
3550
|
* `{ error: "version_mismatch", currentVersion: N }` when it's present
|
|
2757
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}.
|
|
2758
3558
|
*/
|
|
2759
3559
|
async _disambiguateMismatch(data, versionColumn) {
|
|
2760
|
-
const
|
|
2761
|
-
|
|
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({
|
|
2762
3570
|
filter,
|
|
2763
3571
|
controls: {}
|
|
2764
3572
|
}) : null;
|
|
@@ -2774,6 +3582,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2774
3582
|
* **DELETE /:id** — removes a single record by primary key.
|
|
2775
3583
|
*/
|
|
2776
3584
|
async remove(id) {
|
|
3585
|
+
await this.parseRequest("remove");
|
|
2777
3586
|
const resolvedId = await this._checkHook(this.onRemove(id), "Not deleted");
|
|
2778
3587
|
return this._deleteOrThrow(resolvedId);
|
|
2779
3588
|
}
|
|
@@ -2782,6 +3591,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
2782
3591
|
* (composite primary key or compound unique index).
|
|
2783
3592
|
*/
|
|
2784
3593
|
async removeComposite(query) {
|
|
3594
|
+
await this.parseRequest("remove");
|
|
2785
3595
|
const idObj = this.extractIdShape(query);
|
|
2786
3596
|
if (idObj instanceof _moostjs_event_http.HttpError) throw idObj;
|
|
2787
3597
|
const resolvedId = await this._checkHook(this.onRemove(idObj), "Not deleted");
|
|
@@ -2846,6 +3656,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
2846
3656
|
primaryKey;
|
|
2847
3657
|
constructor(boundType, controllerName, app) {
|
|
2848
3658
|
super(boundType, controllerName, app, "value-help");
|
|
3659
|
+
assertNoValueHelpActions(this.constructor);
|
|
2849
3660
|
const fieldMeta = /* @__PURE__ */ new Map();
|
|
2850
3661
|
const explicitlySearchable = [];
|
|
2851
3662
|
const stringProps = [];
|
|
@@ -2863,40 +3674,110 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
2863
3674
|
this.primaryKey = primaryKey;
|
|
2864
3675
|
this.searchableFields = explicitlySearchable.length > 0 ? explicitlySearchable : interfaceSearchable ? stringProps : stringProps;
|
|
2865
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
|
+
*/
|
|
2866
3686
|
hasField(path) {
|
|
2867
3687
|
return this.fieldMeta.has(path);
|
|
2868
3688
|
}
|
|
2869
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
|
+
/**
|
|
2870
3758
|
* **GET /query** — returns an array of matched rows (up to `$limit`).
|
|
2871
3759
|
*/
|
|
2872
3760
|
async runQuery(url) {
|
|
2873
|
-
const parsed = this.
|
|
3761
|
+
const { parsed, controls } = await this.parseRequest("query", url);
|
|
2874
3762
|
const validateError = this.validateParsed(parsed, "query");
|
|
2875
3763
|
if (validateError) return validateError;
|
|
2876
|
-
return (await this.query(
|
|
2877
|
-
filter: parsed.filter,
|
|
2878
|
-
controls: parsed.controls
|
|
2879
|
-
})).data;
|
|
3764
|
+
return (await this.query(await this._scopedQuery(parsed.filter, controls))).data;
|
|
2880
3765
|
}
|
|
2881
3766
|
/**
|
|
2882
3767
|
* **GET /pages** — paginated row window plus total count.
|
|
2883
3768
|
*/
|
|
2884
3769
|
async runPages(url) {
|
|
2885
|
-
const parsed = this.
|
|
3770
|
+
const { parsed, controls } = await this.parseRequest("pages", url);
|
|
2886
3771
|
const validateError = this.validateParsed(parsed, "pages");
|
|
2887
3772
|
if (validateError) return validateError;
|
|
2888
|
-
const controls = parsed.controls;
|
|
2889
3773
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
2890
3774
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
2891
3775
|
const skip = (page - 1) * size;
|
|
2892
|
-
const result = await this.query({
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
$limit: size
|
|
2898
|
-
}
|
|
2899
|
-
});
|
|
3776
|
+
const result = await this.query(await this._scopedQuery(parsed.filter, {
|
|
3777
|
+
...controls,
|
|
3778
|
+
$skip: skip,
|
|
3779
|
+
$limit: size
|
|
3780
|
+
}));
|
|
2900
3781
|
return {
|
|
2901
3782
|
data: result.data,
|
|
2902
3783
|
page,
|
|
@@ -2909,17 +3790,19 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
2909
3790
|
* **GET /one/:id** — retrieves a single row by primary key.
|
|
2910
3791
|
*/
|
|
2911
3792
|
async runGetOne(id) {
|
|
2912
|
-
|
|
3793
|
+
await this.parseRequest("one", "");
|
|
3794
|
+
return this._scopedOne(id);
|
|
2913
3795
|
}
|
|
2914
3796
|
/**
|
|
2915
3797
|
* **GET /one?<pk>=<val>** — retrieves a single row by PK query param (fallback).
|
|
2916
3798
|
*/
|
|
2917
3799
|
async runGetOneComposite(query) {
|
|
3800
|
+
await this.parseRequest("one", "");
|
|
2918
3801
|
const pk = this.primaryKey;
|
|
2919
3802
|
if (!pk) return new _moostjs_event_http.HttpError(400, "No primary key (@meta.id) on value-help interface");
|
|
2920
3803
|
const id = query[pk];
|
|
2921
3804
|
if (id === void 0) return new _moostjs_event_http.HttpError(400, `Missing PK field "${pk}"`);
|
|
2922
|
-
return this.
|
|
3805
|
+
return this._scopedOne(id);
|
|
2923
3806
|
}
|
|
2924
3807
|
/**
|
|
2925
3808
|
* Meta response surfaces `@ui.dict.*` annotations as **hints** for the
|
|
@@ -3016,7 +3899,7 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
|
|
|
3016
3899
|
const search = controls.controls.$search;
|
|
3017
3900
|
if (search) {
|
|
3018
3901
|
const needle = search.toLowerCase();
|
|
3019
|
-
const fields = this.searchableFields;
|
|
3902
|
+
const fields = this.searchableFields.filter((field) => this.hasField(field));
|
|
3020
3903
|
rows = rows.filter((row) => {
|
|
3021
3904
|
for (const field of fields) {
|
|
3022
3905
|
const v = row[field];
|
|
@@ -3068,25 +3951,6 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
|
|
|
3068
3951
|
}
|
|
3069
3952
|
return Object.keys(out).length > 0 ? out : void 0;
|
|
3070
3953
|
}
|
|
3071
|
-
/**
|
|
3072
|
-
* Normalizes the raw `parseUrl` `$select` form to the engine's `{ path: 0 |
|
|
3073
|
-
* 1 }` projection map:
|
|
3074
|
-
* - `string[]` (e.g. from `?$select=a,b`) → inclusion map `{ a: 1, b: 1 }`,
|
|
3075
|
-
* - a plain `{ path: 0 | 1 }` object → passed through (0 / falsy → exclude),
|
|
3076
|
-
* - anything else / empty → `undefined` (no projection; whole rows returned).
|
|
3077
|
-
*/
|
|
3078
|
-
normalizeSelect(select) {
|
|
3079
|
-
if (Array.isArray(select)) {
|
|
3080
|
-
const out = {};
|
|
3081
|
-
for (const field of select) if (typeof field === "string" && field) out[field] = 1;
|
|
3082
|
-
return Object.keys(out).length > 0 ? out : void 0;
|
|
3083
|
-
}
|
|
3084
|
-
if (select && typeof select === "object") {
|
|
3085
|
-
const out = {};
|
|
3086
|
-
for (const [path, v] of Object.entries(select)) out[path] = v === 0 || v === false ? 0 : 1;
|
|
3087
|
-
return Object.keys(out).length > 0 ? out : void 0;
|
|
3088
|
-
}
|
|
3089
|
-
}
|
|
3090
3954
|
};
|
|
3091
3955
|
AsJsonValueHelpController = __decorate([(0, moost.Inherit)(), __decorateMetadata("design:paramtypes", [
|
|
3092
3956
|
Object,
|
|
@@ -3203,187 +4067,6 @@ var ActionDisabledError = class extends _moostjs_event_http.HttpError {
|
|
|
3203
4067
|
}
|
|
3204
4068
|
};
|
|
3205
4069
|
//#endregion
|
|
3206
|
-
//#region src/actions/current-action.ts
|
|
3207
|
-
/** Read the current action's `TDbActionMeta` from the wook context. Returns undefined outside a controller (e.g. direct-wook test paths). */
|
|
3208
|
-
function readCurrentActionMeta(ctx) {
|
|
3209
|
-
let ctrl;
|
|
3210
|
-
let methodName;
|
|
3211
|
-
try {
|
|
3212
|
-
const cc = (0, moost.useControllerContext)(ctx);
|
|
3213
|
-
ctrl = cc.getController();
|
|
3214
|
-
methodName = cc.getMethod();
|
|
3215
|
-
} catch {
|
|
3216
|
-
return;
|
|
3217
|
-
}
|
|
3218
|
-
if (!ctrl || !methodName) return void 0;
|
|
3219
|
-
return getAtscriptDbMate().read(ctrl.constructor, methodName)?.atscript_db_action;
|
|
3220
|
-
}
|
|
3221
|
-
//#endregion
|
|
3222
|
-
//#region src/actions/input-form-cache.ts
|
|
3223
|
-
/**
|
|
3224
|
-
* Cached parse of the action request body. Centralises the shape check so
|
|
3225
|
-
* every per-param resolver (`@DbActionID*`, `@DbActionRow*`, `@InputForm`)
|
|
3226
|
-
* reads through the same gate. An array or scalar root is rejected with the
|
|
3227
|
-
* same `ValidatorError` envelope as today's strict-shape ID failures.
|
|
3228
|
-
*/
|
|
3229
|
-
const dbActionBodySlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
|
|
3230
|
-
const raw = await (0, _wooksjs_http_body.useBody)(ctx).parseBody();
|
|
3231
|
-
if (raw == null) return {};
|
|
3232
|
-
if (typeof raw !== "object" || Array.isArray(raw)) throw new _atscript_typescript_utils.ValidatorError([{
|
|
3233
|
-
path: "",
|
|
3234
|
-
message: "Action body must be an object of shape { ids?, input? }"
|
|
3235
|
-
}]);
|
|
3236
|
-
return raw;
|
|
3237
|
-
});
|
|
3238
|
-
/** Cached `body.input` slot — consumed by `@InputForm()` and `useDbActionInput()`. */
|
|
3239
|
-
const dbActionInputSlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
|
|
3240
|
-
return (await ctx.get(dbActionBodySlot)).input;
|
|
3241
|
-
});
|
|
3242
|
-
/** Composable for in-handler reads of the form input. */
|
|
3243
|
-
const useDbActionInput = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionInputSlot) }));
|
|
3244
|
-
//#endregion
|
|
3245
|
-
//#region src/actions/id-validation.ts
|
|
3246
|
-
const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
|
|
3247
|
-
function getSourceCache(source) {
|
|
3248
|
-
let cache = SOURCE_CACHE.get(source);
|
|
3249
|
-
if (cache) return cache;
|
|
3250
|
-
const identifications = source.identifications;
|
|
3251
|
-
const byKeySig = /* @__PURE__ */ new Map();
|
|
3252
|
-
for (const ident of identifications) byKeySig.set(fieldsSig(ident.fields), ident);
|
|
3253
|
-
const fieldByName = /* @__PURE__ */ new Map();
|
|
3254
|
-
for (const fd of source.fieldDescriptors) fieldByName.set(fd.path, fd);
|
|
3255
|
-
cache = {
|
|
3256
|
-
byKeySig,
|
|
3257
|
-
fieldByName,
|
|
3258
|
-
formatted: identifications.map((id) => `[${id.fields.join(", ")}]`).join(", ")
|
|
3259
|
-
};
|
|
3260
|
-
SOURCE_CACHE.set(source, cache);
|
|
3261
|
-
return cache;
|
|
3262
|
-
}
|
|
3263
|
-
function fieldsSig(fields) {
|
|
3264
|
-
return fields.toSorted().join("");
|
|
3265
|
-
}
|
|
3266
|
-
function isIdValidationSource(value) {
|
|
3267
|
-
if (!value || typeof value !== "object") return false;
|
|
3268
|
-
const v = value;
|
|
3269
|
-
return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
|
|
3270
|
-
}
|
|
3271
|
-
function validateSingleId(body, source, path = "") {
|
|
3272
|
-
const errors = collectIdErrors(body, source, path);
|
|
3273
|
-
if (errors.length > 0) throw new _atscript_typescript_utils.ValidatorError(errors);
|
|
3274
|
-
return body;
|
|
3275
|
-
}
|
|
3276
|
-
function validateMultiId(body, source) {
|
|
3277
|
-
if (!Array.isArray(body)) throw new _atscript_typescript_utils.ValidatorError([{
|
|
3278
|
-
path: "",
|
|
3279
|
-
message: "Expected JSON array of identifier objects",
|
|
3280
|
-
details: []
|
|
3281
|
-
}]);
|
|
3282
|
-
const errors = [];
|
|
3283
|
-
for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
|
|
3284
|
-
if (errors.length > 0) throw new _atscript_typescript_utils.ValidatorError(errors);
|
|
3285
|
-
return body;
|
|
3286
|
-
}
|
|
3287
|
-
function collectIdErrors(value, source, pathPrefix) {
|
|
3288
|
-
if (!isPlainObject(value)) return [{
|
|
3289
|
-
path: pathPrefix,
|
|
3290
|
-
message: "Expected JSON object for row identifier",
|
|
3291
|
-
details: []
|
|
3292
|
-
}];
|
|
3293
|
-
const cache = getSourceCache(source);
|
|
3294
|
-
if (cache.byKeySig.size === 0) return [{
|
|
3295
|
-
path: pathPrefix,
|
|
3296
|
-
message: "Table has no identifier configured",
|
|
3297
|
-
details: []
|
|
3298
|
-
}];
|
|
3299
|
-
const match = cache.byKeySig.get(fieldsSig(Object.keys(value)));
|
|
3300
|
-
if (!match) return [{
|
|
3301
|
-
path: pathPrefix,
|
|
3302
|
-
message: `Identifier fields must exactly match one of: ${cache.formatted}`,
|
|
3303
|
-
details: []
|
|
3304
|
-
}];
|
|
3305
|
-
const errors = [];
|
|
3306
|
-
for (const fieldName of match.fields) {
|
|
3307
|
-
const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
|
|
3308
|
-
const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
|
|
3309
|
-
if (err) errors.push(err);
|
|
3310
|
-
}
|
|
3311
|
-
return errors;
|
|
3312
|
-
}
|
|
3313
|
-
function checkScalar(value, fd, path) {
|
|
3314
|
-
const expected = fd?.designType ?? "string";
|
|
3315
|
-
if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
|
|
3316
|
-
if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
|
|
3317
|
-
if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
|
|
3318
|
-
}
|
|
3319
|
-
function scalarMismatch(path, expected, value) {
|
|
3320
|
-
return {
|
|
3321
|
-
path,
|
|
3322
|
-
message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
|
|
3323
|
-
details: []
|
|
3324
|
-
};
|
|
3325
|
-
}
|
|
3326
|
-
function describe(value) {
|
|
3327
|
-
if (value === null) return "null";
|
|
3328
|
-
if (Array.isArray(value)) return "array";
|
|
3329
|
-
return typeof value;
|
|
3330
|
-
}
|
|
3331
|
-
function isPlainObject(value) {
|
|
3332
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3333
|
-
}
|
|
3334
|
-
//#endregion
|
|
3335
|
-
//#region src/actions/id-cache.ts
|
|
3336
|
-
const boundTableKey = (0, _wooksjs_event_core.key)("atscript_db_action_bound_table");
|
|
3337
|
-
function controllerOf(ctx) {
|
|
3338
|
-
return (0, moost.useControllerContext)(ctx).getController();
|
|
3339
|
-
}
|
|
3340
|
-
function controllerTable(ctx) {
|
|
3341
|
-
const ctrl = controllerOf(ctx);
|
|
3342
|
-
return ctrl?.readable ?? ctrl?.table ?? null;
|
|
3343
|
-
}
|
|
3344
|
-
function getActionTable(ctx) {
|
|
3345
|
-
return (ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0) ?? controllerTable(ctx);
|
|
3346
|
-
}
|
|
3347
|
-
const warnedTags = /* @__PURE__ */ new Set();
|
|
3348
|
-
function noTableError(ctx) {
|
|
3349
|
-
const actionName = readCurrentActionMeta(ctx)?.name;
|
|
3350
|
-
const tag = actionName ? `"${actionName}"` : "<unknown>";
|
|
3351
|
-
if (!warnedTags.has(tag)) {
|
|
3352
|
-
warnedTags.add(tag);
|
|
3353
|
-
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.`);
|
|
3354
|
-
}
|
|
3355
|
-
return new _moostjs_event_http.HttpError(500, {
|
|
3356
|
-
statusCode: 500,
|
|
3357
|
-
error: "Internal Server Error",
|
|
3358
|
-
message: "Internal server error",
|
|
3359
|
-
code: "ACTION_TABLE_NOT_BOUND"
|
|
3360
|
-
});
|
|
3361
|
-
}
|
|
3362
|
-
/**
|
|
3363
|
-
* Validates the body's `ids` against the action table's identifications. For
|
|
3364
|
-
* the controller's own table that is its `idSource` (since 0.1.134): a unique
|
|
3365
|
-
* index over a field `hasField` hides neither addresses a row nor appears in
|
|
3366
|
-
* the "must exactly match one of" message. An `opts.table` binding has no
|
|
3367
|
-
* visibility hook.
|
|
3368
|
-
*/
|
|
3369
|
-
async function resolveValidatedId(ctx, validate) {
|
|
3370
|
-
let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
|
|
3371
|
-
if (!source) {
|
|
3372
|
-
const ctrl = controllerOf(ctx);
|
|
3373
|
-
source = ctrl?.idSource ?? ctrl?.readable ?? ctrl?.table ?? null;
|
|
3374
|
-
}
|
|
3375
|
-
if (!isIdValidationSource(source)) throw noTableError(ctx);
|
|
3376
|
-
const env = await ctx.get(dbActionBodySlot);
|
|
3377
|
-
validate(env.ids, source);
|
|
3378
|
-
return env.ids;
|
|
3379
|
-
}
|
|
3380
|
-
const dbActionIdSlot = (0, _wooksjs_event_core.cached)((ctx) => resolveValidatedId(ctx, validateSingleId));
|
|
3381
|
-
const dbActionIdsSlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
|
|
3382
|
-
return await resolveValidatedId(ctx, validateMultiId);
|
|
3383
|
-
});
|
|
3384
|
-
const useDbActionId = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
|
|
3385
|
-
const useDbActionIds = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
|
|
3386
|
-
//#endregion
|
|
3387
4070
|
//#region src/actions/row-cache.ts
|
|
3388
4071
|
function asFetchTable(value) {
|
|
3389
4072
|
if (!value || typeof value !== "object") return null;
|
|
@@ -3403,13 +4086,28 @@ function readActionFieldSet(ctx) {
|
|
|
3403
4086
|
const opts = action.opts;
|
|
3404
4087
|
return Array.isArray(opts.requiredFields) ? opts.requiredFields : null;
|
|
3405
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
|
+
*/
|
|
3406
4095
|
function seedActionFields(ctx, table) {
|
|
3407
4096
|
const fields = /* @__PURE__ */ new Set();
|
|
3408
4097
|
for (const f of table.preferredId ?? table.primaryKeys) fields.add(f);
|
|
3409
4098
|
const action = readActionFieldSet(ctx);
|
|
3410
|
-
if (action)
|
|
4099
|
+
if (action) {
|
|
4100
|
+
const visible = actionFieldVisibility(ctx);
|
|
4101
|
+
for (const f of action) if (!visible || visible(f)) fields.add(f);
|
|
4102
|
+
}
|
|
3411
4103
|
return fields;
|
|
3412
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
|
+
*/
|
|
3413
4111
|
async function loadRow(ctx) {
|
|
3414
4112
|
const id = await ctx.get(dbActionIdSlot);
|
|
3415
4113
|
const table = asFetchTable(getActionTable(ctx));
|
|
@@ -3417,7 +4115,7 @@ async function loadRow(ctx) {
|
|
|
3417
4115
|
const fields = seedActionFields(ctx, table);
|
|
3418
4116
|
for (const k of Object.keys(id)) fields.add(k);
|
|
3419
4117
|
const row = await table.findOne({
|
|
3420
|
-
filter: id,
|
|
4118
|
+
filter: withOverlay(id, await ctx.get(dbActionOverlaySlot)),
|
|
3421
4119
|
controls: { $select: [...fields] }
|
|
3422
4120
|
});
|
|
3423
4121
|
if (row == null) throw new _moostjs_event_http.HttpError(404, "Row not found for action identifier");
|
|
@@ -3449,7 +4147,7 @@ async function loadRows(ctx) {
|
|
|
3449
4147
|
}
|
|
3450
4148
|
}
|
|
3451
4149
|
const rows = await table.findMany({
|
|
3452
|
-
filter: { $or: dedupedIds },
|
|
4150
|
+
filter: withOverlay({ $or: dedupedIds }, await ctx.get(dbActionOverlaySlot)),
|
|
3453
4151
|
controls: { $select: [...fields] }
|
|
3454
4152
|
});
|
|
3455
4153
|
const rowByKey = /* @__PURE__ */ new Map();
|
|
@@ -3474,7 +4172,6 @@ const useDbActionRow = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: ()
|
|
|
3474
4172
|
const useDbActionRows = (0, _wooksjs_event_core.defineWook)((ctx) => ({ load: () => ctx.get(dbActionRowsSlot) }));
|
|
3475
4173
|
//#endregion
|
|
3476
4174
|
//#region src/actions/gate-interceptor.ts
|
|
3477
|
-
const GATE_PRIORITY = moost.TInterceptorPriority.AFTER_GUARD;
|
|
3478
4175
|
function injectBoundTable(fallback) {
|
|
3479
4176
|
const ctx = (0, _wooksjs_event_core.current)();
|
|
3480
4177
|
if (ctx.has(boundTableKey)) return;
|
|
@@ -3484,62 +4181,98 @@ function injectBoundTable(fallback) {
|
|
|
3484
4181
|
function buildGateInterceptor(opts) {
|
|
3485
4182
|
const { action, level, disabled, onDisabledRows, table } = opts;
|
|
3486
4183
|
return (0, moost.defineBeforeInterceptor)(async () => {
|
|
3487
|
-
injectBoundTable(table);
|
|
3488
4184
|
const ctx = (0, _wooksjs_event_core.current)();
|
|
4185
|
+
await awaitActionPrepared(ctx);
|
|
4186
|
+
injectBoundTable(table);
|
|
3489
4187
|
if (level === "row") {
|
|
3490
4188
|
const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
|
|
3491
4189
|
assertVerdictLength(action, verdicts, 1);
|
|
3492
4190
|
if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
|
|
3493
4191
|
return;
|
|
3494
4192
|
}
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
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);
|
|
3500
4211
|
assertVerdictLength(action, verdicts, existingRows.length);
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
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]);
|
|
3516
4227
|
}
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
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));
|
|
3524
4234
|
}
|
|
3525
|
-
|
|
3526
|
-
}
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds, failingReasons);
|
|
3527
4238
|
}
|
|
3528
|
-
/**
|
|
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
|
+
*/
|
|
3529
4250
|
function buildThinInterceptor(opts) {
|
|
3530
|
-
const { table } = opts;
|
|
3531
|
-
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);
|
|
3532
4255
|
injectBoundTable(table);
|
|
3533
|
-
|
|
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);
|
|
3534
4261
|
}
|
|
3535
4262
|
//#endregion
|
|
3536
4263
|
//#region src/actions/db-action.decorator.ts
|
|
3537
4264
|
/**
|
|
3538
4265
|
* Mark a controller method as a database action surfaced via `/meta`. Writes
|
|
3539
|
-
* `atscript_db_action` metadata and
|
|
3540
|
-
*
|
|
3541
|
-
*
|
|
3542
|
-
*
|
|
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).
|
|
3543
4276
|
*
|
|
3544
4277
|
* Generic over `TRow` (annotate at the call site: `@DbAction<Order>(...)`)
|
|
3545
4278
|
* and `R` (the literal `requiredFields` tuple, inferred via `const R`).
|
|
@@ -3558,7 +4291,8 @@ function DbAction(name, opts = {}) {
|
|
|
3558
4291
|
opts
|
|
3559
4292
|
})
|
|
3560
4293
|
}))(target, propertyKey, descriptor);
|
|
3561
|
-
|
|
4294
|
+
const ctor = typeof target === "function" ? target : target.constructor;
|
|
4295
|
+
if (isAsValueHelpControllerSubclass(ctor)) throw valueHelpActionError(ctor.name, [name]);
|
|
3562
4296
|
const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
|
|
3563
4297
|
const rawOpts = opts;
|
|
3564
4298
|
if (typeof rawOpts.disabled === "function" && (scan.level === "row" || scan.level === "rows")) (0, moost.Intercept)(buildGateInterceptor({
|
|
@@ -3568,7 +4302,17 @@ function DbAction(name, opts = {}) {
|
|
|
3568
4302
|
onDisabledRows: rawOpts.onDisabledRows ?? "reject",
|
|
3569
4303
|
table: rawOpts.table
|
|
3570
4304
|
}))(target, propertyKey, descriptor);
|
|
3571
|
-
else if (scan.
|
|
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);
|
|
3572
4316
|
return descriptor;
|
|
3573
4317
|
});
|
|
3574
4318
|
}
|
|
@@ -3693,7 +4437,7 @@ function DbActionRows() {
|
|
|
3693
4437
|
* `disabled` predicate is type-narrowed by its own `requiredFields` literal.
|
|
3694
4438
|
*
|
|
3695
4439
|
* Multiple `@DbActions` (and shortcut) decorators on the same class
|
|
3696
|
-
* accumulate.
|
|
4440
|
+
* accumulate. Throws on a value-help controller (since 0.1.143).
|
|
3697
4441
|
*/
|
|
3698
4442
|
function DbActions(dict) {
|
|
3699
4443
|
return classLevelActions(dict);
|
|
@@ -3722,10 +4466,14 @@ function classLevelActions(dict, forcedLevel) {
|
|
|
3722
4466
|
entry: merged
|
|
3723
4467
|
});
|
|
3724
4468
|
}
|
|
3725
|
-
|
|
4469
|
+
const decorate = getAtscriptDbMate().decorate((current) => ({
|
|
3726
4470
|
...current,
|
|
3727
4471
|
atscript_db_actions: [...current.atscript_db_actions ?? [], ...entries]
|
|
3728
4472
|
}));
|
|
4473
|
+
return (target) => {
|
|
4474
|
+
if (isAsValueHelpControllerSubclass(target)) throw valueHelpActionError(target.name, entries.map((e) => e.name));
|
|
4475
|
+
return decorate(target);
|
|
4476
|
+
};
|
|
3729
4477
|
}
|
|
3730
4478
|
//#endregion
|
|
3731
4479
|
//#region src/actions/db-action-input-form.decorator.ts
|
|
@@ -3823,6 +4571,39 @@ function InputForm(formType, validatorOpts) {
|
|
|
3823
4571
|
*/
|
|
3824
4572
|
const perRow = (fn) => (rows) => rows.map(fn);
|
|
3825
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
|
|
3826
4607
|
exports.ActionDisabledError = ActionDisabledError;
|
|
3827
4608
|
Object.defineProperty(exports, "AsDbController", {
|
|
3828
4609
|
enumerable: true,
|
|
@@ -3854,6 +4635,7 @@ Object.defineProperty(exports, "AsValueHelpController", {
|
|
|
3854
4635
|
return AsValueHelpController;
|
|
3855
4636
|
}
|
|
3856
4637
|
});
|
|
4638
|
+
exports.DB_CRUD_HANDLERS = DB_CRUD_HANDLERS;
|
|
3857
4639
|
Object.defineProperty(exports, "DEFAULT_DB_SPACE", {
|
|
3858
4640
|
enumerable: true,
|
|
3859
4641
|
get: function() {
|
|
@@ -3880,6 +4662,7 @@ exports.ReadableController = ReadableController;
|
|
|
3880
4662
|
exports.TABLE_DEF = TABLE_DEF;
|
|
3881
4663
|
exports.TableController = TableController;
|
|
3882
4664
|
exports.UseValidationErrorTransform = UseValidationErrorTransform;
|
|
4665
|
+
exports.VALUE_HELP_CRUD_HANDLERS = VALUE_HELP_CRUD_HANDLERS;
|
|
3883
4666
|
exports.ViewController = ViewController;
|
|
3884
4667
|
exports.applyTerminalRefs = applyTerminalRefs;
|
|
3885
4668
|
exports.assertExposed = assertExposed;
|
|
@@ -3904,6 +4687,7 @@ exports.resolveBoundReadable = resolveBoundReadable;
|
|
|
3904
4687
|
exports.resolveDbSpace = require_db_space_registry.resolveDbSpace;
|
|
3905
4688
|
exports.resolveProp = resolveProp;
|
|
3906
4689
|
exports.resolveTerminalRef = resolveTerminalRef;
|
|
4690
|
+
exports.unknownRelationError = unknownRelationError;
|
|
3907
4691
|
exports.useDbActionId = useDbActionId;
|
|
3908
4692
|
exports.useDbActionIds = useDbActionIds;
|
|
3909
4693
|
exports.useDbActionInput = useDbActionInput;
|