@atscript/moost-db 0.1.144 → 0.1.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +526 -195
- package/dist/index.d.cts +143 -6
- package/dist/index.d.mts +143 -6
- package/dist/index.mjs +525 -196
- package/package.json +16 -16
package/dist/index.mjs
CHANGED
|
@@ -347,7 +347,11 @@ function discoverActions(controllerCtor, app, logger) {
|
|
|
347
347
|
actionsCache.set(controllerCtor, out);
|
|
348
348
|
return out;
|
|
349
349
|
}
|
|
350
|
-
/**
|
|
350
|
+
/**
|
|
351
|
+
* The `'row'` / `'rows'`-level subset of {@link discoverActions} (since
|
|
352
|
+
* 0.1.145 public) — the actions `$actions` and `GET /meta/actions/:id`
|
|
353
|
+
* consider, in `/meta.actions` order. Memoized per controller class.
|
|
354
|
+
*/
|
|
351
355
|
function discoverRowLevelActions(controllerCtor, app, logger) {
|
|
352
356
|
const cached = rowLevelActionsCache.get(controllerCtor);
|
|
353
357
|
if (cached) return cached;
|
|
@@ -1109,94 +1113,6 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
|
|
|
1109
1113
|
Object
|
|
1110
1114
|
])], AsReadableController);
|
|
1111
1115
|
//#endregion
|
|
1112
|
-
//#region src/actions/verdict.ts
|
|
1113
|
-
/** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
|
|
1114
|
-
function assertVerdictLength(action, verdicts, expected) {
|
|
1115
|
-
if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
|
|
1116
|
-
}
|
|
1117
|
-
/** The reason a verdict carries — a non-empty string; `undefined` for `true` / falsy verdicts. */
|
|
1118
|
-
function verdictReason(verdict) {
|
|
1119
|
-
return typeof verdict === "string" && verdict !== "" ? verdict : void 0;
|
|
1120
|
-
}
|
|
1121
|
-
//#endregion
|
|
1122
|
-
//#region src/actions/list-augmenter.ts
|
|
1123
|
-
const candidateCache = /* @__PURE__ */ new WeakMap();
|
|
1124
|
-
/** WHY: envelopes are immutable post-discovery, so derived `Candidate` shape is cached for the envelope's lifetime; `null` sentinel pins table-level skip. */
|
|
1125
|
-
function getCandidate(e) {
|
|
1126
|
-
const cached = candidateCache.get(e);
|
|
1127
|
-
if (cached !== void 0) return cached;
|
|
1128
|
-
if (e.info.level !== "row" && e.info.level !== "rows") {
|
|
1129
|
-
candidateCache.set(e, null);
|
|
1130
|
-
return null;
|
|
1131
|
-
}
|
|
1132
|
-
const raw = e.raw;
|
|
1133
|
-
const c = {
|
|
1134
|
-
envelope: e,
|
|
1135
|
-
disabledFn: typeof raw.disabled === "function" ? raw.disabled : void 0,
|
|
1136
|
-
requiredFields: Array.isArray(raw.requiredFields) ? raw.requiredFields : []
|
|
1137
|
-
};
|
|
1138
|
-
candidateCache.set(e, c);
|
|
1139
|
-
return c;
|
|
1140
|
-
}
|
|
1141
|
-
function collectCandidates(envelopes) {
|
|
1142
|
-
const out = [];
|
|
1143
|
-
for (const e of envelopes) {
|
|
1144
|
-
const c = getCandidate(e);
|
|
1145
|
-
if (c !== null) out.push(c);
|
|
1146
|
-
}
|
|
1147
|
-
return out;
|
|
1148
|
-
}
|
|
1149
|
-
function computeStripFields(candidates, resolvedProjection) {
|
|
1150
|
-
let userSet = null;
|
|
1151
|
-
let strip = null;
|
|
1152
|
-
for (const c of candidates) for (const f of c.requiredFields) {
|
|
1153
|
-
if (userSet === null) userSet = new Set(resolvedProjection);
|
|
1154
|
-
if (userSet.has(f)) continue;
|
|
1155
|
-
if (strip === null) strip = /* @__PURE__ */ new Set();
|
|
1156
|
-
strip.add(f);
|
|
1157
|
-
}
|
|
1158
|
-
return strip;
|
|
1159
|
-
}
|
|
1160
|
-
/**
|
|
1161
|
-
* Sets `$actions` on every row (plus `$disabledReasons` on rows where a
|
|
1162
|
-
* predicate returned a reason string) and strips the columns fetched only for an
|
|
1163
|
-
* action's `requiredFields` — IN PLACE; returns the same array, typed as
|
|
1164
|
-
* augmented.
|
|
1165
|
-
*/
|
|
1166
|
-
function augmentRowsWithActions(args) {
|
|
1167
|
-
const { envelopes, rows, resolvedProjection } = args;
|
|
1168
|
-
const candidates = collectCandidates(envelopes);
|
|
1169
|
-
if (candidates.length === 0 || rows.length === 0) return rows;
|
|
1170
|
-
const verdicts = candidates.map((c) => {
|
|
1171
|
-
if (!c.disabledFn) return void 0;
|
|
1172
|
-
const out = c.disabledFn(rows);
|
|
1173
|
-
assertVerdictLength(c.envelope.info.name, out, rows.length);
|
|
1174
|
-
return out;
|
|
1175
|
-
});
|
|
1176
|
-
for (let i = 0; i < rows.length; i++) {
|
|
1177
|
-
const row = rows[i];
|
|
1178
|
-
const names = [];
|
|
1179
|
-
let reasons;
|
|
1180
|
-
for (let j = 0; j < candidates.length; j++) {
|
|
1181
|
-
const name = candidates[j].envelope.info.name;
|
|
1182
|
-
const verdict = verdicts[j]?.[i];
|
|
1183
|
-
if (!verdict) {
|
|
1184
|
-
names.push(name);
|
|
1185
|
-
continue;
|
|
1186
|
-
}
|
|
1187
|
-
const reason = verdictReason(verdict);
|
|
1188
|
-
if (reason !== void 0) (reasons ??= {})[name] = reason;
|
|
1189
|
-
}
|
|
1190
|
-
row.$actions = names;
|
|
1191
|
-
if (reasons) row.$disabledReasons = reasons;
|
|
1192
|
-
}
|
|
1193
|
-
if (resolvedProjection !== null) {
|
|
1194
|
-
const stripFields = computeStripFields(candidates, resolvedProjection);
|
|
1195
|
-
if (stripFields !== null) for (const row of rows) for (const f of stripFields) delete row[f];
|
|
1196
|
-
}
|
|
1197
|
-
return rows;
|
|
1198
|
-
}
|
|
1199
|
-
//#endregion
|
|
1200
1116
|
//#region src/actions/current-action.ts
|
|
1201
1117
|
/** Read the current action's `TDbActionMeta` from the wook context. Returns undefined outside a controller (e.g. direct-wook test paths). */
|
|
1202
1118
|
function readCurrentActionMeta(ctx) {
|
|
@@ -1434,6 +1350,13 @@ const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlo
|
|
|
1434
1350
|
//#endregion
|
|
1435
1351
|
//#region src/actions/row-scope.ts
|
|
1436
1352
|
/**
|
|
1353
|
+
* The key of `AsDbReadableController`'s internal action-overlay method —
|
|
1354
|
+
* `rowOverlay()` AND (since 0.1.145) the action's `actionRowScope`. A
|
|
1355
|
+
* registered symbol: not an overridable seam, and still found when
|
|
1356
|
+
* moost-db loads in two module realms (moost-vite SSR).
|
|
1357
|
+
*/
|
|
1358
|
+
const ACTION_OVERLAY = Symbol.for("atscript-db.actionOverlay");
|
|
1359
|
+
/**
|
|
1437
1360
|
* The controller whose hooks govern this action's rows: only when the action
|
|
1438
1361
|
* runs against the controller's OWN readable — an `opts.table` binding on a
|
|
1439
1362
|
* plain controller has no row overlay or visibility hook. Once per event.
|
|
@@ -1450,18 +1373,24 @@ const scopedControllerSlot = cached((ctx) => {
|
|
|
1450
1373
|
return ctrl;
|
|
1451
1374
|
});
|
|
1452
1375
|
/**
|
|
1453
|
-
* The controller's row overlay for action ids / rows — its
|
|
1454
|
-
* the
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1457
|
-
* `
|
|
1376
|
+
* The controller's row overlay for this action's ids / rows — its
|
|
1377
|
+
* `rowOverlay()` (the overlay `/one/:id` ANDs in) AND, since 0.1.145, the
|
|
1378
|
+
* action's `actionRowScope`; `null` when both are empty (no hook call, no
|
|
1379
|
+
* extra query when the controller overrides none of `transformOne`,
|
|
1380
|
+
* `transformFilter`, `actionRowScope`). Evaluated once per request, after
|
|
1381
|
+
* the controller's `prepareRequest` (since 0.1.143).
|
|
1458
1382
|
*/
|
|
1459
1383
|
const dbActionOverlaySlot = cached(async (ctx) => {
|
|
1460
1384
|
const ctrl = ctx.get(scopedControllerSlot);
|
|
1461
|
-
|
|
1385
|
+
const overlayOf = ctrl?.[ACTION_OVERLAY];
|
|
1386
|
+
if (!overlayOf) return null;
|
|
1462
1387
|
await awaitActionPrepared(ctx);
|
|
1463
|
-
return await
|
|
1388
|
+
return await overlayOf.call(ctrl, readCurrentActionMeta(ctx)?.name) ?? null;
|
|
1464
1389
|
});
|
|
1390
|
+
/** `filter` unless it is absent or `{}`. */
|
|
1391
|
+
function nonEmptyFilter(filter) {
|
|
1392
|
+
return filter && !isEmptyObject(filter) ? filter : void 0;
|
|
1393
|
+
}
|
|
1465
1394
|
/** `filter` AND the overlay (identity without one). */
|
|
1466
1395
|
function withOverlay(filter, overlay) {
|
|
1467
1396
|
return overlay ? { $and: [filter, overlay] } : filter;
|
|
@@ -1476,6 +1405,232 @@ function actionFieldVisibility(ctx) {
|
|
|
1476
1405
|
return visibility?.scoped ? visibility.isVisible : void 0;
|
|
1477
1406
|
}
|
|
1478
1407
|
//#endregion
|
|
1408
|
+
//#region src/actions/rows-by-id.ts
|
|
1409
|
+
function stringifyScalar(value) {
|
|
1410
|
+
if (value === null) return "null";
|
|
1411
|
+
if (value === void 0) return "undefined";
|
|
1412
|
+
return String(value);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* The identity of `id`'s values over `fields` (sorted) — equal for equal
|
|
1416
|
+
* keys across driver representations (a number vs its string, an ObjectId
|
|
1417
|
+
* vs its hex). `undefined` when `row` lacks one of the fields.
|
|
1418
|
+
*/
|
|
1419
|
+
function idKey(row, fields) {
|
|
1420
|
+
let key = "";
|
|
1421
|
+
for (const f of fields) {
|
|
1422
|
+
const v = row[f];
|
|
1423
|
+
if (v === void 0) return void 0;
|
|
1424
|
+
key += `${f}\x1f${stringifyScalar(v)}\x1e`;
|
|
1425
|
+
}
|
|
1426
|
+
return key;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* The rows `ids` address that also match `scope` (none = every row),
|
|
1430
|
+
* aligned with `ids` — `undefined` where nothing matched. One `findMany`
|
|
1431
|
+
* over the deduped ids (`{ $or: ids } AND scope`) selecting `select` plus
|
|
1432
|
+
* every id field; ids of different identification shapes may be mixed.
|
|
1433
|
+
* The action row loader and `$actions` scope check share it.
|
|
1434
|
+
*/
|
|
1435
|
+
async function findRowsByIds(source, ids, scope, select) {
|
|
1436
|
+
if (ids.length === 0) return [];
|
|
1437
|
+
const fields = new Set(select);
|
|
1438
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
1439
|
+
const idKeys = [];
|
|
1440
|
+
const dedupedIds = [];
|
|
1441
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
1442
|
+
for (const id of ids) {
|
|
1443
|
+
const sortedFields = Object.keys(id).toSorted();
|
|
1444
|
+
for (const f of sortedFields) fields.add(f);
|
|
1445
|
+
const sig = sortedFields.join("");
|
|
1446
|
+
if (!shapes.has(sig)) shapes.set(sig, sortedFields);
|
|
1447
|
+
const key = idKey(id, sortedFields);
|
|
1448
|
+
idKeys.push(key);
|
|
1449
|
+
if (key !== void 0 && !seenKeys.has(key)) {
|
|
1450
|
+
seenKeys.add(key);
|
|
1451
|
+
dedupedIds.push(id);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const rows = await source.findMany({
|
|
1455
|
+
filter: withOverlay({ $or: dedupedIds }, scope),
|
|
1456
|
+
controls: { $select: [...fields] }
|
|
1457
|
+
});
|
|
1458
|
+
const rowByKey = /* @__PURE__ */ new Map();
|
|
1459
|
+
for (const row of rows) for (const sortedFields of shapes.values()) {
|
|
1460
|
+
const key = idKey(row, sortedFields);
|
|
1461
|
+
if (key !== void 0 && !rowByKey.has(key)) rowByKey.set(key, row);
|
|
1462
|
+
}
|
|
1463
|
+
return idKeys.map((key) => key === void 0 ? void 0 : rowByKey.get(key));
|
|
1464
|
+
}
|
|
1465
|
+
/** The `requiredFields` of action opts (`@DbAction` opts or a discovered envelope's raw entry). */
|
|
1466
|
+
function requiredFieldsOf(opts) {
|
|
1467
|
+
const fields = opts?.requiredFields;
|
|
1468
|
+
return Array.isArray(fields) ? fields : [];
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Id columns (`preferredId`, else the primary key) plus `requiredFields` —
|
|
1472
|
+
* minus any `isVisible` hides (since 0.1.143; `hasField`, and a derived
|
|
1473
|
+
* field over a hidden source): a hidden column is never loaded, so a
|
|
1474
|
+
* `disabled` predicate sees it as `undefined`. What an action's gate loads.
|
|
1475
|
+
*/
|
|
1476
|
+
function actionRowFields(table, requiredFields, isVisible) {
|
|
1477
|
+
const fields = new Set(table.preferredId ?? table.primaryKeys);
|
|
1478
|
+
for (const f of requiredFields) if (!isVisible || isVisible(f)) fields.add(f);
|
|
1479
|
+
return fields;
|
|
1480
|
+
}
|
|
1481
|
+
/** `true` when `row` carries a top-level key outside `fields` (projecting it would drop something). */
|
|
1482
|
+
function exceedsFields(row, fields) {
|
|
1483
|
+
for (const key in row) if (!fields.has(key)) return true;
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
/** `row` narrowed to `fields` (dot paths copied into fresh nested objects). */
|
|
1487
|
+
function projectRow$1(row, fields) {
|
|
1488
|
+
const out = {};
|
|
1489
|
+
for (const path of fields) {
|
|
1490
|
+
const parts = path.split(".");
|
|
1491
|
+
let src = row;
|
|
1492
|
+
for (const p of parts) src = src?.[p];
|
|
1493
|
+
if (src === void 0) continue;
|
|
1494
|
+
let dst = out;
|
|
1495
|
+
for (let i = 0; i < parts.length - 1; i++) dst = dst[parts[i]] ??= {};
|
|
1496
|
+
dst[parts.at(-1)] = src;
|
|
1497
|
+
}
|
|
1498
|
+
return out;
|
|
1499
|
+
}
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/actions/verdict.ts
|
|
1502
|
+
/** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
|
|
1503
|
+
function assertVerdictLength(action, verdicts, expected) {
|
|
1504
|
+
if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
|
|
1505
|
+
}
|
|
1506
|
+
/** Runs `disabled` over `rows` — one verdict per row (a wrong-length answer → HTTP 500). */
|
|
1507
|
+
function judgeRows(action, disabled, rows) {
|
|
1508
|
+
const verdicts = disabled(rows);
|
|
1509
|
+
assertVerdictLength(action, verdicts, rows.length);
|
|
1510
|
+
return verdicts;
|
|
1511
|
+
}
|
|
1512
|
+
/** {@link judgeRows} for one row. */
|
|
1513
|
+
function judgeRow(action, disabled, row) {
|
|
1514
|
+
return judgeRows(action, disabled, [row])[0];
|
|
1515
|
+
}
|
|
1516
|
+
/** The reason a verdict carries — a non-empty string; `undefined` for `true` / falsy verdicts. */
|
|
1517
|
+
function verdictReason(verdict) {
|
|
1518
|
+
return typeof verdict === "string" && verdict !== "" ? verdict : void 0;
|
|
1519
|
+
}
|
|
1520
|
+
//#endregion
|
|
1521
|
+
//#region src/actions/list-augmenter.ts
|
|
1522
|
+
const candidateCache = /* @__PURE__ */ new WeakMap();
|
|
1523
|
+
/** WHY: envelopes are immutable post-discovery, so derived `Candidate` shape is cached for the envelope's lifetime; `null` sentinel pins table-level skip. */
|
|
1524
|
+
function getCandidate(e) {
|
|
1525
|
+
const cached = candidateCache.get(e);
|
|
1526
|
+
if (cached !== void 0) return cached;
|
|
1527
|
+
if (e.info.level !== "row" && e.info.level !== "rows") {
|
|
1528
|
+
candidateCache.set(e, null);
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
const raw = e.raw;
|
|
1532
|
+
const c = {
|
|
1533
|
+
envelope: e,
|
|
1534
|
+
disabledFn: typeof raw.disabled === "function" ? raw.disabled : void 0,
|
|
1535
|
+
requiredFields: requiredFieldsOf(e.raw)
|
|
1536
|
+
};
|
|
1537
|
+
candidateCache.set(e, c);
|
|
1538
|
+
return c;
|
|
1539
|
+
}
|
|
1540
|
+
function collectCandidates(envelopes) {
|
|
1541
|
+
const out = [];
|
|
1542
|
+
for (const e of envelopes) {
|
|
1543
|
+
const c = getCandidate(e);
|
|
1544
|
+
if (c !== null) out.push(c);
|
|
1545
|
+
}
|
|
1546
|
+
return out;
|
|
1547
|
+
}
|
|
1548
|
+
function computeStripFields(candidates, resolvedProjection) {
|
|
1549
|
+
let userSet = null;
|
|
1550
|
+
let strip = null;
|
|
1551
|
+
for (const c of candidates) for (const f of c.requiredFields) {
|
|
1552
|
+
if (userSet === null) userSet = new Set(resolvedProjection);
|
|
1553
|
+
if (userSet.has(f)) continue;
|
|
1554
|
+
if (strip === null) strip = /* @__PURE__ */ new Set();
|
|
1555
|
+
strip.add(f);
|
|
1556
|
+
}
|
|
1557
|
+
return strip;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Sets `$actions` on every row (plus `$disabledReasons` on rows where a
|
|
1561
|
+
* predicate returned a reason string) and strips the columns fetched only for an
|
|
1562
|
+
* action's `requiredFields` — IN PLACE; returns the same array, typed as
|
|
1563
|
+
* augmented.
|
|
1564
|
+
*/
|
|
1565
|
+
function augmentRowsWithActions(args) {
|
|
1566
|
+
const { envelopes, rows, resolvedProjection, outOfScope, gateFields } = args;
|
|
1567
|
+
const candidates = collectCandidates(envelopes);
|
|
1568
|
+
if (candidates.length === 0 || rows.length === 0) return rows;
|
|
1569
|
+
const verdicts = candidates.map((c) => {
|
|
1570
|
+
if (!c.disabledFn) return void 0;
|
|
1571
|
+
const fields = gateFields?.(c.envelope);
|
|
1572
|
+
const input = fields && rows.some((row) => exceedsFields(row, fields)) ? rows.map((row) => projectRow$1(row, fields)) : rows;
|
|
1573
|
+
return judgeRows(c.envelope.info.name, c.disabledFn, input);
|
|
1574
|
+
});
|
|
1575
|
+
const masks = outOfScope && candidates.map((c) => outOfScope.get(c.envelope.info.name));
|
|
1576
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1577
|
+
const row = rows[i];
|
|
1578
|
+
const names = [];
|
|
1579
|
+
let reasons;
|
|
1580
|
+
for (let j = 0; j < candidates.length; j++) {
|
|
1581
|
+
if (masks?.[j]?.[i]) continue;
|
|
1582
|
+
const name = candidates[j].envelope.info.name;
|
|
1583
|
+
const verdict = verdicts[j]?.[i];
|
|
1584
|
+
if (!verdict) {
|
|
1585
|
+
names.push(name);
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
const reason = verdictReason(verdict);
|
|
1589
|
+
if (reason !== void 0) (reasons ??= {})[name] = reason;
|
|
1590
|
+
}
|
|
1591
|
+
row.$actions = names;
|
|
1592
|
+
if (reasons) row.$disabledReasons = reasons;
|
|
1593
|
+
}
|
|
1594
|
+
if (resolvedProjection !== null) {
|
|
1595
|
+
const stripFields = computeStripFields(candidates, resolvedProjection);
|
|
1596
|
+
if (stripFields !== null) for (const row of rows) for (const f of stripFields) delete row[f];
|
|
1597
|
+
}
|
|
1598
|
+
return rows;
|
|
1599
|
+
}
|
|
1600
|
+
//#endregion
|
|
1601
|
+
//#region src/db-endpoint.ts
|
|
1602
|
+
/**
|
|
1603
|
+
* Tags a framework handler whose authorization is delegated to the
|
|
1604
|
+
* controller's `prepareRequest` (it runs first with `endpoint`), so a
|
|
1605
|
+
* permission layer can recognise it without knowing its method name.
|
|
1606
|
+
* Internal — moost-db tags its own handlers.
|
|
1607
|
+
*/
|
|
1608
|
+
function DbEndpoint(endpoint) {
|
|
1609
|
+
return getAtscriptDbMate().decorate("atscript_db_endpoint", endpoint);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* The `prepareRequest` endpoint a controller handler delegates its
|
|
1613
|
+
* authorization to (since 0.1.145) — `undefined` for a handler that is
|
|
1614
|
+
* authorized on its own like any other route. `target` is the controller
|
|
1615
|
+
* instance or class; the tag is inherited through the class chain (a
|
|
1616
|
+
* subclass override of a tagged handler keeps it). Today only
|
|
1617
|
+
* `GET /meta/actions/:id` and `/meta/actions?…` are tagged
|
|
1618
|
+
* (`"availableActions"`).
|
|
1619
|
+
*
|
|
1620
|
+
* ```ts
|
|
1621
|
+
* const endpoint = getDbEndpoint(controller, methodName);
|
|
1622
|
+
* if (endpoint) return; // prepareRequest({ endpoint }) authorizes this call
|
|
1623
|
+
* ```
|
|
1624
|
+
*/
|
|
1625
|
+
function getDbEndpoint(target, method) {
|
|
1626
|
+
const mate = getAtscriptDbMate();
|
|
1627
|
+
let proto = typeof target === "function" ? target.prototype : Object.getPrototypeOf(target);
|
|
1628
|
+
for (; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
1629
|
+
const endpoint = mate.read(proto, method)?.atscript_db_endpoint;
|
|
1630
|
+
if (endpoint) return endpoint;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
//#endregion
|
|
1479
1634
|
//#region src/decorators.ts
|
|
1480
1635
|
/**
|
|
1481
1636
|
* DI token under which the {@link AtscriptDbReadable} instance
|
|
@@ -1981,6 +2136,8 @@ function writeOnlyError(path, op) {
|
|
|
1981
2136
|
const verdict = writeOnlyVerdict(path, op);
|
|
1982
2137
|
return badRequest(verdict.path, verdict.message);
|
|
1983
2138
|
}
|
|
2139
|
+
/** Controller classes already warned that `actionRowScope` has no row identity to match by. */
|
|
2140
|
+
const warnedNoIdentity = /* @__PURE__ */ new WeakSet();
|
|
1984
2141
|
let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
|
|
1985
2142
|
/** Reference to the underlying readable (table or view). */
|
|
1986
2143
|
readable;
|
|
@@ -2054,6 +2211,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2054
2211
|
_decorates;
|
|
2055
2212
|
/** `true` when a subclass overrides {@link transformOne} or {@link transformFilter} (a row overlay may exist). */
|
|
2056
2213
|
_hasRowOverlay;
|
|
2214
|
+
/** Per-envelope gate fields, memoized while field visibility is not request-scoped. */
|
|
2215
|
+
_gateFieldsMemo = /* @__PURE__ */ new WeakMap();
|
|
2216
|
+
/** `true` when a subclass overrides {@link allowedActions}. */
|
|
2217
|
+
_hasAllowedActions;
|
|
2218
|
+
/** `true` when a subclass overrides {@link actionRowScope} (the gate, `$actions` and `/meta/actions` apply it). */
|
|
2219
|
+
_hasActionRowScope;
|
|
2057
2220
|
/** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
|
|
2058
2221
|
_quantityRefByPath;
|
|
2059
2222
|
/** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
|
|
@@ -2081,6 +2244,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2081
2244
|
this._decorates = typeof this.decorateRows === "function";
|
|
2082
2245
|
const proto = _AsDbReadableController.prototype;
|
|
2083
2246
|
this._hasRowOverlay = this.transformOne !== proto.transformOne || this.transformFilter !== proto.transformFilter;
|
|
2247
|
+
this._hasActionRowScope = this.actionRowScope !== proto.actionRowScope;
|
|
2248
|
+
this._hasAllowedActions = this.allowedActions !== proto.allowedActions;
|
|
2249
|
+
if (this._hasActionRowScope && resolved.preferredId.length === 0 && !warnedNoIdentity.has(new.target)) {
|
|
2250
|
+
warnedNoIdentity.add(new.target);
|
|
2251
|
+
this.logger.warn(`actionRowScope() is overridden but "${resolved.tableName}" has no primary key — \`$actions\` withholds every action with a non-empty row scope`);
|
|
2252
|
+
}
|
|
2084
2253
|
const scoped = this.hasField !== proto.hasField;
|
|
2085
2254
|
this._hasFieldOverridden = scoped;
|
|
2086
2255
|
const isVisible = (path) => {
|
|
@@ -2560,6 +2729,50 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2560
2729
|
return this.transformFilter(filter);
|
|
2561
2730
|
}
|
|
2562
2731
|
/**
|
|
2732
|
+
* The subset of the row-level action `names` the caller may run (since
|
|
2733
|
+
* 0.1.145) — what `$actions` and `GET /meta/actions/:id` list from. The
|
|
2734
|
+
* default keeps the names present in the per-request `/meta` envelope
|
|
2735
|
+
* (`applyMetaOverlay` over the cached one), or all of them when
|
|
2736
|
+
* `applyMetaOverlay` is not overridden (no call). Override it to answer
|
|
2737
|
+
* from per-action permission checks without building the whole `/meta`
|
|
2738
|
+
* overlay on every `$actions` read. Runs after {@link prepareRequest}.
|
|
2739
|
+
* Names it returns that are not in `names` are ignored.
|
|
2740
|
+
*
|
|
2741
|
+
* @since 0.1.145
|
|
2742
|
+
*/
|
|
2743
|
+
allowedActions(names) {
|
|
2744
|
+
if (this._overlayIsNoOp) return names;
|
|
2745
|
+
return (async () => {
|
|
2746
|
+
const present = new Set((await this.resolveMeta()).actions.map((a) => a.name));
|
|
2747
|
+
return names.filter((name) => present.has(name));
|
|
2748
|
+
})();
|
|
2749
|
+
}
|
|
2750
|
+
/**
|
|
2751
|
+
* The rows the row-level action `actionName` may run on (since 0.1.145),
|
|
2752
|
+
* as an extra row filter; `undefined` or `{}` = no restriction (the
|
|
2753
|
+
* default). Enforced by the action gate — ANDed with the {@link rowOverlay}
|
|
2754
|
+
* the action's ids / rows are loaded under, so an id outside it gets the
|
|
2755
|
+
* same 404 "Row not found for action identifier" as a missing one — and
|
|
2756
|
+
* reflected in `$actions` and `GET /meta/actions`, which list the action
|
|
2757
|
+
* only on rows inside it.
|
|
2758
|
+
*
|
|
2759
|
+
* Runs after {@link prepareRequest}, once per action per request. `$actions`
|
|
2760
|
+
* checks the page's rows with one id-only query per distinct filter
|
|
2761
|
+
* OBJECT (return the same object for several actions to share one query),
|
|
2762
|
+
* straight against the bound readable: the filter may use fields
|
|
2763
|
+
* {@link hasField} hides, and nothing of it reaches the response. Not
|
|
2764
|
+
* overriding it costs nothing.
|
|
2765
|
+
*
|
|
2766
|
+
* ```ts
|
|
2767
|
+
* protected actionRowScope(action: string) {
|
|
2768
|
+
* return action === "approve" ? { ownerId: currentUserId() } : undefined
|
|
2769
|
+
* }
|
|
2770
|
+
* ```
|
|
2771
|
+
*
|
|
2772
|
+
* @since 0.1.145
|
|
2773
|
+
*/
|
|
2774
|
+
actionRowScope(_actionName) {}
|
|
2775
|
+
/**
|
|
2563
2776
|
* Transform projection before querying.
|
|
2564
2777
|
* May return a Promise for async lookups.
|
|
2565
2778
|
*/
|
|
@@ -2687,14 +2900,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2687
2900
|
if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
|
|
2688
2901
|
throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
|
|
2689
2902
|
}
|
|
2690
|
-
/**
|
|
2903
|
+
/** Row/rows envelopes narrowed to {@link allowedActions}; no hook call when neither it nor `applyMetaOverlay` is overridden. */
|
|
2691
2904
|
async _resolveAugmentEnvelopes() {
|
|
2692
2905
|
const rowLevelEnvelopes = discoverRowLevelActions(this.constructor, this.app, this.logger);
|
|
2693
2906
|
if (rowLevelEnvelopes.length === 0) return null;
|
|
2694
|
-
if (this._overlayIsNoOp) return rowLevelEnvelopes;
|
|
2695
|
-
const
|
|
2696
|
-
const
|
|
2697
|
-
const filtered = rowLevelEnvelopes.filter((e) => allowedNames.has(e.info.name));
|
|
2907
|
+
if (this._overlayIsNoOp && !this._hasAllowedActions) return rowLevelEnvelopes;
|
|
2908
|
+
const allowed = new Set(await this.allowedActions(rowLevelEnvelopes.map((e) => e.info.name)));
|
|
2909
|
+
const filtered = rowLevelEnvelopes.filter((e) => allowed.has(e.info.name));
|
|
2698
2910
|
return filtered.length === 0 ? null : filtered;
|
|
2699
2911
|
}
|
|
2700
2912
|
/**
|
|
@@ -2706,18 +2918,14 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2706
2918
|
_widenSelectForActions(envelopes, baseSelect) {
|
|
2707
2919
|
let resultSet = null;
|
|
2708
2920
|
let result = null;
|
|
2709
|
-
for (const e of envelopes) {
|
|
2710
|
-
|
|
2711
|
-
if (
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
if (resultSet === null) {
|
|
2715
|
-
resultSet = new Set(baseSelect);
|
|
2716
|
-
result = [...baseSelect];
|
|
2717
|
-
}
|
|
2718
|
-
resultSet.add(f);
|
|
2719
|
-
result.push(f);
|
|
2921
|
+
for (const e of envelopes) for (const f of requiredFieldsOf(e.raw)) {
|
|
2922
|
+
if ((resultSet ? resultSet.has(f) : baseSelect.includes(f)) || !this.fieldVisibility.isVisible(f)) continue;
|
|
2923
|
+
if (resultSet === null) {
|
|
2924
|
+
resultSet = new Set(baseSelect);
|
|
2925
|
+
result = [...baseSelect];
|
|
2720
2926
|
}
|
|
2927
|
+
resultSet.add(f);
|
|
2928
|
+
result.push(f);
|
|
2721
2929
|
}
|
|
2722
2930
|
return result;
|
|
2723
2931
|
}
|
|
@@ -2725,14 +2933,104 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2725
2933
|
if (!controls.$actions) return null;
|
|
2726
2934
|
const envelopes = await this._resolveAugmentEnvelopes();
|
|
2727
2935
|
if (envelopes === null) return null;
|
|
2936
|
+
let scopeGroups = null;
|
|
2937
|
+
if (this._hasActionRowScope) {
|
|
2938
|
+
scopeGroups = this._resolveActionScopeGroups(envelopes);
|
|
2939
|
+
scopeGroups.catch(() => {});
|
|
2940
|
+
}
|
|
2728
2941
|
const resolvedProjection = this._resolveProjectionForAugmenter(select);
|
|
2729
2942
|
return {
|
|
2730
2943
|
envelopes,
|
|
2731
2944
|
resolvedProjection,
|
|
2732
|
-
widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection)
|
|
2945
|
+
widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection),
|
|
2946
|
+
scopeGroups
|
|
2733
2947
|
};
|
|
2734
2948
|
}
|
|
2735
2949
|
/**
|
|
2950
|
+
* {@link actionRowScope} of every offered action (in parallel, alongside
|
|
2951
|
+
* `overlay`), grouped by filter object; each group's filter is composed
|
|
2952
|
+
* with the row overlay exactly as the gate composes it.
|
|
2953
|
+
*/
|
|
2954
|
+
async _resolveActionScopeGroups(envelopes, overlay = this.rowOverlay()) {
|
|
2955
|
+
const [rowOverlay, scopes] = await Promise.all([overlay, Promise.all(envelopes.map(async (e) => this._actionScope(e.info.name)))]);
|
|
2956
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2957
|
+
for (let i = 0; i < envelopes.length; i++) {
|
|
2958
|
+
const scope = scopes[i];
|
|
2959
|
+
if (!scope) continue;
|
|
2960
|
+
let group = groups.get(scope);
|
|
2961
|
+
if (!group) {
|
|
2962
|
+
group = {
|
|
2963
|
+
filter: withOverlay(scope, rowOverlay),
|
|
2964
|
+
actions: []
|
|
2965
|
+
};
|
|
2966
|
+
groups.set(scope, group);
|
|
2967
|
+
}
|
|
2968
|
+
group.actions.push(envelopes[i].info.name);
|
|
2969
|
+
}
|
|
2970
|
+
return [...groups.values()];
|
|
2971
|
+
}
|
|
2972
|
+
/** Per scoped action, a per-row mask of rows outside its scope (one id-only read per group). */
|
|
2973
|
+
async _outOfScopeMasks(rows, groups) {
|
|
2974
|
+
const idFields = this.readable.preferredId;
|
|
2975
|
+
const idIndex = [];
|
|
2976
|
+
const ids = [];
|
|
2977
|
+
for (const row of rows) {
|
|
2978
|
+
const id = {};
|
|
2979
|
+
let complete = idFields.length > 0;
|
|
2980
|
+
for (const f of idFields) {
|
|
2981
|
+
const value = row[f];
|
|
2982
|
+
if (value === void 0 || value === null) {
|
|
2983
|
+
complete = false;
|
|
2984
|
+
break;
|
|
2985
|
+
}
|
|
2986
|
+
id[f] = value;
|
|
2987
|
+
}
|
|
2988
|
+
idIndex.push(complete ? ids.push(id) - 1 : -1);
|
|
2989
|
+
}
|
|
2990
|
+
const source = this.readable;
|
|
2991
|
+
const found = await Promise.all(groups.map((group) => findRowsByIds(source, ids, group.filter, [])));
|
|
2992
|
+
const masks = /* @__PURE__ */ new Map();
|
|
2993
|
+
for (let g = 0; g < groups.length; g++) {
|
|
2994
|
+
const mask = idIndex.map((i) => i < 0 || found[g][i] === void 0);
|
|
2995
|
+
for (const name of groups[g].actions) masks.set(name, mask);
|
|
2996
|
+
}
|
|
2997
|
+
return masks;
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* The row filter {@link actionRowScope} returns for `action` — `undefined`
|
|
3001
|
+
* when empty or when the hook is not overridden (no call). THE per-action
|
|
3002
|
+
* scope rule: the action gate, `$actions` and `/meta/actions` all read it here.
|
|
3003
|
+
*/
|
|
3004
|
+
async _actionScope(action) {
|
|
3005
|
+
if (!this._hasActionRowScope) return void 0;
|
|
3006
|
+
return nonEmptyFilter(await this.actionRowScope(action));
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
3009
|
+
* The fields `envelope`'s gate loads ({@link actionRowFields} under this
|
|
3010
|
+
* request's field visibility) — memoized unless visibility is request-scoped.
|
|
3011
|
+
*/
|
|
3012
|
+
_gateFields(envelope) {
|
|
3013
|
+
const visibility = this.fieldVisibility;
|
|
3014
|
+
const required = requiredFieldsOf(envelope.raw);
|
|
3015
|
+
if (visibility.scoped) return actionRowFields(this.readable, required, visibility.isVisible);
|
|
3016
|
+
let fields = this._gateFieldsMemo.get(envelope);
|
|
3017
|
+
if (!fields) {
|
|
3018
|
+
fields = actionRowFields(this.readable, required, void 0);
|
|
3019
|
+
this._gateFieldsMemo.set(envelope, fields);
|
|
3020
|
+
}
|
|
3021
|
+
return fields;
|
|
3022
|
+
}
|
|
3023
|
+
/**
|
|
3024
|
+
* @internal The action gate's overlay (reached by the `@DbAction`
|
|
3025
|
+
* interceptor through {@link ACTION_OVERLAY}): {@link rowOverlay} AND the
|
|
3026
|
+
* action's {@link actionRowScope} (since 0.1.145) — `undefined` when both
|
|
3027
|
+
* are empty.
|
|
3028
|
+
*/
|
|
3029
|
+
async [ACTION_OVERLAY](action) {
|
|
3030
|
+
const [overlay, scope] = await Promise.all([this.rowOverlay(), action === void 0 ? void 0 : this._actionScope(action)]);
|
|
3031
|
+
return scope ? withOverlay(scope, overlay) : overlay;
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
2736
3034
|
* `@db.column.searchable` paths, minus anything the adapter can't filter
|
|
2737
3035
|
* (JSON storage, encrypted), `@db.writeOnly` fields and navigation
|
|
2738
3036
|
* descendants — physical capability only (manual-mode policy does not
|
|
@@ -2801,7 +3099,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2801
3099
|
if (fields.length === 0) return filter;
|
|
2802
3100
|
const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
|
|
2803
3101
|
const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
|
|
2804
|
-
|
|
3102
|
+
const base = nonEmptyFilter(filter);
|
|
3103
|
+
return base ? { $and: [base, fragment] } : fragment;
|
|
2805
3104
|
}
|
|
2806
3105
|
/**
|
|
2807
3106
|
* The controls a grouped query hands to the adapter. `$search` has two
|
|
@@ -2847,15 +3146,27 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2847
3146
|
}
|
|
2848
3147
|
/**
|
|
2849
3148
|
* Finishes a read's top-level rows in place: `$actions` augmentation (when
|
|
2850
|
-
* the request asked for it — `prep
|
|
3149
|
+
* the request asked for it — `prep`; minus actions whose
|
|
3150
|
+
* {@link actionRowScope} a row misses), then {@link decorateRows} when a
|
|
2851
3151
|
* subclass implements it. Returns the hook's result — `undefined`, with no
|
|
2852
3152
|
* promise or microtask, when there is no hook or it is synchronous.
|
|
2853
3153
|
*/
|
|
2854
3154
|
_finishRows(rows, prep, ctx) {
|
|
3155
|
+
const groups = prep?.scopeGroups;
|
|
3156
|
+
if (!groups) return this._augmentAndDecorate(rows, prep, ctx);
|
|
3157
|
+
return (async () => {
|
|
3158
|
+
const masks = await this._outOfScopeMasks(rows, await groups);
|
|
3159
|
+
await this._augmentAndDecorate(rows, prep, ctx, masks);
|
|
3160
|
+
})();
|
|
3161
|
+
}
|
|
3162
|
+
/** `$actions` augmentation (when `prep`), then {@link decorateRows} when implemented. */
|
|
3163
|
+
_augmentAndDecorate(rows, prep, ctx, outOfScope) {
|
|
2855
3164
|
if (prep) augmentRowsWithActions({
|
|
2856
3165
|
envelopes: prep.envelopes,
|
|
2857
3166
|
rows,
|
|
2858
|
-
resolvedProjection: prep.resolvedProjection
|
|
3167
|
+
resolvedProjection: prep.resolvedProjection,
|
|
3168
|
+
gateFields: (e) => this._gateFields(e),
|
|
3169
|
+
outOfScope
|
|
2859
3170
|
});
|
|
2860
3171
|
return this._decorates ? this.decorateRows(rows, ctx) : void 0;
|
|
2861
3172
|
}
|
|
@@ -2930,8 +3241,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
2930
3241
|
*/
|
|
2931
3242
|
async rowOverlay() {
|
|
2932
3243
|
if (!this._hasRowOverlay) return void 0;
|
|
2933
|
-
|
|
2934
|
-
return overlay && !isEmptyObject(overlay) ? overlay : void 0;
|
|
3244
|
+
return nonEmptyFilter(await this.transformOne({}));
|
|
2935
3245
|
}
|
|
2936
3246
|
/**
|
|
2937
3247
|
* Pick the first identification (PK or unique index) whose fields are all
|
|
@@ -3174,6 +3484,71 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
3174
3484
|
return item;
|
|
3175
3485
|
}
|
|
3176
3486
|
/**
|
|
3487
|
+
* **GET /meta/actions/:id** — the row-level actions the caller may run on
|
|
3488
|
+
* the row `id` addresses (the `/one/:id` id forms), as
|
|
3489
|
+
* `{ actions, disabledReasons? }` — no row data (since 0.1.145). Exactly
|
|
3490
|
+
* what calling each action would do: the action survives
|
|
3491
|
+
* {@link applyMetaOverlay}, the row resolves under its gate overlay
|
|
3492
|
+
* ({@link actionOverlay}) and its `disabled` rule passes on the fields the
|
|
3493
|
+
* gate loads. Needs no read grant; an unknown or out-of-scope id answers
|
|
3494
|
+
* `{ actions: [] }`. {@link prepareRequest} runs first with
|
|
3495
|
+
* `endpoint: "availableActions"`.
|
|
3496
|
+
*/
|
|
3497
|
+
async availableActionsById(id) {
|
|
3498
|
+
await this.parseRequest("availableActions");
|
|
3499
|
+
return this._availableActions(id);
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* **GET /meta/actions?field1=val1&…** — {@link availableActionsById} by
|
|
3503
|
+
* composite key (composite primary key or compound unique index), the
|
|
3504
|
+
* `/one?…` rules.
|
|
3505
|
+
*/
|
|
3506
|
+
async availableActions(query) {
|
|
3507
|
+
await this.parseRequest("availableActions");
|
|
3508
|
+
const idObj = this.extractIdShape(query);
|
|
3509
|
+
if (idObj instanceof HttpError) return idObj;
|
|
3510
|
+
return this._availableActions(idObj);
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* The row resolves ONCE, like `/one/:id` under {@link rowOverlay}; scoped
|
|
3514
|
+
* actions are then checked on it exactly like `$actions` rows.
|
|
3515
|
+
*/
|
|
3516
|
+
async _availableActions(id) {
|
|
3517
|
+
const envelopes = await this._resolveAugmentEnvelopes();
|
|
3518
|
+
if (envelopes === null) return { actions: [] };
|
|
3519
|
+
const overlay = this.rowOverlay();
|
|
3520
|
+
const groups = this._hasActionRowScope ? this._resolveActionScopeGroups(envelopes, overlay) : void 0;
|
|
3521
|
+
groups?.catch(() => {});
|
|
3522
|
+
const idKeys = id !== null && typeof id === "object" ? Object.keys(id) : [];
|
|
3523
|
+
const fieldsOf = envelopes.map((e) => {
|
|
3524
|
+
const fields = this._gateFields(e);
|
|
3525
|
+
return idKeys.every((k) => fields.has(k)) ? fields : new Set([...fields, ...idKeys]);
|
|
3526
|
+
});
|
|
3527
|
+
const select = /* @__PURE__ */ new Set();
|
|
3528
|
+
for (const fields of fieldsOf) for (const f of fields) select.add(f);
|
|
3529
|
+
const row = await this._findRow(id, await overlay, { $select: [...select] });
|
|
3530
|
+
if (!row) return { actions: [] };
|
|
3531
|
+
const masks = groups ? await this._outOfScopeMasks([row], await groups) : void 0;
|
|
3532
|
+
const actions = [];
|
|
3533
|
+
let disabledReasons;
|
|
3534
|
+
for (let i = 0; i < envelopes.length; i++) {
|
|
3535
|
+
const name = envelopes[i].info.name;
|
|
3536
|
+
if (masks?.get(name)?.[0]) continue;
|
|
3537
|
+
const disabled = getCandidate(envelopes[i])?.disabledFn;
|
|
3538
|
+
const verdict = disabled ? judgeRow(name, disabled, projectRow$1(row, fieldsOf[i])) : false;
|
|
3539
|
+
if (!verdict) {
|
|
3540
|
+
actions.push(name);
|
|
3541
|
+
continue;
|
|
3542
|
+
}
|
|
3543
|
+
const reason = verdictReason(verdict);
|
|
3544
|
+
if (reason !== void 0) (disabledReasons ??= {})[name] = reason;
|
|
3545
|
+
}
|
|
3546
|
+
return disabledReasons ? {
|
|
3547
|
+
actions,
|
|
3548
|
+
disabledReasons
|
|
3549
|
+
} : { actions };
|
|
3550
|
+
}
|
|
3551
|
+
/**
|
|
3177
3552
|
* **GET /meta** — returns table/view metadata for UI.
|
|
3178
3553
|
*
|
|
3179
3554
|
* Overrides the base's minimal envelope to add relations, searchable flags,
|
|
@@ -3278,6 +3653,22 @@ __decorate([
|
|
|
3278
3653
|
__decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record, String]),
|
|
3279
3654
|
__decorateMetadata("design:returntype", Promise)
|
|
3280
3655
|
], AsDbReadableController.prototype, "getOneComposite", null);
|
|
3656
|
+
__decorate([
|
|
3657
|
+
Get("meta/actions/:id"),
|
|
3658
|
+
DbEndpoint("availableActions"),
|
|
3659
|
+
__decorateParam(0, Param("id")),
|
|
3660
|
+
__decorateMetadata("design:type", Function),
|
|
3661
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
3662
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3663
|
+
], AsDbReadableController.prototype, "availableActionsById", null);
|
|
3664
|
+
__decorate([
|
|
3665
|
+
Get("meta/actions"),
|
|
3666
|
+
DbEndpoint("availableActions"),
|
|
3667
|
+
__decorateParam(0, Query()),
|
|
3668
|
+
__decorateMetadata("design:type", Function),
|
|
3669
|
+
__decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
|
|
3670
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3671
|
+
], AsDbReadableController.prototype, "availableActions", null);
|
|
3281
3672
|
AsDbReadableController = _AsDbReadableController = __decorate([
|
|
3282
3673
|
Inherit(),
|
|
3283
3674
|
__decorateParam(1, Inject(READABLE_DEF)),
|
|
@@ -4073,33 +4464,13 @@ function asFetchTable(value) {
|
|
|
4073
4464
|
if (Array.isArray(v.primaryKeys) && typeof v.findOne === "function" && typeof v.findMany === "function") return v;
|
|
4074
4465
|
return null;
|
|
4075
4466
|
}
|
|
4076
|
-
function stringifyScalar(value) {
|
|
4077
|
-
if (value === null) return "null";
|
|
4078
|
-
if (value === void 0) return "undefined";
|
|
4079
|
-
return String(value);
|
|
4080
|
-
}
|
|
4081
|
-
/** Returns the action's `requiredFields` or null when called outside a controller context (e.g. direct wook usage in tests). */
|
|
4082
|
-
function readActionFieldSet(ctx) {
|
|
4083
|
-
const action = readCurrentActionMeta(ctx);
|
|
4084
|
-
if (!action) return null;
|
|
4085
|
-
const opts = action.opts;
|
|
4086
|
-
return Array.isArray(opts.requiredFields) ? opts.requiredFields : null;
|
|
4087
|
-
}
|
|
4088
4467
|
/**
|
|
4089
|
-
*
|
|
4090
|
-
*
|
|
4091
|
-
*
|
|
4092
|
-
* predicate sees it as `undefined`.
|
|
4468
|
+
* What this action's gate loads: {@link actionRowFields} of its
|
|
4469
|
+
* `requiredFields` (none outside a controller context, e.g. direct wook
|
|
4470
|
+
* usage in tests) under the controller's field visibility.
|
|
4093
4471
|
*/
|
|
4094
4472
|
function seedActionFields(ctx, table) {
|
|
4095
|
-
|
|
4096
|
-
for (const f of table.preferredId ?? table.primaryKeys) fields.add(f);
|
|
4097
|
-
const action = readActionFieldSet(ctx);
|
|
4098
|
-
if (action) {
|
|
4099
|
-
const visible = actionFieldVisibility(ctx);
|
|
4100
|
-
for (const f of action) if (!visible || visible(f)) fields.add(f);
|
|
4101
|
-
}
|
|
4102
|
-
return fields;
|
|
4473
|
+
return actionRowFields(table, requiredFieldsOf(readCurrentActionMeta(ctx)?.opts), actionFieldVisibility(ctx));
|
|
4103
4474
|
}
|
|
4104
4475
|
/**
|
|
4105
4476
|
* Loaded row / rows are ANDed with the controller's row overlay (see
|
|
@@ -4108,10 +4479,11 @@ function seedActionFields(ctx, table) {
|
|
|
4108
4479
|
* can't be told apart).
|
|
4109
4480
|
*
|
|
4110
4481
|
* The overlay resolves BEFORE the ids — i.e. before the request body is
|
|
4111
|
-
* read. Moost
|
|
4112
|
-
*
|
|
4113
|
-
*
|
|
4114
|
-
*
|
|
4482
|
+
* read. Moost ≤ 0.6.39 released the event's DI scope when the request stream
|
|
4483
|
+
* ended, so a `transformFilter` / `transformOne` instantiating a `FOR_EVENT`
|
|
4484
|
+
* dependency (an ARBAC user provider) failed with "scope isn't registered"
|
|
4485
|
+
* after the body was consumed; since 0.6.42 the scope outlives the response
|
|
4486
|
+
* and the handler, and the order is kept for fail-fast authorization.
|
|
4115
4487
|
*/
|
|
4116
4488
|
async function loadRow(ctx) {
|
|
4117
4489
|
const overlay = await ctx.get(dbActionOverlaySlot);
|
|
@@ -4132,46 +4504,7 @@ async function loadRows(ctx) {
|
|
|
4132
4504
|
const ids = await ctx.get(dbActionIdsSlot);
|
|
4133
4505
|
const table = asFetchTable(getActionTable(ctx));
|
|
4134
4506
|
if (!table) throw noTableError(ctx);
|
|
4135
|
-
|
|
4136
|
-
const fields = seedActionFields(ctx, table);
|
|
4137
|
-
const idKeys = [];
|
|
4138
|
-
const shapes = /* @__PURE__ */ new Map();
|
|
4139
|
-
const dedupedIds = [];
|
|
4140
|
-
const seenKeys = /* @__PURE__ */ new Set();
|
|
4141
|
-
for (const id of ids) {
|
|
4142
|
-
const sortedFields = Object.keys(id).toSorted();
|
|
4143
|
-
const sig = sortedFields.join("");
|
|
4144
|
-
let key = "";
|
|
4145
|
-
for (const f of sortedFields) {
|
|
4146
|
-
fields.add(f);
|
|
4147
|
-
key += `${f}\x1f${stringifyScalar(id[f])}\x1e`;
|
|
4148
|
-
}
|
|
4149
|
-
idKeys.push(key);
|
|
4150
|
-
if (!shapes.has(sig)) shapes.set(sig, sortedFields);
|
|
4151
|
-
if (!seenKeys.has(key)) {
|
|
4152
|
-
seenKeys.add(key);
|
|
4153
|
-
dedupedIds.push(id);
|
|
4154
|
-
}
|
|
4155
|
-
}
|
|
4156
|
-
const rows = await table.findMany({
|
|
4157
|
-
filter: withOverlay({ $or: dedupedIds }, overlay),
|
|
4158
|
-
controls: { $select: [...fields] }
|
|
4159
|
-
});
|
|
4160
|
-
const rowByKey = /* @__PURE__ */ new Map();
|
|
4161
|
-
for (const row of rows) for (const sortedFields of shapes.values()) {
|
|
4162
|
-
let key = "";
|
|
4163
|
-
let ok = true;
|
|
4164
|
-
for (const f of sortedFields) {
|
|
4165
|
-
const v = row[f];
|
|
4166
|
-
if (v === void 0) {
|
|
4167
|
-
ok = false;
|
|
4168
|
-
break;
|
|
4169
|
-
}
|
|
4170
|
-
key += `${f}\x1f${stringifyScalar(v)}\x1e`;
|
|
4171
|
-
}
|
|
4172
|
-
if (ok && !rowByKey.has(key)) rowByKey.set(key, row);
|
|
4173
|
-
}
|
|
4174
|
-
return ids.map((_, i) => rowByKey.get(idKeys[i]));
|
|
4507
|
+
return findRowsByIds(table, ids, overlay, seedActionFields(ctx, table));
|
|
4175
4508
|
}
|
|
4176
4509
|
const dbActionRowSlot = cached((ctx) => loadRow(ctx));
|
|
4177
4510
|
const dbActionRowsSlot = cached((ctx) => loadRows(ctx));
|
|
@@ -4193,9 +4526,8 @@ function buildGateInterceptor(opts) {
|
|
|
4193
4526
|
injectBoundTable(table);
|
|
4194
4527
|
await ctx.get(dbActionOverlaySlot);
|
|
4195
4528
|
if (level === "row") {
|
|
4196
|
-
const
|
|
4197
|
-
|
|
4198
|
-
if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdicts[0])]);
|
|
4529
|
+
const verdict = judgeRow(action, disabled, await ctx.get(dbActionRowSlot));
|
|
4530
|
+
if (verdict) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot), void 0, [verdictReason(verdict)]);
|
|
4199
4531
|
return;
|
|
4200
4532
|
}
|
|
4201
4533
|
await gateRows(ctx, action, disabled, onDisabledRows);
|
|
@@ -4213,11 +4545,7 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
4213
4545
|
const rows = await ctx.get(dbActionRowsSlot);
|
|
4214
4546
|
const existingRows = [];
|
|
4215
4547
|
for (const row of rows) if (row !== void 0) existingRows.push(row);
|
|
4216
|
-
|
|
4217
|
-
if (disabled) {
|
|
4218
|
-
verdicts = disabled(existingRows);
|
|
4219
|
-
assertVerdictLength(action, verdicts, existingRows.length);
|
|
4220
|
-
}
|
|
4548
|
+
const verdicts = disabled ? judgeRows(action, disabled, existingRows) : void 0;
|
|
4221
4549
|
const failingIds = [];
|
|
4222
4550
|
const failingReasons = [];
|
|
4223
4551
|
const passingRows = [];
|
|
@@ -4249,7 +4577,8 @@ async function gateRows(ctx, action, disabled, onDisabledRows) {
|
|
|
4249
4577
|
* `@DbActionRow*` handler of any other level: bound-table injection only):
|
|
4250
4578
|
* runs the controller's `prepareRequest` (when defined, since 0.1.143),
|
|
4251
4579
|
* injects the bound table and — only when the controller has a row overlay
|
|
4252
|
-
* (`transformOne` / `transformFilter` overridden,
|
|
4580
|
+
* for the action (`transformOne` / `transformFilter` overridden, or — since
|
|
4581
|
+
* 0.1.145 — an `actionRowScope` for it; non-empty) — verifies the
|
|
4253
4582
|
* requested ids against it before the handler runs by loading the row(s)
|
|
4254
4583
|
* the handler would get: `'row'` → the 404 of a missing row; `'rows'` →
|
|
4255
4584
|
* out-of-scope and missing ids fail like disabled rows with no reason
|
|
@@ -4612,4 +4941,4 @@ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
|
|
|
4612
4941
|
one: ["runGetOne", "runGetOneComposite"]
|
|
4613
4942
|
});
|
|
4614
4943
|
//#endregion
|
|
4615
|
-
export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
|
|
4944
|
+
export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, validationErrorTransform };
|