@atscript/moost-db 0.1.147 → 0.1.149
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/{db-space-registry-C_wft7kl.mjs → db-space-registry-Cdb0FGCn.mjs} +28 -2
- package/dist/{db-space-registry-CKR6G-hi.d.mts → db-space-registry-Cvd8s9ql.d.mts} +17 -1
- package/dist/{db-space-registry-DqKr5Zdk.cjs → db-space-registry-D5gnC_-6.cjs} +32 -0
- package/dist/{db-space-registry-CWpYwZ4R.d.cts → db-space-registry-fXB8LnGe.d.cts} +17 -1
- package/dist/index.cjs +1475 -286
- package/dist/index.d.cts +445 -51
- package/dist/index.d.mts +445 -51
- package/dist/index.mjs +1474 -287
- package/dist/testing.cjs +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/package.json +16 -14
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as resolveDbSpace, i as provideDbSpace, n as clearDbSpaces, r as closeDbSpaces, t as DEFAULT_DB_SPACE } from "./db-space-registry-Cdb0FGCn.mjs";
|
|
2
2
|
import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
|
|
3
3
|
import { Body, Delete, Get, HttpError, MoostHttp, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
|
|
4
4
|
import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Pipe, Provide, Resolve, TInterceptorPriority, TPipePriority, defineBeforeInterceptor, defineInterceptor, definePipeFn, getMoostMate, useControllerContext, withControllerContext } from "moost";
|
|
5
5
|
import { parseUrl } from "@uniqu/url";
|
|
6
|
-
import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
7
|
-
import { cached, current, defineWook, key, useRouteParams } from "@wooksjs/event-core";
|
|
6
|
+
import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, groupSourceVerdict, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, numericOperandProblem, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
7
|
+
import { cached, current, defineWook, key, routeParamsKey, useRouteParams } from "@wooksjs/event-core";
|
|
8
8
|
import { useBody } from "@wooksjs/http-body";
|
|
9
9
|
import { BUCKET_UNITS } from "@uniqu/core";
|
|
10
10
|
import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
|
|
@@ -70,16 +70,25 @@ function insightError(insights, message) {
|
|
|
70
70
|
const entry = insightPaths.get(insights);
|
|
71
71
|
return entry?.message === message ? badRequest(entry.path, message) : new HttpError(400, message);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The 400 envelope of a `ValidatorError`; `undefined` for any other error.
|
|
75
|
+
* @internal Not part of the public API (not re-exported from the barrel).
|
|
76
|
+
*/
|
|
77
|
+
function validatorErrorToHttp(error) {
|
|
78
|
+
return error instanceof ValidatorError ? errorEnvelope(400, error.message, error.errors) : void 0;
|
|
79
|
+
}
|
|
73
80
|
//#endregion
|
|
74
81
|
//#region src/validation-interceptor.ts
|
|
75
82
|
const dbErrorCodeToStatus = {
|
|
76
83
|
CONFLICT: 409,
|
|
77
84
|
CAS_MISMATCH: 409,
|
|
78
85
|
TX_WAIT_TIMEOUT: 503,
|
|
86
|
+
SPACE_CLOSED: 503,
|
|
79
87
|
BUCKET_TZ_UNAVAILABLE: 501
|
|
80
88
|
};
|
|
81
89
|
function transformValidationError(error, reply) {
|
|
82
|
-
|
|
90
|
+
const validation = validatorErrorToHttp(error);
|
|
91
|
+
if (validation) reply(validation);
|
|
83
92
|
else if (error instanceof DbError) reply(errorEnvelope(dbErrorCodeToStatus[error.code] ?? 400, error.message, error.errors));
|
|
84
93
|
}
|
|
85
94
|
const validationErrorTransform = () => defineInterceptor({ error: transformValidationError }, TInterceptorPriority.BEFORE_ALL);
|
|
@@ -514,6 +523,38 @@ function identityKey(id) {
|
|
|
514
523
|
return idKey(id, Object.keys(id).toSorted());
|
|
515
524
|
}
|
|
516
525
|
/**
|
|
526
|
+
* `resolved` (index-aligned with `requested`) with duplicate identities
|
|
527
|
+
* collapsed to the first, plus the per-request list ({@link TAppliedIds}).
|
|
528
|
+
*/
|
|
529
|
+
function applyResolvedIds(requested, resolved) {
|
|
530
|
+
const ids = [];
|
|
531
|
+
const requests = [];
|
|
532
|
+
const seen = /* @__PURE__ */ new Set();
|
|
533
|
+
let changed = false;
|
|
534
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
535
|
+
const k = identityKey(resolved[i]);
|
|
536
|
+
if (k === void 0) {
|
|
537
|
+
ids.push(resolved[i]);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (k !== identityKey(requested[i])) changed = true;
|
|
541
|
+
requests.push({
|
|
542
|
+
id: requested[i],
|
|
543
|
+
key: k
|
|
544
|
+
});
|
|
545
|
+
if (seen.has(k)) {
|
|
546
|
+
changed = true;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
seen.add(k);
|
|
550
|
+
ids.push(resolved[i]);
|
|
551
|
+
}
|
|
552
|
+
return changed ? {
|
|
553
|
+
ids,
|
|
554
|
+
requests
|
|
555
|
+
} : { ids };
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
517
558
|
* The deduped identities of `rows` over `fields` — a value read by `read`
|
|
518
559
|
* (default: the row's own field) — and, per row, its identity's index in
|
|
519
560
|
* `ids` (`-1`: the row is absent, or a value is missing / null).
|
|
@@ -711,6 +752,10 @@ const ACTION_OVERLAY = Symbol.for("atscript-db.actionOverlay");
|
|
|
711
752
|
const ACTION_SCOPE = Symbol.for("atscript-db.actionScope");
|
|
712
753
|
/** `true` when the controller overrides `actionRowScope` (since 0.1.147). */
|
|
713
754
|
const ACTION_SCOPED = Symbol.for("atscript-db.actionScoped");
|
|
755
|
+
/** The controller's internal `resolveRowIds` call for an action's ids (since 0.1.148). */
|
|
756
|
+
const ROW_RESOLVE_IDS = Symbol.for("atscript-db.resolveRowIds");
|
|
757
|
+
/** `true` when the controller overrides `resolveRowIds` (since 0.1.148). */
|
|
758
|
+
const ROW_RESOLVES = Symbol.for("atscript-db.rowResolves");
|
|
714
759
|
/**
|
|
715
760
|
* The controller whose hooks govern this action's rows: only when the action
|
|
716
761
|
* runs against the controller's OWN readable — an `opts.table` binding on a
|
|
@@ -743,10 +788,6 @@ const dbActionOverlaySlot = cached(async (ctx) => {
|
|
|
743
788
|
await awaitActionPrepared(ctx);
|
|
744
789
|
return await overlayOf.call(ctrl) ?? null;
|
|
745
790
|
});
|
|
746
|
-
/** `true` when this action's controller overrides `actionRowScope`. */
|
|
747
|
-
function isActionScoped(ctx) {
|
|
748
|
-
return ctx.get(scopedControllerSlot)?.[ACTION_SCOPED] === true;
|
|
749
|
-
}
|
|
750
791
|
/**
|
|
751
792
|
* The loaded `rows` (already inside the row overlay) with every row outside
|
|
752
793
|
* the action's `actionRowScope` replaced by `undefined` (since 0.1.147). The
|
|
@@ -1179,6 +1220,26 @@ function isNonEmptyStringArray(value) {
|
|
|
1179
1220
|
}
|
|
1180
1221
|
//#endregion
|
|
1181
1222
|
//#region src/meta/field-capabilities.ts
|
|
1223
|
+
/** The display-only refusal clause per position. */
|
|
1224
|
+
const DISPLAY_ONLY_POSITION = {
|
|
1225
|
+
filter: "a filter",
|
|
1226
|
+
sort: "$sort",
|
|
1227
|
+
groupedSelect: "a grouped $select",
|
|
1228
|
+
groupBy: "$groupBy",
|
|
1229
|
+
having: "$having",
|
|
1230
|
+
aggregate: "an aggregate",
|
|
1231
|
+
bucket: "a calendar bucket"
|
|
1232
|
+
};
|
|
1233
|
+
/** The capability of a declared decoration: selectable, nothing else. */
|
|
1234
|
+
const DECORATION_CAP = {
|
|
1235
|
+
filterable: false,
|
|
1236
|
+
sortable: false,
|
|
1237
|
+
selectable: true,
|
|
1238
|
+
indexed: false,
|
|
1239
|
+
bucketable: false,
|
|
1240
|
+
groupable: false,
|
|
1241
|
+
numeric: false
|
|
1242
|
+
};
|
|
1182
1243
|
const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER_REASON}.`;
|
|
1183
1244
|
const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
|
|
1184
1245
|
const REASON_WRITE_ONLY = "field is @db.writeOnly.";
|
|
@@ -1283,6 +1344,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1283
1344
|
bucketUnits;
|
|
1284
1345
|
/** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
|
|
1285
1346
|
aggregateFns;
|
|
1347
|
+
/** Whether the adapter renders aggregate arithmetic (`/meta.aggregateExpressions`). */
|
|
1348
|
+
aggregateExpressions;
|
|
1286
1349
|
/** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
|
|
1287
1350
|
signature;
|
|
1288
1351
|
/**
|
|
@@ -1292,9 +1355,24 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1292
1355
|
* the index reads must be added here.
|
|
1293
1356
|
*/
|
|
1294
1357
|
static adapterSignature(source) {
|
|
1295
|
-
return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}`;
|
|
1358
|
+
return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}|${source.supportsAggregateExpressions()}`;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* The navigation paths of a readable: its `navFields`, else its relation
|
|
1362
|
+
* names (partial readables list only the latter).
|
|
1363
|
+
*/
|
|
1364
|
+
static navPathsOf(source) {
|
|
1365
|
+
const nav = new Set(source.navFields);
|
|
1366
|
+
if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
|
|
1367
|
+
return nav;
|
|
1296
1368
|
}
|
|
1297
1369
|
_entries = /* @__PURE__ */ new Map();
|
|
1370
|
+
/**
|
|
1371
|
+
* Declared display-only decorations (`@DbDecorations`, since 0.1.148) —
|
|
1372
|
+
* virtual entries: key → the readable paths it `requires`. Selectable only;
|
|
1373
|
+
* visible while every required path is.
|
|
1374
|
+
*/
|
|
1375
|
+
_decorations;
|
|
1298
1376
|
/** Nested-object parents (never listed, always selectable) → their listed leaves. */
|
|
1299
1377
|
_objectParents = /* @__PURE__ */ new Map();
|
|
1300
1378
|
/** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
|
|
@@ -1307,7 +1385,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1307
1385
|
get objectParents() {
|
|
1308
1386
|
return this._objectParents;
|
|
1309
1387
|
}
|
|
1310
|
-
constructor(source, writeOnly) {
|
|
1388
|
+
constructor(source, writeOnly, decorations = /* @__PURE__ */ new Map()) {
|
|
1389
|
+
this._decorations = decorations;
|
|
1311
1390
|
const tableMeta = source.type.metadata;
|
|
1312
1391
|
this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
|
|
1313
1392
|
this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
|
|
@@ -1317,6 +1396,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1317
1396
|
this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
|
|
1318
1397
|
const fns = source.aggregateFns();
|
|
1319
1398
|
this.aggregateFns = [...ALL_AGGREGATE_FNS].filter((fn) => fns.has(fn));
|
|
1399
|
+
this.aggregateExpressions = source.supportsAggregateExpressions();
|
|
1320
1400
|
const physicalNames = /* @__PURE__ */ new Set();
|
|
1321
1401
|
const jsonValueParents = /* @__PURE__ */ new Set();
|
|
1322
1402
|
for (const fd of source.fieldDescriptors) {
|
|
@@ -1329,8 +1409,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1329
1409
|
dimensions: source.dimensions,
|
|
1330
1410
|
measures: source.measures
|
|
1331
1411
|
};
|
|
1332
|
-
const nav =
|
|
1333
|
-
if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
|
|
1412
|
+
const nav = FieldCapabilityIndex.navPathsOf(source);
|
|
1334
1413
|
this.navFields = nav;
|
|
1335
1414
|
const isNavOrDescendant = (path) => selfOrAncestor(path, nav) !== void 0;
|
|
1336
1415
|
const flatMap = source.flatMap;
|
|
@@ -1394,13 +1473,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1394
1473
|
if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
|
|
1395
1474
|
const bucket = isWriteOnly ? void 0 : bucketSourceVerdict(fd, this._bucketTable, source);
|
|
1396
1475
|
const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
|
|
1476
|
+
const group = isWriteOnly ? void 0 : groupSourceVerdict(fd, this._bucketTable, source);
|
|
1477
|
+
const groupReason = !group ? REASON_WRITE_ONLY : group.ok ? void 0 : `${group.reason}.`;
|
|
1397
1478
|
const cap = {
|
|
1398
1479
|
filterable: filterBy.compare === void 0,
|
|
1399
1480
|
sortable: sortReason === void 0,
|
|
1400
1481
|
selectable: true,
|
|
1401
1482
|
indexed: fd.isIndexed === true,
|
|
1402
|
-
bucketable: bucketReason === void 0
|
|
1483
|
+
bucketable: bucketReason === void 0,
|
|
1484
|
+
groupable: groupReason === void 0,
|
|
1485
|
+
numeric: this.aggregateExpressions && physicalReason === void 0 && numericOperandProblem(fd) === void 0
|
|
1403
1486
|
};
|
|
1487
|
+
if (groupReason) cap.groupReason = groupReason;
|
|
1404
1488
|
if (filterOps.length > 0) cap.filterOps = filterOps;
|
|
1405
1489
|
if (filterBy.compare) cap.filterReason = filterBy.compare;
|
|
1406
1490
|
if (sortReason) cap.sortReason = sortReason;
|
|
@@ -1421,6 +1505,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1421
1505
|
entry.fd
|
|
1422
1506
|
];
|
|
1423
1507
|
}
|
|
1508
|
+
/** The declared decoration keys, in declaration order. */
|
|
1509
|
+
get decorationKeys() {
|
|
1510
|
+
return this._decorations.keys();
|
|
1511
|
+
}
|
|
1512
|
+
/** The capability of the declared decoration `key` (selectable only), `undefined` when `key` is none. */
|
|
1513
|
+
decorationCap(key) {
|
|
1514
|
+
return this._decorations.has(key) ? DECORATION_CAP : void 0;
|
|
1515
|
+
}
|
|
1516
|
+
/** The decoration `key` is visible: every path it `requires` passes `exists` (the hidden-field hook). */
|
|
1517
|
+
decorationVisible(key, exists) {
|
|
1518
|
+
return this._decorations.get(key)?.every(exists) === true;
|
|
1519
|
+
}
|
|
1424
1520
|
/** Physical filter capability (adapter ∧ ¬writeOnly ∧ ¬encrypted) — ignores the manual-mode policy. */
|
|
1425
1521
|
isPhysicallyFilterable(path) {
|
|
1426
1522
|
return this._entries.get(path)?.physicalFilterable === true;
|
|
@@ -1445,6 +1541,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1445
1541
|
* JSON-stored column" — clients pin that wording, so do not "align" it
|
|
1446
1542
|
* with the core backstop's text.
|
|
1447
1543
|
*
|
|
1544
|
+
* A declared decoration (`@DbDecorations`) is a virtual entry: `select` while
|
|
1545
|
+
* every path it requires passes `exists`, any other position a display-only
|
|
1546
|
+
* refusal (`groupedSelect` is a `$select` of an aggregate query), and hidden
|
|
1547
|
+
* sources answer `Unknown field` like a nonexistent path.
|
|
1548
|
+
*
|
|
1448
1549
|
* `predicate` is a filter entry's class (`collectQueryPaths` records it per
|
|
1449
1550
|
* occurrence); it only matters for `op === "filter"` on a listed leaf.
|
|
1450
1551
|
*
|
|
@@ -1454,8 +1555,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1454
1555
|
* name the prefixed one. A filter on a navigation path names the predicate
|
|
1455
1556
|
* alternative (`ticket=$some(status=…)`).
|
|
1456
1557
|
*/
|
|
1457
|
-
check(local,
|
|
1558
|
+
check(local, gateOp, exists, predicate = "compare", prefix = "") {
|
|
1458
1559
|
const path = prefix + local;
|
|
1560
|
+
const requires = prefix === "" ? this._decorations.get(local) : void 0;
|
|
1561
|
+
if (requires) {
|
|
1562
|
+
if (!requires.every(exists)) return unknownField(path);
|
|
1563
|
+
if (gateOp === "select") return void 0;
|
|
1564
|
+
return {
|
|
1565
|
+
path,
|
|
1566
|
+
message: `Field "${path}" is display-only and cannot be used in ${DISPLAY_ONLY_POSITION[gateOp]}`
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
const op = gateOp === "groupedSelect" ? "select" : gateOp;
|
|
1459
1570
|
if (!exists(local)) return unknownField(path);
|
|
1460
1571
|
const { kind, parent: localParent } = classifyQueryPath(this, local);
|
|
1461
1572
|
const parent = localParent === void 0 ? void 0 : prefix + localParent;
|
|
@@ -1485,6 +1596,10 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1485
1596
|
path,
|
|
1486
1597
|
message: `Bucketing field "${path}" is not permitted — ${entry.cap.bucketReason}`
|
|
1487
1598
|
};
|
|
1599
|
+
case "groupBy": return entry.cap.groupable ? void 0 : {
|
|
1600
|
+
path,
|
|
1601
|
+
message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.cap.groupReason}`
|
|
1602
|
+
};
|
|
1488
1603
|
default: return entry.physicalFilterable ? void 0 : {
|
|
1489
1604
|
path,
|
|
1490
1605
|
message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
|
|
@@ -1851,6 +1966,10 @@ function hasClientPredicate(filter) {
|
|
|
1851
1966
|
* without copying).
|
|
1852
1967
|
*/
|
|
1853
1968
|
function readRequestContext(endpoint, controls, filter) {
|
|
1969
|
+
if (endpoint === "insert") return controls.$onConflict === "ignore" ? {
|
|
1970
|
+
endpoint,
|
|
1971
|
+
onConflict: "ignore"
|
|
1972
|
+
} : { endpoint };
|
|
1854
1973
|
if (!filter || Object.keys(filter).length === 0) return {
|
|
1855
1974
|
endpoint,
|
|
1856
1975
|
controls,
|
|
@@ -2139,7 +2258,7 @@ let AsReadableController = class AsReadableController {
|
|
|
2139
2258
|
async parseRequest(endpoint, url) {
|
|
2140
2259
|
let request;
|
|
2141
2260
|
if (url !== void 0) {
|
|
2142
|
-
const { parsed, hasNonControl } = endpoint === "one" ? this.parseControlsOnlyFromUrl(url) : {
|
|
2261
|
+
const { parsed, hasNonControl } = endpoint === "one" || endpoint === "insert" ? this.parseControlsOnlyFromUrl(url) : {
|
|
2143
2262
|
parsed: this.parseQueryString(url),
|
|
2144
2263
|
hasNonControl: false
|
|
2145
2264
|
};
|
|
@@ -2559,8 +2678,8 @@ function isIdValidationSource(value) {
|
|
|
2559
2678
|
const v = value;
|
|
2560
2679
|
return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
|
|
2561
2680
|
}
|
|
2562
|
-
function validateSingleId(body, source,
|
|
2563
|
-
const errors = collectIdErrors(body, source,
|
|
2681
|
+
function validateSingleId(body, source, opts = {}) {
|
|
2682
|
+
const errors = collectIdErrors(body, source, opts);
|
|
2564
2683
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
2565
2684
|
return body;
|
|
2566
2685
|
}
|
|
@@ -2577,11 +2696,12 @@ function validateMultiId(body, source, maxIds = Infinity) {
|
|
|
2577
2696
|
details: []
|
|
2578
2697
|
}]);
|
|
2579
2698
|
const errors = [];
|
|
2580
|
-
for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
|
|
2699
|
+
for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, { path: `[${i}]` }));
|
|
2581
2700
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
2582
2701
|
return body;
|
|
2583
2702
|
}
|
|
2584
|
-
function collectIdErrors(value, source,
|
|
2703
|
+
function collectIdErrors(value, source, opts) {
|
|
2704
|
+
const pathPrefix = opts.path ?? "";
|
|
2585
2705
|
if (!isPlainObject$1(value)) return [{
|
|
2586
2706
|
path: pathPrefix,
|
|
2587
2707
|
message: "Expected JSON object for row identifier",
|
|
@@ -2602,14 +2722,21 @@ function collectIdErrors(value, source, pathPrefix) {
|
|
|
2602
2722
|
const errors = [];
|
|
2603
2723
|
for (const fieldName of match.fields) {
|
|
2604
2724
|
const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
|
|
2605
|
-
const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
|
|
2725
|
+
const err = checkScalar(value[fieldName], sub) ?? (opts.strictTypes === false ? void 0 : checkType(value[fieldName], cache.fieldByName.get(fieldName), sub));
|
|
2606
2726
|
if (err) errors.push(err);
|
|
2607
2727
|
}
|
|
2608
2728
|
return errors;
|
|
2609
2729
|
}
|
|
2610
|
-
|
|
2730
|
+
/**
|
|
2731
|
+
* An identifier value is always a scalar: an object would reach the filter
|
|
2732
|
+
* as an operator expression (`{ $ne: null }`) whatever the field's type.
|
|
2733
|
+
*/
|
|
2734
|
+
function checkScalar(value, path) {
|
|
2735
|
+
return typeof value === "object" && value !== null ? scalarMismatch(path, "a scalar", value) : void 0;
|
|
2736
|
+
}
|
|
2737
|
+
/** The value's type against the field's declared design type (default `string`). */
|
|
2738
|
+
function checkType(value, fd, path) {
|
|
2611
2739
|
const expected = fd?.designType ?? "string";
|
|
2612
|
-
if (typeof value === "object" && value !== null) return scalarMismatch(path, "a scalar", value);
|
|
2613
2740
|
if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
|
|
2614
2741
|
if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
|
|
2615
2742
|
if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
|
|
@@ -2632,14 +2759,70 @@ function isPlainObject$1(value) {
|
|
|
2632
2759
|
//#endregion
|
|
2633
2760
|
//#region src/actions/id-cache.ts
|
|
2634
2761
|
/**
|
|
2762
|
+
* EVERY id the client sent, in request order, with the identity of the id it
|
|
2763
|
+
* resolved to (since 0.1.148) — set only when the controller's
|
|
2764
|
+
* `resolveRowIds` changed or collapsed an id. The single model: refusals,
|
|
2765
|
+
* `reasons`, summaries and counts are judged and reported per request id
|
|
2766
|
+
* through {@link echoRequests}; the resolved (deduped) ids serve only the
|
|
2767
|
+
* row load and the handler.
|
|
2768
|
+
*/
|
|
2769
|
+
const dbActionRequestIdsKey = key("atscript_db_action_request_ids");
|
|
2770
|
+
/** Number of request ids (`fallback` when no id was resolved to another). */
|
|
2771
|
+
function requestCount(ctx, fallback) {
|
|
2772
|
+
return ctx.has(dbActionRequestIdsKey) ? ctx.get(dbActionRequestIdsKey).length : fallback;
|
|
2773
|
+
}
|
|
2774
|
+
/** Number of request ids that resolved to one of `ids` (`ids.length` when none was rewritten). */
|
|
2775
|
+
function requestCountOf(ctx, ids) {
|
|
2776
|
+
if (!ctx.has(dbActionRequestIdsKey)) return ids.length;
|
|
2777
|
+
const keys = new Set(ids.map((id) => identityKey(id)));
|
|
2778
|
+
return ctx.get(dbActionRequestIdsKey).filter((r) => keys.has(r.key)).length;
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Entries keyed by a resolved id, reported for every REQUEST id that resolved
|
|
2782
|
+
* to it — in request order, each as the client sent it (`id` replaced). Two
|
|
2783
|
+
* aliases of one row come back exactly like two distinct rows (same order,
|
|
2784
|
+
* same count). Entries whose id no request resolved to stay as they are,
|
|
2785
|
+
* after the request ones.
|
|
2786
|
+
*/
|
|
2787
|
+
function echoRequests(ctx, entries) {
|
|
2788
|
+
if (!ctx.has(dbActionRequestIdsKey)) return [...entries];
|
|
2789
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2790
|
+
const rest = [];
|
|
2791
|
+
for (const e of entries) {
|
|
2792
|
+
const k = identityKey(e.id);
|
|
2793
|
+
if (k === void 0) rest.push(e);
|
|
2794
|
+
else if (!byKey.has(k)) byKey.set(k, e);
|
|
2795
|
+
}
|
|
2796
|
+
const out = [];
|
|
2797
|
+
const matched = /* @__PURE__ */ new Set();
|
|
2798
|
+
for (const r of ctx.get(dbActionRequestIdsKey)) {
|
|
2799
|
+
const e = byKey.get(r.key);
|
|
2800
|
+
if (!e) continue;
|
|
2801
|
+
matched.add(r.key);
|
|
2802
|
+
out.push({
|
|
2803
|
+
...e,
|
|
2804
|
+
id: r.id
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
for (const [k, e] of byKey) if (!matched.has(k)) rest.push(e);
|
|
2808
|
+
return [...out, ...rest];
|
|
2809
|
+
}
|
|
2810
|
+
/** The id as the client sent it (the first, for an id several requests resolved to). */
|
|
2811
|
+
function requestIdOf(ctx, id) {
|
|
2812
|
+
return echoRequests(ctx, [{ id }])[0].id;
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2635
2815
|
* Validates the body's `ids` against the action table's identifications. For
|
|
2636
2816
|
* the controller's own table that is its `idSource` (since 0.1.134): a unique
|
|
2637
2817
|
* index over a field `hasField` hides neither addresses a row nor appears in
|
|
2638
2818
|
* the "must exactly match one of" message. An `opts.table` binding has no
|
|
2639
2819
|
* visibility hook. The controller's `prepareRequest` (since 0.1.143) runs
|
|
2640
2820
|
* first, so the visibility the ids are validated against is the request's.
|
|
2821
|
+
* Then, when the controller overrides `resolveRowIds` (since 0.1.148), the
|
|
2822
|
+
* validated ids go through it — a one-element array for a `'row'` action —
|
|
2823
|
+
* and the resolved ids (duplicates collapsed) are what every consumer sees.
|
|
2641
2824
|
*/
|
|
2642
|
-
async function resolveValidatedId(ctx, validate) {
|
|
2825
|
+
async function resolveValidatedId(ctx, level, validate) {
|
|
2643
2826
|
await awaitActionPrepared(ctx);
|
|
2644
2827
|
let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
|
|
2645
2828
|
if (!source) {
|
|
@@ -2649,9 +2832,21 @@ async function resolveValidatedId(ctx, validate) {
|
|
|
2649
2832
|
if (!isIdValidationSource(source)) throw noTableError(ctx);
|
|
2650
2833
|
const env = await ctx.get(dbActionBodySlot);
|
|
2651
2834
|
validate(env.ids, source);
|
|
2652
|
-
|
|
2835
|
+
const scoped = ctx.get(scopedControllerSlot);
|
|
2836
|
+
const resolve = scoped?.[ROW_RESOLVES] ? scoped[ROW_RESOLVE_IDS] : void 0;
|
|
2837
|
+
if (!resolve) return env.ids;
|
|
2838
|
+
const requested = level === "row" ? [env.ids] : env.ids;
|
|
2839
|
+
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
2840
|
+
const { ids, requests } = await resolve.call(scoped, requested, {
|
|
2841
|
+
purpose: "action",
|
|
2842
|
+
action: readCurrentActionMeta(ctx)?.name,
|
|
2843
|
+
level,
|
|
2844
|
+
overlay: overlay ?? void 0
|
|
2845
|
+
});
|
|
2846
|
+
if (requests) ctx.set(dbActionRequestIdsKey, requests);
|
|
2847
|
+
return level === "row" ? ids[0] : ids;
|
|
2653
2848
|
}
|
|
2654
|
-
const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
|
|
2849
|
+
const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, "row", validateSingleId));
|
|
2655
2850
|
/**
|
|
2656
2851
|
* The `'rows'` action's identifiers: the body's validated `ids`, or — for a
|
|
2657
2852
|
* query target (`query`, since 0.1.147) — the identities of the rows it
|
|
@@ -2661,7 +2856,7 @@ const dbActionIdsSlot = cached(async (ctx) => {
|
|
|
2661
2856
|
await awaitActionPrepared(ctx);
|
|
2662
2857
|
const target = await ctx.get(dbActionQueryTargetSlot);
|
|
2663
2858
|
if (target) return target.ids;
|
|
2664
|
-
return await resolveValidatedId(ctx, (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
|
|
2859
|
+
return await resolveValidatedId(ctx, "rows", (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
|
|
2665
2860
|
});
|
|
2666
2861
|
const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
|
|
2667
2862
|
const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
|
|
@@ -2681,29 +2876,70 @@ function asFetchTable(value) {
|
|
|
2681
2876
|
function seedActionFields(ctx, table) {
|
|
2682
2877
|
return actionRowFields(table, requiredFieldsOf(readCurrentActionMeta(ctx)?.opts), actionFieldVisibility(ctx));
|
|
2683
2878
|
}
|
|
2879
|
+
const UNSCOPED = {
|
|
2880
|
+
kind: "resolved",
|
|
2881
|
+
scope: null
|
|
2882
|
+
};
|
|
2883
|
+
const DEFERRED = { kind: "deferred" };
|
|
2884
|
+
function inPreferredShape(ids, preferred) {
|
|
2885
|
+
return ids.every((id) => {
|
|
2886
|
+
return Object.keys(id).length === preferred.length && preferred.every((f) => f in id);
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
async function resolvePreScope(ctx, level) {
|
|
2890
|
+
const ctrl = ctx.get(scopedControllerSlot);
|
|
2891
|
+
const action = readCurrentActionMeta(ctx)?.name;
|
|
2892
|
+
const scopeOf = ctrl?.[ACTION_SCOPED] ? ctrl[ACTION_SCOPE] : void 0;
|
|
2893
|
+
if (!ctrl || !scopeOf || action === void 0) return UNSCOPED;
|
|
2894
|
+
if (await ctx.get(dbActionOverlaySlot)) return DEFERRED;
|
|
2895
|
+
const table = asFetchTable(getActionTable(ctx));
|
|
2896
|
+
const preferred = table?.preferredId;
|
|
2897
|
+
if (!table || !preferred?.length) return DEFERRED;
|
|
2898
|
+
const [target, requested] = await Promise.all([level === "rows" ? ctx.get(dbActionQueryTargetSlot) : void 0, level === "row" ? ctx.get(dbActionIdSlot).then((id) => [id]) : ctx.get(dbActionIdsSlot)]);
|
|
2899
|
+
if (target || !inPreferredShape(requested, preferred)) return DEFERRED;
|
|
2900
|
+
const ids = level === "row" ? requested : dedupeIdentities(requested, preferred).ids;
|
|
2901
|
+
if (ids.length === 0) return UNSCOPED;
|
|
2902
|
+
return {
|
|
2903
|
+
kind: "resolved",
|
|
2904
|
+
scope: nonEmptyFilter(await scopeOf.call(ctrl, action, createScopeContext("execute", ids, table))) ?? null
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* {@link TPreScope} of the current action — once per event. An event runs one
|
|
2909
|
+
* action, so the first caller's `level` (`'row'` / `'rows'`) is the action's.
|
|
2910
|
+
*/
|
|
2911
|
+
const dbActionPreScopeSlot = cached((ctx) => {
|
|
2912
|
+
let pending;
|
|
2913
|
+
return (level) => pending ??= resolvePreScope(ctx, level);
|
|
2914
|
+
});
|
|
2684
2915
|
/**
|
|
2685
2916
|
* Loaded row / rows are ANDed with the controller's row overlay (see
|
|
2686
2917
|
* `dbActionOverlaySlot`, since 0.1.143) and, since 0.1.147, checked against
|
|
2687
|
-
* the action's `actionRowScope
|
|
2688
|
-
*
|
|
2689
|
-
*
|
|
2918
|
+
* the action's `actionRowScope`: an out-of-scope id loads nothing — exactly
|
|
2919
|
+
* like a missing one (the same 404 on `'row'` actions, so the two can't be
|
|
2920
|
+
* told apart).
|
|
2690
2921
|
*
|
|
2691
2922
|
* Order: `prepareRequest` → the row overlay (BEFORE the request body is
|
|
2692
|
-
* read: fail-fast authorization) → the ids (body) →
|
|
2693
|
-
* `actionRowScope` (
|
|
2923
|
+
* read: fail-fast authorization) → the ids (body) → `resolveRowIds` (since
|
|
2924
|
+
* 0.1.148) → `actionRowScope` (pre-load, when there is no overlay and the
|
|
2925
|
+
* ids are in `preferredId` shape — its restriction joins the one row load;
|
|
2926
|
+
* since 0.1.148) → the row load → otherwise `actionRowScope` on the loaded
|
|
2927
|
+
* candidates (it needs them).
|
|
2694
2928
|
*/
|
|
2695
2929
|
async function loadRow(ctx) {
|
|
2696
2930
|
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
2697
2931
|
const id = await ctx.get(dbActionIdSlot);
|
|
2698
2932
|
const table = asFetchTable(getActionTable(ctx));
|
|
2699
2933
|
if (!table) throw noTableError(ctx);
|
|
2934
|
+
const pre = await ctx.get(dbActionPreScopeSlot)("row");
|
|
2700
2935
|
const fields = seedActionFields(ctx, table);
|
|
2701
2936
|
for (const k of Object.keys(id)) fields.add(k);
|
|
2702
|
-
const
|
|
2703
|
-
|
|
2937
|
+
const idFilter = withOverlay(withOverlay(id, overlay), pre.kind === "resolved" ? pre.scope : null);
|
|
2938
|
+
let row = await table.findOne({
|
|
2939
|
+
filter: idFilter,
|
|
2704
2940
|
controls: { $select: [...fields] }
|
|
2705
|
-
});
|
|
2706
|
-
|
|
2941
|
+
}) ?? void 0;
|
|
2942
|
+
if (row !== void 0 && pre.kind === "deferred") [row] = await applyActionScope(ctx, table, [row]);
|
|
2707
2943
|
if (row === void 0) throw new HttpError(404, "Row not found for action identifier");
|
|
2708
2944
|
return row;
|
|
2709
2945
|
}
|
|
@@ -2714,7 +2950,11 @@ async function loadRows(ctx) {
|
|
|
2714
2950
|
if (!table) throw noTableError(ctx);
|
|
2715
2951
|
const fields = seedActionFields(ctx, table);
|
|
2716
2952
|
const target = await ctx.get(dbActionQueryTargetSlot);
|
|
2717
|
-
if (!target)
|
|
2953
|
+
if (!target) {
|
|
2954
|
+
const pre = await ctx.get(dbActionPreScopeSlot)("rows");
|
|
2955
|
+
if (pre.kind === "resolved") return findRowsByIds(table, ids, pre.scope ? withOverlay(pre.scope, overlay) : overlay, fields);
|
|
2956
|
+
return applyActionScope(ctx, table, await findRowsByIds(table, ids, overlay, fields));
|
|
2957
|
+
}
|
|
2718
2958
|
const rows = await target.load(ids, fields);
|
|
2719
2959
|
const stale = /* @__PURE__ */ new Set();
|
|
2720
2960
|
for (let i = 0; i < rows.length; i++) if (rows[i] === void 0) stale.add(i);
|
|
@@ -2771,25 +3011,52 @@ function DbActionTarget() {
|
|
|
2771
3011
|
var TargetBase = class {
|
|
2772
3012
|
kind;
|
|
2773
3013
|
matched;
|
|
3014
|
+
ctx;
|
|
2774
3015
|
skipped = [];
|
|
2775
3016
|
failed = [];
|
|
2776
3017
|
processed = 0;
|
|
2777
|
-
|
|
3018
|
+
/** Identities of every id handed to the handler so far. */
|
|
3019
|
+
handedKeys = /* @__PURE__ */ new Set();
|
|
3020
|
+
failedKeys = /* @__PURE__ */ new Set();
|
|
3021
|
+
constructor(kind, matched, ctx) {
|
|
2778
3022
|
this.kind = kind;
|
|
2779
3023
|
this.matched = matched;
|
|
3024
|
+
this.ctx = ctx;
|
|
2780
3025
|
}
|
|
3026
|
+
/**
|
|
3027
|
+
* One failure per id, whether or not `resolveRowIds` rewrote any id: a
|
|
3028
|
+
* second `fail()` of the same identity is ignored (the first reason stands),
|
|
3029
|
+
* so `failed` and `processed` agree in both modes.
|
|
3030
|
+
*/
|
|
2781
3031
|
fail(id, reason) {
|
|
3032
|
+
const k = identityKey(id);
|
|
3033
|
+
if (k !== void 0) {
|
|
3034
|
+
if (this.failedKeys.has(k)) return;
|
|
3035
|
+
this.failedKeys.add(k);
|
|
3036
|
+
}
|
|
2782
3037
|
this.failed.push({
|
|
2783
3038
|
id,
|
|
2784
3039
|
reason
|
|
2785
3040
|
});
|
|
2786
3041
|
}
|
|
3042
|
+
/**
|
|
3043
|
+
* A batch handed to the handler, counted per request id (two aliases of one
|
|
3044
|
+
* row count twice, like two distinct rows).
|
|
3045
|
+
*/
|
|
3046
|
+
countProcessed(ids) {
|
|
3047
|
+
for (const id of ids) {
|
|
3048
|
+
const k = identityKey(id);
|
|
3049
|
+
if (k !== void 0) this.handedKeys.add(k);
|
|
3050
|
+
}
|
|
3051
|
+
this.processed += requestCountOf(this.ctx, ids);
|
|
3052
|
+
}
|
|
2787
3053
|
summary() {
|
|
3054
|
+
const failed = echoRequests(this.ctx, this.failed);
|
|
2788
3055
|
return {
|
|
2789
3056
|
matched: this.matched,
|
|
2790
|
-
processed: Math.max(0, this.processed -
|
|
2791
|
-
skipped:
|
|
2792
|
-
failed
|
|
3057
|
+
processed: Math.max(0, this.processed - failed.length),
|
|
3058
|
+
skipped: echoRequests(this.ctx, this.skipped),
|
|
3059
|
+
failed
|
|
2793
3060
|
};
|
|
2794
3061
|
}
|
|
2795
3062
|
};
|
|
@@ -2801,12 +3068,12 @@ var MaterializedTarget = class extends TargetBase {
|
|
|
2801
3068
|
ids;
|
|
2802
3069
|
rows;
|
|
2803
3070
|
iterated = false;
|
|
2804
|
-
constructor(kind, matched, ids, rows, skipped) {
|
|
2805
|
-
super(kind, matched);
|
|
3071
|
+
constructor(kind, matched, ids, rows, skipped, ctx) {
|
|
3072
|
+
super(kind, matched, ctx);
|
|
2806
3073
|
this.ids = ids;
|
|
2807
3074
|
this.rows = rows;
|
|
2808
3075
|
this.skipped.push(...skipped);
|
|
2809
|
-
this.
|
|
3076
|
+
this.countProcessed(ids);
|
|
2810
3077
|
}
|
|
2811
3078
|
async *batches() {
|
|
2812
3079
|
if (this.iterated) throw new Error("[moost-db actions] target.batches() is single-pass");
|
|
@@ -2822,21 +3089,19 @@ async function setMaterializedTarget(ctx) {
|
|
|
2822
3089
|
const target = await resolveActionQueryTarget(ctx, false);
|
|
2823
3090
|
const ids = await ctx.get(dbActionIdsSlot);
|
|
2824
3091
|
const skipped = ctx.has(dbActionSkippedKey) ? ctx.get(dbActionSkippedKey) : [];
|
|
2825
|
-
ctx.set(dbActionTargetKey, new MaterializedTarget(target ? "query" : "ids", target ? target.matched : ids.length + skipped.length, ids, async () => {
|
|
3092
|
+
ctx.set(dbActionTargetKey, new MaterializedTarget(target ? "query" : "ids", target ? target.matched : requestCount(ctx, ids.length + skipped.length), ids, async () => {
|
|
2826
3093
|
return (await ctx.get(dbActionRowsSlot)).filter((r) => r !== void 0);
|
|
2827
|
-
}, skipped));
|
|
3094
|
+
}, skipped, ctx));
|
|
2828
3095
|
}
|
|
2829
3096
|
/** The `@DbActionTarget` surface: the target in batches, each gated when it is reached. */
|
|
2830
3097
|
var StreamedTarget = class extends TargetBase {
|
|
2831
|
-
ctx;
|
|
2832
3098
|
source;
|
|
2833
3099
|
batchSize;
|
|
2834
3100
|
action;
|
|
2835
3101
|
disabled;
|
|
2836
3102
|
iterated = false;
|
|
2837
3103
|
constructor(matched, ctx, source, batchSize, action, disabled) {
|
|
2838
|
-
super(source.kind, matched);
|
|
2839
|
-
this.ctx = ctx;
|
|
3104
|
+
super(source.kind, matched, ctx);
|
|
2840
3105
|
this.source = source;
|
|
2841
3106
|
this.batchSize = batchSize;
|
|
2842
3107
|
this.action = action;
|
|
@@ -2854,7 +3119,7 @@ var StreamedTarget = class extends TargetBase {
|
|
|
2854
3119
|
const batch = await this.gate(ids.slice(start, start + this.batchSize));
|
|
2855
3120
|
this.next = start + this.batchSize;
|
|
2856
3121
|
if (batch.ids.length === 0) continue;
|
|
2857
|
-
this.
|
|
3122
|
+
this.countProcessed(batch.ids);
|
|
2858
3123
|
this.current = batch.ids;
|
|
2859
3124
|
yield batch;
|
|
2860
3125
|
this.current = void 0;
|
|
@@ -2862,30 +3127,66 @@ var StreamedTarget = class extends TargetBase {
|
|
|
2862
3127
|
this.next = ids.length;
|
|
2863
3128
|
}
|
|
2864
3129
|
/**
|
|
2865
|
-
* The summary of a run the handler failed after it received a batch
|
|
2866
|
-
*
|
|
2867
|
-
*
|
|
3130
|
+
* The summary of a run the handler failed after it received a batch, judged
|
|
3131
|
+
* per REQUEST id by its own request position — an alias after the abort
|
|
3132
|
+
* point is "not run" even when its row was judged (run, skipped) earlier,
|
|
3133
|
+
* exactly as the same request of distinct rows would be:
|
|
3134
|
+
* - at or after the position the run stopped at: `failed` as `"not run"`;
|
|
3135
|
+
* - before it, a row the handler failed itself: its reason;
|
|
3136
|
+
* - before it, the batch the handler held (uncertain): the error's message;
|
|
3137
|
+
* - before it, a row the gate skipped: `skipped`; a row that ran: `processed`.
|
|
3138
|
+
* `aborted` is set.
|
|
2868
3139
|
*/
|
|
2869
3140
|
abort(error) {
|
|
2870
3141
|
const message = errorMessage(error);
|
|
2871
|
-
const
|
|
2872
|
-
const
|
|
2873
|
-
|
|
2874
|
-
|
|
3142
|
+
const ids = this.source.ids;
|
|
3143
|
+
const requests = this.ctx.has(dbActionRequestIdsKey) ? this.ctx.get(dbActionRequestIdsKey) : ids.map((id, i) => ({
|
|
3144
|
+
id,
|
|
3145
|
+
key: identityKey(id) ?? `#${i}`
|
|
3146
|
+
}));
|
|
3147
|
+
const held = new Set((this.current ?? []).map((id) => identityKey(id)));
|
|
3148
|
+
let cutoff = requests.length;
|
|
3149
|
+
if (this.ctx.has(dbActionRequestIdsKey)) {
|
|
3150
|
+
const firstOf = (key) => requests.findIndex((r) => r.key === key);
|
|
3151
|
+
if (held.size > 0) cutoff = 1 + Math.max(...[...held].map(firstOf));
|
|
3152
|
+
else if (this.next < ids.length) cutoff = firstOf(identityKey(ids[this.next]));
|
|
3153
|
+
if (cutoff < 0) cutoff = requests.length;
|
|
3154
|
+
} else if (this.next < ids.length) cutoff = this.next;
|
|
3155
|
+
const failedBy = new Map(this.failed.map((f) => [identityKey(f.id), f]));
|
|
3156
|
+
const skippedBy = /* @__PURE__ */ new Map();
|
|
3157
|
+
for (const row of this.skipped) {
|
|
3158
|
+
const k = identityKey(row.id);
|
|
3159
|
+
if (!skippedBy.has(k)) skippedBy.set(k, row);
|
|
3160
|
+
}
|
|
3161
|
+
const failed = [];
|
|
3162
|
+
const skipped = [];
|
|
3163
|
+
let processed = 0;
|
|
3164
|
+
requests.forEach((r, i) => {
|
|
3165
|
+
const own = failedBy.get(r.key);
|
|
3166
|
+
const skip = skippedBy.get(r.key);
|
|
3167
|
+
if (i >= cutoff) failed.push({
|
|
3168
|
+
id: r.id,
|
|
3169
|
+
reason: "not run"
|
|
3170
|
+
});
|
|
3171
|
+
else if (own) failed.push({
|
|
3172
|
+
id: r.id,
|
|
3173
|
+
reason: own.reason
|
|
3174
|
+
});
|
|
3175
|
+
else if (held.has(r.key)) failed.push({
|
|
3176
|
+
id: r.id,
|
|
3177
|
+
reason: message
|
|
3178
|
+
});
|
|
3179
|
+
else if (skip) skipped.push({
|
|
3180
|
+
...skip,
|
|
3181
|
+
id: r.id
|
|
3182
|
+
});
|
|
3183
|
+
else if (this.handedKeys.has(r.key)) processed++;
|
|
3184
|
+
});
|
|
2875
3185
|
return {
|
|
2876
|
-
|
|
2877
|
-
processed
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
...uncertain.map((id) => ({
|
|
2881
|
-
id,
|
|
2882
|
-
reason: message
|
|
2883
|
-
})),
|
|
2884
|
-
...this.source.ids.slice(this.next).map((id) => ({
|
|
2885
|
-
id,
|
|
2886
|
-
reason: "not run"
|
|
2887
|
-
}))
|
|
2888
|
-
],
|
|
3186
|
+
matched: this.matched,
|
|
3187
|
+
processed,
|
|
3188
|
+
skipped,
|
|
3189
|
+
failed,
|
|
2889
3190
|
aborted: {
|
|
2890
3191
|
status: errorStatus(error),
|
|
2891
3192
|
message
|
|
@@ -2980,7 +3281,7 @@ async function setStreamedTarget(ctx, action, disabled) {
|
|
|
2980
3281
|
return findRowsByIds(table, batch, overlay, select);
|
|
2981
3282
|
}
|
|
2982
3283
|
};
|
|
2983
|
-
const target = new StreamedTarget(query ? query.matched : source.ids.length, ctx, source, batchSize, action, disabled);
|
|
3284
|
+
const target = new StreamedTarget(query ? query.matched : requestCount(ctx, source.ids.length), ctx, source, batchSize, action, disabled);
|
|
2984
3285
|
ctx.set(dbActionTargetKey, target);
|
|
2985
3286
|
return {
|
|
2986
3287
|
target,
|
|
@@ -3009,6 +3310,8 @@ const ACTION_SLOTS = [
|
|
|
3009
3310
|
dbActionInputSlot,
|
|
3010
3311
|
dbActionIdSlot,
|
|
3011
3312
|
dbActionIdsSlot,
|
|
3313
|
+
dbActionRequestIdsKey,
|
|
3314
|
+
dbActionPreScopeSlot,
|
|
3012
3315
|
dbActionRowSlot,
|
|
3013
3316
|
dbActionRowsSlot,
|
|
3014
3317
|
dbActionOverlaySlot,
|
|
@@ -3316,6 +3619,398 @@ function getDbEndpoint(target, method) {
|
|
|
3316
3619
|
}
|
|
3317
3620
|
}
|
|
3318
3621
|
//#endregion
|
|
3622
|
+
//#region src/decorations/decoration-index.ts
|
|
3623
|
+
/** A decoration key is a valid `$select` URL name: top-level, no `$` prefix, no dots. */
|
|
3624
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3625
|
+
/**
|
|
3626
|
+
* The own readable paths a `requires` path stands for: itself when it is an
|
|
3627
|
+
* own field, else — a parent object of a flattened (SQL) readable — every own
|
|
3628
|
+
* field below it. Empty when the path is neither.
|
|
3629
|
+
*/
|
|
3630
|
+
function ownLeaves(path, ownPaths) {
|
|
3631
|
+
if (ownPaths.has(path)) return [path];
|
|
3632
|
+
const prefix = `${path}.`;
|
|
3633
|
+
return [...ownPaths].filter((own) => own.startsWith(prefix));
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Validates `@DbDecorations` metadata against the bound readable and indexes
|
|
3637
|
+
* it. Throws `[moost-db]` errors (once per class — the caller memoizes).
|
|
3638
|
+
*/
|
|
3639
|
+
function buildDecorationIndex(controller, meta, source) {
|
|
3640
|
+
const fail = (message) => {
|
|
3641
|
+
throw new Error(`[moost-db] ${controller}: @DbDecorations ${message}`);
|
|
3642
|
+
};
|
|
3643
|
+
const { type } = meta;
|
|
3644
|
+
if (type.type.kind !== "object") fail("expects an object interface");
|
|
3645
|
+
if (type.metadata.has("db.table") || type.metadata.has("db.view")) fail("expects a plain interface — it must not carry @db.table or @db.view");
|
|
3646
|
+
const keys = [...type.type.props.keys()];
|
|
3647
|
+
const fieldPaths = /* @__PURE__ */ new Set();
|
|
3648
|
+
for (const path of source.flatMap?.keys() ?? []) for (let at = path.indexOf(".");; at = path.indexOf(".", at + 1)) {
|
|
3649
|
+
fieldPaths.add(at < 0 ? path : path.slice(0, at));
|
|
3650
|
+
if (at < 0) break;
|
|
3651
|
+
}
|
|
3652
|
+
const isField = (path) => fieldPaths.has(path);
|
|
3653
|
+
for (const key of keys) {
|
|
3654
|
+
if (!KEY_RE.test(key)) fail(`key "${key}" must be a plain top-level identifier (no "$" prefix, no dots)`);
|
|
3655
|
+
if (isField(key) || source.relations?.has(key) || source.navFields.has(key)) fail(`key "${key}" collides with a field or relation of the bound readable`);
|
|
3656
|
+
}
|
|
3657
|
+
const requires = /* @__PURE__ */ new Map();
|
|
3658
|
+
const leavesOf = /* @__PURE__ */ new Map();
|
|
3659
|
+
const nested = (path) => {
|
|
3660
|
+
const prefix = `${path}.`;
|
|
3661
|
+
const below = [...source.flatMap?.keys() ?? []].filter((p) => p.startsWith(prefix));
|
|
3662
|
+
return below.filter((p) => selfOrAncestor(p, source.navFields) === void 0 && !below.some((other) => other.startsWith(`${p}.`)));
|
|
3663
|
+
};
|
|
3664
|
+
for (const key of keys) requires.set(key, []);
|
|
3665
|
+
for (const [key, paths] of Object.entries(meta.requires)) {
|
|
3666
|
+
if (!requires.has(key)) fail(`\`requires\` names "${key}", which is not a declared decoration`);
|
|
3667
|
+
const resolved = [];
|
|
3668
|
+
for (const path of paths) {
|
|
3669
|
+
const leaves = ownLeaves(path, source.ownPaths);
|
|
3670
|
+
if (leaves.length === 0) fail(`"${key}" requires "${path}", which is not an own field of the bound readable`);
|
|
3671
|
+
for (const leaf of leaves) if ([...source.writeOnly].some((wo) => wo.startsWith(`${leaf}.`)) || selfOrAncestor(leaf, source.writeOnly) !== void 0) fail(`"${key}" requires "${path}", which is @db.writeOnly (a sealed value cannot be read)`);
|
|
3672
|
+
for (const leaf of leaves) {
|
|
3673
|
+
if (leavesOf.has(leaf)) continue;
|
|
3674
|
+
const below = nested(leaf);
|
|
3675
|
+
if (below.length > 0) leavesOf.set(leaf, below);
|
|
3676
|
+
}
|
|
3677
|
+
resolved.push(...leaves);
|
|
3678
|
+
}
|
|
3679
|
+
requires.set(key, [...new Set(resolved)]);
|
|
3680
|
+
}
|
|
3681
|
+
const visibleOn = /* @__PURE__ */ new Map();
|
|
3682
|
+
for (const [key, paths] of requires) {
|
|
3683
|
+
const all = new Set(paths);
|
|
3684
|
+
for (const path of paths) for (const leaf of leavesOf.get(path) ?? []) all.add(leaf);
|
|
3685
|
+
visibleOn.set(key, [...all]);
|
|
3686
|
+
}
|
|
3687
|
+
return {
|
|
3688
|
+
type,
|
|
3689
|
+
keys,
|
|
3690
|
+
keySet: new Set(keys),
|
|
3691
|
+
requires,
|
|
3692
|
+
visibleOn,
|
|
3693
|
+
leavesOf,
|
|
3694
|
+
memo: { meta: /* @__PURE__ */ new WeakMap() }
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
//#endregion
|
|
3698
|
+
//#region src/select-shape.ts
|
|
3699
|
+
/** The included (`1` / `true`) and excluded (`0` / `false`) keys of a `$select` map. */
|
|
3700
|
+
function mapShape(map) {
|
|
3701
|
+
const included = [];
|
|
3702
|
+
const excluded = [];
|
|
3703
|
+
for (const [key, value] of Object.entries(map)) if (value === 1 || value === true) included.push(key);
|
|
3704
|
+
else if (value === 0 || value === false) excluded.push(key);
|
|
3705
|
+
return {
|
|
3706
|
+
included,
|
|
3707
|
+
excluded
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
/** The one splitter every `$select` consumer in the controller reads. */
|
|
3711
|
+
function selectShape(raw) {
|
|
3712
|
+
if (raw === void 0 || raw === null) return { kind: "all" };
|
|
3713
|
+
if (Array.isArray(raw)) return {
|
|
3714
|
+
kind: "list",
|
|
3715
|
+
items: raw
|
|
3716
|
+
};
|
|
3717
|
+
const map = raw;
|
|
3718
|
+
return {
|
|
3719
|
+
kind: "map",
|
|
3720
|
+
map,
|
|
3721
|
+
...mapShape(map)
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
//#endregion
|
|
3725
|
+
//#region src/decorations/decoration-planner.ts
|
|
3726
|
+
const NO_DECORATIONS = /* @__PURE__ */ new Set();
|
|
3727
|
+
/**
|
|
3728
|
+
* The decoration plumbing of one controller (`@DbDecorations`, since 0.1.148):
|
|
3729
|
+
* rewrites a read's `$select` ({@link plan}), decides what the response
|
|
3730
|
+
* carries once the final projection is known ({@link serve}), and builds the
|
|
3731
|
+
* `/meta` view ({@link meta}). The request gate is not here — decorations are
|
|
3732
|
+
* virtual entries of the {@link FieldCapabilityIndex}.
|
|
3733
|
+
*/
|
|
3734
|
+
var DecorationPlanner = class {
|
|
3735
|
+
index;
|
|
3736
|
+
host;
|
|
3737
|
+
constructor(index, host) {
|
|
3738
|
+
this.index = index;
|
|
3739
|
+
this.host = host;
|
|
3740
|
+
}
|
|
3741
|
+
visible(key) {
|
|
3742
|
+
return this.host.capabilities().decorationVisible(key, this.host.isVisible);
|
|
3743
|
+
}
|
|
3744
|
+
requiresOf(keys) {
|
|
3745
|
+
return [...new Set(keys.flatMap((key) => this.index.requires.get(key)))];
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* Splits the wire `$select` of a read (non-grouped: the gate already
|
|
3749
|
+
* refused a decoration in a grouped one): `requested` is every declared
|
|
3750
|
+
* decoration key whose sources are visible and that the client named — or,
|
|
3751
|
+
* without a `$select`, all of them (an exclusion map: all minus the excluded
|
|
3752
|
+
* ones). The real `$select` is widened by the requested keys' `requires`;
|
|
3753
|
+
* the paths added only for the hook (`requiresOnly`) are stripped again.
|
|
3754
|
+
* The result keeps the representation of `raw` (array or map).
|
|
3755
|
+
*/
|
|
3756
|
+
plan(raw) {
|
|
3757
|
+
const { keySet, keys } = this.index;
|
|
3758
|
+
const shape = selectShape(raw);
|
|
3759
|
+
if (shape.kind === "all") return {
|
|
3760
|
+
select: void 0,
|
|
3761
|
+
requested: keys.filter((k) => this.visible(k)),
|
|
3762
|
+
requiresOnly: []
|
|
3763
|
+
};
|
|
3764
|
+
const passthrough = {
|
|
3765
|
+
select: raw,
|
|
3766
|
+
requested: [],
|
|
3767
|
+
requiresOnly: []
|
|
3768
|
+
};
|
|
3769
|
+
if (shape.kind === "list") {
|
|
3770
|
+
const named = shape.items.filter((item) => typeof item === "string" && keySet.has(item));
|
|
3771
|
+
if (named.length === 0) return passthrough;
|
|
3772
|
+
const real = shape.items.filter((item) => !(typeof item === "string" && keySet.has(item)));
|
|
3773
|
+
const have = new Set(real.filter((item) => typeof item === "string"));
|
|
3774
|
+
return this.inclusion([...new Set(named)], real, have, (list) => list);
|
|
3775
|
+
}
|
|
3776
|
+
const { map, included, excluded } = shape;
|
|
3777
|
+
if (included.length === 0 && excluded.length > 0) {
|
|
3778
|
+
const skip = new Set(excluded);
|
|
3779
|
+
const requested = keys.filter((key) => !skip.has(key) && this.visible(key));
|
|
3780
|
+
const real = Object.fromEntries(Object.entries(map).filter(([k]) => !keySet.has(k)));
|
|
3781
|
+
const requiresOnly = /* @__PURE__ */ new Set();
|
|
3782
|
+
const remaining = new Set(Object.keys(real));
|
|
3783
|
+
const unexclude = (key) => {
|
|
3784
|
+
requiresOnly.add(key);
|
|
3785
|
+
remaining.delete(key);
|
|
3786
|
+
delete real[key];
|
|
3787
|
+
};
|
|
3788
|
+
for (const path of this.requiresOf(requested)) {
|
|
3789
|
+
const hit = selfOrAncestor(path, remaining);
|
|
3790
|
+
if (hit !== void 0) {
|
|
3791
|
+
unexclude(hit);
|
|
3792
|
+
continue;
|
|
3793
|
+
}
|
|
3794
|
+
const prefix = `${path}.`;
|
|
3795
|
+
for (const key of remaining) if (key.startsWith(prefix)) unexclude(key);
|
|
3796
|
+
}
|
|
3797
|
+
return {
|
|
3798
|
+
select: Object.keys(real).length > 0 ? real : void 0,
|
|
3799
|
+
requested,
|
|
3800
|
+
requiresOnly: [...requiresOnly]
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
const named = included.filter((k) => keySet.has(k));
|
|
3804
|
+
if (named.length === 0) return passthrough;
|
|
3805
|
+
const real = included.filter((k) => !keySet.has(k));
|
|
3806
|
+
return this.inclusion(named, real, new Set(real), (list) => Object.fromEntries(list.map((k) => [k, 1])));
|
|
3807
|
+
}
|
|
3808
|
+
/**
|
|
3809
|
+
* The inclusion forms: `real` (the client's own selection) plus the
|
|
3810
|
+
* requested decorations' `requires` that it lacks, re-emitted by `emit`.
|
|
3811
|
+
*/
|
|
3812
|
+
inclusion(named, real, have, emit) {
|
|
3813
|
+
const requested = named.filter((key) => this.visible(key));
|
|
3814
|
+
const extra = this.requiresOf(requested).filter((path) => selfOrAncestor(path, have) === void 0);
|
|
3815
|
+
const { list, added } = this.nonEmptyInclusion([...real, ...extra]);
|
|
3816
|
+
return {
|
|
3817
|
+
select: emit(list),
|
|
3818
|
+
requested,
|
|
3819
|
+
requiresOnly: [...extra.filter((path) => !this.host.preferred.has(path)), ...added],
|
|
3820
|
+
selected: [...have]
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3823
|
+
/**
|
|
3824
|
+
* An inclusion list is never empty (an empty one selects everything): a
|
|
3825
|
+
* request naming only decorations with nothing to read selects the
|
|
3826
|
+
* `preferredId` fields (which the response carries anyway) or, without one,
|
|
3827
|
+
* a single visible field that is stripped again (`added`).
|
|
3828
|
+
*/
|
|
3829
|
+
nonEmptyInclusion(list) {
|
|
3830
|
+
if (list.length > 0) return {
|
|
3831
|
+
list,
|
|
3832
|
+
added: []
|
|
3833
|
+
};
|
|
3834
|
+
const { preferred } = this.host;
|
|
3835
|
+
if (preferred.size > 0) return {
|
|
3836
|
+
list: [...preferred],
|
|
3837
|
+
added: []
|
|
3838
|
+
};
|
|
3839
|
+
const field = this.host.firstVisibleField();
|
|
3840
|
+
return field === void 0 ? {
|
|
3841
|
+
list,
|
|
3842
|
+
added: []
|
|
3843
|
+
} : {
|
|
3844
|
+
list: [field],
|
|
3845
|
+
added: [field]
|
|
3846
|
+
};
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* The decoration step once the final projection is known: a requested key
|
|
3850
|
+
* is served only if every `requires` path survived `transformProjection`,
|
|
3851
|
+
* the seal and the preferred-id widening (a policy that strips a source
|
|
3852
|
+
* silently drops the decoration, as it drops a field). `kept` is the final
|
|
3853
|
+
* projection's paths (`null` = every field).
|
|
3854
|
+
*/
|
|
3855
|
+
serve(plan, kept) {
|
|
3856
|
+
const { keys, requires, leavesOf } = this.index;
|
|
3857
|
+
const carried = (path, set) => selfOrAncestor(path, set) !== void 0 || (leavesOf.get(path)?.every((leaf) => selfOrAncestor(leaf, set) !== void 0) ?? false);
|
|
3858
|
+
const dropPaths = plan.requiresOnly.map((path) => path.split("."));
|
|
3859
|
+
const keepPaths = plan.requiresOnly.map((path) => (plan.selected ?? []).filter((sel) => sel.startsWith(`${path}.`)).map((sel) => sel.split(".")));
|
|
3860
|
+
const selectedPaths = plan.selected?.map((sel) => sel.split("."));
|
|
3861
|
+
if (plan.requested.length === 0) return {
|
|
3862
|
+
served: NO_DECORATIONS,
|
|
3863
|
+
dropKeys: keys,
|
|
3864
|
+
dropPaths,
|
|
3865
|
+
keepPaths,
|
|
3866
|
+
selectedPaths
|
|
3867
|
+
};
|
|
3868
|
+
const keptSet = kept === null ? void 0 : new Set(kept);
|
|
3869
|
+
const served = new Set(plan.requested.filter((key) => keptSet === void 0 || requires.get(key).every((path) => carried(path, keptSet))));
|
|
3870
|
+
return {
|
|
3871
|
+
served,
|
|
3872
|
+
dropKeys: keys.filter((key) => !served.has(key)),
|
|
3873
|
+
dropPaths,
|
|
3874
|
+
keepPaths,
|
|
3875
|
+
selectedPaths
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* The `/meta` envelope with the declared decorations: each one still
|
|
3880
|
+
* present in `decorations` (an overlay may delete props to hide a
|
|
3881
|
+
* decoration per principal) whose sources are visible is kept and gets its
|
|
3882
|
+
* `fields[key]` entry (from its virtual capability-index entry); the others
|
|
3883
|
+
* are pruned, and `decorations` is dropped when none is left. Runs after
|
|
3884
|
+
* `applyMetaOverlay` — an overlay never sees the decoration `fields`
|
|
3885
|
+
* entries. Memoized per input while visibility is not request-scoped.
|
|
3886
|
+
*/
|
|
3887
|
+
meta(meta) {
|
|
3888
|
+
if (!meta.decorations) return meta;
|
|
3889
|
+
const { memo, keySet } = this.index;
|
|
3890
|
+
const memoize = !this.host.scoped;
|
|
3891
|
+
const hit = memoize ? memo.meta.get(meta) : void 0;
|
|
3892
|
+
if (hit) return hit;
|
|
3893
|
+
const capabilities = this.host.capabilities();
|
|
3894
|
+
const props = meta.decorations.type.props ?? {};
|
|
3895
|
+
const kept = {};
|
|
3896
|
+
const fields = { ...meta.fields };
|
|
3897
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
3898
|
+
const cap = keySet.has(key) && this.visible(key) ? capabilities.decorationCap(key) : void 0;
|
|
3899
|
+
if (!cap) continue;
|
|
3900
|
+
kept[key] = prop;
|
|
3901
|
+
fields[key] = {
|
|
3902
|
+
sortable: cap.sortable,
|
|
3903
|
+
filterable: cap.filterable,
|
|
3904
|
+
decoration: true
|
|
3905
|
+
};
|
|
3906
|
+
}
|
|
3907
|
+
let out;
|
|
3908
|
+
if (Object.keys(kept).length === 0) {
|
|
3909
|
+
const { decorations: _dropped, ...rest } = meta;
|
|
3910
|
+
out = rest;
|
|
3911
|
+
} else out = {
|
|
3912
|
+
...meta,
|
|
3913
|
+
fields,
|
|
3914
|
+
decorations: {
|
|
3915
|
+
...meta.decorations,
|
|
3916
|
+
type: {
|
|
3917
|
+
...meta.decorations.type,
|
|
3918
|
+
props: kept
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
};
|
|
3922
|
+
if (memoize) memo.meta.set(meta, out);
|
|
3923
|
+
return out;
|
|
3924
|
+
}
|
|
3925
|
+
/** The declared interface serialized for `/meta.decorations`, once per class. */
|
|
3926
|
+
serialized(serialize) {
|
|
3927
|
+
return this.index.memo.serialized ??= serialize();
|
|
3928
|
+
}
|
|
3929
|
+
};
|
|
3930
|
+
/** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
|
|
3931
|
+
function stripDecorations(rows, read) {
|
|
3932
|
+
const { dropKeys, dropPaths, keepPaths, selectedPaths } = read;
|
|
3933
|
+
if (dropKeys.length === 0 && dropPaths.length === 0) return;
|
|
3934
|
+
const owned = (prefix) => selectedPaths !== void 0 && selectedPaths.some((sel) => prefix.every((part, i) => sel[i] === part));
|
|
3935
|
+
for (const row of rows) {
|
|
3936
|
+
for (const key of dropKeys) delete row[key];
|
|
3937
|
+
dropPaths.forEach((parts, i) => {
|
|
3938
|
+
const keep = keepPaths[i] ?? [];
|
|
3939
|
+
if (keep.length === 0) deleteDescending(row, parts, 0, selectedPaths !== void 0, owned);
|
|
3940
|
+
else pruneExcept(row, parts, 0, keep);
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
/** An object without keys, or an array (of any nesting) holding nothing but such values (what a strip left of a parent). */
|
|
3945
|
+
function isHollow(value) {
|
|
3946
|
+
if (Array.isArray(value)) return value.every((el) => isHollow(el));
|
|
3947
|
+
return isPlainObject(value) && Object.keys(value).length === 0;
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* Deletes the path `parts` below `value`, descending through arrays of objects
|
|
3951
|
+
* (`items.qty` strips every element's `qty`). With `clean` (an inclusion read),
|
|
3952
|
+
* a parent the strip emptied — or a `null` one — goes too, unless the client
|
|
3953
|
+
* selected something at or below it.
|
|
3954
|
+
*/
|
|
3955
|
+
function deleteDescending(value, parts, depth, clean, owned) {
|
|
3956
|
+
if (Array.isArray(value)) {
|
|
3957
|
+
const dropEmptied = clean && depth > 0 && !owned(parts.slice(0, depth));
|
|
3958
|
+
for (let i = value.length - 1; i >= 0; i--) {
|
|
3959
|
+
const el = value[i];
|
|
3960
|
+
const wasHollow = isHollow(el);
|
|
3961
|
+
deleteDescending(el, parts, depth, clean, owned);
|
|
3962
|
+
if (dropEmptied && !wasHollow && isHollow(el)) value.splice(i, 1);
|
|
3963
|
+
}
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
if (!isPlainObject(value)) return;
|
|
3967
|
+
const key = parts[depth];
|
|
3968
|
+
if (depth === parts.length - 1) {
|
|
3969
|
+
delete value[key];
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3972
|
+
const child = value[key];
|
|
3973
|
+
const prefix = parts.slice(0, depth + 1);
|
|
3974
|
+
if (child === null && clean && !owned(prefix)) {
|
|
3975
|
+
delete value[key];
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
deleteDescending(child, parts, depth + 1, clean, owned);
|
|
3979
|
+
if (clean && isHollow(child) && !owned(prefix)) delete value[key];
|
|
3980
|
+
}
|
|
3981
|
+
/**
|
|
3982
|
+
* Below `parts`, deletes everything except the branches leading to `keep`
|
|
3983
|
+
* (client-selected descendants), through arrays of objects as well.
|
|
3984
|
+
*/
|
|
3985
|
+
function pruneExcept(value, parts, depth, keep) {
|
|
3986
|
+
if (Array.isArray(value)) {
|
|
3987
|
+
for (const el of value) pruneExcept(el, parts, depth, keep);
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3990
|
+
if (!isPlainObject(value)) return;
|
|
3991
|
+
if (depth < parts.length) {
|
|
3992
|
+
pruneExcept(value[parts[depth]], parts, depth + 1, keep);
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
prune(value, parts.length, keep.filter((k) => k.length > parts.length));
|
|
3996
|
+
}
|
|
3997
|
+
function prune(value, depth, branches) {
|
|
3998
|
+
if (Array.isArray(value)) {
|
|
3999
|
+
for (const el of value) prune(el, depth, branches);
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
if (!isPlainObject(value)) return;
|
|
4003
|
+
for (const key of Object.keys(value)) {
|
|
4004
|
+
const next = branches.filter((b) => b[depth] === key);
|
|
4005
|
+
if (next.length === 0) delete value[key];
|
|
4006
|
+
else if (next.some((b) => b.length > depth + 1)) prune(value[key], depth + 1, next);
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
/** `true` when stripping `read` changes nothing — skip the post-hook step. */
|
|
4010
|
+
function stripsNothing(read) {
|
|
4011
|
+
return read === void 0 || read.dropKeys.length === 0 && read.dropPaths.length === 0;
|
|
4012
|
+
}
|
|
4013
|
+
//#endregion
|
|
3319
4014
|
//#region src/decorators.ts
|
|
3320
4015
|
/**
|
|
3321
4016
|
* DI token under which the {@link AtscriptDbReadable} instance
|
|
@@ -3475,7 +4170,8 @@ const QUERY_CONTROLS = [
|
|
|
3475
4170
|
"filter",
|
|
3476
4171
|
"insights",
|
|
3477
4172
|
...dtoControls(QueryControlsDto),
|
|
3478
|
-
"groupBy"
|
|
4173
|
+
"groupBy",
|
|
4174
|
+
"rowOrder"
|
|
3479
4175
|
];
|
|
3480
4176
|
const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
|
|
3481
4177
|
const ONE_CONTROLS = dtoControls(GetOneControlsDto);
|
|
@@ -3501,11 +4197,28 @@ const PATH_OPS = [
|
|
|
3501
4197
|
"aggregate",
|
|
3502
4198
|
"bucket"
|
|
3503
4199
|
];
|
|
4200
|
+
/** The controller of the event that owns the route params (nearest ancestor that set them). */
|
|
4201
|
+
function routedController(ctx) {
|
|
4202
|
+
for (let c = ctx; c; c = c.parent) {
|
|
4203
|
+
try {
|
|
4204
|
+
c.getOwn(routeParamsKey);
|
|
4205
|
+
} catch {
|
|
4206
|
+
continue;
|
|
4207
|
+
}
|
|
4208
|
+
return controllerOf(c);
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
3504
4211
|
/** The 400 of a filter / sort on a `@db.writeOnly` field. */
|
|
3505
4212
|
function writeOnlyError(path, op) {
|
|
3506
4213
|
const verdict = writeOnlyVerdict(path, op);
|
|
3507
4214
|
return badRequest(verdict.path, verdict.message);
|
|
3508
4215
|
}
|
|
4216
|
+
/**
|
|
4217
|
+
* Validated `@DbDecorations` per readable and controller class — a
|
|
4218
|
+
* `FOR_EVENT` controller is constructed per event, and must not re-validate
|
|
4219
|
+
* (nor re-serialize) its declaration each time.
|
|
4220
|
+
*/
|
|
4221
|
+
const decorationIndexes = /* @__PURE__ */ new WeakMap();
|
|
3509
4222
|
/** Controller classes already warned that `actionRowScope` has no row identity to match by. */
|
|
3510
4223
|
const warnedNoIdentity = /* @__PURE__ */ new WeakSet();
|
|
3511
4224
|
let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
|
|
@@ -3537,7 +4250,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3537
4250
|
get capabilities() {
|
|
3538
4251
|
const current = this._capabilities;
|
|
3539
4252
|
if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
|
|
3540
|
-
const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
|
|
4253
|
+
const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet, this._decorations?.visibleOn);
|
|
3541
4254
|
this._capabilities = index;
|
|
3542
4255
|
return index;
|
|
3543
4256
|
}
|
|
@@ -3574,6 +4287,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3574
4287
|
_derivedSource;
|
|
3575
4288
|
/** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
|
|
3576
4289
|
_targetWriteOnly = /* @__PURE__ */ new WeakMap();
|
|
4290
|
+
/** Own leaf paths per readable (bound + `$with` targets), see {@link _leavesOf}. */
|
|
4291
|
+
_targetLeaves = /* @__PURE__ */ new WeakMap();
|
|
3577
4292
|
_indexFieldPathsCache;
|
|
3578
4293
|
/** {@link _nativeSearch} per request, keyed by the request's parsed controls. */
|
|
3579
4294
|
_nativeSearchByRequest = /* @__PURE__ */ new WeakMap();
|
|
@@ -3597,8 +4312,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3597
4312
|
_gateFieldsMemo = /* @__PURE__ */ new WeakMap();
|
|
3598
4313
|
/** `true` when a subclass overrides {@link allowedActions}. */
|
|
3599
4314
|
_hasAllowedActions;
|
|
4315
|
+
/** The class's validated `@DbDecorations` (since 0.1.148), `undefined` when none is declared. */
|
|
4316
|
+
_decorations;
|
|
4317
|
+
/** The decoration plumbing of {@link _decorations} — see `DecorationPlanner`. */
|
|
4318
|
+
_planner;
|
|
3600
4319
|
/** `true` when a subclass overrides {@link actionRowScope} (the gate, `$actions` and `/meta/actions` apply it). */
|
|
3601
4320
|
_hasActionRowScope;
|
|
4321
|
+
/** @internal `true` when a subclass overrides {@link resolveRowIds} (every id-addressed endpoint calls it; since 0.1.148). */
|
|
4322
|
+
[ROW_RESOLVES];
|
|
3602
4323
|
/** `true` when the class declares `@DbActionsFrom` (since 0.1.147). */
|
|
3603
4324
|
_hasDelegations;
|
|
3604
4325
|
/** `transformProjection` is overridden (a delegation's id paths are checked against it). */
|
|
@@ -3622,15 +4343,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3622
4343
|
this._writeOnlySet = this._writeOnlyOf(resolved);
|
|
3623
4344
|
this._derivedSource = this._derivedSourcesOf(resolved);
|
|
3624
4345
|
this._invertibleFields = this._collectInvertibleFields();
|
|
4346
|
+
this._decorates = typeof this.decorateRows === "function";
|
|
4347
|
+
this._decorations = this._resolveDecorations(new.target);
|
|
3625
4348
|
this._searchFallbackFields = this._collectSearchFallbackFields();
|
|
3626
4349
|
this._preferredIdSet = new Set(resolved.preferredId ?? []);
|
|
3627
4350
|
this._quantityRefByPath = this._collectQuantityRefs();
|
|
3628
4351
|
const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
|
|
3629
4352
|
this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
|
|
3630
|
-
this._decorates = typeof this.decorateRows === "function";
|
|
3631
4353
|
const proto = _AsDbReadableController.prototype;
|
|
3632
4354
|
this._hasRowOverlay = this.transformOne !== proto.transformOne || this.transformFilter !== proto.transformFilter;
|
|
3633
4355
|
this._hasActionRowScope = this.actionRowScope !== proto.actionRowScope;
|
|
4356
|
+
this[ROW_RESOLVES] = this.resolveRowIds !== proto.resolveRowIds;
|
|
3634
4357
|
this._hasProjectionHook = this.transformProjection !== proto.transformProjection;
|
|
3635
4358
|
this._hasAllowedActions = this.allowedActions !== proto.allowedActions;
|
|
3636
4359
|
this._hasDelegations = hasActionDelegations(new.target);
|
|
@@ -3647,6 +4370,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3647
4370
|
sealedFor: (readable, prefix = "") => this._sealedFor(readable, prefix)
|
|
3648
4371
|
};
|
|
3649
4372
|
this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
|
|
4373
|
+
this._planner = this._decorations && new DecorationPlanner(this._decorations, {
|
|
4374
|
+
isVisible,
|
|
4375
|
+
scoped,
|
|
4376
|
+
preferred: this._preferredIdSet,
|
|
4377
|
+
capabilities: () => this.capabilities,
|
|
4378
|
+
firstVisibleField: () => this._invertibleFields.find((path) => isVisible(path))
|
|
4379
|
+
});
|
|
4380
|
+
}
|
|
4381
|
+
/**
|
|
4382
|
+
* The class's `@DbDecorations`, validated against the bound readable once
|
|
4383
|
+
* per class and readable (a `[moost-db]` error when invalid); warns once
|
|
4384
|
+
* when `decorateRows` is not implemented.
|
|
4385
|
+
*/
|
|
4386
|
+
_resolveDecorations(ctor) {
|
|
4387
|
+
const meta = getAtscriptDbMate().read(ctor)?.atscript_db_decorations;
|
|
4388
|
+
if (!meta) return void 0;
|
|
4389
|
+
let perCtor = decorationIndexes.get(this.readable);
|
|
4390
|
+
if (!perCtor) decorationIndexes.set(this.readable, perCtor = /* @__PURE__ */ new Map());
|
|
4391
|
+
let index = perCtor.get(ctor);
|
|
4392
|
+
if (!index) {
|
|
4393
|
+
index = buildDecorationIndex(ctor.name, meta, {
|
|
4394
|
+
flatMap: this.readable.flatMap,
|
|
4395
|
+
relations: this.readable.relations,
|
|
4396
|
+
navFields: FieldCapabilityIndex.navPathsOf(this.readable),
|
|
4397
|
+
ownPaths: new Set(this._invertibleFields),
|
|
4398
|
+
writeOnly: this._writeOnlySet
|
|
4399
|
+
});
|
|
4400
|
+
perCtor.set(ctor, index);
|
|
4401
|
+
if (!this._decorates) this.logger.warn(`@DbDecorations declares ${index.keys.join(", ")} but ${ctor.name} does not implement decorateRows() — the keys are never filled`);
|
|
4402
|
+
}
|
|
4403
|
+
return index;
|
|
3650
4404
|
}
|
|
3651
4405
|
/**
|
|
3652
4406
|
* The identifications this request may address rows through (since
|
|
@@ -3672,7 +4426,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3672
4426
|
}
|
|
3673
4427
|
_collectInvertibleFields() {
|
|
3674
4428
|
const out = [];
|
|
3675
|
-
const nav = this.
|
|
4429
|
+
const nav = FieldCapabilityIndex.navPathsOf(this.readable);
|
|
3676
4430
|
for (const fd of this.readable.fieldDescriptors) {
|
|
3677
4431
|
if (fd.ignored) continue;
|
|
3678
4432
|
if (selfOrAncestor(fd.path, nav) !== void 0) continue;
|
|
@@ -3729,7 +4483,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3729
4483
|
* (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
|
|
3730
4484
|
* answers exactly like a nonexistent index (400); a hidden DEFAULT text
|
|
3731
4485
|
* index falls back to the `@db.column.searchable` substring search over
|
|
3732
|
-
* visible fields (or ignores the term when there are none
|
|
4486
|
+
* visible fields (or ignores the term when there are none — on list
|
|
4487
|
+
* endpoints; query targets, delegated targets and {@link resolveQuery}
|
|
4488
|
+
* answer 400 `TARGET_INVALID` instead). A
|
|
3733
4489
|
* `@db.column.derived` field is visible only while its source path is,
|
|
3734
4490
|
* and one whose source is hidden is sealed out of every read projection
|
|
3735
4491
|
* for the request, like a `@db.writeOnly` field. The same holds for a
|
|
@@ -3762,7 +4518,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3762
4518
|
checkCapabilities(parsed) {
|
|
3763
4519
|
const capabilities = this.capabilities;
|
|
3764
4520
|
const isVisible = this.fieldVisibility.isVisible;
|
|
3765
|
-
const
|
|
4521
|
+
const select = parsed.controls?.$select;
|
|
4522
|
+
const refs = collectQueryPaths$1(parsed, Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0);
|
|
3766
4523
|
if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
|
|
3767
4524
|
const relState = { nodes: 0 };
|
|
3768
4525
|
for (const ref of refs.filter) {
|
|
@@ -3778,9 +4535,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3778
4535
|
const recorded = parsed.controls ? this._clientWith.get(parsed.controls) : void 0;
|
|
3779
4536
|
const withRelError = this._relationGate().checkWith(recorded === void 0 ? liveWith : recorded?.tree, relState);
|
|
3780
4537
|
if (withRelError) return withRelError;
|
|
3781
|
-
for (const op of PATH_OPS)
|
|
3782
|
-
const
|
|
3783
|
-
|
|
4538
|
+
for (const op of PATH_OPS) {
|
|
4539
|
+
const gateOp = op === "select" && refs.aggregateMode ? "groupedSelect" : op;
|
|
4540
|
+
for (const path of refs[op]) {
|
|
4541
|
+
const verdict = capabilities.check(path, gateOp, isVisible);
|
|
4542
|
+
if (verdict) return badRequest(verdict.path, verdict.message);
|
|
4543
|
+
}
|
|
3784
4544
|
}
|
|
3785
4545
|
const having = checkHavingKeys(refs);
|
|
3786
4546
|
if (having) return badRequest(having.path, having.message);
|
|
@@ -3806,7 +4566,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3806
4566
|
navFields: capabilities.navFields
|
|
3807
4567
|
});
|
|
3808
4568
|
} catch (error) {
|
|
3809
|
-
if (!(error instanceof DbError)) throw error;
|
|
4569
|
+
if (!(error instanceof DbError) || error.code !== "INVALID_QUERY") throw error;
|
|
3810
4570
|
const [issue] = error.errors;
|
|
3811
4571
|
return badRequest(issue.path, issue.message);
|
|
3812
4572
|
}
|
|
@@ -3984,12 +4744,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3984
4744
|
const sub = nested.$select ?? rel.$select;
|
|
3985
4745
|
return {
|
|
3986
4746
|
...nested,
|
|
3987
|
-
$select: this._sealSelect(sub, sealed)
|
|
4747
|
+
$select: this._sealSelect(sub, sealed, target)
|
|
3988
4748
|
};
|
|
3989
4749
|
});
|
|
3990
4750
|
const out = {
|
|
3991
4751
|
...controls,
|
|
3992
|
-
$select: this._sealSelect(select, vis.sealedFor(this.readable))
|
|
4752
|
+
$select: this._sealSelect(select, vis.sealedFor(this.readable), this.readable)
|
|
3993
4753
|
};
|
|
3994
4754
|
if ($with !== controls.$with) out.$with = $with;
|
|
3995
4755
|
return out;
|
|
@@ -4258,34 +5018,24 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4258
5018
|
* The rows the row-level action `actionName` may run on (since 0.1.145),
|
|
4259
5019
|
* as an extra row filter; `undefined` or `{}` = no restriction (the
|
|
4260
5020
|
* default). Enforced by the action gate — the action's ids / rows are
|
|
4261
|
-
* loaded under {@link rowOverlay}, then checked against this filter
|
|
4262
|
-
*
|
|
5021
|
+
* loaded under {@link rowOverlay}, then checked against this filter (or —
|
|
5022
|
+
* without an overlay — the filter joins the load), so an id outside it gets
|
|
5023
|
+
* the same 404 "Row not found for action identifier"
|
|
4263
5024
|
* as a missing one — and reflected in `$actions` and
|
|
4264
5025
|
* `GET /meta/actions`, which list the action only on rows inside it.
|
|
4265
5026
|
*
|
|
4266
|
-
*
|
|
4267
|
-
*
|
|
4268
|
-
*
|
|
4269
|
-
*
|
|
4270
|
-
*
|
|
4271
|
-
*
|
|
4272
|
-
*
|
|
4273
|
-
*
|
|
4274
|
-
*
|
|
4275
|
-
*
|
|
4276
|
-
*
|
|
4277
|
-
*
|
|
4278
|
-
* one evaluation (memoize on it with a `WeakMap`).
|
|
4279
|
-
* - Candidates are already inside the row overlay — ids that do not exist
|
|
4280
|
-
* or fall outside it never reach the hook.
|
|
4281
|
-
* - The result only restricts (`ids ∧ rowOverlay ∧ scope`); a throw fails
|
|
4282
|
-
* the request — never a silent "allow".
|
|
4283
|
-
* - The filter runs straight against the bound readable: it may use
|
|
4284
|
-
* fields {@link hasField} hides, and nothing of it (nor of
|
|
4285
|
-
* `ctx.loadRows`) reaches the response. Equal filters — the same object,
|
|
4286
|
-
* or structurally equal ones — share one id-only query.
|
|
4287
|
-
* - Runs after {@link prepareRequest} and, on the action route, after the
|
|
4288
|
-
* request body is read (it needs the ids).
|
|
5027
|
+
* The hook receives the candidate rows (`ctx`), so a scope can depend on
|
|
5028
|
+
* them — what `ctx.purpose`, `ctx.ids` and `ctx.loadRows` hold, and when the
|
|
5029
|
+
* hook is asked before any load, is documented on {@link TDbActionScopeContext}.
|
|
5030
|
+
* Called only with at least one candidate, at most once per action per
|
|
5031
|
+
* evaluation. An answer restricting nothing (`undefined`, `null`, `{}`)
|
|
5032
|
+
* makes the gate load no row at all; a restriction is folded into the one
|
|
5033
|
+
* row load. The result only restricts (`ids ∧ rowOverlay ∧ scope`); a throw
|
|
5034
|
+
* fails the request — never a silent "allow". The filter runs straight
|
|
5035
|
+
* against the bound readable: it may use fields {@link hasField} hides, and
|
|
5036
|
+
* nothing of it reaches the response. Runs after {@link prepareRequest} and,
|
|
5037
|
+
* on the action route, after the request body is read and
|
|
5038
|
+
* {@link resolveRowIds}.
|
|
4289
5039
|
*
|
|
4290
5040
|
* Not overriding it costs nothing; a one-parameter override keeps working.
|
|
4291
5041
|
* moost-db always passes `ctx` — it is optional in the signature only so
|
|
@@ -4338,6 +5088,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4338
5088
|
* {@link checkCapabilities}, {@link hasField}), where the client
|
|
4339
5089
|
* predicates' {@link transformRelationFilter} also runs: a query target
|
|
4340
5090
|
* never filters on, nor counts by, a field the caller can't read.
|
|
5091
|
+
* {@link resolveQuery} does not call it (there is no action of this
|
|
5092
|
+
* controller to scope).
|
|
4341
5093
|
*
|
|
4342
5094
|
* @since 0.1.147
|
|
4343
5095
|
*/
|
|
@@ -4351,6 +5103,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4351
5103
|
transformProjection(projection) {
|
|
4352
5104
|
return projection;
|
|
4353
5105
|
}
|
|
5106
|
+
/**
|
|
5107
|
+
* The shared projection step of `/query`, `/pages`, `/geo` and `/one`:
|
|
5108
|
+
* splits the declared decoration keys out of the wire `$select`
|
|
5109
|
+
* ({@link DecorationPlanner.plan}), runs {@link transformProjection} on the
|
|
5110
|
+
* rest (decoration keys never reach it — a permission layer needs no
|
|
5111
|
+
* change; their `requires` paths are added so the hook's inputs are read),
|
|
5112
|
+
* then seals every projection level. `finish` completes it once the
|
|
5113
|
+
* endpoint is past its own checks: the preferred-id widening (an
|
|
5114
|
+
* `HttpError` for a mixed `$select`) and the decoration step — what every
|
|
5115
|
+
* read endpoint then passes to {@link _runReadWithActions}.
|
|
5116
|
+
*/
|
|
5117
|
+
async _projectRead(controls) {
|
|
5118
|
+
const plan = this._planner?.plan(controls.$select);
|
|
5119
|
+
const transformed = await this.transformProjection(plan ? plan.select : controls.$select);
|
|
5120
|
+
const sealed = this._sealControls(controls, transformed);
|
|
5121
|
+
const finish = () => {
|
|
5122
|
+
const select = this.widenPreferredIdProjection(sealed.$select);
|
|
5123
|
+
if (select instanceof HttpError) return select;
|
|
5124
|
+
let kept;
|
|
5125
|
+
const keptPaths = () => kept === void 0 ? kept = this._resolveProjectionForAugmenter(select) : kept;
|
|
5126
|
+
return {
|
|
5127
|
+
select,
|
|
5128
|
+
kept: keptPaths,
|
|
5129
|
+
read: plan && this._planner.serve(plan, keptPaths())
|
|
5130
|
+
};
|
|
5131
|
+
};
|
|
5132
|
+
return {
|
|
5133
|
+
sealed,
|
|
5134
|
+
finish
|
|
5135
|
+
};
|
|
5136
|
+
}
|
|
4354
5137
|
widenPreferredIdProjection(projection) {
|
|
4355
5138
|
const widened = this.widenQuantityRefProjection(projection);
|
|
4356
5139
|
if (widened instanceof HttpError) return widened;
|
|
@@ -4372,12 +5155,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4372
5155
|
return out;
|
|
4373
5156
|
}
|
|
4374
5157
|
_widenMapProjection(projection) {
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
const included =
|
|
4378
|
-
const excluded =
|
|
4379
|
-
for (const [k, v] of entries) if (v === 1 || v === true) included.add(k);
|
|
4380
|
-
else if (v === 0 || v === false) excluded.add(k);
|
|
5158
|
+
if (Object.keys(projection).length === 0) return projection;
|
|
5159
|
+
const shape = mapShape(projection);
|
|
5160
|
+
const included = new Set(shape.included);
|
|
5161
|
+
const excluded = new Set(shape.excluded);
|
|
4381
5162
|
if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
|
|
4382
5163
|
if (excluded.size === 0) {
|
|
4383
5164
|
let allPresent = true;
|
|
@@ -4433,12 +5214,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4433
5214
|
return [...projection, ...toAdd];
|
|
4434
5215
|
}
|
|
4435
5216
|
_widenQuantityMapProjection(projection) {
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
const included =
|
|
4439
|
-
const excluded =
|
|
4440
|
-
for (const [k, v] of entries) if (v === 1 || v === true) included.add(k);
|
|
4441
|
-
else if (v === 0 || v === false) excluded.add(k);
|
|
5217
|
+
if (Object.keys(projection).length === 0) return projection;
|
|
5218
|
+
const shape = mapShape(projection);
|
|
5219
|
+
const included = new Set(shape.included);
|
|
5220
|
+
const excluded = new Set(shape.excluded);
|
|
4442
5221
|
if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
|
|
4443
5222
|
if (excluded.size === 0) {
|
|
4444
5223
|
const toAdd = [];
|
|
@@ -4453,22 +5232,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4453
5232
|
}
|
|
4454
5233
|
/** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
|
|
4455
5234
|
_resolveProjectionForAugmenter(select) {
|
|
4456
|
-
|
|
4457
|
-
if (
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
seen.add(item);
|
|
4462
|
-
out.push(item);
|
|
4463
|
-
}
|
|
4464
|
-
return out;
|
|
4465
|
-
}
|
|
4466
|
-
const obj = select;
|
|
4467
|
-
const included = [];
|
|
4468
|
-
const excluded = [];
|
|
4469
|
-
for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
|
|
4470
|
-
else if (v === 0 || v === false) excluded.push(k);
|
|
4471
|
-
if (included.length > 0 && excluded.length === 0) return included;
|
|
5235
|
+
const shape = selectShape(select);
|
|
5236
|
+
if (shape.kind === "all") return null;
|
|
5237
|
+
if (shape.kind === "list") return [...new Set(shape.items.filter((item) => typeof item === "string"))];
|
|
5238
|
+
const { included, excluded } = shape;
|
|
5239
|
+
if (included.length > 0 && excluded.length === 0) return [...included];
|
|
4472
5240
|
if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
|
|
4473
5241
|
throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
|
|
4474
5242
|
}
|
|
@@ -4514,7 +5282,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4514
5282
|
}
|
|
4515
5283
|
return result;
|
|
4516
5284
|
}
|
|
4517
|
-
async _prepareAugmentation(controls,
|
|
5285
|
+
async _prepareAugmentation(controls, projected) {
|
|
4518
5286
|
if (!controls.$actions) return null;
|
|
4519
5287
|
const [own, delegations] = await Promise.all([this._resolveAugmentEnvelopes(), this._activeDelegations()]);
|
|
4520
5288
|
if (own === null && delegations.length === 0) return null;
|
|
@@ -4524,7 +5292,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4524
5292
|
scopeOverlay = this.rowOverlay();
|
|
4525
5293
|
scopeOverlay.catch(() => {});
|
|
4526
5294
|
}
|
|
4527
|
-
let resolvedProjection =
|
|
5295
|
+
let resolvedProjection = projected.kept();
|
|
4528
5296
|
let widenedSelect = resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection);
|
|
4529
5297
|
if (resolvedProjection !== null && delegations.length > 0) {
|
|
4530
5298
|
const base = widenedSelect ?? resolvedProjection;
|
|
@@ -4629,6 +5397,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4629
5397
|
[ACTION_OVERLAY]() {
|
|
4630
5398
|
return this.rowOverlay();
|
|
4631
5399
|
}
|
|
5400
|
+
/**
|
|
5401
|
+
* @internal {@link resolveRowIds} for an action's validated ids (since
|
|
5402
|
+
* 0.1.148): the output validated, duplicate identities collapsed, and the
|
|
5403
|
+
* ids as the client sent them kept for the error bodies and summaries.
|
|
5404
|
+
*/
|
|
5405
|
+
async [ROW_RESOLVE_IDS](ids, ctx) {
|
|
5406
|
+
return applyResolvedIds(ids, await this._runResolveRowIds(ids, ctx));
|
|
5407
|
+
}
|
|
4632
5408
|
/** @internal {@link actionRowScope} for the gate's loaded candidates (since 0.1.147). */
|
|
4633
5409
|
[ACTION_SCOPE](action, ctx) {
|
|
4634
5410
|
return this._actionScope(action, ctx);
|
|
@@ -4650,18 +5426,76 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4650
5426
|
async [RESOLVE_TARGET](req) {
|
|
4651
5427
|
const { action } = req;
|
|
4652
5428
|
const body = parseQueryTargetBody(action, req.query);
|
|
5429
|
+
const { rows, filter, findMany, exclude, visibleOf, cap } = await this._resolveMatching({
|
|
5430
|
+
label: action,
|
|
5431
|
+
body,
|
|
5432
|
+
cap: req.cap,
|
|
5433
|
+
maxExclude: req.maxExclude,
|
|
5434
|
+
overlay: req.overlay,
|
|
5435
|
+
scopeByAction: true,
|
|
5436
|
+
select: [...new Set(req.select)],
|
|
5437
|
+
sortBy: req.select,
|
|
5438
|
+
excludeShapes: req.excludeShapes,
|
|
5439
|
+
visibleOf: req.visibleOf
|
|
5440
|
+
});
|
|
5441
|
+
const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
|
|
5442
|
+
...q,
|
|
5443
|
+
controls: {
|
|
5444
|
+
...q.controls,
|
|
5445
|
+
$limit: Math.max(ids.length, cap + 1)
|
|
5446
|
+
}
|
|
5447
|
+
}) }, ids, scope, select);
|
|
5448
|
+
const snapshotFields = new Set(req.select);
|
|
5449
|
+
let first = true;
|
|
5450
|
+
return {
|
|
5451
|
+
matched: rows.length,
|
|
5452
|
+
rows,
|
|
5453
|
+
dryRun: body.dryRun === true,
|
|
5454
|
+
exclude,
|
|
5455
|
+
visibleOf,
|
|
5456
|
+
load: (ids, select) => {
|
|
5457
|
+
if (!first) return byIds(findMany, ids, filter, select);
|
|
5458
|
+
first = false;
|
|
5459
|
+
const fields = [...select];
|
|
5460
|
+
if (!fields.every((f) => snapshotFields.has(f))) {
|
|
5461
|
+
const plain = (q) => this.readable.findMany(q);
|
|
5462
|
+
return byIds(plain, ids, void 0, fields);
|
|
5463
|
+
}
|
|
5464
|
+
return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
|
|
5465
|
+
}
|
|
5466
|
+
};
|
|
5467
|
+
}
|
|
5468
|
+
/**
|
|
5469
|
+
* The shared resolver behind query targets and {@link resolveQuery}: the
|
|
5470
|
+
* query body validated, checked and run as a READ of this controller, then
|
|
5471
|
+
* ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
|
|
5472
|
+
* scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
|
|
5473
|
+
* `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
|
|
5474
|
+
* `TARGET_CHANGED`.
|
|
5475
|
+
*
|
|
5476
|
+
* Everything that depends on the read's visibility — the `$search`
|
|
5477
|
+
* fallback, the native-search memo, the un-appliable-term refusal and the
|
|
5478
|
+
* delegated identity — is computed INSIDE the read child, where the
|
|
5479
|
+
* permission layer's per-request state is the read's.
|
|
5480
|
+
*/
|
|
5481
|
+
async _resolveMatching(spec) {
|
|
5482
|
+
const { label, body } = spec;
|
|
5483
|
+
const cap = Math.min(spec.cap, body.maxRows ?? Infinity);
|
|
4653
5484
|
const parsed = this.parseUrlOr400(body.q.startsWith("?") ? body.q.slice(1) : body.q);
|
|
4654
5485
|
const controls = {};
|
|
4655
5486
|
for (const [k, v] of Object.entries(parsed.controls ?? {})) {
|
|
4656
5487
|
if (v === void 0) continue;
|
|
4657
|
-
if (k !== "$search" && k !== "$index") throw targetInvalid(
|
|
4658
|
-
if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(
|
|
5488
|
+
if (k !== "$search" && k !== "$index") throw targetInvalid(label, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
|
|
5489
|
+
if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(label, "$search must be a search term");
|
|
4659
5490
|
controls[k] = k === "$search" ? `${v}` : v;
|
|
4660
5491
|
}
|
|
4661
|
-
if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(
|
|
5492
|
+
if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(label, "$index must be an index name");
|
|
4662
5493
|
const exclude = body.exclude ?? [];
|
|
4663
|
-
const shapes =
|
|
5494
|
+
const shapes = spec.excludeShapes ?? [];
|
|
5495
|
+
const sealedSet = () => new Set([...this.fieldVisibility.sealedFor(this.readable), ...this._leavesOf(this.readable).filter((leaf) => !this.fieldVisibility.isVisible(leaf))]);
|
|
4664
5496
|
const check = () => {
|
|
5497
|
+
let sealed;
|
|
5498
|
+
const readableLeaf = (leaf) => !(sealed ??= sealedSet()).has(leaf);
|
|
4665
5499
|
const controlsError = this.validateControls(controls, "query");
|
|
4666
5500
|
if (controlsError) throw new HttpError(400, controlsError);
|
|
4667
5501
|
const gateError = this.checkCapabilities({
|
|
@@ -4669,71 +5503,160 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4669
5503
|
controls
|
|
4670
5504
|
});
|
|
4671
5505
|
if (gateError) throw gateError;
|
|
4672
|
-
if (exclude.length > 0)
|
|
4673
|
-
|
|
4674
|
-
fields
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
5506
|
+
if (exclude.length > 0) {
|
|
5507
|
+
const source = shapes.length === 0 ? this.idSource : {
|
|
5508
|
+
identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
|
|
5509
|
+
fields,
|
|
5510
|
+
source: "target"
|
|
5511
|
+
}))],
|
|
5512
|
+
fieldDescriptors: this.readable.fieldDescriptors
|
|
5513
|
+
};
|
|
5514
|
+
try {
|
|
5515
|
+
validateMultiId(exclude, source, spec.maxExclude);
|
|
5516
|
+
} catch (error) {
|
|
5517
|
+
throw validatorErrorToHttp(error) ?? error;
|
|
5518
|
+
}
|
|
5519
|
+
}
|
|
5520
|
+
const nav = FieldCapabilityIndex.navPathsOf(this.readable);
|
|
5521
|
+
for (const path of spec.selectGate ?? []) {
|
|
5522
|
+
if (this.capabilities.decorationCap(path) || selfOrAncestor(path, nav) !== void 0) throw badRequest(path, `Unknown field "${path}"`);
|
|
5523
|
+
const verdict = this.capabilities.check(path, "select", this.fieldVisibility.isVisible);
|
|
5524
|
+
if (verdict) throw badRequest(verdict.path, verdict.message);
|
|
5525
|
+
if (this._writeOnlySet.has(path)) throw badRequest(path, `Field "${path}" is @db.writeOnly`);
|
|
5526
|
+
const prefix = `${path}.`;
|
|
5527
|
+
const leaves = this._leavesOf(this.readable).filter((leaf) => leaf.startsWith(prefix));
|
|
5528
|
+
if (leaves.length > 0 && !leaves.some((leaf) => readableLeaf(leaf))) throw badRequest(path, `Unknown field "${path}"`);
|
|
5529
|
+
}
|
|
5530
|
+
for (const path of spec.sortGate ?? []) {
|
|
5531
|
+
const verdict = this.capabilities.check(path, "sort", this.fieldVisibility.isVisible);
|
|
5532
|
+
if (verdict) throw targetInvalid(label, verdict.message);
|
|
5533
|
+
}
|
|
4679
5534
|
};
|
|
4680
|
-
const ownScope =
|
|
4681
|
-
const [
|
|
5535
|
+
const ownScope = spec.scopeByAction === true && (spec.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope);
|
|
5536
|
+
const [read, overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
|
|
4682
5537
|
check();
|
|
4683
|
-
const [clientFilter, readScope] = await Promise.all([
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
5538
|
+
const [clientFilter, readScope, strategy] = await Promise.all([
|
|
5539
|
+
this._relationOverlay(parsed),
|
|
5540
|
+
ownScope ? this.queryTargetScope(label) : void 0,
|
|
5541
|
+
this._resolveReadStrategy(controls)
|
|
5542
|
+
]);
|
|
5543
|
+
const base = spec.overlay === "action" ? clientFilter : await this.transformFilter(clientFilter ?? {});
|
|
5544
|
+
const searched = this.applySearchFallback(base, controls);
|
|
5545
|
+
if (controls.$search && strategy.kind !== "search" && searched === base) throw targetInvalid(label, "$search is not available here");
|
|
5546
|
+
const visibleOf = spec.visibleOf?.filter((f) => this.fieldVisibility.isVisible(f));
|
|
5547
|
+
let select = spec.select;
|
|
5548
|
+
if (spec.selectGate) {
|
|
5549
|
+
const sealed = sealedSet();
|
|
5550
|
+
for (const id of spec.identity ?? []) sealed.delete(id);
|
|
5551
|
+
select = this._sealSelect([...spec.select], sealed, this.readable);
|
|
5552
|
+
}
|
|
5553
|
+
return {
|
|
5554
|
+
searched,
|
|
5555
|
+
readScope,
|
|
5556
|
+
strategy,
|
|
5557
|
+
visibleOf,
|
|
5558
|
+
select
|
|
5559
|
+
};
|
|
5560
|
+
}, spec.routeParams), spec.overlay === "action" ? this.rowOverlay() : void 0]);
|
|
5561
|
+
const { strategy } = read;
|
|
5562
|
+
const filter = andFilters(read.searched, overlay, read.readScope, spec.scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
|
|
4688
5563
|
const findMany = (q) => strategy.kind === "search" ? this.readable.search(strategy.term, q, strategy.index) : this.readable.findMany(q);
|
|
4689
|
-
const cap = Math.min(req.cap, body.maxRows ?? Infinity);
|
|
4690
|
-
const sort = {};
|
|
4691
|
-
for (const f of req.select) sort[f] = 1;
|
|
4692
5564
|
const rows = await findMany({
|
|
4693
5565
|
filter,
|
|
4694
5566
|
controls: {
|
|
4695
|
-
$select: [...
|
|
4696
|
-
$sort:
|
|
5567
|
+
$select: [...read.select],
|
|
5568
|
+
$sort: Object.fromEntries(spec.sortBy.map((f) => [f, 1])),
|
|
4697
5569
|
$limit: cap + 1
|
|
4698
5570
|
}
|
|
4699
5571
|
});
|
|
4700
|
-
if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE",
|
|
4701
|
-
if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED",
|
|
4702
|
-
const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
|
|
4703
|
-
...q,
|
|
4704
|
-
controls: {
|
|
4705
|
-
...q.controls,
|
|
4706
|
-
$limit: Math.max(ids.length, cap + 1)
|
|
4707
|
-
}
|
|
4708
|
-
}) }, ids, scope, select);
|
|
4709
|
-
const snapshotFields = new Set(req.select);
|
|
4710
|
-
let first = true;
|
|
5572
|
+
if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", label, `The query matches more than ${cap} rows`, { cap });
|
|
5573
|
+
if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", label, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
|
|
4711
5574
|
return {
|
|
4712
|
-
matched: rows.length,
|
|
4713
5575
|
rows,
|
|
4714
|
-
|
|
5576
|
+
filter,
|
|
5577
|
+
findMany,
|
|
4715
5578
|
exclude,
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
const fields = [...select];
|
|
4720
|
-
if (!fields.every((f) => snapshotFields.has(f))) {
|
|
4721
|
-
const plain = (q) => this.readable.findMany(q);
|
|
4722
|
-
return byIds(plain, ids, void 0, fields);
|
|
4723
|
-
}
|
|
4724
|
-
return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
|
|
4725
|
-
}
|
|
5579
|
+
visibleOf: read.visibleOf,
|
|
5580
|
+
cap,
|
|
5581
|
+
select: read.select
|
|
4726
5582
|
};
|
|
4727
5583
|
}
|
|
4728
5584
|
/**
|
|
5585
|
+
* Rows of THIS controller matching `q`, resolved as a READ of it for the
|
|
5586
|
+
* current event's caller (since 0.1.149) — from your own command, e.g. to
|
|
5587
|
+
* act on "every issue matching this search". `q` is a `GET /query` string
|
|
5588
|
+
* (`$search` / `$index` and a filter only) or a query-target envelope
|
|
5589
|
+
* `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
|
|
5590
|
+
*
|
|
5591
|
+
* The read runs under this controller's full read policy
|
|
5592
|
+
* ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
|
|
5593
|
+
* {@link validateControls}, the capability / index gate and the
|
|
5594
|
+
* {@link transformFilter} overlay) with the current event's identity.
|
|
5595
|
+
* Route interceptors and guards of the `query` route do not run; read
|
|
5596
|
+
* authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
|
|
5597
|
+
* is not called. Hooks see no route params of the caller (only a call from
|
|
5598
|
+
* the routed event's own controller instance keeps its params); pass route-derived
|
|
5599
|
+
* restrictions as `opts.scope`. Joins the caller's open transaction.
|
|
5600
|
+
*
|
|
5601
|
+
* Rows are ordered by identity (`preferredId`, else the primary key) and
|
|
5602
|
+
* carry the identity fields plus `opts.select` (gated like `/query`
|
|
5603
|
+
* `$select`; `transformProjection` is not applied — hide fields with
|
|
5604
|
+
* {@link hasField}; decoration keys and navigation paths are refused). An
|
|
5605
|
+
* identity-less readable is ordered by `select` (each path sortable, else
|
|
5606
|
+
* `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
|
|
5607
|
+
* `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
|
|
5608
|
+
* `TARGET_CHANGED`; a `$search` that can't be applied → 400
|
|
5609
|
+
* `TARGET_INVALID`. Must be awaited inside a running event handler.
|
|
5610
|
+
*/
|
|
5611
|
+
async resolveQuery(q, opts = {}) {
|
|
5612
|
+
let caller;
|
|
5613
|
+
try {
|
|
5614
|
+
caller = current();
|
|
5615
|
+
} catch {
|
|
5616
|
+
throw new Error("[moost-db] resolveQuery must be awaited inside an event handler");
|
|
5617
|
+
}
|
|
5618
|
+
const cap = opts.cap ?? 1e3;
|
|
5619
|
+
if (!Number.isInteger(cap) || cap < 1) throw new Error("[moost-db] resolveQuery: `cap` must be a positive integer");
|
|
5620
|
+
const select = opts.select ?? [];
|
|
5621
|
+
if (!Array.isArray(select) || select.some((p) => typeof p !== "string")) throw new Error("[moost-db] resolveQuery: `select` must be an array of field paths");
|
|
5622
|
+
const ids = this.readable.preferredId?.length ? this.readable.preferredId : this.readable.primaryKeys;
|
|
5623
|
+
const order = ids.length > 0 ? ids : select;
|
|
5624
|
+
if (order.length === 0) throw new Error("[moost-db] resolveQuery: this readable has no identity — pass `select` (rows are ordered by it)");
|
|
5625
|
+
const label = readCurrentActionMeta(caller)?.name ?? "";
|
|
5626
|
+
const body = parseQueryTargetBody(label, typeof q === "string" ? { q } : q);
|
|
5627
|
+
if (body.dryRun !== void 0) throw targetInvalid(label, "resolveQuery takes no `dryRun` — read `query.dryRun` yourself");
|
|
5628
|
+
let sameRoute = false;
|
|
5629
|
+
try {
|
|
5630
|
+
sameRoute = controllerOf(caller) === this && routedController(caller) === this;
|
|
5631
|
+
} catch {}
|
|
5632
|
+
const fields = [...new Set([...ids, ...select])];
|
|
5633
|
+
const { rows, select: sealedSelect } = await this._resolveMatching({
|
|
5634
|
+
label,
|
|
5635
|
+
body,
|
|
5636
|
+
cap,
|
|
5637
|
+
maxExclude: DEFAULT_MAX_ACTION_IDS,
|
|
5638
|
+
overlay: "read",
|
|
5639
|
+
scope: opts.scope,
|
|
5640
|
+
select: fields,
|
|
5641
|
+
sortBy: order,
|
|
5642
|
+
selectGate: select,
|
|
5643
|
+
identity: ids,
|
|
5644
|
+
sortGate: ids.length > 0 ? void 0 : order,
|
|
5645
|
+
routeParams: sameRoute ? void 0 : {}
|
|
5646
|
+
});
|
|
5647
|
+
return rows.map((row) => projectRow$1(row, sealedSelect));
|
|
5648
|
+
}
|
|
5649
|
+
/**
|
|
4729
5650
|
* Runs `fn` as a READ of this controller (since 0.1.147): in a child of
|
|
4730
5651
|
* the current event whose controller context is this controller's `query`
|
|
4731
5652
|
* handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
|
|
4732
5653
|
* request-scoped state a permission layer builds there (read grant, field
|
|
4733
|
-
* visibility) is the read's and stays in the child.
|
|
5654
|
+
* visibility) is the read's and stays in the child. `routeParams` (since
|
|
5655
|
+
* 0.1.149) replaces the route params the child's hooks read.
|
|
4734
5656
|
*/
|
|
4735
|
-
_asRead(controls, filter, fn) {
|
|
5657
|
+
_asRead(controls, filter, fn, routeParams) {
|
|
4736
5658
|
return runAsController(this, "query", async () => {
|
|
5659
|
+
if (routeParams) current().set(routeParamsKey, routeParams);
|
|
4737
5660
|
if (typeof this.prepareRequest === "function") await this.prepareRequest(readRequestContext("query", controls, filter));
|
|
4738
5661
|
return fn();
|
|
4739
5662
|
});
|
|
@@ -4757,9 +5680,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4757
5680
|
/**
|
|
4758
5681
|
* `select` without the `sealed` paths (see {@link _sealControls}); an
|
|
4759
5682
|
* exclusion of them is forced when there is no projection, or when every
|
|
4760
|
-
* requested path was sealed.
|
|
5683
|
+
* requested path was sealed. An inclusion naming a PARENT of a sealed path
|
|
5684
|
+
* (`secret` over a write-only `secret.hash`) is replaced by the parent's
|
|
5685
|
+
* unsealed leaves, so a sealed descendant never rides along with it.
|
|
4761
5686
|
*/
|
|
4762
|
-
_sealSelect(select, writeOnly) {
|
|
5687
|
+
_sealSelect(select, writeOnly, readable) {
|
|
4763
5688
|
if (writeOnly.size === 0) return select;
|
|
4764
5689
|
const exclusion = () => {
|
|
4765
5690
|
const out = {};
|
|
@@ -4767,29 +5692,47 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4767
5692
|
return out;
|
|
4768
5693
|
};
|
|
4769
5694
|
if (select === void 0) return exclusion();
|
|
5695
|
+
const expand = (path) => {
|
|
5696
|
+
if (writeOnly.has(path)) return [];
|
|
5697
|
+
const prefix = `${path}.`;
|
|
5698
|
+
let parent = false;
|
|
5699
|
+
for (const sealed of writeOnly) if (sealed.startsWith(prefix)) {
|
|
5700
|
+
parent = true;
|
|
5701
|
+
break;
|
|
5702
|
+
}
|
|
5703
|
+
if (!parent) return [path];
|
|
5704
|
+
return this._leavesOf(readable).filter((leaf) => leaf.startsWith(prefix) && selfOrAncestor(leaf, writeOnly) === void 0);
|
|
5705
|
+
};
|
|
4770
5706
|
if (Array.isArray(select)) {
|
|
4771
|
-
const kept =
|
|
5707
|
+
const kept = [];
|
|
5708
|
+
for (const item of select) if (typeof item === "string") kept.push(...expand(item));
|
|
5709
|
+
else if (!writeOnly.has(item.$field ?? "")) kept.push(item);
|
|
4772
5710
|
return kept.length > 0 ? kept : exclusion();
|
|
4773
5711
|
}
|
|
4774
5712
|
const entries = Object.entries(select);
|
|
4775
5713
|
if (entries.length > 0 && (entries[0][1] === 1 || entries[0][1] === true)) {
|
|
4776
5714
|
const out = {};
|
|
4777
|
-
for (const [k, v] of entries)
|
|
5715
|
+
for (const [k, v] of entries) for (const path of expand(k)) out[path] = v;
|
|
4778
5716
|
return Object.keys(out).length > 0 ? out : exclusion();
|
|
4779
5717
|
}
|
|
4780
5718
|
const out = { ...select };
|
|
4781
5719
|
for (const f of writeOnly) out[f] = 0;
|
|
4782
5720
|
return out;
|
|
4783
5721
|
}
|
|
4784
|
-
/**
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
const
|
|
4791
|
-
|
|
5722
|
+
/** Own leaf field paths (no navigation, no ignored field) of `readable`, once per readable. */
|
|
5723
|
+
_leavesOf(readable) {
|
|
5724
|
+
let leaves = this._targetLeaves.get(readable);
|
|
5725
|
+
if (!leaves) {
|
|
5726
|
+
const paths = [...readable.flatMap?.keys() ?? []].filter((path) => path !== "");
|
|
5727
|
+
const parents = /* @__PURE__ */ new Set();
|
|
5728
|
+
for (const path of paths) for (let at = path.indexOf("."); at >= 0; at = path.indexOf(".", at + 1)) parents.add(path.slice(0, at));
|
|
5729
|
+
const nav = readable.navFields ?? /* @__PURE__ */ new Set();
|
|
5730
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
5731
|
+
for (const fd of readable.fieldDescriptors) if (fd.ignored) ignored.add(fd.path);
|
|
5732
|
+
leaves = paths.filter((path) => !parents.has(path) && selfOrAncestor(path, nav) === void 0 && selfOrAncestor(path, ignored) === void 0);
|
|
5733
|
+
this._targetLeaves.set(readable, leaves);
|
|
4792
5734
|
}
|
|
5735
|
+
return leaves;
|
|
4793
5736
|
}
|
|
4794
5737
|
/**
|
|
4795
5738
|
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
@@ -4797,7 +5740,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4797
5740
|
* with the existing filter. Applies only when native search does not serve
|
|
4798
5741
|
* the request (no native search, or — since 0.1.143 — its default index
|
|
4799
5742
|
* reads a field {@link hasField} hides) and the request isn't a vector
|
|
4800
|
-
* search (`$vector` consumes the term).
|
|
5743
|
+
* search (`$vector` consumes the term). Lenient on list endpoints: a term
|
|
5744
|
+
* nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
|
|
5745
|
+
* refuse it — a subclass override that applies the term must return a new
|
|
5746
|
+
* filter object.
|
|
4801
5747
|
*/
|
|
4802
5748
|
applySearchFallback(filter, controls) {
|
|
4803
5749
|
const term = controls.$search;
|
|
@@ -4859,13 +5805,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4859
5805
|
* subclass implements it. Returns the hook's result — `undefined`, with no
|
|
4860
5806
|
* promise or microtask, when there is no hook or it is synchronous.
|
|
4861
5807
|
*/
|
|
4862
|
-
_finishRows(rows, prep, ctx) {
|
|
5808
|
+
_finishRows(rows, prep, ctx, read) {
|
|
4863
5809
|
const overlay = prep?.scopeOverlay;
|
|
4864
|
-
if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx);
|
|
5810
|
+
if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx, read);
|
|
4865
5811
|
return (async () => {
|
|
4866
5812
|
const names = prep.envelopes.map((e) => e.info.name);
|
|
4867
5813
|
const [masks, delegated] = await Promise.all([overlay ? this._scopeMasks(rows, names, "rows", overlay) : void 0, Promise.all(prep.delegations.map((d) => this._delegatedRowVerdicts(rows, d)))]);
|
|
4868
|
-
await this._augmentAndDecorate(rows, prep, ctx, masks, delegated);
|
|
5814
|
+
await this._augmentAndDecorate(rows, prep, ctx, read, masks, delegated);
|
|
4869
5815
|
})();
|
|
4870
5816
|
}
|
|
4871
5817
|
/**
|
|
@@ -4873,7 +5819,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4873
5819
|
* ones (`delegated`: per delegation, per row) — then {@link decorateRows}
|
|
4874
5820
|
* when implemented.
|
|
4875
5821
|
*/
|
|
4876
|
-
_augmentAndDecorate(rows, prep, ctx, outOfScope, delegated) {
|
|
5822
|
+
_augmentAndDecorate(rows, prep, ctx, read, outOfScope, delegated) {
|
|
4877
5823
|
if (prep) {
|
|
4878
5824
|
augmentRowsWithActions({
|
|
4879
5825
|
envelopes: prep.envelopes,
|
|
@@ -4884,7 +5830,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4884
5830
|
});
|
|
4885
5831
|
if (prep.delegations.length > 0) mergeDelegatedActions(rows, delegated ?? []);
|
|
4886
5832
|
}
|
|
4887
|
-
|
|
5833
|
+
const decorated = this._decorates ? this.decorateRows(rows, ctx) : void 0;
|
|
5834
|
+
if (!read || stripsNothing(read)) return decorated;
|
|
5835
|
+
const strip = () => stripDecorations(rows, read);
|
|
5836
|
+
if (decorated === void 0) return strip();
|
|
5837
|
+
return Promise.resolve(decorated).then(strip);
|
|
4888
5838
|
}
|
|
4889
5839
|
/**
|
|
4890
5840
|
* The app of the current event, through DI — never the one this
|
|
@@ -4960,9 +5910,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4960
5910
|
*/
|
|
4961
5911
|
resolveMeta() {
|
|
4962
5912
|
const own = super.resolveMeta();
|
|
4963
|
-
if (!this._hasDelegations && !this._hasFieldOverridden) return own;
|
|
5913
|
+
if (!this._hasDelegations && !this._hasFieldOverridden && !this._planner) return own;
|
|
4964
5914
|
return (async () => {
|
|
4965
|
-
const [
|
|
5915
|
+
const [overlaid, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
|
|
5916
|
+
const meta = this._planner ? this._planner.meta(overlaid) : overlaid;
|
|
4966
5917
|
const visible = this._applyIndexVisibility(meta);
|
|
4967
5918
|
return delegated.length > 0 ? {
|
|
4968
5919
|
...visible,
|
|
@@ -5016,7 +5967,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5016
5967
|
/** @internal Source side of a delegation: `GET /meta/actions` for one id, `names` only. */
|
|
5017
5968
|
async [AVAILABLE_ACTIONS](id, names) {
|
|
5018
5969
|
await this.parseRequest("availableActions");
|
|
5019
|
-
return this.
|
|
5970
|
+
return (await this._availableResolved(id, names)).own;
|
|
5020
5971
|
}
|
|
5021
5972
|
/** @internal Source side of a delegation: the `names` the caller may run (`allowedActions`). */
|
|
5022
5973
|
async [ALLOWED_ACTIONS](names) {
|
|
@@ -5035,8 +5986,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5035
5986
|
* `$actions=true`, then run {@link decorateRows}. Caller dispatches the
|
|
5036
5987
|
* strategy to its read-method family (count vs no-count).
|
|
5037
5988
|
*/
|
|
5038
|
-
async _runReadWithActions(endpoint, queryObj, controls,
|
|
5039
|
-
const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls,
|
|
5989
|
+
async _runReadWithActions(endpoint, queryObj, controls, projected, exec) {
|
|
5990
|
+
const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveReadStrategy(controls)]);
|
|
5040
5991
|
const result = await exec(prep?.widenedSelect ? {
|
|
5041
5992
|
...queryObj,
|
|
5042
5993
|
controls: {
|
|
@@ -5046,19 +5997,120 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5046
5997
|
} : queryObj, strategy);
|
|
5047
5998
|
const pending = this._finishRows(result.data, prep, {
|
|
5048
5999
|
endpoint,
|
|
5049
|
-
projection: select,
|
|
5050
|
-
controls
|
|
5051
|
-
|
|
6000
|
+
projection: projected.select,
|
|
6001
|
+
controls,
|
|
6002
|
+
decorations: projected.read?.served ?? NO_DECORATIONS
|
|
6003
|
+
}, projected.read);
|
|
5052
6004
|
if (pending) await pending;
|
|
5053
6005
|
return result;
|
|
5054
6006
|
}
|
|
5055
6007
|
/**
|
|
6008
|
+
* Maps the ids an id-addressed endpoint received to the rows' current ids
|
|
6009
|
+
* (since 0.1.148) — the seam for stale or alias ids, e.g. a natural key that
|
|
6010
|
+
* was renamed. Called once per request, after {@link prepareRequest} and
|
|
6011
|
+
* after the request's own validation, before anything reads the row, by:
|
|
6012
|
+
*
|
|
6013
|
+
* | `ctx.purpose` | endpoint | `ids` |
|
|
6014
|
+
* | --- | --- | --- |
|
|
6015
|
+
* | `"one"` | `GET /one/:id`, `GET /one?…` | one id: the path string, or the `?`-form identification object |
|
|
6016
|
+
* | `"available"` | `GET /meta/actions/:id`, `?…` (and a view asking its source) | one id, as above |
|
|
6017
|
+
* | `"remove"` | `DELETE /:id`, `DELETE /?…` (`AsDbController`) | one id, as above |
|
|
6018
|
+
* | `"action"` | an action route | the body's validated ids (one for a `'row'` action) |
|
|
6019
|
+
*
|
|
6020
|
+
* Contract:
|
|
6021
|
+
*
|
|
6022
|
+
* - Return one id per input id, index-aligned (anything else is a 500). An
|
|
6023
|
+
* id that already names a row must come back UNCHANGED — the current
|
|
6024
|
+
* holder of a key wins over an alias; so must an id you cannot resolve
|
|
6025
|
+
* (never throw for an unknown alias: a custom error is an oracle — the
|
|
6026
|
+
* endpoint then answers its normal miss).
|
|
6027
|
+
* - The output is validated (a server bug is a 500, never a client 400): a
|
|
6028
|
+
* scalar is resolved like a path scalar (primary key first, then the
|
|
6029
|
+
* visible unique keys, inside the row overlay); an object must be one of
|
|
6030
|
+
* the visible identifications ({@link idSource}); an `"action"` id must
|
|
6031
|
+
* be such an object.
|
|
6032
|
+
* - The resolved id is never trusted for access: the endpoint still reads
|
|
6033
|
+
* or deletes it under {@link rowOverlay} and the visible identifications.
|
|
6034
|
+
* `ctx.overlay` is that overlay — resolve INSIDE it when an alias could
|
|
6035
|
+
* name several rows, so a row the caller can't reach never shadows one
|
|
6036
|
+
* they can. Don't log or return the canonical id in errors.
|
|
6037
|
+
* - Handlers (`@DbActionID()`, `useDbActionId()`), {@link rowOverlay} reads,
|
|
6038
|
+
* {@link actionRowScope} and `onRemove` / `guardRemove` receive the
|
|
6039
|
+
* resolved ids; error bodies and `summary()` echo the ids the client sent.
|
|
6040
|
+
* - Write bodies (`POST` / `PUT` / `PATCH`) are not resolved — use
|
|
6041
|
+
* `onWrite`. Not called by value-help controllers, `$actions` on a read
|
|
6042
|
+
* or a query target. Not overriding it costs nothing.
|
|
6043
|
+
*
|
|
6044
|
+
* It is NOT overridden by {@link resolveRowFilter}, which does not take part
|
|
6045
|
+
* in `/one` for real tables and views.
|
|
6046
|
+
*
|
|
6047
|
+
* ```ts
|
|
6048
|
+
* protected async resolveRowIds(ids: readonly TDbRowIdInput[], ctx: TDbRowIdsContext) {
|
|
6049
|
+
* return Promise.all(ids.map(async (id) => {
|
|
6050
|
+
* const key = typeof id === "object" ? id.code : id
|
|
6051
|
+
* if (typeof key !== "string") return id
|
|
6052
|
+
* // the current holder of the key wins; consult the alias table on a miss
|
|
6053
|
+
* // resolve INSIDE the overlay: a row the caller cannot reach never wins
|
|
6054
|
+
* const inScope = (code: string) =>
|
|
6055
|
+
* this.readable.count({ filter: ctx.overlay ? { $and: [{ code }, ctx.overlay] } : { code } })
|
|
6056
|
+
* if (await inScope(key)) return id
|
|
6057
|
+
* const alias = await aliases.findOne({ filter: { oldCode: key } })
|
|
6058
|
+
* if (!alias || !(await inScope(alias.newCode))) return id
|
|
6059
|
+
* return typeof id === "object" ? { code: alias.newCode } : alias.newCode
|
|
6060
|
+
* }))
|
|
6061
|
+
* }
|
|
6062
|
+
* ```
|
|
6063
|
+
*
|
|
6064
|
+
* @since 0.1.148
|
|
6065
|
+
*/
|
|
6066
|
+
resolveRowIds(ids, _ctx) {
|
|
6067
|
+
return ids;
|
|
6068
|
+
}
|
|
6069
|
+
/** {@link resolveRowIds} with its output validated (a server bug is a 500). */
|
|
6070
|
+
async _runResolveRowIds(ids, ctx) {
|
|
6071
|
+
const out = await this.resolveRowIds(ids, ctx);
|
|
6072
|
+
if (!Array.isArray(out) || out.length !== ids.length) throw new HttpError(500, "resolveRowIds must return one id per request id");
|
|
6073
|
+
const source = this.idSource;
|
|
6074
|
+
for (const id of out) {
|
|
6075
|
+
if ((typeof id === "string" || typeof id === "number" || typeof id === "boolean") && ctx.purpose !== "action") continue;
|
|
6076
|
+
try {
|
|
6077
|
+
validateSingleId(id, source, { strictTypes: ctx.purpose === "action" });
|
|
6078
|
+
} catch (error) {
|
|
6079
|
+
if (error instanceof ValidatorError) throw new HttpError(500, "resolveRowIds returned an invalid id");
|
|
6080
|
+
throw error;
|
|
6081
|
+
}
|
|
6082
|
+
}
|
|
6083
|
+
return out;
|
|
6084
|
+
}
|
|
6085
|
+
/**
|
|
6086
|
+
* @internal The one id of an id-addressed endpoint through
|
|
6087
|
+
* {@link resolveRowIds} (identity, at no cost, when it is not overridden)
|
|
6088
|
+
* and the row overlay it was resolved inside — computed once, for the
|
|
6089
|
+
* endpoint's read or delete under that overlay.
|
|
6090
|
+
*/
|
|
6091
|
+
async _resolveWithOverlay(id, purpose) {
|
|
6092
|
+
const overlay = await this.rowOverlay();
|
|
6093
|
+
if (!this[ROW_RESOLVES]) return {
|
|
6094
|
+
id,
|
|
6095
|
+
overlay
|
|
6096
|
+
};
|
|
6097
|
+
const [resolved] = await this._runResolveRowIds([id], {
|
|
6098
|
+
purpose,
|
|
6099
|
+
overlay
|
|
6100
|
+
});
|
|
6101
|
+
return {
|
|
6102
|
+
id: resolved,
|
|
6103
|
+
overlay
|
|
6104
|
+
};
|
|
6105
|
+
}
|
|
6106
|
+
/**
|
|
5056
6107
|
* The filter addressing exactly the ONE row `id` means — the readable's
|
|
5057
6108
|
* PK-first `resolveRowFilter` (since 0.1.143) under this request's
|
|
5058
6109
|
* identifications (`_idOpts`). `scope` (the row overlay) restricts which
|
|
5059
6110
|
* rows count while the id is pinned, so a row outside it never shadows one
|
|
5060
6111
|
* inside it. Readables without it (partial mocks) fall back to
|
|
5061
|
-
* `resolveIdFilter`.
|
|
6112
|
+
* `resolveIdFilter`. Not an alias seam: `/one` reads through `findOneByRow`
|
|
6113
|
+
* on every real table or view — map stale ids in {@link resolveRowIds}.
|
|
5062
6114
|
*/
|
|
5063
6115
|
resolveRowFilter(id, scope) {
|
|
5064
6116
|
const readable = this.readable;
|
|
@@ -5132,8 +6184,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5132
6184
|
if (groupBy?.length && controls.$vector !== void 0) return new HttpError(400, "Cannot combine $vector and $groupBy in the same query");
|
|
5133
6185
|
const error = this.validateParsed(parsed, "query");
|
|
5134
6186
|
if (error) return error;
|
|
5135
|
-
if (groupBy?.length) {
|
|
5136
|
-
const
|
|
6187
|
+
if (groupBy?.length && this._writeOnlySet.size > 0) {
|
|
6188
|
+
const refs = collectQueryPaths$1(parsed, true);
|
|
6189
|
+
const sealed = [
|
|
6190
|
+
...refs.groupBy,
|
|
6191
|
+
...refs.aggregate,
|
|
6192
|
+
...refs.bucket,
|
|
6193
|
+
...refs.select,
|
|
6194
|
+
...refs.sort
|
|
6195
|
+
].find((path) => this._writeOnlySet.has(path) && this.fieldVisibility.isVisible(path));
|
|
5137
6196
|
if (sealed) return new HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
|
|
5138
6197
|
}
|
|
5139
6198
|
const gateError = this.checkCapabilities(parsed);
|
|
@@ -5147,9 +6206,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5147
6206
|
insights: parsed.insights
|
|
5148
6207
|
});
|
|
5149
6208
|
}
|
|
5150
|
-
const [transformedFilter,
|
|
6209
|
+
const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
5151
6210
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
5152
|
-
const sealed = this._sealControls(controls, transformedSelect);
|
|
5153
6211
|
if (controls.$count) return this.readable.count({
|
|
5154
6212
|
filter,
|
|
5155
6213
|
controls: {
|
|
@@ -5157,19 +6215,19 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5157
6215
|
$select: sealed.$select
|
|
5158
6216
|
}
|
|
5159
6217
|
});
|
|
5160
|
-
const
|
|
5161
|
-
if (
|
|
6218
|
+
const projected = finish();
|
|
6219
|
+
if (projected instanceof HttpError) return projected;
|
|
5162
6220
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
5163
6221
|
const queryObj = {
|
|
5164
6222
|
filter,
|
|
5165
6223
|
controls: {
|
|
5166
6224
|
...sealed,
|
|
5167
|
-
$select: select,
|
|
6225
|
+
$select: projected.select,
|
|
5168
6226
|
$limit: controls.$limit || 1e3,
|
|
5169
6227
|
$threshold: threshold
|
|
5170
6228
|
}
|
|
5171
6229
|
};
|
|
5172
|
-
return (await this._runReadWithActions("query", queryObj, controls,
|
|
6230
|
+
return (await this._runReadWithActions("query", queryObj, controls, projected, async (q, strategy) => {
|
|
5173
6231
|
switch (strategy.kind) {
|
|
5174
6232
|
case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
|
|
5175
6233
|
case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
|
|
@@ -5190,23 +6248,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5190
6248
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
5191
6249
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
5192
6250
|
const skip = (page - 1) * size;
|
|
5193
|
-
const [transformedFilter,
|
|
6251
|
+
const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
5194
6252
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
5195
|
-
const
|
|
5196
|
-
|
|
5197
|
-
if (select instanceof HttpError) return select;
|
|
6253
|
+
const projected = finish();
|
|
6254
|
+
if (projected instanceof HttpError) return projected;
|
|
5198
6255
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
5199
6256
|
const query = {
|
|
5200
6257
|
filter,
|
|
5201
6258
|
controls: {
|
|
5202
6259
|
...sealed,
|
|
5203
|
-
$select: select,
|
|
6260
|
+
$select: projected.select,
|
|
5204
6261
|
$skip: skip,
|
|
5205
6262
|
$limit: size,
|
|
5206
6263
|
$threshold: threshold
|
|
5207
6264
|
}
|
|
5208
6265
|
};
|
|
5209
|
-
const result = await this._runReadWithActions("pages", query, controls,
|
|
6266
|
+
const result = await this._runReadWithActions("pages", query, controls, projected, async (q, strategy) => {
|
|
5210
6267
|
switch (strategy.kind) {
|
|
5211
6268
|
case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
|
|
5212
6269
|
case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
|
|
@@ -5248,10 +6305,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5248
6305
|
const gateError = this.checkCapabilities(parsed);
|
|
5249
6306
|
if (gateError) return gateError;
|
|
5250
6307
|
const clientFilter = await this._relationOverlay(parsed);
|
|
5251
|
-
const [filter,
|
|
5252
|
-
const
|
|
5253
|
-
|
|
5254
|
-
if (select instanceof HttpError) return select;
|
|
6308
|
+
const [filter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
6309
|
+
const projected = finish();
|
|
6310
|
+
if (projected instanceof HttpError) return projected;
|
|
5255
6311
|
const paginated = controls.$page !== void 0 || controls.$size !== void 0;
|
|
5256
6312
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
5257
6313
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
@@ -5261,7 +6317,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5261
6317
|
...sealed,
|
|
5262
6318
|
$center: void 0,
|
|
5263
6319
|
$index: void 0,
|
|
5264
|
-
$select: select,
|
|
6320
|
+
$select: projected.select,
|
|
5265
6321
|
...paginated ? {
|
|
5266
6322
|
$skip: (page - 1) * size,
|
|
5267
6323
|
$limit: size
|
|
@@ -5269,7 +6325,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5269
6325
|
}
|
|
5270
6326
|
};
|
|
5271
6327
|
if (paginated) {
|
|
5272
|
-
const result = await this._runReadWithActions("geo", queryObj, controls,
|
|
6328
|
+
const result = await this._runReadWithActions("geo", queryObj, controls, projected, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
|
|
5273
6329
|
return {
|
|
5274
6330
|
data: result.data,
|
|
5275
6331
|
page,
|
|
@@ -5278,7 +6334,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5278
6334
|
count: result.count
|
|
5279
6335
|
};
|
|
5280
6336
|
}
|
|
5281
|
-
return (await this._runReadWithActions("geo", queryObj, controls,
|
|
6337
|
+
return (await this._runReadWithActions("geo", queryObj, controls, projected, async (q) => ({ data: await (indexName ? this.readable.geoSearch(indexName, point, q) : this.readable.geoSearch(point, q)) }))).data;
|
|
5282
6338
|
}
|
|
5283
6339
|
/** Parses the `$center` control: `"lng,lat"` string (or tuple) → `[number, number]`. */
|
|
5284
6340
|
_parseGeoCenter(raw) {
|
|
@@ -5327,21 +6383,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5327
6383
|
const error = this.validateParsed(parsed, "getOne") ?? this.checkCapabilities(parsed);
|
|
5328
6384
|
if (error) return error;
|
|
5329
6385
|
await this._relationOverlay(parsed);
|
|
5330
|
-
const sealed =
|
|
5331
|
-
const
|
|
5332
|
-
if (
|
|
5333
|
-
const [prep, overlay] = await Promise.all([this._prepareAugmentation(controls,
|
|
6386
|
+
const { sealed, finish } = await this._projectRead(controls);
|
|
6387
|
+
const projected = finish();
|
|
6388
|
+
if (projected instanceof HttpError) return projected;
|
|
6389
|
+
const [prep, { id: resolvedId, overlay }] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveWithOverlay(id, "one")]);
|
|
5334
6390
|
const readControls = {
|
|
5335
6391
|
...sealed,
|
|
5336
|
-
$select: prep?.widenedSelect ?? select
|
|
6392
|
+
$select: prep?.widenedSelect ?? projected.select
|
|
5337
6393
|
};
|
|
5338
|
-
const item = await this.returnOne(this._findRow(
|
|
6394
|
+
const item = await this.returnOne(this._findRow(resolvedId, overlay, readControls));
|
|
5339
6395
|
if (item instanceof HttpError) return item;
|
|
5340
6396
|
const pending = this._finishRows([item], prep, {
|
|
5341
6397
|
endpoint: "one",
|
|
5342
|
-
projection: select,
|
|
5343
|
-
controls
|
|
5344
|
-
|
|
6398
|
+
projection: projected.select,
|
|
6399
|
+
controls,
|
|
6400
|
+
decorations: projected.read?.served ?? NO_DECORATIONS
|
|
6401
|
+
}, projected.read);
|
|
5345
6402
|
if (pending) await pending;
|
|
5346
6403
|
return item;
|
|
5347
6404
|
}
|
|
@@ -5358,10 +6415,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5358
6415
|
*/
|
|
5359
6416
|
async availableActionsById(id) {
|
|
5360
6417
|
await this.parseRequest("availableActions");
|
|
5361
|
-
const own = await this.
|
|
6418
|
+
const { own, id: resolved } = await this._availableResolved(id);
|
|
5362
6419
|
if (!this._hasDelegations) return own;
|
|
5363
|
-
|
|
5364
|
-
return this._delegatedAvailable(own, (d) => preferred.length === 1 && d.paths.every((p) => p === preferred[0]) ? Object.fromEntries(Object.keys(d.idMap).map((f) => [f, id])) : void 0);
|
|
6420
|
+
return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved));
|
|
5365
6421
|
}
|
|
5366
6422
|
/**
|
|
5367
6423
|
* **GET /meta/actions?field1=val1&…** — {@link availableActionsById} by
|
|
@@ -5371,13 +6427,50 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5371
6427
|
async availableActions(query) {
|
|
5372
6428
|
await this.parseRequest("availableActions");
|
|
5373
6429
|
const idObj = this.extractIdShape(query);
|
|
5374
|
-
const sourceIdOf = (d) => d.paths.every((p) => query[p] !== void 0) ? Object.fromEntries(Object.entries(d.idMap).map(([f, p]) => [f, query[p]])) : void 0;
|
|
5375
6430
|
if (idObj instanceof HttpError) {
|
|
5376
|
-
if (!(await this._activeDelegations()).some((d) =>
|
|
5377
|
-
return this._delegatedAvailable({ actions: [] },
|
|
6431
|
+
if (!(await this._activeDelegations()).some((d) => this._sourceIdOf(d, void 0, query) !== void 0)) return idObj;
|
|
6432
|
+
return this._delegatedAvailable({ actions: [] }, (d) => this._sourceIdOf(d, void 0, query));
|
|
5378
6433
|
}
|
|
5379
|
-
const own = await this.
|
|
5380
|
-
|
|
6434
|
+
const { own, id: resolved } = await this._availableResolved(idObj);
|
|
6435
|
+
if (!this._hasDelegations) return own;
|
|
6436
|
+
return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved, query, this._identificationFields()));
|
|
6437
|
+
}
|
|
6438
|
+
/** Every field of every identification (primary key and unique indexes) the view addresses a row by. */
|
|
6439
|
+
_identificationFields() {
|
|
6440
|
+
return [...new Set(this.idSource.identifications.flatMap((i) => i.fields))];
|
|
6441
|
+
}
|
|
6442
|
+
/**
|
|
6443
|
+
* A delegation's source id: each source id field from the row's mapped path
|
|
6444
|
+
* of the RESOLVED `id`. A path of ANY of the view's identifications
|
|
6445
|
+
* (`consumed` — not just the one the request matched: `?id=1&code=T-OLD` names
|
|
6446
|
+
* `code` too) is NEVER taken from the raw `?` query: that value may be an
|
|
6447
|
+
* alias `resolveRowIds` rewrote, and the source would see (and answer for)
|
|
6448
|
+
* it. Paths outside the identification fall back to `fallback`'s (the raw
|
|
6449
|
+
* query's) value; `undefined` when a path has none.
|
|
6450
|
+
*/
|
|
6451
|
+
_sourceIdOf(d, id, fallback, consumed = []) {
|
|
6452
|
+
const value = (path) => id?.[path] ?? (consumed.includes(path) ? void 0 : fallback?.[path]);
|
|
6453
|
+
return d.paths.every((path) => value(path) !== void 0) ? Object.fromEntries(Object.entries(d.idMap).map(([field, path]) => [field, value(path)])) : void 0;
|
|
6454
|
+
}
|
|
6455
|
+
/**
|
|
6456
|
+
* {@link _availableActions} for a request id (`names`: only those actions):
|
|
6457
|
+
* through {@link resolveRowIds} (`"available"`) first, the row overlay
|
|
6458
|
+
* computed once for both — and the resolved id returned as an object (a
|
|
6459
|
+
* scalar is the single-field `preferredId` value) for the delegated part to
|
|
6460
|
+
* derive its source id from.
|
|
6461
|
+
*/
|
|
6462
|
+
async _availableResolved(id, names) {
|
|
6463
|
+
const { id: resolved, overlay } = await this._resolveWithOverlay(id, "available");
|
|
6464
|
+
const own = await this._availableActions(resolved, overlay, names);
|
|
6465
|
+
if (typeof resolved === "object") return {
|
|
6466
|
+
own,
|
|
6467
|
+
id: resolved
|
|
6468
|
+
};
|
|
6469
|
+
const preferred = this.readable.preferredId;
|
|
6470
|
+
return {
|
|
6471
|
+
own,
|
|
6472
|
+
id: preferred.length === 1 ? { [preferred[0]]: resolved } : void 0
|
|
6473
|
+
};
|
|
5381
6474
|
}
|
|
5382
6475
|
/**
|
|
5383
6476
|
* **POST /delegated-actions/:name** — a query target for a `@DbActionsFrom`
|
|
@@ -5440,7 +6533,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5440
6533
|
maxExclude: limits.maxIds,
|
|
5441
6534
|
overlay: "read",
|
|
5442
6535
|
select: [...new Set([...identity, ...delegation.paths])],
|
|
5443
|
-
excludeShapes: [delegation.paths]
|
|
6536
|
+
excludeShapes: [delegation.paths],
|
|
6537
|
+
visibleOf: identity
|
|
5444
6538
|
});
|
|
5445
6539
|
if (resolved.dryRun) return { matched: resolved.matched };
|
|
5446
6540
|
const summary = {
|
|
@@ -5450,7 +6544,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5450
6544
|
failed: []
|
|
5451
6545
|
};
|
|
5452
6546
|
const { ids, index } = mapToSourceIds(resolved.rows, delegation.idMap);
|
|
5453
|
-
const visibleIdentity =
|
|
6547
|
+
const visibleIdentity = resolved.visibleOf ?? [];
|
|
5454
6548
|
for (let i = 0; i < index.length; i++) {
|
|
5455
6549
|
if (index[i] >= 0) continue;
|
|
5456
6550
|
const row = resolved.rows[i];
|
|
@@ -5549,8 +6643,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5549
6643
|
* actions are then checked on it (`purpose: "available"`) exactly like
|
|
5550
6644
|
* `$actions` rows.
|
|
5551
6645
|
*/
|
|
5552
|
-
async _availableActions(id, names) {
|
|
5553
|
-
const
|
|
6646
|
+
async _availableActions(id, overlay, names) {
|
|
6647
|
+
const envelopes = await (names ? this._envelopesNamed(names) : this._resolveAugmentEnvelopes());
|
|
5554
6648
|
if (!envelopes?.length) return { actions: [] };
|
|
5555
6649
|
const idKeys = id !== null && typeof id === "object" ? Object.keys(id) : [];
|
|
5556
6650
|
const fieldsOf = envelopes.map((e) => {
|
|
@@ -5631,7 +6725,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5631
6725
|
if (fd.encrypted) entry.encrypted = true;
|
|
5632
6726
|
if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
|
|
5633
6727
|
if (this._writeOnlySet.has(path)) entry.writeOnly = true;
|
|
6728
|
+
if (cap.groupable) entry.groupable = true;
|
|
5634
6729
|
if (cap.bucketable) entry.bucketable = true;
|
|
6730
|
+
if (cap.numeric) entry.numeric = true;
|
|
5635
6731
|
if (fd.derived) entry.derived = true;
|
|
5636
6732
|
if (fd.computed) entry.computed = true;
|
|
5637
6733
|
fields[path] = entry;
|
|
@@ -5646,11 +6742,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5646
6742
|
relations,
|
|
5647
6743
|
fields,
|
|
5648
6744
|
type: this.getSerializedType(),
|
|
6745
|
+
...this._decorations && { decorations: this._planner.serialized(() => this.serializeForMeta(this._decorations.type)) },
|
|
5649
6746
|
actions: this.buildActions(),
|
|
5650
6747
|
crud: this.buildCrud(),
|
|
5651
6748
|
versionColumn: this.readable.versionColumn,
|
|
5652
6749
|
...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] },
|
|
5653
|
-
aggregateFns: [...capabilities.aggregateFns]
|
|
6750
|
+
aggregateFns: [...capabilities.aggregateFns],
|
|
6751
|
+
aggregateExpressions: capabilities.aggregateExpressions
|
|
5654
6752
|
};
|
|
5655
6753
|
}
|
|
5656
6754
|
buildCrud() {
|
|
@@ -5781,7 +6879,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5781
6879
|
buildCrud() {
|
|
5782
6880
|
return {
|
|
5783
6881
|
...super.buildCrud(),
|
|
5784
|
-
insert: [],
|
|
6882
|
+
insert: this.readable.dbAdapter?.supportsInsertIgnore() ? ["onConflict"] : [],
|
|
5785
6883
|
update: [],
|
|
5786
6884
|
replace: [],
|
|
5787
6885
|
remove: []
|
|
@@ -5801,7 +6899,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5801
6899
|
return data;
|
|
5802
6900
|
}
|
|
5803
6901
|
/**
|
|
5804
|
-
* Intercepts delete operations.
|
|
6902
|
+
* Intercepts delete operations. Receives the id {@link resolveRowIds}
|
|
6903
|
+
* resolved (the one the request carried when it is not overridden; since
|
|
6904
|
+
* 0.1.148). Return `undefined` to abort (500 "Not
|
|
5805
6905
|
* deleted"); return an `Error` instance to respond with that error.
|
|
5806
6906
|
* Runs outside any transaction. May be async (e.g. to resolve composite
|
|
5807
6907
|
* ids from external state).
|
|
@@ -5938,8 +7038,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5938
7038
|
* table's transaction and an out-of-scope row is not deleted — a 404,
|
|
5939
7039
|
* exactly like a missing one.
|
|
5940
7040
|
*/
|
|
5941
|
-
async _deleteOrThrow(id) {
|
|
5942
|
-
const scope = await this.rowOverlay();
|
|
7041
|
+
async _deleteOrThrow(id, scope) {
|
|
5943
7042
|
const args = scope ? [{
|
|
5944
7043
|
...this._removeArgs[0],
|
|
5945
7044
|
scope
|
|
@@ -5950,16 +7049,34 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5950
7049
|
}
|
|
5951
7050
|
/**
|
|
5952
7051
|
* **POST /** — inserts one or many records.
|
|
7052
|
+
*
|
|
7053
|
+
* `?$onConflict=ignore` (since 0.1.148) skips rows colliding on the primary
|
|
7054
|
+
* key or a unique index instead of answering 409. The response then is
|
|
7055
|
+
* `{ insertedId?, conflict }` for an object body and
|
|
7056
|
+
* `{ insertedCount, insertedIds, inserted, conflicts }` for an array body.
|
|
7057
|
+
* Any other `$` control on POST answers 400.
|
|
5953
7058
|
*/
|
|
5954
|
-
async insert(payload) {
|
|
5955
|
-
await this.parseRequest("insert");
|
|
7059
|
+
async insert(payload, url) {
|
|
7060
|
+
const { controls } = await this.parseRequest("insert", url ?? "");
|
|
7061
|
+
const onConflict = this._readOnConflict(controls);
|
|
5956
7062
|
assertWriteShape(payload);
|
|
7063
|
+
const args = onConflict ? [{
|
|
7064
|
+
...this._writeArgs[0],
|
|
7065
|
+
onConflict
|
|
7066
|
+
}] : this._writeArgs;
|
|
5957
7067
|
if (Array.isArray(payload)) {
|
|
5958
7068
|
const rows = await this._writeBody("insertMany", payload, true);
|
|
5959
|
-
return this.table.insertMany(rows, ...
|
|
7069
|
+
return this.table.insertMany(rows, ...args);
|
|
5960
7070
|
}
|
|
5961
7071
|
const row = await this._writeBody("insert", payload, false);
|
|
5962
|
-
return this.table.insertOne(row, ...
|
|
7072
|
+
return this.table.insertOne(row, ...args);
|
|
7073
|
+
}
|
|
7074
|
+
/** The only POST control: `$onConflict` (`error` | `ignore`). Anything else `$…` → 400. */
|
|
7075
|
+
_readOnConflict(controls) {
|
|
7076
|
+
for (const key of Object.keys(controls)) if (key !== "$onConflict") throw badRequest("", `Unsupported control "${key}" on insert`);
|
|
7077
|
+
const mode = controls.$onConflict;
|
|
7078
|
+
if (mode !== void 0 && mode !== "error" && mode !== "ignore") throw badRequest("", `$onConflict must be "error" or "ignore", got ${JSON.stringify(mode)}`);
|
|
7079
|
+
return mode === "ignore" ? "ignore" : void 0;
|
|
5963
7080
|
}
|
|
5964
7081
|
/**
|
|
5965
7082
|
* **PUT /** — fully replaces one or many records matched by primary key.
|
|
@@ -6025,7 +7142,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6025
7142
|
if (typeof table.recordFilter === "function") try {
|
|
6026
7143
|
filter = table.recordFilter(data, this._idOpts);
|
|
6027
7144
|
} catch (error) {
|
|
6028
|
-
if (!(error instanceof DbError)) throw error;
|
|
7145
|
+
if (!(error instanceof DbError) || error.code === "SPACE_CLOSED") throw error;
|
|
6029
7146
|
filter = null;
|
|
6030
7147
|
}
|
|
6031
7148
|
else filter = await this.resolveRowFilter(data);
|
|
@@ -6046,8 +7163,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6046
7163
|
*/
|
|
6047
7164
|
async remove(id) {
|
|
6048
7165
|
await this.parseRequest("remove");
|
|
6049
|
-
const
|
|
6050
|
-
|
|
7166
|
+
const { id: resolved, overlay } = await this._resolveWithOverlay(id, "remove");
|
|
7167
|
+
const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
|
|
7168
|
+
return this._deleteOrThrow(resolvedId, overlay);
|
|
6051
7169
|
}
|
|
6052
7170
|
/**
|
|
6053
7171
|
* **DELETE /?field1=val1&field2=val2** — removes a record by composite key
|
|
@@ -6057,15 +7175,17 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6057
7175
|
await this.parseRequest("remove");
|
|
6058
7176
|
const idObj = this.extractIdShape(query);
|
|
6059
7177
|
if (idObj instanceof HttpError) throw idObj;
|
|
6060
|
-
const
|
|
6061
|
-
|
|
7178
|
+
const { id: resolved, overlay } = await this._resolveWithOverlay(idObj, "remove");
|
|
7179
|
+
const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
|
|
7180
|
+
return this._deleteOrThrow(resolvedId, overlay);
|
|
6062
7181
|
}
|
|
6063
7182
|
};
|
|
6064
7183
|
__decorate([
|
|
6065
7184
|
Post(""),
|
|
6066
7185
|
__decorateParam(0, Body()),
|
|
7186
|
+
__decorateParam(1, Url()),
|
|
6067
7187
|
__decorateMetadata("design:type", Function),
|
|
6068
|
-
__decorateMetadata("design:paramtypes", [Object]),
|
|
7188
|
+
__decorateMetadata("design:paramtypes", [Object, String]),
|
|
6069
7189
|
__decorateMetadata("design:returntype", Promise)
|
|
6070
7190
|
], AsDbController.prototype, "insert", null);
|
|
6071
7191
|
__decorate([
|
|
@@ -6562,7 +7682,7 @@ function buildGateInterceptor(opts) {
|
|
|
6562
7682
|
await ctx.get(dbActionOverlaySlot);
|
|
6563
7683
|
if (level === "row") {
|
|
6564
7684
|
const verdict = judgeRow(action, disabled, await ctx.get(dbActionRowSlot));
|
|
6565
|
-
if (verdict) throw
|
|
7685
|
+
if (verdict) throw rowDisabledError(ctx, action, await ctx.get(dbActionIdSlot), verdictReason(verdict));
|
|
6566
7686
|
return;
|
|
6567
7687
|
}
|
|
6568
7688
|
if (await queryTargetReply(ctx, reply)) return;
|
|
@@ -6586,8 +7706,7 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
6586
7706
|
const existingRows = [];
|
|
6587
7707
|
for (const row of rows) if (row !== void 0) existingRows.push(row);
|
|
6588
7708
|
const verdicts = disabled ? judgeRows(action, disabled, existingRows) : void 0;
|
|
6589
|
-
const
|
|
6590
|
-
const failingReasons = [];
|
|
7709
|
+
const failing = [];
|
|
6591
7710
|
const passingRows = [];
|
|
6592
7711
|
const passingIds = [];
|
|
6593
7712
|
const skipped = [];
|
|
@@ -6598,8 +7717,10 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
6598
7717
|
const verdict = row === void 0 ? void 0 : verdicts?.[verdictIndex++];
|
|
6599
7718
|
if (row === void 0 || verdict) {
|
|
6600
7719
|
const reason = verdictReason(verdict);
|
|
6601
|
-
|
|
6602
|
-
|
|
7720
|
+
failing.push({
|
|
7721
|
+
id: ids[i],
|
|
7722
|
+
reason
|
|
7723
|
+
});
|
|
6603
7724
|
const skipReason = reason ?? (stale?.has(i) ? "stale" : void 0);
|
|
6604
7725
|
skipped.push(skipReason === void 0 ? { id: ids[i] } : {
|
|
6605
7726
|
id: ids[i],
|
|
@@ -6611,27 +7732,47 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
6611
7732
|
}
|
|
6612
7733
|
}
|
|
6613
7734
|
if (onDisabledRows === "skip") {
|
|
6614
|
-
if (passingRows.length === 0) throw
|
|
6615
|
-
if (
|
|
7735
|
+
if (passingRows.length === 0) throw disabledError(ctx, action, failing);
|
|
7736
|
+
if (failing.length > 0) {
|
|
6616
7737
|
ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
|
|
6617
7738
|
ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
|
|
6618
7739
|
ctx.set(dbActionSkippedKey, skipped);
|
|
6619
7740
|
}
|
|
6620
7741
|
return;
|
|
6621
7742
|
}
|
|
6622
|
-
if (
|
|
7743
|
+
if (failing.length > 0) throw disabledError(ctx, action, failing);
|
|
7744
|
+
}
|
|
7745
|
+
/**
|
|
7746
|
+
* The 409 of a `'rows'` gate: every failing REQUEST id in request order, each
|
|
7747
|
+
* as the client sent it, with its own reason (`resolveRowIds`, since 0.1.148)
|
|
7748
|
+
* — {@link echoRequests} is the one place that maps resolved ids back.
|
|
7749
|
+
*/
|
|
7750
|
+
function disabledError(ctx, action, failing) {
|
|
7751
|
+
const echoed = echoRequests(ctx, failing);
|
|
7752
|
+
return new ActionDisabledError(action, void 0, echoed.map((f) => f.id), echoed.map((f) => f.reason));
|
|
7753
|
+
}
|
|
7754
|
+
/** The 409 of a `'row'` gate: the id as the client sent it. */
|
|
7755
|
+
function rowDisabledError(ctx, action, id, reason) {
|
|
7756
|
+
return new ActionDisabledError(action, requestIdOf(ctx, id), void 0, [reason]);
|
|
7757
|
+
}
|
|
7758
|
+
/** The action's scope restricts (or needs the loaded rows to decide) — see `TPreScope`. */
|
|
7759
|
+
async function needsScopeLoad(ctx, level) {
|
|
7760
|
+
const pre = await ctx.get(dbActionPreScopeSlot)(level);
|
|
7761
|
+
return pre.kind === "deferred" || pre.scope !== null;
|
|
6623
7762
|
}
|
|
6624
7763
|
/**
|
|
6625
7764
|
* Interceptor for `'row'` / `'rows'` actions without `disabled` (and for a
|
|
6626
7765
|
* `@DbActionRow*` handler of any other level: bound-table injection only):
|
|
6627
7766
|
* runs the controller's `prepareRequest` (when defined, since 0.1.143),
|
|
6628
|
-
* injects the bound table and — only when
|
|
6629
|
-
*
|
|
6630
|
-
* `
|
|
6631
|
-
*
|
|
6632
|
-
*
|
|
6633
|
-
* `
|
|
6634
|
-
*
|
|
7767
|
+
* injects the bound table and — only when there is something to verify —
|
|
7768
|
+
* checks the requested ids before the handler runs by loading the row(s) the
|
|
7769
|
+
* handler would get: `'row'` → the 404 of a missing row; `'rows'` →
|
|
7770
|
+
* out-of-scope and missing ids fail like disabled rows with no reason
|
|
7771
|
+
* (`onDisabledRows`). Something to verify: a row overlay (`transformOne` /
|
|
7772
|
+
* `transformFilter` overridden, non-empty), a non-empty `actionRowScope` for
|
|
7773
|
+
* the action (the hook is asked first, with the request's ids — an override
|
|
7774
|
+
* that restricts nothing verifies nothing; since 0.1.148), or a query target
|
|
7775
|
+
* (since 0.1.147). Nothing to verify → no query.
|
|
6635
7776
|
*/
|
|
6636
7777
|
function buildThinInterceptor(opts) {
|
|
6637
7778
|
const { table, scope } = opts;
|
|
@@ -6642,12 +7783,12 @@ function buildThinInterceptor(opts) {
|
|
|
6642
7783
|
if (!scope) return;
|
|
6643
7784
|
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
6644
7785
|
if (scope.level === "row") {
|
|
6645
|
-
if (overlay ||
|
|
7786
|
+
if (overlay || await needsScopeLoad(ctx, "row")) await ctx.get(dbActionRowSlot);
|
|
6646
7787
|
return;
|
|
6647
7788
|
}
|
|
6648
7789
|
if (await queryTargetReply(ctx, reply)) return;
|
|
6649
7790
|
const target = await ctx.get(dbActionQueryTargetSlot);
|
|
6650
|
-
if (overlay || target ||
|
|
7791
|
+
if (overlay || target || await needsScopeLoad(ctx, "rows")) await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
|
|
6651
7792
|
await setMaterializedTarget(ctx);
|
|
6652
7793
|
}, ACTION_GATE_PRIORITY);
|
|
6653
7794
|
}
|
|
@@ -7070,6 +8211,52 @@ function DbActionsFrom(source, opts = {}) {
|
|
|
7070
8211
|
*/
|
|
7071
8212
|
const perRow = (fn) => (rows) => rows.map(fn);
|
|
7072
8213
|
//#endregion
|
|
8214
|
+
//#region src/decorations/db-decorations.decorator.ts
|
|
8215
|
+
/**
|
|
8216
|
+
* Declares display-only (decoration) fields of a table or view controller —
|
|
8217
|
+
* values `decorateRows` computes and attaches to rows (since 0.1.148).
|
|
8218
|
+
*
|
|
8219
|
+
* `type` is a plain atscript interface (no `@db.table` / `@db.view`) whose
|
|
8220
|
+
* top-level props are the decorations: their `@meta.label`, `@expect.*` and
|
|
8221
|
+
* `@ui.*` annotations travel in `/meta.decorations`, each key is listed in
|
|
8222
|
+
* `/meta.fields` with `decoration: true`, and a client may name it in
|
|
8223
|
+
* `$select`. A decoration is never filterable, sortable or groupable, and it
|
|
8224
|
+
* is not part of `/meta.type` (forms and write validation never see it).
|
|
8225
|
+
*
|
|
8226
|
+
* ```ts
|
|
8227
|
+
* @TableController(TicketTable)
|
|
8228
|
+
* @DbDecorations(TicketDecorations, { requires: { ownerName: ["ownerId"] } })
|
|
8229
|
+
* export class TicketsController extends AsDbController<typeof TicketTable> {
|
|
8230
|
+
* protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
|
|
8231
|
+
* if (ctx.decorations.has("ownerName")) {
|
|
8232
|
+
* // read rows[i].ownerId, set rows[i].ownerName
|
|
8233
|
+
* }
|
|
8234
|
+
* }
|
|
8235
|
+
* }
|
|
8236
|
+
* ```
|
|
8237
|
+
*
|
|
8238
|
+
* Validated once per class at first use (a `[moost-db]` error): the type is an
|
|
8239
|
+
* object interface; keys are top-level identifiers that collide with no field
|
|
8240
|
+
* or relation of the readable; every `requires` path is an own, readable
|
|
8241
|
+
* (not `@db.writeOnly`) field or a parent object of own fields (on SQL a nested
|
|
8242
|
+
* object is flattened to leaf columns; the hook still gets the whole object). Inherited under `@Inherit()`. Not supported on
|
|
8243
|
+
* value-help controllers.
|
|
8244
|
+
*
|
|
8245
|
+
* @since 0.1.148
|
|
8246
|
+
*/
|
|
8247
|
+
function DbDecorations(type, opts = {}) {
|
|
8248
|
+
if (!isAnnotatedType(type)) throw new Error("[moost-db] @DbDecorations: expects a compiled atscript interface");
|
|
8249
|
+
const meta = {
|
|
8250
|
+
type,
|
|
8251
|
+
requires: Object.fromEntries(Object.entries(opts.requires ?? {}).map(([key, paths]) => [key, [...paths ?? []]]))
|
|
8252
|
+
};
|
|
8253
|
+
const decorate = getAtscriptDbMate().decorate("atscript_db_decorations", meta);
|
|
8254
|
+
return (target) => {
|
|
8255
|
+
if (isAsValueHelpControllerSubclass(target)) throw new Error(`[moost-db] ${target.name} is a value-help controller — @DbDecorations is not supported there.`);
|
|
8256
|
+
return decorate(target);
|
|
8257
|
+
};
|
|
8258
|
+
}
|
|
8259
|
+
//#endregion
|
|
7073
8260
|
//#region src/permissions/crud-handlers.ts
|
|
7074
8261
|
/**
|
|
7075
8262
|
* The handler method(s) serving each CRUD op on `AsDbReadableController` /
|
|
@@ -7103,4 +8290,4 @@ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
|
|
|
7103
8290
|
one: ["runGetOne", "runGetOneComposite"]
|
|
7104
8291
|
});
|
|
7105
8292
|
//#endregion
|
|
7106
|
-
export { ActionDisabledError, ActionTargetError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
|
|
8293
|
+
export { ActionDisabledError, ActionTargetError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
|