@atscript/moost-db 0.1.147 → 0.1.148
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 +1248 -233
- package/dist/index.d.cts +373 -48
- package/dist/index.d.mts +373 -48
- package/dist/index.mjs +1246 -233
- 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,9 +1,9 @@
|
|
|
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";
|
|
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
7
|
import { cached, current, defineWook, key, useRouteParams } from "@wooksjs/event-core";
|
|
8
8
|
import { useBody } from "@wooksjs/http-body";
|
|
9
9
|
import { BUCKET_UNITS } from "@uniqu/core";
|
|
@@ -76,6 +76,7 @@ const dbErrorCodeToStatus = {
|
|
|
76
76
|
CONFLICT: 409,
|
|
77
77
|
CAS_MISMATCH: 409,
|
|
78
78
|
TX_WAIT_TIMEOUT: 503,
|
|
79
|
+
SPACE_CLOSED: 503,
|
|
79
80
|
BUCKET_TZ_UNAVAILABLE: 501
|
|
80
81
|
};
|
|
81
82
|
function transformValidationError(error, reply) {
|
|
@@ -514,6 +515,38 @@ function identityKey(id) {
|
|
|
514
515
|
return idKey(id, Object.keys(id).toSorted());
|
|
515
516
|
}
|
|
516
517
|
/**
|
|
518
|
+
* `resolved` (index-aligned with `requested`) with duplicate identities
|
|
519
|
+
* collapsed to the first, plus the per-request list ({@link TAppliedIds}).
|
|
520
|
+
*/
|
|
521
|
+
function applyResolvedIds(requested, resolved) {
|
|
522
|
+
const ids = [];
|
|
523
|
+
const requests = [];
|
|
524
|
+
const seen = /* @__PURE__ */ new Set();
|
|
525
|
+
let changed = false;
|
|
526
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
527
|
+
const k = identityKey(resolved[i]);
|
|
528
|
+
if (k === void 0) {
|
|
529
|
+
ids.push(resolved[i]);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (k !== identityKey(requested[i])) changed = true;
|
|
533
|
+
requests.push({
|
|
534
|
+
id: requested[i],
|
|
535
|
+
key: k
|
|
536
|
+
});
|
|
537
|
+
if (seen.has(k)) {
|
|
538
|
+
changed = true;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
seen.add(k);
|
|
542
|
+
ids.push(resolved[i]);
|
|
543
|
+
}
|
|
544
|
+
return changed ? {
|
|
545
|
+
ids,
|
|
546
|
+
requests
|
|
547
|
+
} : { ids };
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
517
550
|
* The deduped identities of `rows` over `fields` — a value read by `read`
|
|
518
551
|
* (default: the row's own field) — and, per row, its identity's index in
|
|
519
552
|
* `ids` (`-1`: the row is absent, or a value is missing / null).
|
|
@@ -711,6 +744,10 @@ const ACTION_OVERLAY = Symbol.for("atscript-db.actionOverlay");
|
|
|
711
744
|
const ACTION_SCOPE = Symbol.for("atscript-db.actionScope");
|
|
712
745
|
/** `true` when the controller overrides `actionRowScope` (since 0.1.147). */
|
|
713
746
|
const ACTION_SCOPED = Symbol.for("atscript-db.actionScoped");
|
|
747
|
+
/** The controller's internal `resolveRowIds` call for an action's ids (since 0.1.148). */
|
|
748
|
+
const ROW_RESOLVE_IDS = Symbol.for("atscript-db.resolveRowIds");
|
|
749
|
+
/** `true` when the controller overrides `resolveRowIds` (since 0.1.148). */
|
|
750
|
+
const ROW_RESOLVES = Symbol.for("atscript-db.rowResolves");
|
|
714
751
|
/**
|
|
715
752
|
* The controller whose hooks govern this action's rows: only when the action
|
|
716
753
|
* runs against the controller's OWN readable — an `opts.table` binding on a
|
|
@@ -743,10 +780,6 @@ const dbActionOverlaySlot = cached(async (ctx) => {
|
|
|
743
780
|
await awaitActionPrepared(ctx);
|
|
744
781
|
return await overlayOf.call(ctrl) ?? null;
|
|
745
782
|
});
|
|
746
|
-
/** `true` when this action's controller overrides `actionRowScope`. */
|
|
747
|
-
function isActionScoped(ctx) {
|
|
748
|
-
return ctx.get(scopedControllerSlot)?.[ACTION_SCOPED] === true;
|
|
749
|
-
}
|
|
750
783
|
/**
|
|
751
784
|
* The loaded `rows` (already inside the row overlay) with every row outside
|
|
752
785
|
* the action's `actionRowScope` replaced by `undefined` (since 0.1.147). The
|
|
@@ -1179,6 +1212,26 @@ function isNonEmptyStringArray(value) {
|
|
|
1179
1212
|
}
|
|
1180
1213
|
//#endregion
|
|
1181
1214
|
//#region src/meta/field-capabilities.ts
|
|
1215
|
+
/** The display-only refusal clause per position. */
|
|
1216
|
+
const DISPLAY_ONLY_POSITION = {
|
|
1217
|
+
filter: "a filter",
|
|
1218
|
+
sort: "$sort",
|
|
1219
|
+
groupedSelect: "a grouped $select",
|
|
1220
|
+
groupBy: "$groupBy",
|
|
1221
|
+
having: "$having",
|
|
1222
|
+
aggregate: "an aggregate",
|
|
1223
|
+
bucket: "a calendar bucket"
|
|
1224
|
+
};
|
|
1225
|
+
/** The capability of a declared decoration: selectable, nothing else. */
|
|
1226
|
+
const DECORATION_CAP = {
|
|
1227
|
+
filterable: false,
|
|
1228
|
+
sortable: false,
|
|
1229
|
+
selectable: true,
|
|
1230
|
+
indexed: false,
|
|
1231
|
+
bucketable: false,
|
|
1232
|
+
groupable: false,
|
|
1233
|
+
numeric: false
|
|
1234
|
+
};
|
|
1182
1235
|
const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER_REASON}.`;
|
|
1183
1236
|
const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
|
|
1184
1237
|
const REASON_WRITE_ONLY = "field is @db.writeOnly.";
|
|
@@ -1283,6 +1336,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1283
1336
|
bucketUnits;
|
|
1284
1337
|
/** Aggregate functions the adapter renders, in canonical `ALL_AGGREGATE_FNS` order (`/meta.aggregateFns`). */
|
|
1285
1338
|
aggregateFns;
|
|
1339
|
+
/** Whether the adapter renders aggregate arithmetic (`/meta.aggregateExpressions`). */
|
|
1340
|
+
aggregateExpressions;
|
|
1286
1341
|
/** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
|
|
1287
1342
|
signature;
|
|
1288
1343
|
/**
|
|
@@ -1292,9 +1347,24 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1292
1347
|
* the index reads must be added here.
|
|
1293
1348
|
*/
|
|
1294
1349
|
static adapterSignature(source) {
|
|
1295
|
-
return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}`;
|
|
1350
|
+
return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}|${[...source.aggregateFns()].join(",")}|${source.supportsAggregateExpressions()}`;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* The navigation paths of a readable: its `navFields`, else its relation
|
|
1354
|
+
* names (partial readables list only the latter).
|
|
1355
|
+
*/
|
|
1356
|
+
static navPathsOf(source) {
|
|
1357
|
+
const nav = new Set(source.navFields);
|
|
1358
|
+
if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
|
|
1359
|
+
return nav;
|
|
1296
1360
|
}
|
|
1297
1361
|
_entries = /* @__PURE__ */ new Map();
|
|
1362
|
+
/**
|
|
1363
|
+
* Declared display-only decorations (`@DbDecorations`, since 0.1.148) —
|
|
1364
|
+
* virtual entries: key → the readable paths it `requires`. Selectable only;
|
|
1365
|
+
* visible while every required path is.
|
|
1366
|
+
*/
|
|
1367
|
+
_decorations;
|
|
1298
1368
|
/** Nested-object parents (never listed, always selectable) → their listed leaves. */
|
|
1299
1369
|
_objectParents = /* @__PURE__ */ new Map();
|
|
1300
1370
|
/** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
|
|
@@ -1307,7 +1377,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1307
1377
|
get objectParents() {
|
|
1308
1378
|
return this._objectParents;
|
|
1309
1379
|
}
|
|
1310
|
-
constructor(source, writeOnly) {
|
|
1380
|
+
constructor(source, writeOnly, decorations = /* @__PURE__ */ new Map()) {
|
|
1381
|
+
this._decorations = decorations;
|
|
1311
1382
|
const tableMeta = source.type.metadata;
|
|
1312
1383
|
this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
|
|
1313
1384
|
this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
|
|
@@ -1317,6 +1388,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1317
1388
|
this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
|
|
1318
1389
|
const fns = source.aggregateFns();
|
|
1319
1390
|
this.aggregateFns = [...ALL_AGGREGATE_FNS].filter((fn) => fns.has(fn));
|
|
1391
|
+
this.aggregateExpressions = source.supportsAggregateExpressions();
|
|
1320
1392
|
const physicalNames = /* @__PURE__ */ new Set();
|
|
1321
1393
|
const jsonValueParents = /* @__PURE__ */ new Set();
|
|
1322
1394
|
for (const fd of source.fieldDescriptors) {
|
|
@@ -1329,8 +1401,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1329
1401
|
dimensions: source.dimensions,
|
|
1330
1402
|
measures: source.measures
|
|
1331
1403
|
};
|
|
1332
|
-
const nav =
|
|
1333
|
-
if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
|
|
1404
|
+
const nav = FieldCapabilityIndex.navPathsOf(source);
|
|
1334
1405
|
this.navFields = nav;
|
|
1335
1406
|
const isNavOrDescendant = (path) => selfOrAncestor(path, nav) !== void 0;
|
|
1336
1407
|
const flatMap = source.flatMap;
|
|
@@ -1394,13 +1465,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1394
1465
|
if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
|
|
1395
1466
|
const bucket = isWriteOnly ? void 0 : bucketSourceVerdict(fd, this._bucketTable, source);
|
|
1396
1467
|
const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
|
|
1468
|
+
const group = isWriteOnly ? void 0 : groupSourceVerdict(fd, this._bucketTable, source);
|
|
1469
|
+
const groupReason = !group ? REASON_WRITE_ONLY : group.ok ? void 0 : `${group.reason}.`;
|
|
1397
1470
|
const cap = {
|
|
1398
1471
|
filterable: filterBy.compare === void 0,
|
|
1399
1472
|
sortable: sortReason === void 0,
|
|
1400
1473
|
selectable: true,
|
|
1401
1474
|
indexed: fd.isIndexed === true,
|
|
1402
|
-
bucketable: bucketReason === void 0
|
|
1475
|
+
bucketable: bucketReason === void 0,
|
|
1476
|
+
groupable: groupReason === void 0,
|
|
1477
|
+
numeric: this.aggregateExpressions && physicalReason === void 0 && numericOperandProblem(fd) === void 0
|
|
1403
1478
|
};
|
|
1479
|
+
if (groupReason) cap.groupReason = groupReason;
|
|
1404
1480
|
if (filterOps.length > 0) cap.filterOps = filterOps;
|
|
1405
1481
|
if (filterBy.compare) cap.filterReason = filterBy.compare;
|
|
1406
1482
|
if (sortReason) cap.sortReason = sortReason;
|
|
@@ -1421,6 +1497,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1421
1497
|
entry.fd
|
|
1422
1498
|
];
|
|
1423
1499
|
}
|
|
1500
|
+
/** The declared decoration keys, in declaration order. */
|
|
1501
|
+
get decorationKeys() {
|
|
1502
|
+
return this._decorations.keys();
|
|
1503
|
+
}
|
|
1504
|
+
/** The capability of the declared decoration `key` (selectable only), `undefined` when `key` is none. */
|
|
1505
|
+
decorationCap(key) {
|
|
1506
|
+
return this._decorations.has(key) ? DECORATION_CAP : void 0;
|
|
1507
|
+
}
|
|
1508
|
+
/** The decoration `key` is visible: every path it `requires` passes `exists` (the hidden-field hook). */
|
|
1509
|
+
decorationVisible(key, exists) {
|
|
1510
|
+
return this._decorations.get(key)?.every(exists) === true;
|
|
1511
|
+
}
|
|
1424
1512
|
/** Physical filter capability (adapter ∧ ¬writeOnly ∧ ¬encrypted) — ignores the manual-mode policy. */
|
|
1425
1513
|
isPhysicallyFilterable(path) {
|
|
1426
1514
|
return this._entries.get(path)?.physicalFilterable === true;
|
|
@@ -1445,6 +1533,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1445
1533
|
* JSON-stored column" — clients pin that wording, so do not "align" it
|
|
1446
1534
|
* with the core backstop's text.
|
|
1447
1535
|
*
|
|
1536
|
+
* A declared decoration (`@DbDecorations`) is a virtual entry: `select` while
|
|
1537
|
+
* every path it requires passes `exists`, any other position a display-only
|
|
1538
|
+
* refusal (`groupedSelect` is a `$select` of an aggregate query), and hidden
|
|
1539
|
+
* sources answer `Unknown field` like a nonexistent path.
|
|
1540
|
+
*
|
|
1448
1541
|
* `predicate` is a filter entry's class (`collectQueryPaths` records it per
|
|
1449
1542
|
* occurrence); it only matters for `op === "filter"` on a listed leaf.
|
|
1450
1543
|
*
|
|
@@ -1454,8 +1547,18 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1454
1547
|
* name the prefixed one. A filter on a navigation path names the predicate
|
|
1455
1548
|
* alternative (`ticket=$some(status=…)`).
|
|
1456
1549
|
*/
|
|
1457
|
-
check(local,
|
|
1550
|
+
check(local, gateOp, exists, predicate = "compare", prefix = "") {
|
|
1458
1551
|
const path = prefix + local;
|
|
1552
|
+
const requires = prefix === "" ? this._decorations.get(local) : void 0;
|
|
1553
|
+
if (requires) {
|
|
1554
|
+
if (!requires.every(exists)) return unknownField(path);
|
|
1555
|
+
if (gateOp === "select") return void 0;
|
|
1556
|
+
return {
|
|
1557
|
+
path,
|
|
1558
|
+
message: `Field "${path}" is display-only and cannot be used in ${DISPLAY_ONLY_POSITION[gateOp]}`
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
const op = gateOp === "groupedSelect" ? "select" : gateOp;
|
|
1459
1562
|
if (!exists(local)) return unknownField(path);
|
|
1460
1563
|
const { kind, parent: localParent } = classifyQueryPath(this, local);
|
|
1461
1564
|
const parent = localParent === void 0 ? void 0 : prefix + localParent;
|
|
@@ -1485,6 +1588,10 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
|
|
|
1485
1588
|
path,
|
|
1486
1589
|
message: `Bucketing field "${path}" is not permitted — ${entry.cap.bucketReason}`
|
|
1487
1590
|
};
|
|
1591
|
+
case "groupBy": return entry.cap.groupable ? void 0 : {
|
|
1592
|
+
path,
|
|
1593
|
+
message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.cap.groupReason}`
|
|
1594
|
+
};
|
|
1488
1595
|
default: return entry.physicalFilterable ? void 0 : {
|
|
1489
1596
|
path,
|
|
1490
1597
|
message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
|
|
@@ -1851,6 +1958,10 @@ function hasClientPredicate(filter) {
|
|
|
1851
1958
|
* without copying).
|
|
1852
1959
|
*/
|
|
1853
1960
|
function readRequestContext(endpoint, controls, filter) {
|
|
1961
|
+
if (endpoint === "insert") return controls.$onConflict === "ignore" ? {
|
|
1962
|
+
endpoint,
|
|
1963
|
+
onConflict: "ignore"
|
|
1964
|
+
} : { endpoint };
|
|
1854
1965
|
if (!filter || Object.keys(filter).length === 0) return {
|
|
1855
1966
|
endpoint,
|
|
1856
1967
|
controls,
|
|
@@ -2139,7 +2250,7 @@ let AsReadableController = class AsReadableController {
|
|
|
2139
2250
|
async parseRequest(endpoint, url) {
|
|
2140
2251
|
let request;
|
|
2141
2252
|
if (url !== void 0) {
|
|
2142
|
-
const { parsed, hasNonControl } = endpoint === "one" ? this.parseControlsOnlyFromUrl(url) : {
|
|
2253
|
+
const { parsed, hasNonControl } = endpoint === "one" || endpoint === "insert" ? this.parseControlsOnlyFromUrl(url) : {
|
|
2143
2254
|
parsed: this.parseQueryString(url),
|
|
2144
2255
|
hasNonControl: false
|
|
2145
2256
|
};
|
|
@@ -2559,8 +2670,8 @@ function isIdValidationSource(value) {
|
|
|
2559
2670
|
const v = value;
|
|
2560
2671
|
return Array.isArray(v.identifications) && Array.isArray(v.fieldDescriptors);
|
|
2561
2672
|
}
|
|
2562
|
-
function validateSingleId(body, source,
|
|
2563
|
-
const errors = collectIdErrors(body, source,
|
|
2673
|
+
function validateSingleId(body, source, opts = {}) {
|
|
2674
|
+
const errors = collectIdErrors(body, source, opts);
|
|
2564
2675
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
2565
2676
|
return body;
|
|
2566
2677
|
}
|
|
@@ -2577,11 +2688,12 @@ function validateMultiId(body, source, maxIds = Infinity) {
|
|
|
2577
2688
|
details: []
|
|
2578
2689
|
}]);
|
|
2579
2690
|
const errors = [];
|
|
2580
|
-
for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
|
|
2691
|
+
for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, { path: `[${i}]` }));
|
|
2581
2692
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
2582
2693
|
return body;
|
|
2583
2694
|
}
|
|
2584
|
-
function collectIdErrors(value, source,
|
|
2695
|
+
function collectIdErrors(value, source, opts) {
|
|
2696
|
+
const pathPrefix = opts.path ?? "";
|
|
2585
2697
|
if (!isPlainObject$1(value)) return [{
|
|
2586
2698
|
path: pathPrefix,
|
|
2587
2699
|
message: "Expected JSON object for row identifier",
|
|
@@ -2602,14 +2714,21 @@ function collectIdErrors(value, source, pathPrefix) {
|
|
|
2602
2714
|
const errors = [];
|
|
2603
2715
|
for (const fieldName of match.fields) {
|
|
2604
2716
|
const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
|
|
2605
|
-
const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
|
|
2717
|
+
const err = checkScalar(value[fieldName], sub) ?? (opts.strictTypes === false ? void 0 : checkType(value[fieldName], cache.fieldByName.get(fieldName), sub));
|
|
2606
2718
|
if (err) errors.push(err);
|
|
2607
2719
|
}
|
|
2608
2720
|
return errors;
|
|
2609
2721
|
}
|
|
2610
|
-
|
|
2722
|
+
/**
|
|
2723
|
+
* An identifier value is always a scalar: an object would reach the filter
|
|
2724
|
+
* as an operator expression (`{ $ne: null }`) whatever the field's type.
|
|
2725
|
+
*/
|
|
2726
|
+
function checkScalar(value, path) {
|
|
2727
|
+
return typeof value === "object" && value !== null ? scalarMismatch(path, "a scalar", value) : void 0;
|
|
2728
|
+
}
|
|
2729
|
+
/** The value's type against the field's declared design type (default `string`). */
|
|
2730
|
+
function checkType(value, fd, path) {
|
|
2611
2731
|
const expected = fd?.designType ?? "string";
|
|
2612
|
-
if (typeof value === "object" && value !== null) return scalarMismatch(path, "a scalar", value);
|
|
2613
2732
|
if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
|
|
2614
2733
|
if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
|
|
2615
2734
|
if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
|
|
@@ -2632,14 +2751,70 @@ function isPlainObject$1(value) {
|
|
|
2632
2751
|
//#endregion
|
|
2633
2752
|
//#region src/actions/id-cache.ts
|
|
2634
2753
|
/**
|
|
2754
|
+
* EVERY id the client sent, in request order, with the identity of the id it
|
|
2755
|
+
* resolved to (since 0.1.148) — set only when the controller's
|
|
2756
|
+
* `resolveRowIds` changed or collapsed an id. The single model: refusals,
|
|
2757
|
+
* `reasons`, summaries and counts are judged and reported per request id
|
|
2758
|
+
* through {@link echoRequests}; the resolved (deduped) ids serve only the
|
|
2759
|
+
* row load and the handler.
|
|
2760
|
+
*/
|
|
2761
|
+
const dbActionRequestIdsKey = key("atscript_db_action_request_ids");
|
|
2762
|
+
/** Number of request ids (`fallback` when no id was resolved to another). */
|
|
2763
|
+
function requestCount(ctx, fallback) {
|
|
2764
|
+
return ctx.has(dbActionRequestIdsKey) ? ctx.get(dbActionRequestIdsKey).length : fallback;
|
|
2765
|
+
}
|
|
2766
|
+
/** Number of request ids that resolved to one of `ids` (`ids.length` when none was rewritten). */
|
|
2767
|
+
function requestCountOf(ctx, ids) {
|
|
2768
|
+
if (!ctx.has(dbActionRequestIdsKey)) return ids.length;
|
|
2769
|
+
const keys = new Set(ids.map((id) => identityKey(id)));
|
|
2770
|
+
return ctx.get(dbActionRequestIdsKey).filter((r) => keys.has(r.key)).length;
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Entries keyed by a resolved id, reported for every REQUEST id that resolved
|
|
2774
|
+
* to it — in request order, each as the client sent it (`id` replaced). Two
|
|
2775
|
+
* aliases of one row come back exactly like two distinct rows (same order,
|
|
2776
|
+
* same count). Entries whose id no request resolved to stay as they are,
|
|
2777
|
+
* after the request ones.
|
|
2778
|
+
*/
|
|
2779
|
+
function echoRequests(ctx, entries) {
|
|
2780
|
+
if (!ctx.has(dbActionRequestIdsKey)) return [...entries];
|
|
2781
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2782
|
+
const rest = [];
|
|
2783
|
+
for (const e of entries) {
|
|
2784
|
+
const k = identityKey(e.id);
|
|
2785
|
+
if (k === void 0) rest.push(e);
|
|
2786
|
+
else if (!byKey.has(k)) byKey.set(k, e);
|
|
2787
|
+
}
|
|
2788
|
+
const out = [];
|
|
2789
|
+
const matched = /* @__PURE__ */ new Set();
|
|
2790
|
+
for (const r of ctx.get(dbActionRequestIdsKey)) {
|
|
2791
|
+
const e = byKey.get(r.key);
|
|
2792
|
+
if (!e) continue;
|
|
2793
|
+
matched.add(r.key);
|
|
2794
|
+
out.push({
|
|
2795
|
+
...e,
|
|
2796
|
+
id: r.id
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
for (const [k, e] of byKey) if (!matched.has(k)) rest.push(e);
|
|
2800
|
+
return [...out, ...rest];
|
|
2801
|
+
}
|
|
2802
|
+
/** The id as the client sent it (the first, for an id several requests resolved to). */
|
|
2803
|
+
function requestIdOf(ctx, id) {
|
|
2804
|
+
return echoRequests(ctx, [{ id }])[0].id;
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2635
2807
|
* Validates the body's `ids` against the action table's identifications. For
|
|
2636
2808
|
* the controller's own table that is its `idSource` (since 0.1.134): a unique
|
|
2637
2809
|
* index over a field `hasField` hides neither addresses a row nor appears in
|
|
2638
2810
|
* the "must exactly match one of" message. An `opts.table` binding has no
|
|
2639
2811
|
* visibility hook. The controller's `prepareRequest` (since 0.1.143) runs
|
|
2640
2812
|
* first, so the visibility the ids are validated against is the request's.
|
|
2813
|
+
* Then, when the controller overrides `resolveRowIds` (since 0.1.148), the
|
|
2814
|
+
* validated ids go through it — a one-element array for a `'row'` action —
|
|
2815
|
+
* and the resolved ids (duplicates collapsed) are what every consumer sees.
|
|
2641
2816
|
*/
|
|
2642
|
-
async function resolveValidatedId(ctx, validate) {
|
|
2817
|
+
async function resolveValidatedId(ctx, level, validate) {
|
|
2643
2818
|
await awaitActionPrepared(ctx);
|
|
2644
2819
|
let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
|
|
2645
2820
|
if (!source) {
|
|
@@ -2649,9 +2824,21 @@ async function resolveValidatedId(ctx, validate) {
|
|
|
2649
2824
|
if (!isIdValidationSource(source)) throw noTableError(ctx);
|
|
2650
2825
|
const env = await ctx.get(dbActionBodySlot);
|
|
2651
2826
|
validate(env.ids, source);
|
|
2652
|
-
|
|
2827
|
+
const scoped = ctx.get(scopedControllerSlot);
|
|
2828
|
+
const resolve = scoped?.[ROW_RESOLVES] ? scoped[ROW_RESOLVE_IDS] : void 0;
|
|
2829
|
+
if (!resolve) return env.ids;
|
|
2830
|
+
const requested = level === "row" ? [env.ids] : env.ids;
|
|
2831
|
+
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
2832
|
+
const { ids, requests } = await resolve.call(scoped, requested, {
|
|
2833
|
+
purpose: "action",
|
|
2834
|
+
action: readCurrentActionMeta(ctx)?.name,
|
|
2835
|
+
level,
|
|
2836
|
+
overlay: overlay ?? void 0
|
|
2837
|
+
});
|
|
2838
|
+
if (requests) ctx.set(dbActionRequestIdsKey, requests);
|
|
2839
|
+
return level === "row" ? ids[0] : ids;
|
|
2653
2840
|
}
|
|
2654
|
-
const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
|
|
2841
|
+
const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, "row", validateSingleId));
|
|
2655
2842
|
/**
|
|
2656
2843
|
* The `'rows'` action's identifiers: the body's validated `ids`, or — for a
|
|
2657
2844
|
* query target (`query`, since 0.1.147) — the identities of the rows it
|
|
@@ -2661,7 +2848,7 @@ const dbActionIdsSlot = cached(async (ctx) => {
|
|
|
2661
2848
|
await awaitActionPrepared(ctx);
|
|
2662
2849
|
const target = await ctx.get(dbActionQueryTargetSlot);
|
|
2663
2850
|
if (target) return target.ids;
|
|
2664
|
-
return await resolveValidatedId(ctx, (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
|
|
2851
|
+
return await resolveValidatedId(ctx, "rows", (body, src) => validateMultiId(body, src, actionMaxIds(ctx)));
|
|
2665
2852
|
});
|
|
2666
2853
|
const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
|
|
2667
2854
|
const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
|
|
@@ -2681,29 +2868,70 @@ function asFetchTable(value) {
|
|
|
2681
2868
|
function seedActionFields(ctx, table) {
|
|
2682
2869
|
return actionRowFields(table, requiredFieldsOf(readCurrentActionMeta(ctx)?.opts), actionFieldVisibility(ctx));
|
|
2683
2870
|
}
|
|
2871
|
+
const UNSCOPED = {
|
|
2872
|
+
kind: "resolved",
|
|
2873
|
+
scope: null
|
|
2874
|
+
};
|
|
2875
|
+
const DEFERRED = { kind: "deferred" };
|
|
2876
|
+
function inPreferredShape(ids, preferred) {
|
|
2877
|
+
return ids.every((id) => {
|
|
2878
|
+
return Object.keys(id).length === preferred.length && preferred.every((f) => f in id);
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
async function resolvePreScope(ctx, level) {
|
|
2882
|
+
const ctrl = ctx.get(scopedControllerSlot);
|
|
2883
|
+
const action = readCurrentActionMeta(ctx)?.name;
|
|
2884
|
+
const scopeOf = ctrl?.[ACTION_SCOPED] ? ctrl[ACTION_SCOPE] : void 0;
|
|
2885
|
+
if (!ctrl || !scopeOf || action === void 0) return UNSCOPED;
|
|
2886
|
+
if (await ctx.get(dbActionOverlaySlot)) return DEFERRED;
|
|
2887
|
+
const table = asFetchTable(getActionTable(ctx));
|
|
2888
|
+
const preferred = table?.preferredId;
|
|
2889
|
+
if (!table || !preferred?.length) return DEFERRED;
|
|
2890
|
+
const [target, requested] = await Promise.all([level === "rows" ? ctx.get(dbActionQueryTargetSlot) : void 0, level === "row" ? ctx.get(dbActionIdSlot).then((id) => [id]) : ctx.get(dbActionIdsSlot)]);
|
|
2891
|
+
if (target || !inPreferredShape(requested, preferred)) return DEFERRED;
|
|
2892
|
+
const ids = level === "row" ? requested : dedupeIdentities(requested, preferred).ids;
|
|
2893
|
+
if (ids.length === 0) return UNSCOPED;
|
|
2894
|
+
return {
|
|
2895
|
+
kind: "resolved",
|
|
2896
|
+
scope: nonEmptyFilter(await scopeOf.call(ctrl, action, createScopeContext("execute", ids, table))) ?? null
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* {@link TPreScope} of the current action — once per event. An event runs one
|
|
2901
|
+
* action, so the first caller's `level` (`'row'` / `'rows'`) is the action's.
|
|
2902
|
+
*/
|
|
2903
|
+
const dbActionPreScopeSlot = cached((ctx) => {
|
|
2904
|
+
let pending;
|
|
2905
|
+
return (level) => pending ??= resolvePreScope(ctx, level);
|
|
2906
|
+
});
|
|
2684
2907
|
/**
|
|
2685
2908
|
* Loaded row / rows are ANDed with the controller's row overlay (see
|
|
2686
2909
|
* `dbActionOverlaySlot`, since 0.1.143) and, since 0.1.147, checked against
|
|
2687
|
-
* the action's `actionRowScope
|
|
2688
|
-
*
|
|
2689
|
-
*
|
|
2910
|
+
* the action's `actionRowScope`: an out-of-scope id loads nothing — exactly
|
|
2911
|
+
* like a missing one (the same 404 on `'row'` actions, so the two can't be
|
|
2912
|
+
* told apart).
|
|
2690
2913
|
*
|
|
2691
2914
|
* Order: `prepareRequest` → the row overlay (BEFORE the request body is
|
|
2692
|
-
* read: fail-fast authorization) → the ids (body) →
|
|
2693
|
-
* `actionRowScope` (
|
|
2915
|
+
* read: fail-fast authorization) → the ids (body) → `resolveRowIds` (since
|
|
2916
|
+
* 0.1.148) → `actionRowScope` (pre-load, when there is no overlay and the
|
|
2917
|
+
* ids are in `preferredId` shape — its restriction joins the one row load;
|
|
2918
|
+
* since 0.1.148) → the row load → otherwise `actionRowScope` on the loaded
|
|
2919
|
+
* candidates (it needs them).
|
|
2694
2920
|
*/
|
|
2695
2921
|
async function loadRow(ctx) {
|
|
2696
2922
|
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
2697
2923
|
const id = await ctx.get(dbActionIdSlot);
|
|
2698
2924
|
const table = asFetchTable(getActionTable(ctx));
|
|
2699
2925
|
if (!table) throw noTableError(ctx);
|
|
2926
|
+
const pre = await ctx.get(dbActionPreScopeSlot)("row");
|
|
2700
2927
|
const fields = seedActionFields(ctx, table);
|
|
2701
2928
|
for (const k of Object.keys(id)) fields.add(k);
|
|
2702
|
-
const
|
|
2703
|
-
|
|
2929
|
+
const idFilter = withOverlay(withOverlay(id, overlay), pre.kind === "resolved" ? pre.scope : null);
|
|
2930
|
+
let row = await table.findOne({
|
|
2931
|
+
filter: idFilter,
|
|
2704
2932
|
controls: { $select: [...fields] }
|
|
2705
|
-
});
|
|
2706
|
-
|
|
2933
|
+
}) ?? void 0;
|
|
2934
|
+
if (row !== void 0 && pre.kind === "deferred") [row] = await applyActionScope(ctx, table, [row]);
|
|
2707
2935
|
if (row === void 0) throw new HttpError(404, "Row not found for action identifier");
|
|
2708
2936
|
return row;
|
|
2709
2937
|
}
|
|
@@ -2714,7 +2942,11 @@ async function loadRows(ctx) {
|
|
|
2714
2942
|
if (!table) throw noTableError(ctx);
|
|
2715
2943
|
const fields = seedActionFields(ctx, table);
|
|
2716
2944
|
const target = await ctx.get(dbActionQueryTargetSlot);
|
|
2717
|
-
if (!target)
|
|
2945
|
+
if (!target) {
|
|
2946
|
+
const pre = await ctx.get(dbActionPreScopeSlot)("rows");
|
|
2947
|
+
if (pre.kind === "resolved") return findRowsByIds(table, ids, pre.scope ? withOverlay(pre.scope, overlay) : overlay, fields);
|
|
2948
|
+
return applyActionScope(ctx, table, await findRowsByIds(table, ids, overlay, fields));
|
|
2949
|
+
}
|
|
2718
2950
|
const rows = await target.load(ids, fields);
|
|
2719
2951
|
const stale = /* @__PURE__ */ new Set();
|
|
2720
2952
|
for (let i = 0; i < rows.length; i++) if (rows[i] === void 0) stale.add(i);
|
|
@@ -2771,25 +3003,52 @@ function DbActionTarget() {
|
|
|
2771
3003
|
var TargetBase = class {
|
|
2772
3004
|
kind;
|
|
2773
3005
|
matched;
|
|
3006
|
+
ctx;
|
|
2774
3007
|
skipped = [];
|
|
2775
3008
|
failed = [];
|
|
2776
3009
|
processed = 0;
|
|
2777
|
-
|
|
3010
|
+
/** Identities of every id handed to the handler so far. */
|
|
3011
|
+
handedKeys = /* @__PURE__ */ new Set();
|
|
3012
|
+
failedKeys = /* @__PURE__ */ new Set();
|
|
3013
|
+
constructor(kind, matched, ctx) {
|
|
2778
3014
|
this.kind = kind;
|
|
2779
3015
|
this.matched = matched;
|
|
3016
|
+
this.ctx = ctx;
|
|
2780
3017
|
}
|
|
3018
|
+
/**
|
|
3019
|
+
* One failure per id, whether or not `resolveRowIds` rewrote any id: a
|
|
3020
|
+
* second `fail()` of the same identity is ignored (the first reason stands),
|
|
3021
|
+
* so `failed` and `processed` agree in both modes.
|
|
3022
|
+
*/
|
|
2781
3023
|
fail(id, reason) {
|
|
3024
|
+
const k = identityKey(id);
|
|
3025
|
+
if (k !== void 0) {
|
|
3026
|
+
if (this.failedKeys.has(k)) return;
|
|
3027
|
+
this.failedKeys.add(k);
|
|
3028
|
+
}
|
|
2782
3029
|
this.failed.push({
|
|
2783
3030
|
id,
|
|
2784
3031
|
reason
|
|
2785
3032
|
});
|
|
2786
3033
|
}
|
|
3034
|
+
/**
|
|
3035
|
+
* A batch handed to the handler, counted per request id (two aliases of one
|
|
3036
|
+
* row count twice, like two distinct rows).
|
|
3037
|
+
*/
|
|
3038
|
+
countProcessed(ids) {
|
|
3039
|
+
for (const id of ids) {
|
|
3040
|
+
const k = identityKey(id);
|
|
3041
|
+
if (k !== void 0) this.handedKeys.add(k);
|
|
3042
|
+
}
|
|
3043
|
+
this.processed += requestCountOf(this.ctx, ids);
|
|
3044
|
+
}
|
|
2787
3045
|
summary() {
|
|
3046
|
+
const failed = echoRequests(this.ctx, this.failed);
|
|
2788
3047
|
return {
|
|
2789
3048
|
matched: this.matched,
|
|
2790
|
-
processed: Math.max(0, this.processed -
|
|
2791
|
-
skipped:
|
|
2792
|
-
failed
|
|
3049
|
+
processed: Math.max(0, this.processed - failed.length),
|
|
3050
|
+
skipped: echoRequests(this.ctx, this.skipped),
|
|
3051
|
+
failed
|
|
2793
3052
|
};
|
|
2794
3053
|
}
|
|
2795
3054
|
};
|
|
@@ -2801,12 +3060,12 @@ var MaterializedTarget = class extends TargetBase {
|
|
|
2801
3060
|
ids;
|
|
2802
3061
|
rows;
|
|
2803
3062
|
iterated = false;
|
|
2804
|
-
constructor(kind, matched, ids, rows, skipped) {
|
|
2805
|
-
super(kind, matched);
|
|
3063
|
+
constructor(kind, matched, ids, rows, skipped, ctx) {
|
|
3064
|
+
super(kind, matched, ctx);
|
|
2806
3065
|
this.ids = ids;
|
|
2807
3066
|
this.rows = rows;
|
|
2808
3067
|
this.skipped.push(...skipped);
|
|
2809
|
-
this.
|
|
3068
|
+
this.countProcessed(ids);
|
|
2810
3069
|
}
|
|
2811
3070
|
async *batches() {
|
|
2812
3071
|
if (this.iterated) throw new Error("[moost-db actions] target.batches() is single-pass");
|
|
@@ -2822,21 +3081,19 @@ async function setMaterializedTarget(ctx) {
|
|
|
2822
3081
|
const target = await resolveActionQueryTarget(ctx, false);
|
|
2823
3082
|
const ids = await ctx.get(dbActionIdsSlot);
|
|
2824
3083
|
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 () => {
|
|
3084
|
+
ctx.set(dbActionTargetKey, new MaterializedTarget(target ? "query" : "ids", target ? target.matched : requestCount(ctx, ids.length + skipped.length), ids, async () => {
|
|
2826
3085
|
return (await ctx.get(dbActionRowsSlot)).filter((r) => r !== void 0);
|
|
2827
|
-
}, skipped));
|
|
3086
|
+
}, skipped, ctx));
|
|
2828
3087
|
}
|
|
2829
3088
|
/** The `@DbActionTarget` surface: the target in batches, each gated when it is reached. */
|
|
2830
3089
|
var StreamedTarget = class extends TargetBase {
|
|
2831
|
-
ctx;
|
|
2832
3090
|
source;
|
|
2833
3091
|
batchSize;
|
|
2834
3092
|
action;
|
|
2835
3093
|
disabled;
|
|
2836
3094
|
iterated = false;
|
|
2837
3095
|
constructor(matched, ctx, source, batchSize, action, disabled) {
|
|
2838
|
-
super(source.kind, matched);
|
|
2839
|
-
this.ctx = ctx;
|
|
3096
|
+
super(source.kind, matched, ctx);
|
|
2840
3097
|
this.source = source;
|
|
2841
3098
|
this.batchSize = batchSize;
|
|
2842
3099
|
this.action = action;
|
|
@@ -2854,7 +3111,7 @@ var StreamedTarget = class extends TargetBase {
|
|
|
2854
3111
|
const batch = await this.gate(ids.slice(start, start + this.batchSize));
|
|
2855
3112
|
this.next = start + this.batchSize;
|
|
2856
3113
|
if (batch.ids.length === 0) continue;
|
|
2857
|
-
this.
|
|
3114
|
+
this.countProcessed(batch.ids);
|
|
2858
3115
|
this.current = batch.ids;
|
|
2859
3116
|
yield batch;
|
|
2860
3117
|
this.current = void 0;
|
|
@@ -2862,30 +3119,66 @@ var StreamedTarget = class extends TargetBase {
|
|
|
2862
3119
|
this.next = ids.length;
|
|
2863
3120
|
}
|
|
2864
3121
|
/**
|
|
2865
|
-
* The summary of a run the handler failed after it received a batch
|
|
2866
|
-
*
|
|
2867
|
-
*
|
|
3122
|
+
* The summary of a run the handler failed after it received a batch, judged
|
|
3123
|
+
* per REQUEST id by its own request position — an alias after the abort
|
|
3124
|
+
* point is "not run" even when its row was judged (run, skipped) earlier,
|
|
3125
|
+
* exactly as the same request of distinct rows would be:
|
|
3126
|
+
* - at or after the position the run stopped at: `failed` as `"not run"`;
|
|
3127
|
+
* - before it, a row the handler failed itself: its reason;
|
|
3128
|
+
* - before it, the batch the handler held (uncertain): the error's message;
|
|
3129
|
+
* - before it, a row the gate skipped: `skipped`; a row that ran: `processed`.
|
|
3130
|
+
* `aborted` is set.
|
|
2868
3131
|
*/
|
|
2869
3132
|
abort(error) {
|
|
2870
3133
|
const message = errorMessage(error);
|
|
2871
|
-
const
|
|
2872
|
-
const
|
|
2873
|
-
|
|
2874
|
-
|
|
3134
|
+
const ids = this.source.ids;
|
|
3135
|
+
const requests = this.ctx.has(dbActionRequestIdsKey) ? this.ctx.get(dbActionRequestIdsKey) : ids.map((id, i) => ({
|
|
3136
|
+
id,
|
|
3137
|
+
key: identityKey(id) ?? `#${i}`
|
|
3138
|
+
}));
|
|
3139
|
+
const held = new Set((this.current ?? []).map((id) => identityKey(id)));
|
|
3140
|
+
let cutoff = requests.length;
|
|
3141
|
+
if (this.ctx.has(dbActionRequestIdsKey)) {
|
|
3142
|
+
const firstOf = (key) => requests.findIndex((r) => r.key === key);
|
|
3143
|
+
if (held.size > 0) cutoff = 1 + Math.max(...[...held].map(firstOf));
|
|
3144
|
+
else if (this.next < ids.length) cutoff = firstOf(identityKey(ids[this.next]));
|
|
3145
|
+
if (cutoff < 0) cutoff = requests.length;
|
|
3146
|
+
} else if (this.next < ids.length) cutoff = this.next;
|
|
3147
|
+
const failedBy = new Map(this.failed.map((f) => [identityKey(f.id), f]));
|
|
3148
|
+
const skippedBy = /* @__PURE__ */ new Map();
|
|
3149
|
+
for (const row of this.skipped) {
|
|
3150
|
+
const k = identityKey(row.id);
|
|
3151
|
+
if (!skippedBy.has(k)) skippedBy.set(k, row);
|
|
3152
|
+
}
|
|
3153
|
+
const failed = [];
|
|
3154
|
+
const skipped = [];
|
|
3155
|
+
let processed = 0;
|
|
3156
|
+
requests.forEach((r, i) => {
|
|
3157
|
+
const own = failedBy.get(r.key);
|
|
3158
|
+
const skip = skippedBy.get(r.key);
|
|
3159
|
+
if (i >= cutoff) failed.push({
|
|
3160
|
+
id: r.id,
|
|
3161
|
+
reason: "not run"
|
|
3162
|
+
});
|
|
3163
|
+
else if (own) failed.push({
|
|
3164
|
+
id: r.id,
|
|
3165
|
+
reason: own.reason
|
|
3166
|
+
});
|
|
3167
|
+
else if (held.has(r.key)) failed.push({
|
|
3168
|
+
id: r.id,
|
|
3169
|
+
reason: message
|
|
3170
|
+
});
|
|
3171
|
+
else if (skip) skipped.push({
|
|
3172
|
+
...skip,
|
|
3173
|
+
id: r.id
|
|
3174
|
+
});
|
|
3175
|
+
else if (this.handedKeys.has(r.key)) processed++;
|
|
3176
|
+
});
|
|
2875
3177
|
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
|
-
],
|
|
3178
|
+
matched: this.matched,
|
|
3179
|
+
processed,
|
|
3180
|
+
skipped,
|
|
3181
|
+
failed,
|
|
2889
3182
|
aborted: {
|
|
2890
3183
|
status: errorStatus(error),
|
|
2891
3184
|
message
|
|
@@ -2980,7 +3273,7 @@ async function setStreamedTarget(ctx, action, disabled) {
|
|
|
2980
3273
|
return findRowsByIds(table, batch, overlay, select);
|
|
2981
3274
|
}
|
|
2982
3275
|
};
|
|
2983
|
-
const target = new StreamedTarget(query ? query.matched : source.ids.length, ctx, source, batchSize, action, disabled);
|
|
3276
|
+
const target = new StreamedTarget(query ? query.matched : requestCount(ctx, source.ids.length), ctx, source, batchSize, action, disabled);
|
|
2984
3277
|
ctx.set(dbActionTargetKey, target);
|
|
2985
3278
|
return {
|
|
2986
3279
|
target,
|
|
@@ -3009,6 +3302,8 @@ const ACTION_SLOTS = [
|
|
|
3009
3302
|
dbActionInputSlot,
|
|
3010
3303
|
dbActionIdSlot,
|
|
3011
3304
|
dbActionIdsSlot,
|
|
3305
|
+
dbActionRequestIdsKey,
|
|
3306
|
+
dbActionPreScopeSlot,
|
|
3012
3307
|
dbActionRowSlot,
|
|
3013
3308
|
dbActionRowsSlot,
|
|
3014
3309
|
dbActionOverlaySlot,
|
|
@@ -3316,6 +3611,398 @@ function getDbEndpoint(target, method) {
|
|
|
3316
3611
|
}
|
|
3317
3612
|
}
|
|
3318
3613
|
//#endregion
|
|
3614
|
+
//#region src/decorations/decoration-index.ts
|
|
3615
|
+
/** A decoration key is a valid `$select` URL name: top-level, no `$` prefix, no dots. */
|
|
3616
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3617
|
+
/**
|
|
3618
|
+
* The own readable paths a `requires` path stands for: itself when it is an
|
|
3619
|
+
* own field, else — a parent object of a flattened (SQL) readable — every own
|
|
3620
|
+
* field below it. Empty when the path is neither.
|
|
3621
|
+
*/
|
|
3622
|
+
function ownLeaves(path, ownPaths) {
|
|
3623
|
+
if (ownPaths.has(path)) return [path];
|
|
3624
|
+
const prefix = `${path}.`;
|
|
3625
|
+
return [...ownPaths].filter((own) => own.startsWith(prefix));
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Validates `@DbDecorations` metadata against the bound readable and indexes
|
|
3629
|
+
* it. Throws `[moost-db]` errors (once per class — the caller memoizes).
|
|
3630
|
+
*/
|
|
3631
|
+
function buildDecorationIndex(controller, meta, source) {
|
|
3632
|
+
const fail = (message) => {
|
|
3633
|
+
throw new Error(`[moost-db] ${controller}: @DbDecorations ${message}`);
|
|
3634
|
+
};
|
|
3635
|
+
const { type } = meta;
|
|
3636
|
+
if (type.type.kind !== "object") fail("expects an object interface");
|
|
3637
|
+
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");
|
|
3638
|
+
const keys = [...type.type.props.keys()];
|
|
3639
|
+
const fieldPaths = /* @__PURE__ */ new Set();
|
|
3640
|
+
for (const path of source.flatMap?.keys() ?? []) for (let at = path.indexOf(".");; at = path.indexOf(".", at + 1)) {
|
|
3641
|
+
fieldPaths.add(at < 0 ? path : path.slice(0, at));
|
|
3642
|
+
if (at < 0) break;
|
|
3643
|
+
}
|
|
3644
|
+
const isField = (path) => fieldPaths.has(path);
|
|
3645
|
+
for (const key of keys) {
|
|
3646
|
+
if (!KEY_RE.test(key)) fail(`key "${key}" must be a plain top-level identifier (no "$" prefix, no dots)`);
|
|
3647
|
+
if (isField(key) || source.relations?.has(key) || source.navFields.has(key)) fail(`key "${key}" collides with a field or relation of the bound readable`);
|
|
3648
|
+
}
|
|
3649
|
+
const requires = /* @__PURE__ */ new Map();
|
|
3650
|
+
const leavesOf = /* @__PURE__ */ new Map();
|
|
3651
|
+
const nested = (path) => {
|
|
3652
|
+
const prefix = `${path}.`;
|
|
3653
|
+
const below = [...source.flatMap?.keys() ?? []].filter((p) => p.startsWith(prefix));
|
|
3654
|
+
return below.filter((p) => selfOrAncestor(p, source.navFields) === void 0 && !below.some((other) => other.startsWith(`${p}.`)));
|
|
3655
|
+
};
|
|
3656
|
+
for (const key of keys) requires.set(key, []);
|
|
3657
|
+
for (const [key, paths] of Object.entries(meta.requires)) {
|
|
3658
|
+
if (!requires.has(key)) fail(`\`requires\` names "${key}", which is not a declared decoration`);
|
|
3659
|
+
const resolved = [];
|
|
3660
|
+
for (const path of paths) {
|
|
3661
|
+
const leaves = ownLeaves(path, source.ownPaths);
|
|
3662
|
+
if (leaves.length === 0) fail(`"${key}" requires "${path}", which is not an own field of the bound readable`);
|
|
3663
|
+
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)`);
|
|
3664
|
+
for (const leaf of leaves) {
|
|
3665
|
+
if (leavesOf.has(leaf)) continue;
|
|
3666
|
+
const below = nested(leaf);
|
|
3667
|
+
if (below.length > 0) leavesOf.set(leaf, below);
|
|
3668
|
+
}
|
|
3669
|
+
resolved.push(...leaves);
|
|
3670
|
+
}
|
|
3671
|
+
requires.set(key, [...new Set(resolved)]);
|
|
3672
|
+
}
|
|
3673
|
+
const visibleOn = /* @__PURE__ */ new Map();
|
|
3674
|
+
for (const [key, paths] of requires) {
|
|
3675
|
+
const all = new Set(paths);
|
|
3676
|
+
for (const path of paths) for (const leaf of leavesOf.get(path) ?? []) all.add(leaf);
|
|
3677
|
+
visibleOn.set(key, [...all]);
|
|
3678
|
+
}
|
|
3679
|
+
return {
|
|
3680
|
+
type,
|
|
3681
|
+
keys,
|
|
3682
|
+
keySet: new Set(keys),
|
|
3683
|
+
requires,
|
|
3684
|
+
visibleOn,
|
|
3685
|
+
leavesOf,
|
|
3686
|
+
memo: { meta: /* @__PURE__ */ new WeakMap() }
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
//#endregion
|
|
3690
|
+
//#region src/select-shape.ts
|
|
3691
|
+
/** The included (`1` / `true`) and excluded (`0` / `false`) keys of a `$select` map. */
|
|
3692
|
+
function mapShape(map) {
|
|
3693
|
+
const included = [];
|
|
3694
|
+
const excluded = [];
|
|
3695
|
+
for (const [key, value] of Object.entries(map)) if (value === 1 || value === true) included.push(key);
|
|
3696
|
+
else if (value === 0 || value === false) excluded.push(key);
|
|
3697
|
+
return {
|
|
3698
|
+
included,
|
|
3699
|
+
excluded
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
/** The one splitter every `$select` consumer in the controller reads. */
|
|
3703
|
+
function selectShape(raw) {
|
|
3704
|
+
if (raw === void 0 || raw === null) return { kind: "all" };
|
|
3705
|
+
if (Array.isArray(raw)) return {
|
|
3706
|
+
kind: "list",
|
|
3707
|
+
items: raw
|
|
3708
|
+
};
|
|
3709
|
+
const map = raw;
|
|
3710
|
+
return {
|
|
3711
|
+
kind: "map",
|
|
3712
|
+
map,
|
|
3713
|
+
...mapShape(map)
|
|
3714
|
+
};
|
|
3715
|
+
}
|
|
3716
|
+
//#endregion
|
|
3717
|
+
//#region src/decorations/decoration-planner.ts
|
|
3718
|
+
const NO_DECORATIONS = /* @__PURE__ */ new Set();
|
|
3719
|
+
/**
|
|
3720
|
+
* The decoration plumbing of one controller (`@DbDecorations`, since 0.1.148):
|
|
3721
|
+
* rewrites a read's `$select` ({@link plan}), decides what the response
|
|
3722
|
+
* carries once the final projection is known ({@link serve}), and builds the
|
|
3723
|
+
* `/meta` view ({@link meta}). The request gate is not here — decorations are
|
|
3724
|
+
* virtual entries of the {@link FieldCapabilityIndex}.
|
|
3725
|
+
*/
|
|
3726
|
+
var DecorationPlanner = class {
|
|
3727
|
+
index;
|
|
3728
|
+
host;
|
|
3729
|
+
constructor(index, host) {
|
|
3730
|
+
this.index = index;
|
|
3731
|
+
this.host = host;
|
|
3732
|
+
}
|
|
3733
|
+
visible(key) {
|
|
3734
|
+
return this.host.capabilities().decorationVisible(key, this.host.isVisible);
|
|
3735
|
+
}
|
|
3736
|
+
requiresOf(keys) {
|
|
3737
|
+
return [...new Set(keys.flatMap((key) => this.index.requires.get(key)))];
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* Splits the wire `$select` of a read (non-grouped: the gate already
|
|
3741
|
+
* refused a decoration in a grouped one): `requested` is every declared
|
|
3742
|
+
* decoration key whose sources are visible and that the client named — or,
|
|
3743
|
+
* without a `$select`, all of them (an exclusion map: all minus the excluded
|
|
3744
|
+
* ones). The real `$select` is widened by the requested keys' `requires`;
|
|
3745
|
+
* the paths added only for the hook (`requiresOnly`) are stripped again.
|
|
3746
|
+
* The result keeps the representation of `raw` (array or map).
|
|
3747
|
+
*/
|
|
3748
|
+
plan(raw) {
|
|
3749
|
+
const { keySet, keys } = this.index;
|
|
3750
|
+
const shape = selectShape(raw);
|
|
3751
|
+
if (shape.kind === "all") return {
|
|
3752
|
+
select: void 0,
|
|
3753
|
+
requested: keys.filter((k) => this.visible(k)),
|
|
3754
|
+
requiresOnly: []
|
|
3755
|
+
};
|
|
3756
|
+
const passthrough = {
|
|
3757
|
+
select: raw,
|
|
3758
|
+
requested: [],
|
|
3759
|
+
requiresOnly: []
|
|
3760
|
+
};
|
|
3761
|
+
if (shape.kind === "list") {
|
|
3762
|
+
const named = shape.items.filter((item) => typeof item === "string" && keySet.has(item));
|
|
3763
|
+
if (named.length === 0) return passthrough;
|
|
3764
|
+
const real = shape.items.filter((item) => !(typeof item === "string" && keySet.has(item)));
|
|
3765
|
+
const have = new Set(real.filter((item) => typeof item === "string"));
|
|
3766
|
+
return this.inclusion([...new Set(named)], real, have, (list) => list);
|
|
3767
|
+
}
|
|
3768
|
+
const { map, included, excluded } = shape;
|
|
3769
|
+
if (included.length === 0 && excluded.length > 0) {
|
|
3770
|
+
const skip = new Set(excluded);
|
|
3771
|
+
const requested = keys.filter((key) => !skip.has(key) && this.visible(key));
|
|
3772
|
+
const real = Object.fromEntries(Object.entries(map).filter(([k]) => !keySet.has(k)));
|
|
3773
|
+
const requiresOnly = /* @__PURE__ */ new Set();
|
|
3774
|
+
const remaining = new Set(Object.keys(real));
|
|
3775
|
+
const unexclude = (key) => {
|
|
3776
|
+
requiresOnly.add(key);
|
|
3777
|
+
remaining.delete(key);
|
|
3778
|
+
delete real[key];
|
|
3779
|
+
};
|
|
3780
|
+
for (const path of this.requiresOf(requested)) {
|
|
3781
|
+
const hit = selfOrAncestor(path, remaining);
|
|
3782
|
+
if (hit !== void 0) {
|
|
3783
|
+
unexclude(hit);
|
|
3784
|
+
continue;
|
|
3785
|
+
}
|
|
3786
|
+
const prefix = `${path}.`;
|
|
3787
|
+
for (const key of remaining) if (key.startsWith(prefix)) unexclude(key);
|
|
3788
|
+
}
|
|
3789
|
+
return {
|
|
3790
|
+
select: Object.keys(real).length > 0 ? real : void 0,
|
|
3791
|
+
requested,
|
|
3792
|
+
requiresOnly: [...requiresOnly]
|
|
3793
|
+
};
|
|
3794
|
+
}
|
|
3795
|
+
const named = included.filter((k) => keySet.has(k));
|
|
3796
|
+
if (named.length === 0) return passthrough;
|
|
3797
|
+
const real = included.filter((k) => !keySet.has(k));
|
|
3798
|
+
return this.inclusion(named, real, new Set(real), (list) => Object.fromEntries(list.map((k) => [k, 1])));
|
|
3799
|
+
}
|
|
3800
|
+
/**
|
|
3801
|
+
* The inclusion forms: `real` (the client's own selection) plus the
|
|
3802
|
+
* requested decorations' `requires` that it lacks, re-emitted by `emit`.
|
|
3803
|
+
*/
|
|
3804
|
+
inclusion(named, real, have, emit) {
|
|
3805
|
+
const requested = named.filter((key) => this.visible(key));
|
|
3806
|
+
const extra = this.requiresOf(requested).filter((path) => selfOrAncestor(path, have) === void 0);
|
|
3807
|
+
const { list, added } = this.nonEmptyInclusion([...real, ...extra]);
|
|
3808
|
+
return {
|
|
3809
|
+
select: emit(list),
|
|
3810
|
+
requested,
|
|
3811
|
+
requiresOnly: [...extra.filter((path) => !this.host.preferred.has(path)), ...added],
|
|
3812
|
+
selected: [...have]
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
/**
|
|
3816
|
+
* An inclusion list is never empty (an empty one selects everything): a
|
|
3817
|
+
* request naming only decorations with nothing to read selects the
|
|
3818
|
+
* `preferredId` fields (which the response carries anyway) or, without one,
|
|
3819
|
+
* a single visible field that is stripped again (`added`).
|
|
3820
|
+
*/
|
|
3821
|
+
nonEmptyInclusion(list) {
|
|
3822
|
+
if (list.length > 0) return {
|
|
3823
|
+
list,
|
|
3824
|
+
added: []
|
|
3825
|
+
};
|
|
3826
|
+
const { preferred } = this.host;
|
|
3827
|
+
if (preferred.size > 0) return {
|
|
3828
|
+
list: [...preferred],
|
|
3829
|
+
added: []
|
|
3830
|
+
};
|
|
3831
|
+
const field = this.host.firstVisibleField();
|
|
3832
|
+
return field === void 0 ? {
|
|
3833
|
+
list,
|
|
3834
|
+
added: []
|
|
3835
|
+
} : {
|
|
3836
|
+
list: [field],
|
|
3837
|
+
added: [field]
|
|
3838
|
+
};
|
|
3839
|
+
}
|
|
3840
|
+
/**
|
|
3841
|
+
* The decoration step once the final projection is known: a requested key
|
|
3842
|
+
* is served only if every `requires` path survived `transformProjection`,
|
|
3843
|
+
* the seal and the preferred-id widening (a policy that strips a source
|
|
3844
|
+
* silently drops the decoration, as it drops a field). `kept` is the final
|
|
3845
|
+
* projection's paths (`null` = every field).
|
|
3846
|
+
*/
|
|
3847
|
+
serve(plan, kept) {
|
|
3848
|
+
const { keys, requires, leavesOf } = this.index;
|
|
3849
|
+
const carried = (path, set) => selfOrAncestor(path, set) !== void 0 || (leavesOf.get(path)?.every((leaf) => selfOrAncestor(leaf, set) !== void 0) ?? false);
|
|
3850
|
+
const dropPaths = plan.requiresOnly.map((path) => path.split("."));
|
|
3851
|
+
const keepPaths = plan.requiresOnly.map((path) => (plan.selected ?? []).filter((sel) => sel.startsWith(`${path}.`)).map((sel) => sel.split(".")));
|
|
3852
|
+
const selectedPaths = plan.selected?.map((sel) => sel.split("."));
|
|
3853
|
+
if (plan.requested.length === 0) return {
|
|
3854
|
+
served: NO_DECORATIONS,
|
|
3855
|
+
dropKeys: keys,
|
|
3856
|
+
dropPaths,
|
|
3857
|
+
keepPaths,
|
|
3858
|
+
selectedPaths
|
|
3859
|
+
};
|
|
3860
|
+
const keptSet = kept === null ? void 0 : new Set(kept);
|
|
3861
|
+
const served = new Set(plan.requested.filter((key) => keptSet === void 0 || requires.get(key).every((path) => carried(path, keptSet))));
|
|
3862
|
+
return {
|
|
3863
|
+
served,
|
|
3864
|
+
dropKeys: keys.filter((key) => !served.has(key)),
|
|
3865
|
+
dropPaths,
|
|
3866
|
+
keepPaths,
|
|
3867
|
+
selectedPaths
|
|
3868
|
+
};
|
|
3869
|
+
}
|
|
3870
|
+
/**
|
|
3871
|
+
* The `/meta` envelope with the declared decorations: each one still
|
|
3872
|
+
* present in `decorations` (an overlay may delete props to hide a
|
|
3873
|
+
* decoration per principal) whose sources are visible is kept and gets its
|
|
3874
|
+
* `fields[key]` entry (from its virtual capability-index entry); the others
|
|
3875
|
+
* are pruned, and `decorations` is dropped when none is left. Runs after
|
|
3876
|
+
* `applyMetaOverlay` — an overlay never sees the decoration `fields`
|
|
3877
|
+
* entries. Memoized per input while visibility is not request-scoped.
|
|
3878
|
+
*/
|
|
3879
|
+
meta(meta) {
|
|
3880
|
+
if (!meta.decorations) return meta;
|
|
3881
|
+
const { memo, keySet } = this.index;
|
|
3882
|
+
const memoize = !this.host.scoped;
|
|
3883
|
+
const hit = memoize ? memo.meta.get(meta) : void 0;
|
|
3884
|
+
if (hit) return hit;
|
|
3885
|
+
const capabilities = this.host.capabilities();
|
|
3886
|
+
const props = meta.decorations.type.props ?? {};
|
|
3887
|
+
const kept = {};
|
|
3888
|
+
const fields = { ...meta.fields };
|
|
3889
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
3890
|
+
const cap = keySet.has(key) && this.visible(key) ? capabilities.decorationCap(key) : void 0;
|
|
3891
|
+
if (!cap) continue;
|
|
3892
|
+
kept[key] = prop;
|
|
3893
|
+
fields[key] = {
|
|
3894
|
+
sortable: cap.sortable,
|
|
3895
|
+
filterable: cap.filterable,
|
|
3896
|
+
decoration: true
|
|
3897
|
+
};
|
|
3898
|
+
}
|
|
3899
|
+
let out;
|
|
3900
|
+
if (Object.keys(kept).length === 0) {
|
|
3901
|
+
const { decorations: _dropped, ...rest } = meta;
|
|
3902
|
+
out = rest;
|
|
3903
|
+
} else out = {
|
|
3904
|
+
...meta,
|
|
3905
|
+
fields,
|
|
3906
|
+
decorations: {
|
|
3907
|
+
...meta.decorations,
|
|
3908
|
+
type: {
|
|
3909
|
+
...meta.decorations.type,
|
|
3910
|
+
props: kept
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
};
|
|
3914
|
+
if (memoize) memo.meta.set(meta, out);
|
|
3915
|
+
return out;
|
|
3916
|
+
}
|
|
3917
|
+
/** The declared interface serialized for `/meta.decorations`, once per class. */
|
|
3918
|
+
serialized(serialize) {
|
|
3919
|
+
return this.index.memo.serialized ??= serialize();
|
|
3920
|
+
}
|
|
3921
|
+
};
|
|
3922
|
+
/** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
|
|
3923
|
+
function stripDecorations(rows, read) {
|
|
3924
|
+
const { dropKeys, dropPaths, keepPaths, selectedPaths } = read;
|
|
3925
|
+
if (dropKeys.length === 0 && dropPaths.length === 0) return;
|
|
3926
|
+
const owned = (prefix) => selectedPaths !== void 0 && selectedPaths.some((sel) => prefix.every((part, i) => sel[i] === part));
|
|
3927
|
+
for (const row of rows) {
|
|
3928
|
+
for (const key of dropKeys) delete row[key];
|
|
3929
|
+
dropPaths.forEach((parts, i) => {
|
|
3930
|
+
const keep = keepPaths[i] ?? [];
|
|
3931
|
+
if (keep.length === 0) deleteDescending(row, parts, 0, selectedPaths !== void 0, owned);
|
|
3932
|
+
else pruneExcept(row, parts, 0, keep);
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
/** An object without keys, or an array (of any nesting) holding nothing but such values (what a strip left of a parent). */
|
|
3937
|
+
function isHollow(value) {
|
|
3938
|
+
if (Array.isArray(value)) return value.every((el) => isHollow(el));
|
|
3939
|
+
return isPlainObject(value) && Object.keys(value).length === 0;
|
|
3940
|
+
}
|
|
3941
|
+
/**
|
|
3942
|
+
* Deletes the path `parts` below `value`, descending through arrays of objects
|
|
3943
|
+
* (`items.qty` strips every element's `qty`). With `clean` (an inclusion read),
|
|
3944
|
+
* a parent the strip emptied — or a `null` one — goes too, unless the client
|
|
3945
|
+
* selected something at or below it.
|
|
3946
|
+
*/
|
|
3947
|
+
function deleteDescending(value, parts, depth, clean, owned) {
|
|
3948
|
+
if (Array.isArray(value)) {
|
|
3949
|
+
const dropEmptied = clean && depth > 0 && !owned(parts.slice(0, depth));
|
|
3950
|
+
for (let i = value.length - 1; i >= 0; i--) {
|
|
3951
|
+
const el = value[i];
|
|
3952
|
+
const wasHollow = isHollow(el);
|
|
3953
|
+
deleteDescending(el, parts, depth, clean, owned);
|
|
3954
|
+
if (dropEmptied && !wasHollow && isHollow(el)) value.splice(i, 1);
|
|
3955
|
+
}
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
if (!isPlainObject(value)) return;
|
|
3959
|
+
const key = parts[depth];
|
|
3960
|
+
if (depth === parts.length - 1) {
|
|
3961
|
+
delete value[key];
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3964
|
+
const child = value[key];
|
|
3965
|
+
const prefix = parts.slice(0, depth + 1);
|
|
3966
|
+
if (child === null && clean && !owned(prefix)) {
|
|
3967
|
+
delete value[key];
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
deleteDescending(child, parts, depth + 1, clean, owned);
|
|
3971
|
+
if (clean && isHollow(child) && !owned(prefix)) delete value[key];
|
|
3972
|
+
}
|
|
3973
|
+
/**
|
|
3974
|
+
* Below `parts`, deletes everything except the branches leading to `keep`
|
|
3975
|
+
* (client-selected descendants), through arrays of objects as well.
|
|
3976
|
+
*/
|
|
3977
|
+
function pruneExcept(value, parts, depth, keep) {
|
|
3978
|
+
if (Array.isArray(value)) {
|
|
3979
|
+
for (const el of value) pruneExcept(el, parts, depth, keep);
|
|
3980
|
+
return;
|
|
3981
|
+
}
|
|
3982
|
+
if (!isPlainObject(value)) return;
|
|
3983
|
+
if (depth < parts.length) {
|
|
3984
|
+
pruneExcept(value[parts[depth]], parts, depth + 1, keep);
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
prune(value, parts.length, keep.filter((k) => k.length > parts.length));
|
|
3988
|
+
}
|
|
3989
|
+
function prune(value, depth, branches) {
|
|
3990
|
+
if (Array.isArray(value)) {
|
|
3991
|
+
for (const el of value) prune(el, depth, branches);
|
|
3992
|
+
return;
|
|
3993
|
+
}
|
|
3994
|
+
if (!isPlainObject(value)) return;
|
|
3995
|
+
for (const key of Object.keys(value)) {
|
|
3996
|
+
const next = branches.filter((b) => b[depth] === key);
|
|
3997
|
+
if (next.length === 0) delete value[key];
|
|
3998
|
+
else if (next.some((b) => b.length > depth + 1)) prune(value[key], depth + 1, next);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
/** `true` when stripping `read` changes nothing — skip the post-hook step. */
|
|
4002
|
+
function stripsNothing(read) {
|
|
4003
|
+
return read === void 0 || read.dropKeys.length === 0 && read.dropPaths.length === 0;
|
|
4004
|
+
}
|
|
4005
|
+
//#endregion
|
|
3319
4006
|
//#region src/decorators.ts
|
|
3320
4007
|
/**
|
|
3321
4008
|
* DI token under which the {@link AtscriptDbReadable} instance
|
|
@@ -3475,7 +4162,8 @@ const QUERY_CONTROLS = [
|
|
|
3475
4162
|
"filter",
|
|
3476
4163
|
"insights",
|
|
3477
4164
|
...dtoControls(QueryControlsDto),
|
|
3478
|
-
"groupBy"
|
|
4165
|
+
"groupBy",
|
|
4166
|
+
"rowOrder"
|
|
3479
4167
|
];
|
|
3480
4168
|
const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
|
|
3481
4169
|
const ONE_CONTROLS = dtoControls(GetOneControlsDto);
|
|
@@ -3506,6 +4194,12 @@ function writeOnlyError(path, op) {
|
|
|
3506
4194
|
const verdict = writeOnlyVerdict(path, op);
|
|
3507
4195
|
return badRequest(verdict.path, verdict.message);
|
|
3508
4196
|
}
|
|
4197
|
+
/**
|
|
4198
|
+
* Validated `@DbDecorations` per readable and controller class — a
|
|
4199
|
+
* `FOR_EVENT` controller is constructed per event, and must not re-validate
|
|
4200
|
+
* (nor re-serialize) its declaration each time.
|
|
4201
|
+
*/
|
|
4202
|
+
const decorationIndexes = /* @__PURE__ */ new WeakMap();
|
|
3509
4203
|
/** Controller classes already warned that `actionRowScope` has no row identity to match by. */
|
|
3510
4204
|
const warnedNoIdentity = /* @__PURE__ */ new WeakSet();
|
|
3511
4205
|
let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
|
|
@@ -3537,7 +4231,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3537
4231
|
get capabilities() {
|
|
3538
4232
|
const current = this._capabilities;
|
|
3539
4233
|
if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
|
|
3540
|
-
const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
|
|
4234
|
+
const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet, this._decorations?.visibleOn);
|
|
3541
4235
|
this._capabilities = index;
|
|
3542
4236
|
return index;
|
|
3543
4237
|
}
|
|
@@ -3574,6 +4268,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3574
4268
|
_derivedSource;
|
|
3575
4269
|
/** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
|
|
3576
4270
|
_targetWriteOnly = /* @__PURE__ */ new WeakMap();
|
|
4271
|
+
/** Own leaf paths per readable (bound + `$with` targets), see {@link _leavesOf}. */
|
|
4272
|
+
_targetLeaves = /* @__PURE__ */ new WeakMap();
|
|
3577
4273
|
_indexFieldPathsCache;
|
|
3578
4274
|
/** {@link _nativeSearch} per request, keyed by the request's parsed controls. */
|
|
3579
4275
|
_nativeSearchByRequest = /* @__PURE__ */ new WeakMap();
|
|
@@ -3597,8 +4293,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3597
4293
|
_gateFieldsMemo = /* @__PURE__ */ new WeakMap();
|
|
3598
4294
|
/** `true` when a subclass overrides {@link allowedActions}. */
|
|
3599
4295
|
_hasAllowedActions;
|
|
4296
|
+
/** The class's validated `@DbDecorations` (since 0.1.148), `undefined` when none is declared. */
|
|
4297
|
+
_decorations;
|
|
4298
|
+
/** The decoration plumbing of {@link _decorations} — see `DecorationPlanner`. */
|
|
4299
|
+
_planner;
|
|
3600
4300
|
/** `true` when a subclass overrides {@link actionRowScope} (the gate, `$actions` and `/meta/actions` apply it). */
|
|
3601
4301
|
_hasActionRowScope;
|
|
4302
|
+
/** @internal `true` when a subclass overrides {@link resolveRowIds} (every id-addressed endpoint calls it; since 0.1.148). */
|
|
4303
|
+
[ROW_RESOLVES];
|
|
3602
4304
|
/** `true` when the class declares `@DbActionsFrom` (since 0.1.147). */
|
|
3603
4305
|
_hasDelegations;
|
|
3604
4306
|
/** `transformProjection` is overridden (a delegation's id paths are checked against it). */
|
|
@@ -3622,15 +4324,17 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3622
4324
|
this._writeOnlySet = this._writeOnlyOf(resolved);
|
|
3623
4325
|
this._derivedSource = this._derivedSourcesOf(resolved);
|
|
3624
4326
|
this._invertibleFields = this._collectInvertibleFields();
|
|
4327
|
+
this._decorates = typeof this.decorateRows === "function";
|
|
4328
|
+
this._decorations = this._resolveDecorations(new.target);
|
|
3625
4329
|
this._searchFallbackFields = this._collectSearchFallbackFields();
|
|
3626
4330
|
this._preferredIdSet = new Set(resolved.preferredId ?? []);
|
|
3627
4331
|
this._quantityRefByPath = this._collectQuantityRefs();
|
|
3628
4332
|
const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
|
|
3629
4333
|
this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
|
|
3630
|
-
this._decorates = typeof this.decorateRows === "function";
|
|
3631
4334
|
const proto = _AsDbReadableController.prototype;
|
|
3632
4335
|
this._hasRowOverlay = this.transformOne !== proto.transformOne || this.transformFilter !== proto.transformFilter;
|
|
3633
4336
|
this._hasActionRowScope = this.actionRowScope !== proto.actionRowScope;
|
|
4337
|
+
this[ROW_RESOLVES] = this.resolveRowIds !== proto.resolveRowIds;
|
|
3634
4338
|
this._hasProjectionHook = this.transformProjection !== proto.transformProjection;
|
|
3635
4339
|
this._hasAllowedActions = this.allowedActions !== proto.allowedActions;
|
|
3636
4340
|
this._hasDelegations = hasActionDelegations(new.target);
|
|
@@ -3647,6 +4351,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3647
4351
|
sealedFor: (readable, prefix = "") => this._sealedFor(readable, prefix)
|
|
3648
4352
|
};
|
|
3649
4353
|
this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
|
|
4354
|
+
this._planner = this._decorations && new DecorationPlanner(this._decorations, {
|
|
4355
|
+
isVisible,
|
|
4356
|
+
scoped,
|
|
4357
|
+
preferred: this._preferredIdSet,
|
|
4358
|
+
capabilities: () => this.capabilities,
|
|
4359
|
+
firstVisibleField: () => this._invertibleFields.find((path) => isVisible(path))
|
|
4360
|
+
});
|
|
4361
|
+
}
|
|
4362
|
+
/**
|
|
4363
|
+
* The class's `@DbDecorations`, validated against the bound readable once
|
|
4364
|
+
* per class and readable (a `[moost-db]` error when invalid); warns once
|
|
4365
|
+
* when `decorateRows` is not implemented.
|
|
4366
|
+
*/
|
|
4367
|
+
_resolveDecorations(ctor) {
|
|
4368
|
+
const meta = getAtscriptDbMate().read(ctor)?.atscript_db_decorations;
|
|
4369
|
+
if (!meta) return void 0;
|
|
4370
|
+
let perCtor = decorationIndexes.get(this.readable);
|
|
4371
|
+
if (!perCtor) decorationIndexes.set(this.readable, perCtor = /* @__PURE__ */ new Map());
|
|
4372
|
+
let index = perCtor.get(ctor);
|
|
4373
|
+
if (!index) {
|
|
4374
|
+
index = buildDecorationIndex(ctor.name, meta, {
|
|
4375
|
+
flatMap: this.readable.flatMap,
|
|
4376
|
+
relations: this.readable.relations,
|
|
4377
|
+
navFields: FieldCapabilityIndex.navPathsOf(this.readable),
|
|
4378
|
+
ownPaths: new Set(this._invertibleFields),
|
|
4379
|
+
writeOnly: this._writeOnlySet
|
|
4380
|
+
});
|
|
4381
|
+
perCtor.set(ctor, index);
|
|
4382
|
+
if (!this._decorates) this.logger.warn(`@DbDecorations declares ${index.keys.join(", ")} but ${ctor.name} does not implement decorateRows() — the keys are never filled`);
|
|
4383
|
+
}
|
|
4384
|
+
return index;
|
|
3650
4385
|
}
|
|
3651
4386
|
/**
|
|
3652
4387
|
* The identifications this request may address rows through (since
|
|
@@ -3672,7 +4407,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3672
4407
|
}
|
|
3673
4408
|
_collectInvertibleFields() {
|
|
3674
4409
|
const out = [];
|
|
3675
|
-
const nav = this.
|
|
4410
|
+
const nav = FieldCapabilityIndex.navPathsOf(this.readable);
|
|
3676
4411
|
for (const fd of this.readable.fieldDescriptors) {
|
|
3677
4412
|
if (fd.ignored) continue;
|
|
3678
4413
|
if (selfOrAncestor(fd.path, nav) !== void 0) continue;
|
|
@@ -3762,7 +4497,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3762
4497
|
checkCapabilities(parsed) {
|
|
3763
4498
|
const capabilities = this.capabilities;
|
|
3764
4499
|
const isVisible = this.fieldVisibility.isVisible;
|
|
3765
|
-
const
|
|
4500
|
+
const select = parsed.controls?.$select;
|
|
4501
|
+
const refs = collectQueryPaths$1(parsed, Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0);
|
|
3766
4502
|
if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
|
|
3767
4503
|
const relState = { nodes: 0 };
|
|
3768
4504
|
for (const ref of refs.filter) {
|
|
@@ -3778,9 +4514,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3778
4514
|
const recorded = parsed.controls ? this._clientWith.get(parsed.controls) : void 0;
|
|
3779
4515
|
const withRelError = this._relationGate().checkWith(recorded === void 0 ? liveWith : recorded?.tree, relState);
|
|
3780
4516
|
if (withRelError) return withRelError;
|
|
3781
|
-
for (const op of PATH_OPS)
|
|
3782
|
-
const
|
|
3783
|
-
|
|
4517
|
+
for (const op of PATH_OPS) {
|
|
4518
|
+
const gateOp = op === "select" && refs.aggregateMode ? "groupedSelect" : op;
|
|
4519
|
+
for (const path of refs[op]) {
|
|
4520
|
+
const verdict = capabilities.check(path, gateOp, isVisible);
|
|
4521
|
+
if (verdict) return badRequest(verdict.path, verdict.message);
|
|
4522
|
+
}
|
|
3784
4523
|
}
|
|
3785
4524
|
const having = checkHavingKeys(refs);
|
|
3786
4525
|
if (having) return badRequest(having.path, having.message);
|
|
@@ -3806,7 +4545,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3806
4545
|
navFields: capabilities.navFields
|
|
3807
4546
|
});
|
|
3808
4547
|
} catch (error) {
|
|
3809
|
-
if (!(error instanceof DbError)) throw error;
|
|
4548
|
+
if (!(error instanceof DbError) || error.code !== "INVALID_QUERY") throw error;
|
|
3810
4549
|
const [issue] = error.errors;
|
|
3811
4550
|
return badRequest(issue.path, issue.message);
|
|
3812
4551
|
}
|
|
@@ -3984,12 +4723,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3984
4723
|
const sub = nested.$select ?? rel.$select;
|
|
3985
4724
|
return {
|
|
3986
4725
|
...nested,
|
|
3987
|
-
$select: this._sealSelect(sub, sealed)
|
|
4726
|
+
$select: this._sealSelect(sub, sealed, target)
|
|
3988
4727
|
};
|
|
3989
4728
|
});
|
|
3990
4729
|
const out = {
|
|
3991
4730
|
...controls,
|
|
3992
|
-
$select: this._sealSelect(select, vis.sealedFor(this.readable))
|
|
4731
|
+
$select: this._sealSelect(select, vis.sealedFor(this.readable), this.readable)
|
|
3993
4732
|
};
|
|
3994
4733
|
if ($with !== controls.$with) out.$with = $with;
|
|
3995
4734
|
return out;
|
|
@@ -4258,34 +4997,24 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4258
4997
|
* The rows the row-level action `actionName` may run on (since 0.1.145),
|
|
4259
4998
|
* as an extra row filter; `undefined` or `{}` = no restriction (the
|
|
4260
4999
|
* default). Enforced by the action gate — the action's ids / rows are
|
|
4261
|
-
* loaded under {@link rowOverlay}, then checked against this filter
|
|
4262
|
-
*
|
|
5000
|
+
* loaded under {@link rowOverlay}, then checked against this filter (or —
|
|
5001
|
+
* without an overlay — the filter joins the load), so an id outside it gets
|
|
5002
|
+
* the same 404 "Row not found for action identifier"
|
|
4263
5003
|
* as a missing one — and reflected in `$actions` and
|
|
4264
5004
|
* `GET /meta/actions`, which list the action only on rows inside it.
|
|
4265
5005
|
*
|
|
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).
|
|
5006
|
+
* The hook receives the candidate rows (`ctx`), so a scope can depend on
|
|
5007
|
+
* them — what `ctx.purpose`, `ctx.ids` and `ctx.loadRows` hold, and when the
|
|
5008
|
+
* hook is asked before any load, is documented on {@link TDbActionScopeContext}.
|
|
5009
|
+
* Called only with at least one candidate, at most once per action per
|
|
5010
|
+
* evaluation. An answer restricting nothing (`undefined`, `null`, `{}`)
|
|
5011
|
+
* makes the gate load no row at all; a restriction is folded into the one
|
|
5012
|
+
* row load. The result only restricts (`ids ∧ rowOverlay ∧ scope`); a throw
|
|
5013
|
+
* fails the request — never a silent "allow". The filter runs straight
|
|
5014
|
+
* against the bound readable: it may use fields {@link hasField} hides, and
|
|
5015
|
+
* nothing of it reaches the response. Runs after {@link prepareRequest} and,
|
|
5016
|
+
* on the action route, after the request body is read and
|
|
5017
|
+
* {@link resolveRowIds}.
|
|
4289
5018
|
*
|
|
4290
5019
|
* Not overriding it costs nothing; a one-parameter override keeps working.
|
|
4291
5020
|
* moost-db always passes `ctx` — it is optional in the signature only so
|
|
@@ -4351,6 +5080,37 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4351
5080
|
transformProjection(projection) {
|
|
4352
5081
|
return projection;
|
|
4353
5082
|
}
|
|
5083
|
+
/**
|
|
5084
|
+
* The shared projection step of `/query`, `/pages`, `/geo` and `/one`:
|
|
5085
|
+
* splits the declared decoration keys out of the wire `$select`
|
|
5086
|
+
* ({@link DecorationPlanner.plan}), runs {@link transformProjection} on the
|
|
5087
|
+
* rest (decoration keys never reach it — a permission layer needs no
|
|
5088
|
+
* change; their `requires` paths are added so the hook's inputs are read),
|
|
5089
|
+
* then seals every projection level. `finish` completes it once the
|
|
5090
|
+
* endpoint is past its own checks: the preferred-id widening (an
|
|
5091
|
+
* `HttpError` for a mixed `$select`) and the decoration step — what every
|
|
5092
|
+
* read endpoint then passes to {@link _runReadWithActions}.
|
|
5093
|
+
*/
|
|
5094
|
+
async _projectRead(controls) {
|
|
5095
|
+
const plan = this._planner?.plan(controls.$select);
|
|
5096
|
+
const transformed = await this.transformProjection(plan ? plan.select : controls.$select);
|
|
5097
|
+
const sealed = this._sealControls(controls, transformed);
|
|
5098
|
+
const finish = () => {
|
|
5099
|
+
const select = this.widenPreferredIdProjection(sealed.$select);
|
|
5100
|
+
if (select instanceof HttpError) return select;
|
|
5101
|
+
let kept;
|
|
5102
|
+
const keptPaths = () => kept === void 0 ? kept = this._resolveProjectionForAugmenter(select) : kept;
|
|
5103
|
+
return {
|
|
5104
|
+
select,
|
|
5105
|
+
kept: keptPaths,
|
|
5106
|
+
read: plan && this._planner.serve(plan, keptPaths())
|
|
5107
|
+
};
|
|
5108
|
+
};
|
|
5109
|
+
return {
|
|
5110
|
+
sealed,
|
|
5111
|
+
finish
|
|
5112
|
+
};
|
|
5113
|
+
}
|
|
4354
5114
|
widenPreferredIdProjection(projection) {
|
|
4355
5115
|
const widened = this.widenQuantityRefProjection(projection);
|
|
4356
5116
|
if (widened instanceof HttpError) return widened;
|
|
@@ -4372,12 +5132,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4372
5132
|
return out;
|
|
4373
5133
|
}
|
|
4374
5134
|
_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);
|
|
5135
|
+
if (Object.keys(projection).length === 0) return projection;
|
|
5136
|
+
const shape = mapShape(projection);
|
|
5137
|
+
const included = new Set(shape.included);
|
|
5138
|
+
const excluded = new Set(shape.excluded);
|
|
4381
5139
|
if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
|
|
4382
5140
|
if (excluded.size === 0) {
|
|
4383
5141
|
let allPresent = true;
|
|
@@ -4433,12 +5191,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4433
5191
|
return [...projection, ...toAdd];
|
|
4434
5192
|
}
|
|
4435
5193
|
_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);
|
|
5194
|
+
if (Object.keys(projection).length === 0) return projection;
|
|
5195
|
+
const shape = mapShape(projection);
|
|
5196
|
+
const included = new Set(shape.included);
|
|
5197
|
+
const excluded = new Set(shape.excluded);
|
|
4442
5198
|
if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
|
|
4443
5199
|
if (excluded.size === 0) {
|
|
4444
5200
|
const toAdd = [];
|
|
@@ -4453,22 +5209,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4453
5209
|
}
|
|
4454
5210
|
/** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
|
|
4455
5211
|
_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;
|
|
5212
|
+
const shape = selectShape(select);
|
|
5213
|
+
if (shape.kind === "all") return null;
|
|
5214
|
+
if (shape.kind === "list") return [...new Set(shape.items.filter((item) => typeof item === "string"))];
|
|
5215
|
+
const { included, excluded } = shape;
|
|
5216
|
+
if (included.length > 0 && excluded.length === 0) return [...included];
|
|
4472
5217
|
if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
|
|
4473
5218
|
throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
|
|
4474
5219
|
}
|
|
@@ -4514,7 +5259,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4514
5259
|
}
|
|
4515
5260
|
return result;
|
|
4516
5261
|
}
|
|
4517
|
-
async _prepareAugmentation(controls,
|
|
5262
|
+
async _prepareAugmentation(controls, projected) {
|
|
4518
5263
|
if (!controls.$actions) return null;
|
|
4519
5264
|
const [own, delegations] = await Promise.all([this._resolveAugmentEnvelopes(), this._activeDelegations()]);
|
|
4520
5265
|
if (own === null && delegations.length === 0) return null;
|
|
@@ -4524,7 +5269,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4524
5269
|
scopeOverlay = this.rowOverlay();
|
|
4525
5270
|
scopeOverlay.catch(() => {});
|
|
4526
5271
|
}
|
|
4527
|
-
let resolvedProjection =
|
|
5272
|
+
let resolvedProjection = projected.kept();
|
|
4528
5273
|
let widenedSelect = resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection);
|
|
4529
5274
|
if (resolvedProjection !== null && delegations.length > 0) {
|
|
4530
5275
|
const base = widenedSelect ?? resolvedProjection;
|
|
@@ -4629,6 +5374,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4629
5374
|
[ACTION_OVERLAY]() {
|
|
4630
5375
|
return this.rowOverlay();
|
|
4631
5376
|
}
|
|
5377
|
+
/**
|
|
5378
|
+
* @internal {@link resolveRowIds} for an action's validated ids (since
|
|
5379
|
+
* 0.1.148): the output validated, duplicate identities collapsed, and the
|
|
5380
|
+
* ids as the client sent them kept for the error bodies and summaries.
|
|
5381
|
+
*/
|
|
5382
|
+
async [ROW_RESOLVE_IDS](ids, ctx) {
|
|
5383
|
+
return applyResolvedIds(ids, await this._runResolveRowIds(ids, ctx));
|
|
5384
|
+
}
|
|
4632
5385
|
/** @internal {@link actionRowScope} for the gate's loaded candidates (since 0.1.147). */
|
|
4633
5386
|
[ACTION_SCOPE](action, ctx) {
|
|
4634
5387
|
return this._actionScope(action, ctx);
|
|
@@ -4757,9 +5510,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4757
5510
|
/**
|
|
4758
5511
|
* `select` without the `sealed` paths (see {@link _sealControls}); an
|
|
4759
5512
|
* exclusion of them is forced when there is no projection, or when every
|
|
4760
|
-
* requested path was sealed.
|
|
5513
|
+
* requested path was sealed. An inclusion naming a PARENT of a sealed path
|
|
5514
|
+
* (`secret` over a write-only `secret.hash`) is replaced by the parent's
|
|
5515
|
+
* unsealed leaves, so a sealed descendant never rides along with it.
|
|
4761
5516
|
*/
|
|
4762
|
-
_sealSelect(select, writeOnly) {
|
|
5517
|
+
_sealSelect(select, writeOnly, readable) {
|
|
4763
5518
|
if (writeOnly.size === 0) return select;
|
|
4764
5519
|
const exclusion = () => {
|
|
4765
5520
|
const out = {};
|
|
@@ -4767,29 +5522,47 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4767
5522
|
return out;
|
|
4768
5523
|
};
|
|
4769
5524
|
if (select === void 0) return exclusion();
|
|
5525
|
+
const expand = (path) => {
|
|
5526
|
+
if (writeOnly.has(path)) return [];
|
|
5527
|
+
const prefix = `${path}.`;
|
|
5528
|
+
let parent = false;
|
|
5529
|
+
for (const sealed of writeOnly) if (sealed.startsWith(prefix)) {
|
|
5530
|
+
parent = true;
|
|
5531
|
+
break;
|
|
5532
|
+
}
|
|
5533
|
+
if (!parent) return [path];
|
|
5534
|
+
return this._leavesOf(readable).filter((leaf) => leaf.startsWith(prefix) && selfOrAncestor(leaf, writeOnly) === void 0);
|
|
5535
|
+
};
|
|
4770
5536
|
if (Array.isArray(select)) {
|
|
4771
|
-
const kept =
|
|
5537
|
+
const kept = [];
|
|
5538
|
+
for (const item of select) if (typeof item === "string") kept.push(...expand(item));
|
|
5539
|
+
else if (!writeOnly.has(item.$field ?? "")) kept.push(item);
|
|
4772
5540
|
return kept.length > 0 ? kept : exclusion();
|
|
4773
5541
|
}
|
|
4774
5542
|
const entries = Object.entries(select);
|
|
4775
5543
|
if (entries.length > 0 && (entries[0][1] === 1 || entries[0][1] === true)) {
|
|
4776
5544
|
const out = {};
|
|
4777
|
-
for (const [k, v] of entries)
|
|
5545
|
+
for (const [k, v] of entries) for (const path of expand(k)) out[path] = v;
|
|
4778
5546
|
return Object.keys(out).length > 0 ? out : exclusion();
|
|
4779
5547
|
}
|
|
4780
5548
|
const out = { ...select };
|
|
4781
5549
|
for (const f of writeOnly) out[f] = 0;
|
|
4782
5550
|
return out;
|
|
4783
5551
|
}
|
|
4784
|
-
/**
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
const
|
|
4791
|
-
|
|
5552
|
+
/** Own leaf field paths (no navigation, no ignored field) of `readable`, once per readable. */
|
|
5553
|
+
_leavesOf(readable) {
|
|
5554
|
+
let leaves = this._targetLeaves.get(readable);
|
|
5555
|
+
if (!leaves) {
|
|
5556
|
+
const paths = [...readable.flatMap?.keys() ?? []].filter((path) => path !== "");
|
|
5557
|
+
const parents = /* @__PURE__ */ new Set();
|
|
5558
|
+
for (const path of paths) for (let at = path.indexOf("."); at >= 0; at = path.indexOf(".", at + 1)) parents.add(path.slice(0, at));
|
|
5559
|
+
const nav = readable.navFields ?? /* @__PURE__ */ new Set();
|
|
5560
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
5561
|
+
for (const fd of readable.fieldDescriptors) if (fd.ignored) ignored.add(fd.path);
|
|
5562
|
+
leaves = paths.filter((path) => !parents.has(path) && selfOrAncestor(path, nav) === void 0 && selfOrAncestor(path, ignored) === void 0);
|
|
5563
|
+
this._targetLeaves.set(readable, leaves);
|
|
4792
5564
|
}
|
|
5565
|
+
return leaves;
|
|
4793
5566
|
}
|
|
4794
5567
|
/**
|
|
4795
5568
|
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
@@ -4859,13 +5632,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4859
5632
|
* subclass implements it. Returns the hook's result — `undefined`, with no
|
|
4860
5633
|
* promise or microtask, when there is no hook or it is synchronous.
|
|
4861
5634
|
*/
|
|
4862
|
-
_finishRows(rows, prep, ctx) {
|
|
5635
|
+
_finishRows(rows, prep, ctx, read) {
|
|
4863
5636
|
const overlay = prep?.scopeOverlay;
|
|
4864
|
-
if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx);
|
|
5637
|
+
if (!prep || !overlay && prep.delegations.length === 0) return this._augmentAndDecorate(rows, prep, ctx, read);
|
|
4865
5638
|
return (async () => {
|
|
4866
5639
|
const names = prep.envelopes.map((e) => e.info.name);
|
|
4867
5640
|
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);
|
|
5641
|
+
await this._augmentAndDecorate(rows, prep, ctx, read, masks, delegated);
|
|
4869
5642
|
})();
|
|
4870
5643
|
}
|
|
4871
5644
|
/**
|
|
@@ -4873,7 +5646,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4873
5646
|
* ones (`delegated`: per delegation, per row) — then {@link decorateRows}
|
|
4874
5647
|
* when implemented.
|
|
4875
5648
|
*/
|
|
4876
|
-
_augmentAndDecorate(rows, prep, ctx, outOfScope, delegated) {
|
|
5649
|
+
_augmentAndDecorate(rows, prep, ctx, read, outOfScope, delegated) {
|
|
4877
5650
|
if (prep) {
|
|
4878
5651
|
augmentRowsWithActions({
|
|
4879
5652
|
envelopes: prep.envelopes,
|
|
@@ -4884,7 +5657,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4884
5657
|
});
|
|
4885
5658
|
if (prep.delegations.length > 0) mergeDelegatedActions(rows, delegated ?? []);
|
|
4886
5659
|
}
|
|
4887
|
-
|
|
5660
|
+
const decorated = this._decorates ? this.decorateRows(rows, ctx) : void 0;
|
|
5661
|
+
if (!read || stripsNothing(read)) return decorated;
|
|
5662
|
+
const strip = () => stripDecorations(rows, read);
|
|
5663
|
+
if (decorated === void 0) return strip();
|
|
5664
|
+
return Promise.resolve(decorated).then(strip);
|
|
4888
5665
|
}
|
|
4889
5666
|
/**
|
|
4890
5667
|
* The app of the current event, through DI — never the one this
|
|
@@ -4960,9 +5737,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4960
5737
|
*/
|
|
4961
5738
|
resolveMeta() {
|
|
4962
5739
|
const own = super.resolveMeta();
|
|
4963
|
-
if (!this._hasDelegations && !this._hasFieldOverridden) return own;
|
|
5740
|
+
if (!this._hasDelegations && !this._hasFieldOverridden && !this._planner) return own;
|
|
4964
5741
|
return (async () => {
|
|
4965
|
-
const [
|
|
5742
|
+
const [overlaid, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
|
|
5743
|
+
const meta = this._planner ? this._planner.meta(overlaid) : overlaid;
|
|
4966
5744
|
const visible = this._applyIndexVisibility(meta);
|
|
4967
5745
|
return delegated.length > 0 ? {
|
|
4968
5746
|
...visible,
|
|
@@ -5016,7 +5794,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5016
5794
|
/** @internal Source side of a delegation: `GET /meta/actions` for one id, `names` only. */
|
|
5017
5795
|
async [AVAILABLE_ACTIONS](id, names) {
|
|
5018
5796
|
await this.parseRequest("availableActions");
|
|
5019
|
-
return this.
|
|
5797
|
+
return (await this._availableResolved(id, names)).own;
|
|
5020
5798
|
}
|
|
5021
5799
|
/** @internal Source side of a delegation: the `names` the caller may run (`allowedActions`). */
|
|
5022
5800
|
async [ALLOWED_ACTIONS](names) {
|
|
@@ -5035,8 +5813,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5035
5813
|
* `$actions=true`, then run {@link decorateRows}. Caller dispatches the
|
|
5036
5814
|
* strategy to its read-method family (count vs no-count).
|
|
5037
5815
|
*/
|
|
5038
|
-
async _runReadWithActions(endpoint, queryObj, controls,
|
|
5039
|
-
const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls,
|
|
5816
|
+
async _runReadWithActions(endpoint, queryObj, controls, projected, exec) {
|
|
5817
|
+
const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveReadStrategy(controls)]);
|
|
5040
5818
|
const result = await exec(prep?.widenedSelect ? {
|
|
5041
5819
|
...queryObj,
|
|
5042
5820
|
controls: {
|
|
@@ -5046,19 +5824,120 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5046
5824
|
} : queryObj, strategy);
|
|
5047
5825
|
const pending = this._finishRows(result.data, prep, {
|
|
5048
5826
|
endpoint,
|
|
5049
|
-
projection: select,
|
|
5050
|
-
controls
|
|
5051
|
-
|
|
5827
|
+
projection: projected.select,
|
|
5828
|
+
controls,
|
|
5829
|
+
decorations: projected.read?.served ?? NO_DECORATIONS
|
|
5830
|
+
}, projected.read);
|
|
5052
5831
|
if (pending) await pending;
|
|
5053
5832
|
return result;
|
|
5054
5833
|
}
|
|
5055
5834
|
/**
|
|
5835
|
+
* Maps the ids an id-addressed endpoint received to the rows' current ids
|
|
5836
|
+
* (since 0.1.148) — the seam for stale or alias ids, e.g. a natural key that
|
|
5837
|
+
* was renamed. Called once per request, after {@link prepareRequest} and
|
|
5838
|
+
* after the request's own validation, before anything reads the row, by:
|
|
5839
|
+
*
|
|
5840
|
+
* | `ctx.purpose` | endpoint | `ids` |
|
|
5841
|
+
* | --- | --- | --- |
|
|
5842
|
+
* | `"one"` | `GET /one/:id`, `GET /one?…` | one id: the path string, or the `?`-form identification object |
|
|
5843
|
+
* | `"available"` | `GET /meta/actions/:id`, `?…` (and a view asking its source) | one id, as above |
|
|
5844
|
+
* | `"remove"` | `DELETE /:id`, `DELETE /?…` (`AsDbController`) | one id, as above |
|
|
5845
|
+
* | `"action"` | an action route | the body's validated ids (one for a `'row'` action) |
|
|
5846
|
+
*
|
|
5847
|
+
* Contract:
|
|
5848
|
+
*
|
|
5849
|
+
* - Return one id per input id, index-aligned (anything else is a 500). An
|
|
5850
|
+
* id that already names a row must come back UNCHANGED — the current
|
|
5851
|
+
* holder of a key wins over an alias; so must an id you cannot resolve
|
|
5852
|
+
* (never throw for an unknown alias: a custom error is an oracle — the
|
|
5853
|
+
* endpoint then answers its normal miss).
|
|
5854
|
+
* - The output is validated (a server bug is a 500, never a client 400): a
|
|
5855
|
+
* scalar is resolved like a path scalar (primary key first, then the
|
|
5856
|
+
* visible unique keys, inside the row overlay); an object must be one of
|
|
5857
|
+
* the visible identifications ({@link idSource}); an `"action"` id must
|
|
5858
|
+
* be such an object.
|
|
5859
|
+
* - The resolved id is never trusted for access: the endpoint still reads
|
|
5860
|
+
* or deletes it under {@link rowOverlay} and the visible identifications.
|
|
5861
|
+
* `ctx.overlay` is that overlay — resolve INSIDE it when an alias could
|
|
5862
|
+
* name several rows, so a row the caller can't reach never shadows one
|
|
5863
|
+
* they can. Don't log or return the canonical id in errors.
|
|
5864
|
+
* - Handlers (`@DbActionID()`, `useDbActionId()`), {@link rowOverlay} reads,
|
|
5865
|
+
* {@link actionRowScope} and `onRemove` / `guardRemove` receive the
|
|
5866
|
+
* resolved ids; error bodies and `summary()` echo the ids the client sent.
|
|
5867
|
+
* - Write bodies (`POST` / `PUT` / `PATCH`) are not resolved — use
|
|
5868
|
+
* `onWrite`. Not called by value-help controllers, `$actions` on a read
|
|
5869
|
+
* or a query target. Not overriding it costs nothing.
|
|
5870
|
+
*
|
|
5871
|
+
* It is NOT overridden by {@link resolveRowFilter}, which does not take part
|
|
5872
|
+
* in `/one` for real tables and views.
|
|
5873
|
+
*
|
|
5874
|
+
* ```ts
|
|
5875
|
+
* protected async resolveRowIds(ids: readonly TDbRowIdInput[], ctx: TDbRowIdsContext) {
|
|
5876
|
+
* return Promise.all(ids.map(async (id) => {
|
|
5877
|
+
* const key = typeof id === "object" ? id.code : id
|
|
5878
|
+
* if (typeof key !== "string") return id
|
|
5879
|
+
* // the current holder of the key wins; consult the alias table on a miss
|
|
5880
|
+
* // resolve INSIDE the overlay: a row the caller cannot reach never wins
|
|
5881
|
+
* const inScope = (code: string) =>
|
|
5882
|
+
* this.readable.count({ filter: ctx.overlay ? { $and: [{ code }, ctx.overlay] } : { code } })
|
|
5883
|
+
* if (await inScope(key)) return id
|
|
5884
|
+
* const alias = await aliases.findOne({ filter: { oldCode: key } })
|
|
5885
|
+
* if (!alias || !(await inScope(alias.newCode))) return id
|
|
5886
|
+
* return typeof id === "object" ? { code: alias.newCode } : alias.newCode
|
|
5887
|
+
* }))
|
|
5888
|
+
* }
|
|
5889
|
+
* ```
|
|
5890
|
+
*
|
|
5891
|
+
* @since 0.1.148
|
|
5892
|
+
*/
|
|
5893
|
+
resolveRowIds(ids, _ctx) {
|
|
5894
|
+
return ids;
|
|
5895
|
+
}
|
|
5896
|
+
/** {@link resolveRowIds} with its output validated (a server bug is a 500). */
|
|
5897
|
+
async _runResolveRowIds(ids, ctx) {
|
|
5898
|
+
const out = await this.resolveRowIds(ids, ctx);
|
|
5899
|
+
if (!Array.isArray(out) || out.length !== ids.length) throw new HttpError(500, "resolveRowIds must return one id per request id");
|
|
5900
|
+
const source = this.idSource;
|
|
5901
|
+
for (const id of out) {
|
|
5902
|
+
if ((typeof id === "string" || typeof id === "number" || typeof id === "boolean") && ctx.purpose !== "action") continue;
|
|
5903
|
+
try {
|
|
5904
|
+
validateSingleId(id, source, { strictTypes: ctx.purpose === "action" });
|
|
5905
|
+
} catch (error) {
|
|
5906
|
+
if (error instanceof ValidatorError) throw new HttpError(500, "resolveRowIds returned an invalid id");
|
|
5907
|
+
throw error;
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
return out;
|
|
5911
|
+
}
|
|
5912
|
+
/**
|
|
5913
|
+
* @internal The one id of an id-addressed endpoint through
|
|
5914
|
+
* {@link resolveRowIds} (identity, at no cost, when it is not overridden)
|
|
5915
|
+
* and the row overlay it was resolved inside — computed once, for the
|
|
5916
|
+
* endpoint's read or delete under that overlay.
|
|
5917
|
+
*/
|
|
5918
|
+
async _resolveWithOverlay(id, purpose) {
|
|
5919
|
+
const overlay = await this.rowOverlay();
|
|
5920
|
+
if (!this[ROW_RESOLVES]) return {
|
|
5921
|
+
id,
|
|
5922
|
+
overlay
|
|
5923
|
+
};
|
|
5924
|
+
const [resolved] = await this._runResolveRowIds([id], {
|
|
5925
|
+
purpose,
|
|
5926
|
+
overlay
|
|
5927
|
+
});
|
|
5928
|
+
return {
|
|
5929
|
+
id: resolved,
|
|
5930
|
+
overlay
|
|
5931
|
+
};
|
|
5932
|
+
}
|
|
5933
|
+
/**
|
|
5056
5934
|
* The filter addressing exactly the ONE row `id` means — the readable's
|
|
5057
5935
|
* PK-first `resolveRowFilter` (since 0.1.143) under this request's
|
|
5058
5936
|
* identifications (`_idOpts`). `scope` (the row overlay) restricts which
|
|
5059
5937
|
* rows count while the id is pinned, so a row outside it never shadows one
|
|
5060
5938
|
* inside it. Readables without it (partial mocks) fall back to
|
|
5061
|
-
* `resolveIdFilter`.
|
|
5939
|
+
* `resolveIdFilter`. Not an alias seam: `/one` reads through `findOneByRow`
|
|
5940
|
+
* on every real table or view — map stale ids in {@link resolveRowIds}.
|
|
5062
5941
|
*/
|
|
5063
5942
|
resolveRowFilter(id, scope) {
|
|
5064
5943
|
const readable = this.readable;
|
|
@@ -5132,8 +6011,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5132
6011
|
if (groupBy?.length && controls.$vector !== void 0) return new HttpError(400, "Cannot combine $vector and $groupBy in the same query");
|
|
5133
6012
|
const error = this.validateParsed(parsed, "query");
|
|
5134
6013
|
if (error) return error;
|
|
5135
|
-
if (groupBy?.length) {
|
|
5136
|
-
const
|
|
6014
|
+
if (groupBy?.length && this._writeOnlySet.size > 0) {
|
|
6015
|
+
const refs = collectQueryPaths$1(parsed, true);
|
|
6016
|
+
const sealed = [
|
|
6017
|
+
...refs.groupBy,
|
|
6018
|
+
...refs.aggregate,
|
|
6019
|
+
...refs.bucket,
|
|
6020
|
+
...refs.select,
|
|
6021
|
+
...refs.sort
|
|
6022
|
+
].find((path) => this._writeOnlySet.has(path) && this.fieldVisibility.isVisible(path));
|
|
5137
6023
|
if (sealed) return new HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
|
|
5138
6024
|
}
|
|
5139
6025
|
const gateError = this.checkCapabilities(parsed);
|
|
@@ -5147,9 +6033,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5147
6033
|
insights: parsed.insights
|
|
5148
6034
|
});
|
|
5149
6035
|
}
|
|
5150
|
-
const [transformedFilter,
|
|
6036
|
+
const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
5151
6037
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
5152
|
-
const sealed = this._sealControls(controls, transformedSelect);
|
|
5153
6038
|
if (controls.$count) return this.readable.count({
|
|
5154
6039
|
filter,
|
|
5155
6040
|
controls: {
|
|
@@ -5157,19 +6042,19 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5157
6042
|
$select: sealed.$select
|
|
5158
6043
|
}
|
|
5159
6044
|
});
|
|
5160
|
-
const
|
|
5161
|
-
if (
|
|
6045
|
+
const projected = finish();
|
|
6046
|
+
if (projected instanceof HttpError) return projected;
|
|
5162
6047
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
5163
6048
|
const queryObj = {
|
|
5164
6049
|
filter,
|
|
5165
6050
|
controls: {
|
|
5166
6051
|
...sealed,
|
|
5167
|
-
$select: select,
|
|
6052
|
+
$select: projected.select,
|
|
5168
6053
|
$limit: controls.$limit || 1e3,
|
|
5169
6054
|
$threshold: threshold
|
|
5170
6055
|
}
|
|
5171
6056
|
};
|
|
5172
|
-
return (await this._runReadWithActions("query", queryObj, controls,
|
|
6057
|
+
return (await this._runReadWithActions("query", queryObj, controls, projected, async (q, strategy) => {
|
|
5173
6058
|
switch (strategy.kind) {
|
|
5174
6059
|
case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
|
|
5175
6060
|
case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
|
|
@@ -5190,23 +6075,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5190
6075
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
5191
6076
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
5192
6077
|
const skip = (page - 1) * size;
|
|
5193
|
-
const [transformedFilter,
|
|
6078
|
+
const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
5194
6079
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
5195
|
-
const
|
|
5196
|
-
|
|
5197
|
-
if (select instanceof HttpError) return select;
|
|
6080
|
+
const projected = finish();
|
|
6081
|
+
if (projected instanceof HttpError) return projected;
|
|
5198
6082
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
5199
6083
|
const query = {
|
|
5200
6084
|
filter,
|
|
5201
6085
|
controls: {
|
|
5202
6086
|
...sealed,
|
|
5203
|
-
$select: select,
|
|
6087
|
+
$select: projected.select,
|
|
5204
6088
|
$skip: skip,
|
|
5205
6089
|
$limit: size,
|
|
5206
6090
|
$threshold: threshold
|
|
5207
6091
|
}
|
|
5208
6092
|
};
|
|
5209
|
-
const result = await this._runReadWithActions("pages", query, controls,
|
|
6093
|
+
const result = await this._runReadWithActions("pages", query, controls, projected, async (q, strategy) => {
|
|
5210
6094
|
switch (strategy.kind) {
|
|
5211
6095
|
case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
|
|
5212
6096
|
case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
|
|
@@ -5248,10 +6132,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5248
6132
|
const gateError = this.checkCapabilities(parsed);
|
|
5249
6133
|
if (gateError) return gateError;
|
|
5250
6134
|
const clientFilter = await this._relationOverlay(parsed);
|
|
5251
|
-
const [filter,
|
|
5252
|
-
const
|
|
5253
|
-
|
|
5254
|
-
if (select instanceof HttpError) return select;
|
|
6135
|
+
const [filter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
6136
|
+
const projected = finish();
|
|
6137
|
+
if (projected instanceof HttpError) return projected;
|
|
5255
6138
|
const paginated = controls.$page !== void 0 || controls.$size !== void 0;
|
|
5256
6139
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
5257
6140
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
@@ -5261,7 +6144,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5261
6144
|
...sealed,
|
|
5262
6145
|
$center: void 0,
|
|
5263
6146
|
$index: void 0,
|
|
5264
|
-
$select: select,
|
|
6147
|
+
$select: projected.select,
|
|
5265
6148
|
...paginated ? {
|
|
5266
6149
|
$skip: (page - 1) * size,
|
|
5267
6150
|
$limit: size
|
|
@@ -5269,7 +6152,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5269
6152
|
}
|
|
5270
6153
|
};
|
|
5271
6154
|
if (paginated) {
|
|
5272
|
-
const result = await this._runReadWithActions("geo", queryObj, controls,
|
|
6155
|
+
const result = await this._runReadWithActions("geo", queryObj, controls, projected, async (q) => indexName ? this.readable.geoSearchWithCount(indexName, point, q) : this.readable.geoSearchWithCount(point, q));
|
|
5273
6156
|
return {
|
|
5274
6157
|
data: result.data,
|
|
5275
6158
|
page,
|
|
@@ -5278,7 +6161,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5278
6161
|
count: result.count
|
|
5279
6162
|
};
|
|
5280
6163
|
}
|
|
5281
|
-
return (await this._runReadWithActions("geo", queryObj, controls,
|
|
6164
|
+
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
6165
|
}
|
|
5283
6166
|
/** Parses the `$center` control: `"lng,lat"` string (or tuple) → `[number, number]`. */
|
|
5284
6167
|
_parseGeoCenter(raw) {
|
|
@@ -5327,21 +6210,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5327
6210
|
const error = this.validateParsed(parsed, "getOne") ?? this.checkCapabilities(parsed);
|
|
5328
6211
|
if (error) return error;
|
|
5329
6212
|
await this._relationOverlay(parsed);
|
|
5330
|
-
const sealed =
|
|
5331
|
-
const
|
|
5332
|
-
if (
|
|
5333
|
-
const [prep, overlay] = await Promise.all([this._prepareAugmentation(controls,
|
|
6213
|
+
const { sealed, finish } = await this._projectRead(controls);
|
|
6214
|
+
const projected = finish();
|
|
6215
|
+
if (projected instanceof HttpError) return projected;
|
|
6216
|
+
const [prep, { id: resolvedId, overlay }] = await Promise.all([this._prepareAugmentation(controls, projected), this._resolveWithOverlay(id, "one")]);
|
|
5334
6217
|
const readControls = {
|
|
5335
6218
|
...sealed,
|
|
5336
|
-
$select: prep?.widenedSelect ?? select
|
|
6219
|
+
$select: prep?.widenedSelect ?? projected.select
|
|
5337
6220
|
};
|
|
5338
|
-
const item = await this.returnOne(this._findRow(
|
|
6221
|
+
const item = await this.returnOne(this._findRow(resolvedId, overlay, readControls));
|
|
5339
6222
|
if (item instanceof HttpError) return item;
|
|
5340
6223
|
const pending = this._finishRows([item], prep, {
|
|
5341
6224
|
endpoint: "one",
|
|
5342
|
-
projection: select,
|
|
5343
|
-
controls
|
|
5344
|
-
|
|
6225
|
+
projection: projected.select,
|
|
6226
|
+
controls,
|
|
6227
|
+
decorations: projected.read?.served ?? NO_DECORATIONS
|
|
6228
|
+
}, projected.read);
|
|
5345
6229
|
if (pending) await pending;
|
|
5346
6230
|
return item;
|
|
5347
6231
|
}
|
|
@@ -5358,10 +6242,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5358
6242
|
*/
|
|
5359
6243
|
async availableActionsById(id) {
|
|
5360
6244
|
await this.parseRequest("availableActions");
|
|
5361
|
-
const own = await this.
|
|
6245
|
+
const { own, id: resolved } = await this._availableResolved(id);
|
|
5362
6246
|
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);
|
|
6247
|
+
return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved));
|
|
5365
6248
|
}
|
|
5366
6249
|
/**
|
|
5367
6250
|
* **GET /meta/actions?field1=val1&…** — {@link availableActionsById} by
|
|
@@ -5371,13 +6254,50 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5371
6254
|
async availableActions(query) {
|
|
5372
6255
|
await this.parseRequest("availableActions");
|
|
5373
6256
|
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
6257
|
if (idObj instanceof HttpError) {
|
|
5376
|
-
if (!(await this._activeDelegations()).some((d) =>
|
|
5377
|
-
return this._delegatedAvailable({ actions: [] },
|
|
6258
|
+
if (!(await this._activeDelegations()).some((d) => this._sourceIdOf(d, void 0, query) !== void 0)) return idObj;
|
|
6259
|
+
return this._delegatedAvailable({ actions: [] }, (d) => this._sourceIdOf(d, void 0, query));
|
|
5378
6260
|
}
|
|
5379
|
-
const own = await this.
|
|
5380
|
-
|
|
6261
|
+
const { own, id: resolved } = await this._availableResolved(idObj);
|
|
6262
|
+
if (!this._hasDelegations) return own;
|
|
6263
|
+
return this._delegatedAvailable(own, (d) => this._sourceIdOf(d, resolved, query, this._identificationFields()));
|
|
6264
|
+
}
|
|
6265
|
+
/** Every field of every identification (primary key and unique indexes) the view addresses a row by. */
|
|
6266
|
+
_identificationFields() {
|
|
6267
|
+
return [...new Set(this.idSource.identifications.flatMap((i) => i.fields))];
|
|
6268
|
+
}
|
|
6269
|
+
/**
|
|
6270
|
+
* A delegation's source id: each source id field from the row's mapped path
|
|
6271
|
+
* of the RESOLVED `id`. A path of ANY of the view's identifications
|
|
6272
|
+
* (`consumed` — not just the one the request matched: `?id=1&code=T-OLD` names
|
|
6273
|
+
* `code` too) is NEVER taken from the raw `?` query: that value may be an
|
|
6274
|
+
* alias `resolveRowIds` rewrote, and the source would see (and answer for)
|
|
6275
|
+
* it. Paths outside the identification fall back to `fallback`'s (the raw
|
|
6276
|
+
* query's) value; `undefined` when a path has none.
|
|
6277
|
+
*/
|
|
6278
|
+
_sourceIdOf(d, id, fallback, consumed = []) {
|
|
6279
|
+
const value = (path) => id?.[path] ?? (consumed.includes(path) ? void 0 : fallback?.[path]);
|
|
6280
|
+
return d.paths.every((path) => value(path) !== void 0) ? Object.fromEntries(Object.entries(d.idMap).map(([field, path]) => [field, value(path)])) : void 0;
|
|
6281
|
+
}
|
|
6282
|
+
/**
|
|
6283
|
+
* {@link _availableActions} for a request id (`names`: only those actions):
|
|
6284
|
+
* through {@link resolveRowIds} (`"available"`) first, the row overlay
|
|
6285
|
+
* computed once for both — and the resolved id returned as an object (a
|
|
6286
|
+
* scalar is the single-field `preferredId` value) for the delegated part to
|
|
6287
|
+
* derive its source id from.
|
|
6288
|
+
*/
|
|
6289
|
+
async _availableResolved(id, names) {
|
|
6290
|
+
const { id: resolved, overlay } = await this._resolveWithOverlay(id, "available");
|
|
6291
|
+
const own = await this._availableActions(resolved, overlay, names);
|
|
6292
|
+
if (typeof resolved === "object") return {
|
|
6293
|
+
own,
|
|
6294
|
+
id: resolved
|
|
6295
|
+
};
|
|
6296
|
+
const preferred = this.readable.preferredId;
|
|
6297
|
+
return {
|
|
6298
|
+
own,
|
|
6299
|
+
id: preferred.length === 1 ? { [preferred[0]]: resolved } : void 0
|
|
6300
|
+
};
|
|
5381
6301
|
}
|
|
5382
6302
|
/**
|
|
5383
6303
|
* **POST /delegated-actions/:name** — a query target for a `@DbActionsFrom`
|
|
@@ -5549,8 +6469,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5549
6469
|
* actions are then checked on it (`purpose: "available"`) exactly like
|
|
5550
6470
|
* `$actions` rows.
|
|
5551
6471
|
*/
|
|
5552
|
-
async _availableActions(id, names) {
|
|
5553
|
-
const
|
|
6472
|
+
async _availableActions(id, overlay, names) {
|
|
6473
|
+
const envelopes = await (names ? this._envelopesNamed(names) : this._resolveAugmentEnvelopes());
|
|
5554
6474
|
if (!envelopes?.length) return { actions: [] };
|
|
5555
6475
|
const idKeys = id !== null && typeof id === "object" ? Object.keys(id) : [];
|
|
5556
6476
|
const fieldsOf = envelopes.map((e) => {
|
|
@@ -5631,7 +6551,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5631
6551
|
if (fd.encrypted) entry.encrypted = true;
|
|
5632
6552
|
if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
|
|
5633
6553
|
if (this._writeOnlySet.has(path)) entry.writeOnly = true;
|
|
6554
|
+
if (cap.groupable) entry.groupable = true;
|
|
5634
6555
|
if (cap.bucketable) entry.bucketable = true;
|
|
6556
|
+
if (cap.numeric) entry.numeric = true;
|
|
5635
6557
|
if (fd.derived) entry.derived = true;
|
|
5636
6558
|
if (fd.computed) entry.computed = true;
|
|
5637
6559
|
fields[path] = entry;
|
|
@@ -5646,11 +6568,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5646
6568
|
relations,
|
|
5647
6569
|
fields,
|
|
5648
6570
|
type: this.getSerializedType(),
|
|
6571
|
+
...this._decorations && { decorations: this._planner.serialized(() => this.serializeForMeta(this._decorations.type)) },
|
|
5649
6572
|
actions: this.buildActions(),
|
|
5650
6573
|
crud: this.buildCrud(),
|
|
5651
6574
|
versionColumn: this.readable.versionColumn,
|
|
5652
6575
|
...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] },
|
|
5653
|
-
aggregateFns: [...capabilities.aggregateFns]
|
|
6576
|
+
aggregateFns: [...capabilities.aggregateFns],
|
|
6577
|
+
aggregateExpressions: capabilities.aggregateExpressions
|
|
5654
6578
|
};
|
|
5655
6579
|
}
|
|
5656
6580
|
buildCrud() {
|
|
@@ -5781,7 +6705,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5781
6705
|
buildCrud() {
|
|
5782
6706
|
return {
|
|
5783
6707
|
...super.buildCrud(),
|
|
5784
|
-
insert: [],
|
|
6708
|
+
insert: this.readable.dbAdapter?.supportsInsertIgnore() ? ["onConflict"] : [],
|
|
5785
6709
|
update: [],
|
|
5786
6710
|
replace: [],
|
|
5787
6711
|
remove: []
|
|
@@ -5801,7 +6725,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5801
6725
|
return data;
|
|
5802
6726
|
}
|
|
5803
6727
|
/**
|
|
5804
|
-
* Intercepts delete operations.
|
|
6728
|
+
* Intercepts delete operations. Receives the id {@link resolveRowIds}
|
|
6729
|
+
* resolved (the one the request carried when it is not overridden; since
|
|
6730
|
+
* 0.1.148). Return `undefined` to abort (500 "Not
|
|
5805
6731
|
* deleted"); return an `Error` instance to respond with that error.
|
|
5806
6732
|
* Runs outside any transaction. May be async (e.g. to resolve composite
|
|
5807
6733
|
* ids from external state).
|
|
@@ -5938,8 +6864,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5938
6864
|
* table's transaction and an out-of-scope row is not deleted — a 404,
|
|
5939
6865
|
* exactly like a missing one.
|
|
5940
6866
|
*/
|
|
5941
|
-
async _deleteOrThrow(id) {
|
|
5942
|
-
const scope = await this.rowOverlay();
|
|
6867
|
+
async _deleteOrThrow(id, scope) {
|
|
5943
6868
|
const args = scope ? [{
|
|
5944
6869
|
...this._removeArgs[0],
|
|
5945
6870
|
scope
|
|
@@ -5950,16 +6875,34 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
5950
6875
|
}
|
|
5951
6876
|
/**
|
|
5952
6877
|
* **POST /** — inserts one or many records.
|
|
6878
|
+
*
|
|
6879
|
+
* `?$onConflict=ignore` (since 0.1.148) skips rows colliding on the primary
|
|
6880
|
+
* key or a unique index instead of answering 409. The response then is
|
|
6881
|
+
* `{ insertedId?, conflict }` for an object body and
|
|
6882
|
+
* `{ insertedCount, insertedIds, inserted, conflicts }` for an array body.
|
|
6883
|
+
* Any other `$` control on POST answers 400.
|
|
5953
6884
|
*/
|
|
5954
|
-
async insert(payload) {
|
|
5955
|
-
await this.parseRequest("insert");
|
|
6885
|
+
async insert(payload, url) {
|
|
6886
|
+
const { controls } = await this.parseRequest("insert", url ?? "");
|
|
6887
|
+
const onConflict = this._readOnConflict(controls);
|
|
5956
6888
|
assertWriteShape(payload);
|
|
6889
|
+
const args = onConflict ? [{
|
|
6890
|
+
...this._writeArgs[0],
|
|
6891
|
+
onConflict
|
|
6892
|
+
}] : this._writeArgs;
|
|
5957
6893
|
if (Array.isArray(payload)) {
|
|
5958
6894
|
const rows = await this._writeBody("insertMany", payload, true);
|
|
5959
|
-
return this.table.insertMany(rows, ...
|
|
6895
|
+
return this.table.insertMany(rows, ...args);
|
|
5960
6896
|
}
|
|
5961
6897
|
const row = await this._writeBody("insert", payload, false);
|
|
5962
|
-
return this.table.insertOne(row, ...
|
|
6898
|
+
return this.table.insertOne(row, ...args);
|
|
6899
|
+
}
|
|
6900
|
+
/** The only POST control: `$onConflict` (`error` | `ignore`). Anything else `$…` → 400. */
|
|
6901
|
+
_readOnConflict(controls) {
|
|
6902
|
+
for (const key of Object.keys(controls)) if (key !== "$onConflict") throw badRequest("", `Unsupported control "${key}" on insert`);
|
|
6903
|
+
const mode = controls.$onConflict;
|
|
6904
|
+
if (mode !== void 0 && mode !== "error" && mode !== "ignore") throw badRequest("", `$onConflict must be "error" or "ignore", got ${JSON.stringify(mode)}`);
|
|
6905
|
+
return mode === "ignore" ? "ignore" : void 0;
|
|
5963
6906
|
}
|
|
5964
6907
|
/**
|
|
5965
6908
|
* **PUT /** — fully replaces one or many records matched by primary key.
|
|
@@ -6025,7 +6968,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6025
6968
|
if (typeof table.recordFilter === "function") try {
|
|
6026
6969
|
filter = table.recordFilter(data, this._idOpts);
|
|
6027
6970
|
} catch (error) {
|
|
6028
|
-
if (!(error instanceof DbError)) throw error;
|
|
6971
|
+
if (!(error instanceof DbError) || error.code === "SPACE_CLOSED") throw error;
|
|
6029
6972
|
filter = null;
|
|
6030
6973
|
}
|
|
6031
6974
|
else filter = await this.resolveRowFilter(data);
|
|
@@ -6046,8 +6989,9 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6046
6989
|
*/
|
|
6047
6990
|
async remove(id) {
|
|
6048
6991
|
await this.parseRequest("remove");
|
|
6049
|
-
const
|
|
6050
|
-
|
|
6992
|
+
const { id: resolved, overlay } = await this._resolveWithOverlay(id, "remove");
|
|
6993
|
+
const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
|
|
6994
|
+
return this._deleteOrThrow(resolvedId, overlay);
|
|
6051
6995
|
}
|
|
6052
6996
|
/**
|
|
6053
6997
|
* **DELETE /?field1=val1&field2=val2** — removes a record by composite key
|
|
@@ -6057,15 +7001,17 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6057
7001
|
await this.parseRequest("remove");
|
|
6058
7002
|
const idObj = this.extractIdShape(query);
|
|
6059
7003
|
if (idObj instanceof HttpError) throw idObj;
|
|
6060
|
-
const
|
|
6061
|
-
|
|
7004
|
+
const { id: resolved, overlay } = await this._resolveWithOverlay(idObj, "remove");
|
|
7005
|
+
const resolvedId = await this._checkHook(this.onRemove(resolved), "Not deleted");
|
|
7006
|
+
return this._deleteOrThrow(resolvedId, overlay);
|
|
6062
7007
|
}
|
|
6063
7008
|
};
|
|
6064
7009
|
__decorate([
|
|
6065
7010
|
Post(""),
|
|
6066
7011
|
__decorateParam(0, Body()),
|
|
7012
|
+
__decorateParam(1, Url()),
|
|
6067
7013
|
__decorateMetadata("design:type", Function),
|
|
6068
|
-
__decorateMetadata("design:paramtypes", [Object]),
|
|
7014
|
+
__decorateMetadata("design:paramtypes", [Object, String]),
|
|
6069
7015
|
__decorateMetadata("design:returntype", Promise)
|
|
6070
7016
|
], AsDbController.prototype, "insert", null);
|
|
6071
7017
|
__decorate([
|
|
@@ -6562,7 +7508,7 @@ function buildGateInterceptor(opts) {
|
|
|
6562
7508
|
await ctx.get(dbActionOverlaySlot);
|
|
6563
7509
|
if (level === "row") {
|
|
6564
7510
|
const verdict = judgeRow(action, disabled, await ctx.get(dbActionRowSlot));
|
|
6565
|
-
if (verdict) throw
|
|
7511
|
+
if (verdict) throw rowDisabledError(ctx, action, await ctx.get(dbActionIdSlot), verdictReason(verdict));
|
|
6566
7512
|
return;
|
|
6567
7513
|
}
|
|
6568
7514
|
if (await queryTargetReply(ctx, reply)) return;
|
|
@@ -6586,8 +7532,7 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
6586
7532
|
const existingRows = [];
|
|
6587
7533
|
for (const row of rows) if (row !== void 0) existingRows.push(row);
|
|
6588
7534
|
const verdicts = disabled ? judgeRows(action, disabled, existingRows) : void 0;
|
|
6589
|
-
const
|
|
6590
|
-
const failingReasons = [];
|
|
7535
|
+
const failing = [];
|
|
6591
7536
|
const passingRows = [];
|
|
6592
7537
|
const passingIds = [];
|
|
6593
7538
|
const skipped = [];
|
|
@@ -6598,8 +7543,10 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
6598
7543
|
const verdict = row === void 0 ? void 0 : verdicts?.[verdictIndex++];
|
|
6599
7544
|
if (row === void 0 || verdict) {
|
|
6600
7545
|
const reason = verdictReason(verdict);
|
|
6601
|
-
|
|
6602
|
-
|
|
7546
|
+
failing.push({
|
|
7547
|
+
id: ids[i],
|
|
7548
|
+
reason
|
|
7549
|
+
});
|
|
6603
7550
|
const skipReason = reason ?? (stale?.has(i) ? "stale" : void 0);
|
|
6604
7551
|
skipped.push(skipReason === void 0 ? { id: ids[i] } : {
|
|
6605
7552
|
id: ids[i],
|
|
@@ -6611,27 +7558,47 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
6611
7558
|
}
|
|
6612
7559
|
}
|
|
6613
7560
|
if (onDisabledRows === "skip") {
|
|
6614
|
-
if (passingRows.length === 0) throw
|
|
6615
|
-
if (
|
|
7561
|
+
if (passingRows.length === 0) throw disabledError(ctx, action, failing);
|
|
7562
|
+
if (failing.length > 0) {
|
|
6616
7563
|
ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
|
|
6617
7564
|
ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
|
|
6618
7565
|
ctx.set(dbActionSkippedKey, skipped);
|
|
6619
7566
|
}
|
|
6620
7567
|
return;
|
|
6621
7568
|
}
|
|
6622
|
-
if (
|
|
7569
|
+
if (failing.length > 0) throw disabledError(ctx, action, failing);
|
|
7570
|
+
}
|
|
7571
|
+
/**
|
|
7572
|
+
* The 409 of a `'rows'` gate: every failing REQUEST id in request order, each
|
|
7573
|
+
* as the client sent it, with its own reason (`resolveRowIds`, since 0.1.148)
|
|
7574
|
+
* — {@link echoRequests} is the one place that maps resolved ids back.
|
|
7575
|
+
*/
|
|
7576
|
+
function disabledError(ctx, action, failing) {
|
|
7577
|
+
const echoed = echoRequests(ctx, failing);
|
|
7578
|
+
return new ActionDisabledError(action, void 0, echoed.map((f) => f.id), echoed.map((f) => f.reason));
|
|
7579
|
+
}
|
|
7580
|
+
/** The 409 of a `'row'` gate: the id as the client sent it. */
|
|
7581
|
+
function rowDisabledError(ctx, action, id, reason) {
|
|
7582
|
+
return new ActionDisabledError(action, requestIdOf(ctx, id), void 0, [reason]);
|
|
7583
|
+
}
|
|
7584
|
+
/** The action's scope restricts (or needs the loaded rows to decide) — see `TPreScope`. */
|
|
7585
|
+
async function needsScopeLoad(ctx, level) {
|
|
7586
|
+
const pre = await ctx.get(dbActionPreScopeSlot)(level);
|
|
7587
|
+
return pre.kind === "deferred" || pre.scope !== null;
|
|
6623
7588
|
}
|
|
6624
7589
|
/**
|
|
6625
7590
|
* Interceptor for `'row'` / `'rows'` actions without `disabled` (and for a
|
|
6626
7591
|
* `@DbActionRow*` handler of any other level: bound-table injection only):
|
|
6627
7592
|
* 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
|
-
*
|
|
7593
|
+
* injects the bound table and — only when there is something to verify —
|
|
7594
|
+
* checks the requested ids before the handler runs by loading the row(s) the
|
|
7595
|
+
* handler would get: `'row'` → the 404 of a missing row; `'rows'` →
|
|
7596
|
+
* out-of-scope and missing ids fail like disabled rows with no reason
|
|
7597
|
+
* (`onDisabledRows`). Something to verify: a row overlay (`transformOne` /
|
|
7598
|
+
* `transformFilter` overridden, non-empty), a non-empty `actionRowScope` for
|
|
7599
|
+
* the action (the hook is asked first, with the request's ids — an override
|
|
7600
|
+
* that restricts nothing verifies nothing; since 0.1.148), or a query target
|
|
7601
|
+
* (since 0.1.147). Nothing to verify → no query.
|
|
6635
7602
|
*/
|
|
6636
7603
|
function buildThinInterceptor(opts) {
|
|
6637
7604
|
const { table, scope } = opts;
|
|
@@ -6642,12 +7609,12 @@ function buildThinInterceptor(opts) {
|
|
|
6642
7609
|
if (!scope) return;
|
|
6643
7610
|
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
6644
7611
|
if (scope.level === "row") {
|
|
6645
|
-
if (overlay ||
|
|
7612
|
+
if (overlay || await needsScopeLoad(ctx, "row")) await ctx.get(dbActionRowSlot);
|
|
6646
7613
|
return;
|
|
6647
7614
|
}
|
|
6648
7615
|
if (await queryTargetReply(ctx, reply)) return;
|
|
6649
7616
|
const target = await ctx.get(dbActionQueryTargetSlot);
|
|
6650
|
-
if (overlay || target ||
|
|
7617
|
+
if (overlay || target || await needsScopeLoad(ctx, "rows")) await gateRows(ctx, scope.action, void 0, scope.onDisabledRows);
|
|
6651
7618
|
await setMaterializedTarget(ctx);
|
|
6652
7619
|
}, ACTION_GATE_PRIORITY);
|
|
6653
7620
|
}
|
|
@@ -7070,6 +8037,52 @@ function DbActionsFrom(source, opts = {}) {
|
|
|
7070
8037
|
*/
|
|
7071
8038
|
const perRow = (fn) => (rows) => rows.map(fn);
|
|
7072
8039
|
//#endregion
|
|
8040
|
+
//#region src/decorations/db-decorations.decorator.ts
|
|
8041
|
+
/**
|
|
8042
|
+
* Declares display-only (decoration) fields of a table or view controller —
|
|
8043
|
+
* values `decorateRows` computes and attaches to rows (since 0.1.148).
|
|
8044
|
+
*
|
|
8045
|
+
* `type` is a plain atscript interface (no `@db.table` / `@db.view`) whose
|
|
8046
|
+
* top-level props are the decorations: their `@meta.label`, `@expect.*` and
|
|
8047
|
+
* `@ui.*` annotations travel in `/meta.decorations`, each key is listed in
|
|
8048
|
+
* `/meta.fields` with `decoration: true`, and a client may name it in
|
|
8049
|
+
* `$select`. A decoration is never filterable, sortable or groupable, and it
|
|
8050
|
+
* is not part of `/meta.type` (forms and write validation never see it).
|
|
8051
|
+
*
|
|
8052
|
+
* ```ts
|
|
8053
|
+
* @TableController(TicketTable)
|
|
8054
|
+
* @DbDecorations(TicketDecorations, { requires: { ownerName: ["ownerId"] } })
|
|
8055
|
+
* export class TicketsController extends AsDbController<typeof TicketTable> {
|
|
8056
|
+
* protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
|
|
8057
|
+
* if (ctx.decorations.has("ownerName")) {
|
|
8058
|
+
* // read rows[i].ownerId, set rows[i].ownerName
|
|
8059
|
+
* }
|
|
8060
|
+
* }
|
|
8061
|
+
* }
|
|
8062
|
+
* ```
|
|
8063
|
+
*
|
|
8064
|
+
* Validated once per class at first use (a `[moost-db]` error): the type is an
|
|
8065
|
+
* object interface; keys are top-level identifiers that collide with no field
|
|
8066
|
+
* or relation of the readable; every `requires` path is an own, readable
|
|
8067
|
+
* (not `@db.writeOnly`) field or a parent object of own fields (on SQL a nested
|
|
8068
|
+
* object is flattened to leaf columns; the hook still gets the whole object). Inherited under `@Inherit()`. Not supported on
|
|
8069
|
+
* value-help controllers.
|
|
8070
|
+
*
|
|
8071
|
+
* @since 0.1.148
|
|
8072
|
+
*/
|
|
8073
|
+
function DbDecorations(type, opts = {}) {
|
|
8074
|
+
if (!isAnnotatedType(type)) throw new Error("[moost-db] @DbDecorations: expects a compiled atscript interface");
|
|
8075
|
+
const meta = {
|
|
8076
|
+
type,
|
|
8077
|
+
requires: Object.fromEntries(Object.entries(opts.requires ?? {}).map(([key, paths]) => [key, [...paths ?? []]]))
|
|
8078
|
+
};
|
|
8079
|
+
const decorate = getAtscriptDbMate().decorate("atscript_db_decorations", meta);
|
|
8080
|
+
return (target) => {
|
|
8081
|
+
if (isAsValueHelpControllerSubclass(target)) throw new Error(`[moost-db] ${target.name} is a value-help controller — @DbDecorations is not supported there.`);
|
|
8082
|
+
return decorate(target);
|
|
8083
|
+
};
|
|
8084
|
+
}
|
|
8085
|
+
//#endregion
|
|
7073
8086
|
//#region src/permissions/crud-handlers.ts
|
|
7074
8087
|
/**
|
|
7075
8088
|
* The handler method(s) serving each CRUD op on `AsDbReadableController` /
|
|
@@ -7103,4 +8116,4 @@ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
|
|
|
7103
8116
|
one: ["runGetOne", "runGetOneComposite"]
|
|
7104
8117
|
});
|
|
7105
8118
|
//#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 };
|
|
8119
|
+
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 };
|