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