@atscript/moost-db 0.1.58 → 0.1.60
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/README.md +1 -1
- package/dist/index.cjs +608 -290
- package/dist/index.d.cts +180 -136
- package/dist/index.d.mts +180 -136
- package/dist/index.mjs +604 -287
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -98,7 +98,7 @@ var SelectControlDto = class {
|
|
|
98
98
|
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
99
99
|
}
|
|
100
100
|
};
|
|
101
|
-
defineAnnotatedType("object", QueryControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$count", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
|
|
101
|
+
defineAnnotatedType("object", QueryControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$count", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
|
|
102
102
|
defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedType().designType("string").tags("string").annotate("expect.pattern", {
|
|
103
103
|
pattern: "^\\d+$",
|
|
104
104
|
flags: "u",
|
|
@@ -107,8 +107,8 @@ defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedTyp
|
|
|
107
107
|
pattern: "^\\d+$",
|
|
108
108
|
flags: "u",
|
|
109
109
|
message: "Expected positive number"
|
|
110
|
-
}, true).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
|
|
111
|
-
defineAnnotatedType("object", GetOneControlsDto).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
|
|
110
|
+
}, true).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$search", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$index", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$vector", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$threshold", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
|
|
111
|
+
defineAnnotatedType("object", GetOneControlsDto).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type).prop("$actions", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type);
|
|
112
112
|
defineAnnotatedType("object", WithRelationDto).prop("name", defineAnnotatedType().designType("string").tags("string").$type).prop("filter", defineAnnotatedType().refTo(WithFilterDto).optional().$type).prop("controls", defineAnnotatedType().refTo(WithRelationControlsDto).optional().$type).prop("insights", defineAnnotatedType().refTo(WithFilterDto).optional().$type);
|
|
113
113
|
defineAnnotatedType("object", WithRelationControlsDto).prop("$skip", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$limit", defineAnnotatedType().designType("number").tags("positive", "int", "number").annotate("expect.int", true).annotate("expect.min", { minValue: 0 }).optional().$type).prop("$sort", defineAnnotatedType().refTo(SortControlDto).optional().$type).prop("$select", defineAnnotatedType("union").item(defineAnnotatedType().refTo(SelectControlDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).optional().$type).prop("$with", defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithRelationDto).$type).optional().$type);
|
|
114
114
|
defineAnnotatedType("object", WithFilterDto).propPattern(/./, defineAnnotatedType("union").item(defineAnnotatedType().designType("string").tags("string").$type).item(defineAnnotatedType().designType("number").tags("number").$type).item(defineAnnotatedType().designType("boolean").tags("boolean").$type).item(defineAnnotatedType().designType("null").tags("null").$type).item(defineAnnotatedType().refTo(WithFilterDto).$type).item(defineAnnotatedType("array").of(defineAnnotatedType().refTo(WithFilterDto).$type).$type).$type);
|
|
@@ -209,7 +209,7 @@ const WARN_PREFIX = "[moost-db actions]";
|
|
|
209
209
|
const MOOST_DB_ACTION = "atscript_db_action";
|
|
210
210
|
/** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
|
|
211
211
|
const MOOST_DB_ACTIONS = "atscript_db_actions";
|
|
212
|
-
/** Param-level metadata key — written by `@
|
|
212
|
+
/** Param-level metadata key — written by `@DbActionID()` / `@DbActionIDs()`. Drives level inference. */
|
|
213
213
|
const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
|
|
214
214
|
/** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
|
|
215
215
|
const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
|
|
@@ -239,8 +239,8 @@ function scanParamLevel(params) {
|
|
|
239
239
|
let hasBody = false;
|
|
240
240
|
for (const p of params) {
|
|
241
241
|
const kind = p[MOOST_DB_ACTION_PARAM];
|
|
242
|
-
if (kind === "
|
|
243
|
-
else if (kind === "
|
|
242
|
+
if (kind === "id") single = true;
|
|
243
|
+
else if (kind === "ids") multi = true;
|
|
244
244
|
if (p["atscript_db_action_row"]) {
|
|
245
245
|
single = true;
|
|
246
246
|
hasRowParam = true;
|
|
@@ -261,11 +261,7 @@ function scanParamLevel(params) {
|
|
|
261
261
|
}
|
|
262
262
|
//#endregion
|
|
263
263
|
//#region src/actions/discover.ts
|
|
264
|
-
/**
|
|
265
|
-
* Pure structural-copy fields. `disabled` and `requiredFields` are handled
|
|
266
|
-
* as special cases in {@link emitInfo} so the function-to-string transform
|
|
267
|
-
* stays out of the copy loop.
|
|
268
|
-
*/
|
|
264
|
+
/** Structural-copy fields; `disabled` is handled separately in {@link emitInfo} (function-to-string transform). */
|
|
269
265
|
const OPTIONAL_FIELDS = [
|
|
270
266
|
"icon",
|
|
271
267
|
"intent",
|
|
@@ -276,27 +272,29 @@ const OPTIONAL_FIELDS = [
|
|
|
276
272
|
"shortcut"
|
|
277
273
|
];
|
|
278
274
|
const actionsCache = /* @__PURE__ */ new WeakMap();
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
* Reads class + method metadata via `getMoostMate()` and resolves bound POST
|
|
282
|
-
* paths through the Moost controller overview.
|
|
283
|
-
*
|
|
284
|
-
* Result is memoized per controller constructor — discovery walks every
|
|
285
|
-
* handler entry and reads decorator metadata, which is wasted work to repeat
|
|
286
|
-
* across instances.
|
|
287
|
-
*/
|
|
275
|
+
const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
|
|
276
|
+
/** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
|
|
288
277
|
function discoverActions(controllerCtor, app, logger) {
|
|
289
278
|
const cached = actionsCache.get(controllerCtor);
|
|
290
279
|
if (cached) return cached;
|
|
291
280
|
const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
|
|
292
281
|
const out = [];
|
|
293
|
-
|
|
294
|
-
|
|
282
|
+
const seen = /* @__PURE__ */ new Set();
|
|
283
|
+
collectMethodActions(controllerCtor, overview, logger, out, seen);
|
|
284
|
+
collectClassActions(controllerCtor, logger, out, seen);
|
|
295
285
|
applyDefaultPerLevel(out, logger);
|
|
296
286
|
actionsCache.set(controllerCtor, out);
|
|
297
287
|
return out;
|
|
298
288
|
}
|
|
299
|
-
|
|
289
|
+
/** Row/rows-level subset of {@link discoverActions}; memoized per ctor. */
|
|
290
|
+
function discoverRowLevelActions(controllerCtor, app, logger) {
|
|
291
|
+
const cached = rowLevelActionsCache.get(controllerCtor);
|
|
292
|
+
if (cached) return cached;
|
|
293
|
+
const filtered = discoverActions(controllerCtor, app, logger).filter((e) => e.info.level === "row" || e.info.level === "rows");
|
|
294
|
+
rowLevelActionsCache.set(controllerCtor, filtered);
|
|
295
|
+
return filtered;
|
|
296
|
+
}
|
|
297
|
+
function collectMethodActions(ctor, overview, logger, out, seen) {
|
|
300
298
|
if (!overview) return;
|
|
301
299
|
const byMethod = /* @__PURE__ */ new Map();
|
|
302
300
|
for (const h of overview.handlers) {
|
|
@@ -315,13 +313,17 @@ function collectMethodActions(ctor, overview, logger, out) {
|
|
|
315
313
|
const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
|
|
316
314
|
if (!levelInfer) continue;
|
|
317
315
|
if (levelInfer.bodyConflict) {
|
|
318
|
-
logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @
|
|
316
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionID*/@DbActionIDs/@DbActionRow*/@DbActionRows with @Body() — dropping`);
|
|
319
317
|
continue;
|
|
320
318
|
}
|
|
321
319
|
if (levelInfer.level === "table" && action.opts.disabled !== void 0) {
|
|
322
320
|
logger.warn(`${WARN_PREFIX} action "${action.name}" — \`disabled\` is not allowed at the 'table' level; row-state predicates are not meaningful when no row is in scope. Use @Authenticate / arbac for table-level access — dropping`);
|
|
323
321
|
continue;
|
|
324
322
|
}
|
|
323
|
+
if (action.opts.disabled !== void 0 && !isNonEmptyStringArray(action.opts.requiredFields)) {
|
|
324
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" — \`disabled\` requires a non-empty \`requiredFields\` array (the predicate's field dependencies must be declared explicitly) — dropping`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
325
327
|
if (action.opts.disabled !== void 0 || levelInfer.hasRowParam) {
|
|
326
328
|
const extendsReadable = isAsDbReadableControllerSubclass(ctor);
|
|
327
329
|
const hasOptsTable = action.opts.table != null;
|
|
@@ -345,6 +347,10 @@ function collectMethodActions(ctor, overview, logger, out) {
|
|
|
345
347
|
logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
|
|
346
348
|
continue;
|
|
347
349
|
}
|
|
350
|
+
if (seen.has(action.name)) {
|
|
351
|
+
logger.warn(`${WARN_PREFIX} duplicate action name "${action.name}" within controller — dropping the second declaration`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
348
354
|
const info = {
|
|
349
355
|
name: action.name,
|
|
350
356
|
label,
|
|
@@ -352,14 +358,18 @@ function collectMethodActions(ctor, overview, logger, out) {
|
|
|
352
358
|
processor: "backend",
|
|
353
359
|
value: path
|
|
354
360
|
};
|
|
355
|
-
emitInfo(info, action.opts
|
|
356
|
-
|
|
361
|
+
emitInfo(info, action.opts);
|
|
362
|
+
seen.add(action.name);
|
|
363
|
+
out.push({
|
|
364
|
+
info,
|
|
365
|
+
raw: action.opts
|
|
366
|
+
});
|
|
357
367
|
}
|
|
358
368
|
}
|
|
359
369
|
function inferMethodLevel(params, actionName, logger) {
|
|
360
370
|
const scan = scanParamLevel(params);
|
|
361
371
|
if (scan.single && scan.multi) {
|
|
362
|
-
logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@
|
|
372
|
+
logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionID / @DbActionRow vs @DbActionIDs / @DbActionRows) — dropping`);
|
|
363
373
|
return null;
|
|
364
374
|
}
|
|
365
375
|
return {
|
|
@@ -368,12 +378,22 @@ function inferMethodLevel(params, actionName, logger) {
|
|
|
368
378
|
hasRowParam: scan.hasRowParam
|
|
369
379
|
};
|
|
370
380
|
}
|
|
371
|
-
function collectClassActions(ctor, logger, out) {
|
|
381
|
+
function collectClassActions(ctor, logger, out, seen) {
|
|
372
382
|
const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
|
|
373
383
|
if (!list) return;
|
|
374
384
|
for (const { name, entry } of list) {
|
|
385
|
+
if (seen.has(name)) {
|
|
386
|
+
logger.warn(`${WARN_PREFIX} duplicate action name "${name}" within controller — dropping the second declaration`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
375
389
|
const built = buildClassEntry(name, entry, logger);
|
|
376
|
-
if (built)
|
|
390
|
+
if (built) {
|
|
391
|
+
seen.add(name);
|
|
392
|
+
out.push({
|
|
393
|
+
info: built,
|
|
394
|
+
raw: entry
|
|
395
|
+
});
|
|
396
|
+
}
|
|
377
397
|
}
|
|
378
398
|
}
|
|
379
399
|
function buildClassEntry(name, entry, logger) {
|
|
@@ -390,6 +410,10 @@ function buildClassEntry(name, entry, logger) {
|
|
|
390
410
|
logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` is not allowed at the 'table' level — dropping`);
|
|
391
411
|
return null;
|
|
392
412
|
}
|
|
413
|
+
if (entry.disabled !== void 0 && !isNonEmptyStringArray(entry.requiredFields)) {
|
|
414
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` requires a non-empty \`requiredFields\` array (the predicate's field dependencies must be declared explicitly) — dropping`);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
393
417
|
const processor = entry.processor;
|
|
394
418
|
let value;
|
|
395
419
|
if (processor === "navigate" || processor === "backend") {
|
|
@@ -417,39 +441,27 @@ function buildClassEntry(name, entry, logger) {
|
|
|
417
441
|
processor,
|
|
418
442
|
value
|
|
419
443
|
};
|
|
420
|
-
emitInfo(info, entry
|
|
444
|
+
emitInfo(info, entry);
|
|
421
445
|
return info;
|
|
422
446
|
}
|
|
423
|
-
function applyDefaultPerLevel(
|
|
447
|
+
function applyDefaultPerLevel(envelopes, logger) {
|
|
424
448
|
const winners = /* @__PURE__ */ new Map();
|
|
425
|
-
for (const
|
|
426
|
-
if (!
|
|
427
|
-
const existing = winners.get(
|
|
449
|
+
for (const { info } of envelopes) {
|
|
450
|
+
if (!info.default) continue;
|
|
451
|
+
const existing = winners.get(info.level);
|
|
428
452
|
if (existing) {
|
|
429
|
-
|
|
430
|
-
logger.warn(`${WARN_PREFIX} duplicate default action at level "${
|
|
431
|
-
} else winners.set(
|
|
453
|
+
info.default = false;
|
|
454
|
+
logger.warn(`${WARN_PREFIX} duplicate default action at level "${info.level}": "${existing}" wins, "${info.name}" demoted`);
|
|
455
|
+
} else winners.set(info.level, info.name);
|
|
432
456
|
}
|
|
433
457
|
}
|
|
434
|
-
/**
|
|
435
|
-
|
|
436
|
-
* `requiredFields` (forwarded verbatim — server doesn't auto-derive).
|
|
437
|
-
* `requiredFields` without `disabled` is dropped with a warning before the
|
|
438
|
-
* structural copy runs, so method-decorator and class-level-dict origins
|
|
439
|
-
* stay symmetric.
|
|
440
|
-
*/
|
|
441
|
-
function emitInfo(info, source, name, logger) {
|
|
458
|
+
/** Emit structural-copy fields plus stringified `disabled`. `requiredFields` is server-internal (never on the wire). */
|
|
459
|
+
function emitInfo(info, source) {
|
|
442
460
|
const disabled = source.disabled;
|
|
443
461
|
const hasDisabled = typeof disabled === "function";
|
|
444
|
-
let requiredFields = source.requiredFields;
|
|
445
|
-
if (!hasDisabled && requiredFields !== void 0) {
|
|
446
|
-
logger.warn(`${WARN_PREFIX} action "${name}" has \`requiredFields\` without \`disabled\` — \`requiredFields\` is purely a UI hint and meaningless without a predicate. Dropping \`requiredFields\` from /meta.`);
|
|
447
|
-
requiredFields = void 0;
|
|
448
|
-
}
|
|
449
462
|
copyOptionalFields(info, source);
|
|
450
463
|
if (Array.isArray(info.promptText)) info.promptText = info.promptText.slice();
|
|
451
464
|
if (hasDisabled) info.disabled = disabled.toString();
|
|
452
|
-
if (Array.isArray(requiredFields)) info.requiredFields = requiredFields.slice();
|
|
453
465
|
}
|
|
454
466
|
function copyOptionalFields(info, source) {
|
|
455
467
|
for (const key of OPTIONAL_FIELDS) {
|
|
@@ -457,6 +469,9 @@ function copyOptionalFields(info, source) {
|
|
|
457
469
|
if (value !== void 0) info[key] = value;
|
|
458
470
|
}
|
|
459
471
|
}
|
|
472
|
+
function isNonEmptyStringArray(value) {
|
|
473
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string");
|
|
474
|
+
}
|
|
460
475
|
//#endregion
|
|
461
476
|
//#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
|
|
462
477
|
function __decorateMetadata(k, v) {
|
|
@@ -639,6 +654,7 @@ let AsReadableController = class AsReadableController {
|
|
|
639
654
|
vectorSearchable: false,
|
|
640
655
|
searchIndexes: [],
|
|
641
656
|
primaryKeys: [],
|
|
657
|
+
preferredId: [],
|
|
642
658
|
relations: [],
|
|
643
659
|
fields: {},
|
|
644
660
|
type: this.getSerializedType(),
|
|
@@ -652,7 +668,7 @@ let AsReadableController = class AsReadableController {
|
|
|
652
668
|
* controllers — see {@link AsValueHelpController#buildMetaResponse}.
|
|
653
669
|
*/
|
|
654
670
|
buildActions() {
|
|
655
|
-
return discoverActions(this.constructor, this.app, this.logger);
|
|
671
|
+
return discoverActions(this.constructor, this.app, this.logger).map((e) => e.info);
|
|
656
672
|
}
|
|
657
673
|
/**
|
|
658
674
|
* Declares the built-in CRUD operations this controller exposes. Subclasses
|
|
@@ -687,6 +703,80 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
|
|
|
687
703
|
Object
|
|
688
704
|
])], AsReadableController);
|
|
689
705
|
//#endregion
|
|
706
|
+
//#region src/actions/verdict.ts
|
|
707
|
+
/** Assert that a `disabled` predicate returned a `boolean[]` of the expected length; throws HTTP 500 otherwise. */
|
|
708
|
+
function assertVerdictLength(action, verdicts, expected) {
|
|
709
|
+
if (!Array.isArray(verdicts) || verdicts.length !== expected) throw new HttpError(500, `Action "${action}" disabled predicate returned an invalid verdict array`);
|
|
710
|
+
}
|
|
711
|
+
//#endregion
|
|
712
|
+
//#region src/actions/list-augmenter.ts
|
|
713
|
+
const candidateCache = /* @__PURE__ */ new WeakMap();
|
|
714
|
+
/** WHY: envelopes are immutable post-discovery, so derived `Candidate` shape is cached for the envelope's lifetime; `null` sentinel pins table-level skip. */
|
|
715
|
+
function getCandidate(e) {
|
|
716
|
+
const cached = candidateCache.get(e);
|
|
717
|
+
if (cached !== void 0) return cached;
|
|
718
|
+
if (e.info.level !== "row" && e.info.level !== "rows") {
|
|
719
|
+
candidateCache.set(e, null);
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
const raw = e.raw;
|
|
723
|
+
const c = {
|
|
724
|
+
envelope: e,
|
|
725
|
+
disabledFn: typeof raw.disabled === "function" ? raw.disabled : void 0,
|
|
726
|
+
requiredFields: Array.isArray(raw.requiredFields) ? raw.requiredFields : []
|
|
727
|
+
};
|
|
728
|
+
candidateCache.set(e, c);
|
|
729
|
+
return c;
|
|
730
|
+
}
|
|
731
|
+
function collectCandidates(envelopes) {
|
|
732
|
+
const out = [];
|
|
733
|
+
for (const e of envelopes) {
|
|
734
|
+
const c = getCandidate(e);
|
|
735
|
+
if (c !== null) out.push(c);
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
function computeStripFields(candidates, resolvedProjection) {
|
|
740
|
+
let userSet = null;
|
|
741
|
+
let strip = null;
|
|
742
|
+
for (const c of candidates) for (const f of c.requiredFields) {
|
|
743
|
+
if (userSet === null) userSet = new Set(resolvedProjection);
|
|
744
|
+
if (userSet.has(f)) continue;
|
|
745
|
+
if (strip === null) strip = /* @__PURE__ */ new Set();
|
|
746
|
+
strip.add(f);
|
|
747
|
+
}
|
|
748
|
+
return strip;
|
|
749
|
+
}
|
|
750
|
+
function augmentRowsWithActions(args) {
|
|
751
|
+
const { envelopes, rows, resolvedProjection } = args;
|
|
752
|
+
const candidates = collectCandidates(envelopes);
|
|
753
|
+
if (candidates.length === 0 || rows.length === 0) return rows;
|
|
754
|
+
const verdicts = candidates.map((c) => {
|
|
755
|
+
if (!c.disabledFn) return void 0;
|
|
756
|
+
const out = c.disabledFn(rows);
|
|
757
|
+
assertVerdictLength(c.envelope.info.name, out, rows.length);
|
|
758
|
+
return out;
|
|
759
|
+
});
|
|
760
|
+
for (let i = 0; i < rows.length; i++) {
|
|
761
|
+
const row = rows[i];
|
|
762
|
+
const names = [];
|
|
763
|
+
for (let j = 0; j < candidates.length; j++) {
|
|
764
|
+
const v = verdicts[j];
|
|
765
|
+
if (v === void 0) {
|
|
766
|
+
names.push(candidates[j].envelope.info.name);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (!v[i]) names.push(candidates[j].envelope.info.name);
|
|
770
|
+
}
|
|
771
|
+
row.$actions = names;
|
|
772
|
+
}
|
|
773
|
+
if (resolvedProjection !== null) {
|
|
774
|
+
const stripFields = computeStripFields(candidates, resolvedProjection);
|
|
775
|
+
if (stripFields !== null) for (const row of rows) for (const f of stripFields) delete row[f];
|
|
776
|
+
}
|
|
777
|
+
return rows;
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
690
780
|
//#region src/decorators.ts
|
|
691
781
|
/**
|
|
692
782
|
* DI token under which the {@link AtscriptDbReadable} instance
|
|
@@ -781,10 +871,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
781
871
|
/** Reference to the underlying readable (table or view). */
|
|
782
872
|
readable;
|
|
783
873
|
_gates;
|
|
874
|
+
_preferredIdSet;
|
|
875
|
+
_compositeIdShapes;
|
|
876
|
+
_overlayIsNoOp;
|
|
784
877
|
constructor(readable, app) {
|
|
785
878
|
super(readable.type, readable.tableName, app, readable.isView ? "view" : "table");
|
|
786
879
|
this.readable = readable;
|
|
787
880
|
this._gates = this._buildGates();
|
|
881
|
+
this._preferredIdSet = new Set(readable.preferredId ?? []);
|
|
882
|
+
this._compositeIdShapes = (readable.identifications ?? []).filter((id) => id.fields.length >= 2);
|
|
883
|
+
const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
|
|
884
|
+
this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
|
|
788
885
|
}
|
|
789
886
|
_buildGates() {
|
|
790
887
|
const meta = this.readable.type.metadata;
|
|
@@ -852,16 +949,169 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
852
949
|
transformProjection(projection) {
|
|
853
950
|
return projection;
|
|
854
951
|
}
|
|
952
|
+
widenPreferredIdProjection(projection) {
|
|
953
|
+
if (this._preferredIdSet.size === 0 || projection === void 0) return projection;
|
|
954
|
+
if (Array.isArray(projection)) return this._widenArrayProjection(projection);
|
|
955
|
+
return this._widenMapProjection(projection);
|
|
956
|
+
}
|
|
957
|
+
_widenArrayProjection(projection) {
|
|
958
|
+
const stringItems = /* @__PURE__ */ new Set();
|
|
959
|
+
for (const item of projection) if (typeof item === "string") stringItems.add(item);
|
|
960
|
+
let allPresent = true;
|
|
961
|
+
for (const field of this._preferredIdSet) if (!stringItems.has(field)) {
|
|
962
|
+
allPresent = false;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
if (allPresent) return projection;
|
|
966
|
+
const out = [...projection];
|
|
967
|
+
for (const field of this._preferredIdSet) if (!stringItems.has(field)) out.push(field);
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
_widenMapProjection(projection) {
|
|
971
|
+
const entries = Object.entries(projection);
|
|
972
|
+
if (entries.length === 0) return projection;
|
|
973
|
+
const included = /* @__PURE__ */ new Set();
|
|
974
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
975
|
+
for (const [k, v] of entries) if (v === 1 || v === true) included.add(k);
|
|
976
|
+
else if (v === 0 || v === false) excluded.add(k);
|
|
977
|
+
if (included.size > 0 && excluded.size > 0) return new HttpError(400, "Mixed inclusion/exclusion $select maps are not supported");
|
|
978
|
+
if (excluded.size === 0) {
|
|
979
|
+
let allPresent = true;
|
|
980
|
+
for (const field of this._preferredIdSet) if (!included.has(field)) {
|
|
981
|
+
allPresent = false;
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
if (allPresent) return projection;
|
|
985
|
+
const widened = {};
|
|
986
|
+
for (const k of included) widened[k] = 1;
|
|
987
|
+
for (const field of this._preferredIdSet) widened[field] = 1;
|
|
988
|
+
return widened;
|
|
989
|
+
}
|
|
990
|
+
const widened = {};
|
|
991
|
+
for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !excluded.has(fd.path)) widened[fd.path] = 1;
|
|
992
|
+
for (const field of this._preferredIdSet) widened[field] = 1;
|
|
993
|
+
return widened;
|
|
994
|
+
}
|
|
995
|
+
/** WHY: the URL parser only auto-coerces `$count`; every other boolean control reaches us as `"true"`/`"1"` and would fail DTO validation. */
|
|
996
|
+
_coerceActionsControl(controls) {
|
|
997
|
+
const v = controls.$actions;
|
|
998
|
+
if (typeof v === "string") controls.$actions = v === "true" || v === "1" || v === "";
|
|
999
|
+
}
|
|
1000
|
+
/** Normalize a post-`widenPreferredIdProjection` $select into `string[] | null` (`null` = all fields). */
|
|
1001
|
+
_resolveProjectionForAugmenter(select) {
|
|
1002
|
+
if (select === void 0) return null;
|
|
1003
|
+
if (Array.isArray(select)) {
|
|
1004
|
+
const out = [];
|
|
1005
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1006
|
+
for (const item of select) if (typeof item === "string" && !seen.has(item)) {
|
|
1007
|
+
seen.add(item);
|
|
1008
|
+
out.push(item);
|
|
1009
|
+
}
|
|
1010
|
+
return out;
|
|
1011
|
+
}
|
|
1012
|
+
const obj = select;
|
|
1013
|
+
const included = [];
|
|
1014
|
+
const excluded = [];
|
|
1015
|
+
for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
|
|
1016
|
+
else if (v === 0 || v === false) excluded.push(k);
|
|
1017
|
+
if (included.length > 0 && excluded.length === 0) return included;
|
|
1018
|
+
if (excluded.length > 0 && included.length === 0) {
|
|
1019
|
+
const excludedSet = new Set(excluded);
|
|
1020
|
+
const out = [];
|
|
1021
|
+
for (const fd of this.readable.fieldDescriptors) if (!fd.ignored && !excludedSet.has(fd.path)) out.push(fd.path);
|
|
1022
|
+
return out;
|
|
1023
|
+
}
|
|
1024
|
+
throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
|
|
1025
|
+
}
|
|
1026
|
+
/** WHY: filter row/rows envelopes by the per-request `applyMetaOverlay` action set; skip `meta()` when overlay is identity. */
|
|
1027
|
+
async _resolveAugmentEnvelopes() {
|
|
1028
|
+
const rowLevelEnvelopes = discoverRowLevelActions(this.constructor, this.app, this.logger);
|
|
1029
|
+
if (rowLevelEnvelopes.length === 0) return null;
|
|
1030
|
+
if (this._overlayIsNoOp) return rowLevelEnvelopes;
|
|
1031
|
+
const overlayMeta = await this.meta();
|
|
1032
|
+
const allowedNames = new Set(overlayMeta.actions.map((a) => a.name));
|
|
1033
|
+
const filtered = rowLevelEnvelopes.filter((e) => allowedNames.has(e.info.name));
|
|
1034
|
+
return filtered.length === 0 ? null : filtered;
|
|
1035
|
+
}
|
|
1036
|
+
/** Returns a widened `$select` only when at least one `requiredFields` entry is missing; `null` means "no widening needed". */
|
|
1037
|
+
_widenSelectForActions(envelopes, baseSelect) {
|
|
1038
|
+
let resultSet = null;
|
|
1039
|
+
let result = null;
|
|
1040
|
+
for (const e of envelopes) {
|
|
1041
|
+
const raw = e.raw;
|
|
1042
|
+
if (!Array.isArray(raw.requiredFields)) continue;
|
|
1043
|
+
for (const f of raw.requiredFields) {
|
|
1044
|
+
if (resultSet ? resultSet.has(f) : baseSelect.includes(f)) continue;
|
|
1045
|
+
if (resultSet === null) {
|
|
1046
|
+
resultSet = new Set(baseSelect);
|
|
1047
|
+
result = [...baseSelect];
|
|
1048
|
+
}
|
|
1049
|
+
resultSet.add(f);
|
|
1050
|
+
result.push(f);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return result;
|
|
1054
|
+
}
|
|
1055
|
+
async _prepareAugmentation(controls, select) {
|
|
1056
|
+
if (!controls.$actions) return null;
|
|
1057
|
+
const envelopes = await this._resolveAugmentEnvelopes();
|
|
1058
|
+
if (envelopes === null) return null;
|
|
1059
|
+
const resolvedProjection = this._resolveProjectionForAugmenter(select);
|
|
1060
|
+
return {
|
|
1061
|
+
envelopes,
|
|
1062
|
+
resolvedProjection,
|
|
1063
|
+
widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection)
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
async _resolveReadStrategy(controls) {
|
|
1067
|
+
const searchTerm = controls.$search;
|
|
1068
|
+
const indexName = controls.$index;
|
|
1069
|
+
const vectorField = controls.$vector;
|
|
1070
|
+
if (vectorField !== void 0 && searchTerm) return {
|
|
1071
|
+
kind: "vector",
|
|
1072
|
+
vector: await this.computeEmbedding(searchTerm, vectorField || void 0),
|
|
1073
|
+
vectorField
|
|
1074
|
+
};
|
|
1075
|
+
if (searchTerm && this.readable.isSearchable()) return {
|
|
1076
|
+
kind: "search",
|
|
1077
|
+
term: searchTerm,
|
|
1078
|
+
index: indexName
|
|
1079
|
+
};
|
|
1080
|
+
return { kind: "plain" };
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Shared `query` / `pages` pipeline: prepare actions augmentation + read
|
|
1084
|
+
* strategy in parallel, pre-widen $select for `requiredFields`, run
|
|
1085
|
+
* `exec`, and augment `result.data` with `$actions` when the request set
|
|
1086
|
+
* `$actions=true`. Caller dispatches the strategy to its read-method
|
|
1087
|
+
* family (count vs no-count).
|
|
1088
|
+
*/
|
|
1089
|
+
async _runReadWithActions(queryObj, controls, select, exec) {
|
|
1090
|
+
const [prep, strategy] = await Promise.all([this._prepareAugmentation(controls, select), this._resolveReadStrategy(controls)]);
|
|
1091
|
+
const result = await exec(prep?.widenedSelect ? {
|
|
1092
|
+
...queryObj,
|
|
1093
|
+
controls: {
|
|
1094
|
+
...queryObj.controls,
|
|
1095
|
+
$select: prep.widenedSelect
|
|
1096
|
+
}
|
|
1097
|
+
} : queryObj, strategy);
|
|
1098
|
+
if (!prep) return result;
|
|
1099
|
+
result.data = augmentRowsWithActions({
|
|
1100
|
+
envelopes: prep.envelopes,
|
|
1101
|
+
rows: result.data,
|
|
1102
|
+
resolvedProjection: prep.resolvedProjection
|
|
1103
|
+
});
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
855
1106
|
/**
|
|
856
1107
|
* Extracts a composite identifier object from query params.
|
|
857
1108
|
* Tries composite primary key first, then compound unique indexes.
|
|
858
1109
|
*/
|
|
859
1110
|
extractCompositeId(query) {
|
|
860
|
-
const
|
|
861
|
-
if (pkFields.length > 1) {
|
|
1111
|
+
for (const id of this._compositeIdShapes) {
|
|
862
1112
|
const idObj = {};
|
|
863
1113
|
let allPresent = true;
|
|
864
|
-
for (const field of
|
|
1114
|
+
for (const field of id.fields) {
|
|
865
1115
|
if (query[field] === void 0) {
|
|
866
1116
|
allPresent = false;
|
|
867
1117
|
break;
|
|
@@ -870,19 +1120,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
870
1120
|
}
|
|
871
1121
|
if (allPresent) return idObj;
|
|
872
1122
|
}
|
|
873
|
-
for (const index of this.readable.indexes.values()) {
|
|
874
|
-
if (index.type !== "unique" || index.fields.length < 2) continue;
|
|
875
|
-
const idObj = {};
|
|
876
|
-
let allPresent = true;
|
|
877
|
-
for (const indexField of index.fields) {
|
|
878
|
-
if (query[indexField.name] === void 0) {
|
|
879
|
-
allPresent = false;
|
|
880
|
-
break;
|
|
881
|
-
}
|
|
882
|
-
idObj[indexField.name] = query[indexField.name];
|
|
883
|
-
}
|
|
884
|
-
if (allPresent) return idObj;
|
|
885
|
-
}
|
|
886
1123
|
return new HttpError(400, "Query params do not match any composite primary key or compound unique index");
|
|
887
1124
|
}
|
|
888
1125
|
/**
|
|
@@ -891,6 +1128,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
891
1128
|
async query(url) {
|
|
892
1129
|
const parsed = this.parseQueryString(url);
|
|
893
1130
|
const controls = parsed.controls;
|
|
1131
|
+
this._coerceActionsControl(controls);
|
|
894
1132
|
if (controls.$groupBy?.length) {
|
|
895
1133
|
if (controls.$with?.length) return new HttpError(400, "Cannot combine $with and $groupBy in the same query");
|
|
896
1134
|
if (parsed.insights) {
|
|
@@ -908,17 +1146,16 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
908
1146
|
if (error) return error;
|
|
909
1147
|
const gateError = this.checkGates(parsed.filter, controls, this._gates);
|
|
910
1148
|
if (gateError) return gateError;
|
|
911
|
-
const [filter,
|
|
1149
|
+
const [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
912
1150
|
if (controls.$count) return this.readable.count({
|
|
913
1151
|
filter,
|
|
914
1152
|
controls: {
|
|
915
1153
|
...controls,
|
|
916
|
-
$select:
|
|
1154
|
+
$select: rawSelect
|
|
917
1155
|
}
|
|
918
1156
|
});
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
const vectorField = controls.$vector;
|
|
1157
|
+
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1158
|
+
if (select instanceof HttpError) return select;
|
|
922
1159
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
923
1160
|
const queryObj = {
|
|
924
1161
|
filter,
|
|
@@ -929,19 +1166,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
929
1166
|
$threshold: threshold
|
|
930
1167
|
}
|
|
931
1168
|
};
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1169
|
+
return (await this._runReadWithActions(queryObj, controls, select, async (q, strategy) => {
|
|
1170
|
+
switch (strategy.kind) {
|
|
1171
|
+
case "vector": return { data: await (strategy.vectorField ? this.readable.vectorSearch(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearch(strategy.vector, q)) };
|
|
1172
|
+
case "search": return { data: await this.readable.search(strategy.term, q, strategy.index) };
|
|
1173
|
+
case "plain": return { data: await this.readable.findMany(q) };
|
|
1174
|
+
}
|
|
1175
|
+
})).data;
|
|
939
1176
|
}
|
|
940
1177
|
/**
|
|
941
1178
|
* **GET /pages** — returns paginated records with metadata.
|
|
942
1179
|
*/
|
|
943
1180
|
async pages(url) {
|
|
944
1181
|
const parsed = this.parseQueryString(url);
|
|
1182
|
+
this._coerceActionsControl(parsed.controls);
|
|
945
1183
|
const error = this.validateParsed(parsed, "pages");
|
|
946
1184
|
if (error) return error;
|
|
947
1185
|
const controls = parsed.controls;
|
|
@@ -950,10 +1188,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
950
1188
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
951
1189
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
952
1190
|
const skip = (page - 1) * size;
|
|
953
|
-
const [filter,
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
const vectorField = controls.$vector;
|
|
1191
|
+
const [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1192
|
+
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1193
|
+
if (select instanceof HttpError) return select;
|
|
957
1194
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
958
1195
|
const query = {
|
|
959
1196
|
filter,
|
|
@@ -965,13 +1202,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
965
1202
|
$threshold: threshold
|
|
966
1203
|
}
|
|
967
1204
|
};
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1205
|
+
const result = await this._runReadWithActions(query, controls, select, async (q, strategy) => {
|
|
1206
|
+
switch (strategy.kind) {
|
|
1207
|
+
case "vector": return strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q);
|
|
1208
|
+
case "search": return this.readable.searchWithCount(strategy.term, q, strategy.index);
|
|
1209
|
+
case "plain": return this.readable.findManyWithCount(q);
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
975
1212
|
return {
|
|
976
1213
|
data: result.data,
|
|
977
1214
|
page,
|
|
@@ -985,15 +1222,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
985
1222
|
*/
|
|
986
1223
|
async getOne(id, url) {
|
|
987
1224
|
const parsed = this.parseQueryString(url);
|
|
1225
|
+
this._coerceActionsControl(parsed.controls);
|
|
988
1226
|
if (Object.keys(parsed.filter).length > 0) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
|
|
989
1227
|
const error = this.validateParsed(parsed, "getOne");
|
|
990
1228
|
if (error) return error;
|
|
991
|
-
const
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
};
|
|
996
|
-
return this.returnOne(this.readable.findById(id, { controls }));
|
|
1229
|
+
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
1230
|
+
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1231
|
+
if (select instanceof HttpError) return select;
|
|
1232
|
+
return this._findByIdAndAugment(id, parsed.controls, select);
|
|
997
1233
|
}
|
|
998
1234
|
/**
|
|
999
1235
|
* **GET /one?field1=val1&field2=val2** — retrieves a single record by composite key
|
|
@@ -1003,12 +1239,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1003
1239
|
const idObj = this.extractCompositeId(query);
|
|
1004
1240
|
if (idObj instanceof HttpError) return idObj;
|
|
1005
1241
|
const parsed = this.parseQueryString(url);
|
|
1006
|
-
|
|
1242
|
+
this._coerceActionsControl(parsed.controls);
|
|
1243
|
+
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
1244
|
+
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1245
|
+
if (select instanceof HttpError) return select;
|
|
1246
|
+
return this._findByIdAndAugment(idObj, parsed.controls, select);
|
|
1247
|
+
}
|
|
1248
|
+
async _findByIdAndAugment(id, parsedControls, select) {
|
|
1249
|
+
const prep = await this._prepareAugmentation(parsedControls, select);
|
|
1250
|
+
const initialSelect = prep?.widenedSelect ?? select;
|
|
1007
1251
|
const controls = {
|
|
1008
|
-
...
|
|
1009
|
-
$select:
|
|
1252
|
+
...parsedControls,
|
|
1253
|
+
$select: initialSelect
|
|
1010
1254
|
};
|
|
1011
|
-
|
|
1255
|
+
const row = await this.readable.findById(id, { controls });
|
|
1256
|
+
const item = await this.returnOne(Promise.resolve(row));
|
|
1257
|
+
if (item instanceof HttpError) return item;
|
|
1258
|
+
if (!prep) return item;
|
|
1259
|
+
const [augmented] = augmentRowsWithActions({
|
|
1260
|
+
envelopes: prep.envelopes,
|
|
1261
|
+
rows: [item],
|
|
1262
|
+
resolvedProjection: prep.resolvedProjection
|
|
1263
|
+
});
|
|
1264
|
+
return augmented;
|
|
1012
1265
|
}
|
|
1013
1266
|
/**
|
|
1014
1267
|
* **GET /meta** — returns table/view metadata for UI.
|
|
@@ -1042,6 +1295,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1042
1295
|
vectorSearchable: this.readable.isVectorSearchable(),
|
|
1043
1296
|
searchIndexes: this.readable.getSearchIndexes(),
|
|
1044
1297
|
primaryKeys: [...this.readable.primaryKeys],
|
|
1298
|
+
preferredId: [...this.readable.preferredId],
|
|
1045
1299
|
relations,
|
|
1046
1300
|
fields,
|
|
1047
1301
|
type: this.getSerializedType(),
|
|
@@ -1340,6 +1594,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
1340
1594
|
vectorSearchable: false,
|
|
1341
1595
|
searchIndexes: [],
|
|
1342
1596
|
primaryKeys: this.primaryKey ? [this.primaryKey] : [],
|
|
1597
|
+
preferredId: this.primaryKey ? [this.primaryKey] : [],
|
|
1343
1598
|
relations: [],
|
|
1344
1599
|
fields,
|
|
1345
1600
|
type: this.getSerializedType(),
|
|
@@ -1554,8 +1809,8 @@ function applySelect(rows, select) {
|
|
|
1554
1809
|
}
|
|
1555
1810
|
//#endregion
|
|
1556
1811
|
//#region src/actions/action-disabled-error.ts
|
|
1557
|
-
function buildMessage(action,
|
|
1558
|
-
if (
|
|
1812
|
+
function buildMessage(action, ids) {
|
|
1813
|
+
if (ids !== void 0) return `Action "${action}" is disabled for ${ids.length} of the selected rows`;
|
|
1559
1814
|
return `Action "${action}" is disabled for this row`;
|
|
1560
1815
|
}
|
|
1561
1816
|
/**
|
|
@@ -1563,95 +1818,110 @@ function buildMessage(action, pks) {
|
|
|
1563
1818
|
* with Moost's existing error mapper to produce HTTP 409 with the wire body
|
|
1564
1819
|
* defined by {@link ActionDisabledErrorBody}.
|
|
1565
1820
|
*
|
|
1566
|
-
* - `'row'`-level rejection: pass `(action,
|
|
1567
|
-
* - `'rows'`-level rejection: pass `(action, undefined,
|
|
1568
|
-
* emits `
|
|
1569
|
-
* of request
|
|
1821
|
+
* - `'row'`-level rejection: pass `(action, id)` — the body emits `id`.
|
|
1822
|
+
* - `'rows'`-level rejection: pass `(action, undefined, ids)` — the body
|
|
1823
|
+
* emits `ids` (the FULL list of failing IDs in reject mode; the FULL list
|
|
1824
|
+
* of request IDs in skip mode with zero survivors).
|
|
1570
1825
|
*/
|
|
1571
1826
|
var ActionDisabledError = class extends HttpError {
|
|
1572
1827
|
name = "ActionDisabledError";
|
|
1573
|
-
constructor(action,
|
|
1828
|
+
constructor(action, id, ids) {
|
|
1574
1829
|
const body = {
|
|
1575
1830
|
name: "ActionDisabledError",
|
|
1576
|
-
message: buildMessage(action,
|
|
1831
|
+
message: buildMessage(action, ids),
|
|
1577
1832
|
statusCode: 409,
|
|
1578
1833
|
action
|
|
1579
1834
|
};
|
|
1580
|
-
if (
|
|
1581
|
-
else if (
|
|
1835
|
+
if (ids !== void 0) body.ids = ids;
|
|
1836
|
+
else if (id !== void 0) body.id = id;
|
|
1582
1837
|
super(409, body);
|
|
1583
1838
|
}
|
|
1584
1839
|
};
|
|
1585
1840
|
//#endregion
|
|
1586
|
-
//#region src/actions/
|
|
1587
|
-
|
|
1841
|
+
//#region src/actions/current-action.ts
|
|
1842
|
+
/** Read the current action's `TDbActionMeta` from the wook context. Returns undefined outside a controller (e.g. direct-wook test paths). */
|
|
1843
|
+
function readCurrentActionMeta(ctx) {
|
|
1844
|
+
let ctrl;
|
|
1845
|
+
let methodName;
|
|
1846
|
+
try {
|
|
1847
|
+
const cc = useControllerContext(ctx);
|
|
1848
|
+
ctrl = cc.getController();
|
|
1849
|
+
methodName = cc.getMethod();
|
|
1850
|
+
} catch {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (!ctrl || !methodName) return void 0;
|
|
1854
|
+
return getMoostMate().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION];
|
|
1855
|
+
}
|
|
1856
|
+
//#endregion
|
|
1857
|
+
//#region src/actions/id-validation.ts
|
|
1858
|
+
const SOURCE_CACHE = /* @__PURE__ */ new WeakMap();
|
|
1859
|
+
function getSourceCache(source) {
|
|
1860
|
+
let cache = SOURCE_CACHE.get(source);
|
|
1861
|
+
if (cache) return cache;
|
|
1862
|
+
const identifications = source.getIdentifications();
|
|
1863
|
+
const byKeySig = /* @__PURE__ */ new Map();
|
|
1864
|
+
for (const ident of identifications) byKeySig.set(fieldsSig(ident.fields), ident);
|
|
1865
|
+
const fieldByName = /* @__PURE__ */ new Map();
|
|
1866
|
+
for (const fd of source.fieldDescriptors) fieldByName.set(fd.path, fd);
|
|
1867
|
+
cache = {
|
|
1868
|
+
byKeySig,
|
|
1869
|
+
fieldByName,
|
|
1870
|
+
formatted: identifications.map((id) => `[${id.fields.join(", ")}]`).join(", ")
|
|
1871
|
+
};
|
|
1872
|
+
SOURCE_CACHE.set(source, cache);
|
|
1873
|
+
return cache;
|
|
1874
|
+
}
|
|
1875
|
+
function fieldsSig(fields) {
|
|
1876
|
+
return fields.toSorted().join("");
|
|
1877
|
+
}
|
|
1878
|
+
function isIdValidationSource(value) {
|
|
1588
1879
|
if (!value || typeof value !== "object") return false;
|
|
1589
1880
|
const v = value;
|
|
1590
|
-
return
|
|
1881
|
+
return typeof v.getIdentifications === "function" && Array.isArray(v.fieldDescriptors);
|
|
1591
1882
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
* composite). Throws {@link ValidatorError} with structured `errors` so the
|
|
1595
|
-
* existing validation interceptor returns HTTP 400.
|
|
1596
|
-
*/
|
|
1597
|
-
function validateSinglePk(body, source, path = "") {
|
|
1598
|
-
const errors = collectPkErrors(body, source, path);
|
|
1883
|
+
function validateSingleId(body, source, path = "") {
|
|
1884
|
+
const errors = collectIdErrors(body, source, path);
|
|
1599
1885
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
1886
|
+
return body;
|
|
1600
1887
|
}
|
|
1601
|
-
|
|
1602
|
-
* Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
|
|
1603
|
-
* The body MUST be an array; each element is validated against the PK schema.
|
|
1604
|
-
*/
|
|
1605
|
-
function validateMultiPk(body, source) {
|
|
1888
|
+
function validateMultiId(body, source) {
|
|
1606
1889
|
if (!Array.isArray(body)) throw new ValidatorError([{
|
|
1607
1890
|
path: "",
|
|
1608
|
-
message: "Expected JSON array of
|
|
1891
|
+
message: "Expected JSON array of identifier objects",
|
|
1609
1892
|
details: []
|
|
1610
1893
|
}]);
|
|
1611
1894
|
const errors = [];
|
|
1612
|
-
for (let i = 0; i < body.length; i++) errors.push(...
|
|
1895
|
+
for (let i = 0; i < body.length; i++) errors.push(...collectIdErrors(body[i], source, `[${i}]`));
|
|
1613
1896
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
1897
|
+
return body;
|
|
1614
1898
|
}
|
|
1615
|
-
function
|
|
1616
|
-
|
|
1617
|
-
|
|
1899
|
+
function collectIdErrors(value, source, pathPrefix) {
|
|
1900
|
+
if (!isPlainObject(value)) return [{
|
|
1901
|
+
path: pathPrefix,
|
|
1902
|
+
message: "Expected JSON object for row identifier",
|
|
1903
|
+
details: []
|
|
1904
|
+
}];
|
|
1905
|
+
const cache = getSourceCache(source);
|
|
1906
|
+
if (cache.byKeySig.size === 0) return [{
|
|
1907
|
+
path: pathPrefix,
|
|
1908
|
+
message: "Table has no identifier configured",
|
|
1909
|
+
details: []
|
|
1910
|
+
}];
|
|
1911
|
+
const match = cache.byKeySig.get(fieldsSig(Object.keys(value)));
|
|
1912
|
+
if (!match) return [{
|
|
1618
1913
|
path: pathPrefix,
|
|
1619
|
-
message:
|
|
1914
|
+
message: `Identifier fields must exactly match one of: ${cache.formatted}`,
|
|
1620
1915
|
details: []
|
|
1621
1916
|
}];
|
|
1622
1917
|
const errors = [];
|
|
1623
|
-
|
|
1624
|
-
const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
|
|
1625
|
-
if (err) errors.push(err);
|
|
1626
|
-
return errors;
|
|
1627
|
-
}
|
|
1628
|
-
if (!isPlainObject(value)) {
|
|
1629
|
-
errors.push({
|
|
1630
|
-
path: pathPrefix,
|
|
1631
|
-
message: "Expected JSON object for composite primary key",
|
|
1632
|
-
details: []
|
|
1633
|
-
});
|
|
1634
|
-
return errors;
|
|
1635
|
-
}
|
|
1636
|
-
for (const fieldName of pkFields) {
|
|
1918
|
+
for (const fieldName of match.fields) {
|
|
1637
1919
|
const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
|
|
1638
|
-
|
|
1639
|
-
errors.push({
|
|
1640
|
-
path: sub,
|
|
1641
|
-
message: `Missing primary-key field "${fieldName}"`,
|
|
1642
|
-
details: []
|
|
1643
|
-
});
|
|
1644
|
-
continue;
|
|
1645
|
-
}
|
|
1646
|
-
const fd = findFieldDescriptor(source, fieldName);
|
|
1647
|
-
const err = checkScalar(value[fieldName], fd, sub);
|
|
1920
|
+
const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
|
|
1648
1921
|
if (err) errors.push(err);
|
|
1649
1922
|
}
|
|
1650
1923
|
return errors;
|
|
1651
1924
|
}
|
|
1652
|
-
function findFieldDescriptor(source, name) {
|
|
1653
|
-
for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
|
|
1654
|
-
}
|
|
1655
1925
|
function checkScalar(value, fd, path) {
|
|
1656
1926
|
const expected = fd?.designType ?? "string";
|
|
1657
1927
|
if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
|
|
@@ -1661,7 +1931,7 @@ function checkScalar(value, fd, path) {
|
|
|
1661
1931
|
function scalarMismatch(path, expected, value) {
|
|
1662
1932
|
return {
|
|
1663
1933
|
path,
|
|
1664
|
-
message: `Expected
|
|
1934
|
+
message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
|
|
1665
1935
|
details: []
|
|
1666
1936
|
};
|
|
1667
1937
|
}
|
|
@@ -1674,105 +1944,115 @@ function isPlainObject(value) {
|
|
|
1674
1944
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1675
1945
|
}
|
|
1676
1946
|
//#endregion
|
|
1677
|
-
//#region src/actions/
|
|
1947
|
+
//#region src/actions/id-cache.ts
|
|
1678
1948
|
const boundTableKey = key("atscript_db_action_bound_table");
|
|
1679
1949
|
function getActionTable(ctx) {
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
if (fromSlot) return fromSlot;
|
|
1683
|
-
}
|
|
1950
|
+
const fromSlot = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
|
|
1951
|
+
if (fromSlot) return fromSlot;
|
|
1684
1952
|
const ctrl = useControllerContext(ctx).getController();
|
|
1685
|
-
|
|
1686
|
-
const t = ctrl.readable ?? ctrl.table;
|
|
1687
|
-
if (t) return t;
|
|
1688
|
-
}
|
|
1689
|
-
return null;
|
|
1953
|
+
return ctrl?.readable ?? ctrl?.table ?? null;
|
|
1690
1954
|
}
|
|
1691
1955
|
function noTableError(ctx) {
|
|
1692
|
-
const
|
|
1693
|
-
const ctrl = cc.getController();
|
|
1694
|
-
const methodName = cc.getMethod();
|
|
1695
|
-
let actionName;
|
|
1696
|
-
if (ctrl && methodName) actionName = getMoostMate().read(ctrl.constructor, methodName)?.[MOOST_DB_ACTION]?.name;
|
|
1956
|
+
const actionName = readCurrentActionMeta(ctx)?.name;
|
|
1697
1957
|
return new HttpError(500, `${WARN_PREFIX} ${actionName ? `"${actionName}"` : "<unknown>"}: controller has no readable/table property and the action declares no opts.table. Either expose readable/table on the controller, extend AsDbReadableController, or pass opts.table on @DbAction.`);
|
|
1698
1958
|
}
|
|
1699
|
-
async function
|
|
1959
|
+
async function resolveValidatedId(ctx, validate) {
|
|
1700
1960
|
const table = getActionTable(ctx);
|
|
1701
|
-
if (!
|
|
1961
|
+
if (!isIdValidationSource(table)) throw noTableError(ctx);
|
|
1702
1962
|
const body = await useBody(ctx).parseBody();
|
|
1703
1963
|
validate(body, table);
|
|
1704
1964
|
return body;
|
|
1705
1965
|
}
|
|
1706
|
-
const
|
|
1707
|
-
const
|
|
1708
|
-
return await
|
|
1966
|
+
const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
|
|
1967
|
+
const dbActionIdsSlot = cached(async (ctx) => {
|
|
1968
|
+
return await resolveValidatedId(ctx, validateMultiId);
|
|
1709
1969
|
});
|
|
1710
|
-
const
|
|
1711
|
-
const
|
|
1970
|
+
const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
|
|
1971
|
+
const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
|
|
1712
1972
|
//#endregion
|
|
1713
1973
|
//#region src/actions/row-cache.ts
|
|
1714
1974
|
function asFetchTable(value) {
|
|
1715
1975
|
if (!value || typeof value !== "object") return null;
|
|
1716
1976
|
const v = value;
|
|
1717
|
-
|
|
1977
|
+
if (Array.isArray(v.primaryKeys) && typeof v.findOne === "function" && typeof v.findMany === "function") return v;
|
|
1978
|
+
return null;
|
|
1979
|
+
}
|
|
1980
|
+
function stringifyScalar(value) {
|
|
1981
|
+
if (value === null) return "null";
|
|
1982
|
+
if (value === void 0) return "undefined";
|
|
1983
|
+
return String(value);
|
|
1984
|
+
}
|
|
1985
|
+
/** Returns the action's `requiredFields` or null when called outside a controller context (e.g. direct wook usage in tests). */
|
|
1986
|
+
function readActionFieldSet(ctx) {
|
|
1987
|
+
const action = readCurrentActionMeta(ctx);
|
|
1988
|
+
if (!action) return null;
|
|
1989
|
+
const opts = action.opts;
|
|
1990
|
+
return Array.isArray(opts.requiredFields) ? opts.requiredFields : null;
|
|
1718
1991
|
}
|
|
1719
|
-
function
|
|
1720
|
-
|
|
1992
|
+
function seedActionFields(ctx, table) {
|
|
1993
|
+
const fields = /* @__PURE__ */ new Set();
|
|
1994
|
+
for (const f of table.preferredId ?? table.primaryKeys) fields.add(f);
|
|
1995
|
+
const action = readActionFieldSet(ctx);
|
|
1996
|
+
if (action) for (const f of action) fields.add(f);
|
|
1997
|
+
return fields;
|
|
1721
1998
|
}
|
|
1722
1999
|
async function loadRow(ctx) {
|
|
1723
|
-
const
|
|
1724
|
-
const
|
|
1725
|
-
if (
|
|
2000
|
+
const id = await ctx.get(dbActionIdSlot);
|
|
2001
|
+
const table = asFetchTable(getActionTable(ctx));
|
|
2002
|
+
if (!table) throw noTableError(ctx);
|
|
2003
|
+
const fields = seedActionFields(ctx, table);
|
|
2004
|
+
for (const k of Object.keys(id)) fields.add(k);
|
|
2005
|
+
const row = await table.findOne({
|
|
2006
|
+
filter: id,
|
|
2007
|
+
controls: { $select: [...fields] }
|
|
2008
|
+
});
|
|
2009
|
+
if (row == null) throw new HttpError(404, "Row not found for action identifier");
|
|
1726
2010
|
return row;
|
|
1727
2011
|
}
|
|
1728
2012
|
async function loadRows(ctx) {
|
|
1729
|
-
const
|
|
1730
|
-
const table = asFetchTable(getActionTable(ctx))
|
|
1731
|
-
if (
|
|
1732
|
-
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2013
|
+
const ids = await ctx.get(dbActionIdsSlot);
|
|
2014
|
+
const table = asFetchTable(getActionTable(ctx));
|
|
2015
|
+
if (!table) throw noTableError(ctx);
|
|
2016
|
+
if (ids.length === 0) return [];
|
|
2017
|
+
const fields = seedActionFields(ctx, table);
|
|
2018
|
+
const idKeys = [];
|
|
2019
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
2020
|
+
const dedupedIds = [];
|
|
2021
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
2022
|
+
for (const id of ids) {
|
|
2023
|
+
const sortedFields = Object.keys(id).toSorted();
|
|
2024
|
+
const sig = sortedFields.join("");
|
|
2025
|
+
let key = "";
|
|
2026
|
+
for (const f of sortedFields) {
|
|
2027
|
+
fields.add(f);
|
|
2028
|
+
key += `${f}\x1f${stringifyScalar(id[f])}\x1e`;
|
|
2029
|
+
}
|
|
2030
|
+
idKeys.push(key);
|
|
2031
|
+
if (!shapes.has(sig)) shapes.set(sig, sortedFields);
|
|
2032
|
+
if (!seenKeys.has(key)) {
|
|
2033
|
+
seenKeys.add(key);
|
|
2034
|
+
dedupedIds.push(id);
|
|
1742
2035
|
}
|
|
1743
|
-
return ordered;
|
|
1744
|
-
}
|
|
1745
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
1746
|
-
for (const row of rows) byKey.set(compositeKey(row, primaryKeys), row);
|
|
1747
|
-
const ordered = [];
|
|
1748
|
-
for (const pk of pks) {
|
|
1749
|
-
const found = byKey.get(compositeKey(pk, primaryKeys));
|
|
1750
|
-
if (found !== void 0) ordered.push(found);
|
|
1751
|
-
}
|
|
1752
|
-
return ordered;
|
|
1753
|
-
}
|
|
1754
|
-
function buildPksFilter(pks, primaryKeys) {
|
|
1755
|
-
if (primaryKeys.length === 1) return { [primaryKeys[0]]: { $in: pks } };
|
|
1756
|
-
return { $or: pks.map((pk) => {
|
|
1757
|
-
const obj = pk;
|
|
1758
|
-
const clause = {};
|
|
1759
|
-
for (const field of primaryKeys) clause[field] = obj[field];
|
|
1760
|
-
return clause;
|
|
1761
|
-
}) };
|
|
1762
|
-
}
|
|
1763
|
-
function compositeKey(obj, primaryKeys) {
|
|
1764
|
-
let out = "";
|
|
1765
|
-
for (const f of primaryKeys) {
|
|
1766
|
-
if (out !== "") out += "\0";
|
|
1767
|
-
const v = obj[f];
|
|
1768
|
-
if (v === null) out += "n";
|
|
1769
|
-
else if (v === void 0) out += "u";
|
|
1770
|
-
else if (typeof v === "string") out += `s\x02${v}`;
|
|
1771
|
-
else if (typeof v === "number") out += `n\x02${v}`;
|
|
1772
|
-
else if (typeof v === "boolean") out += `b\x02${v}`;
|
|
1773
|
-
else out += `j\x02${JSON.stringify(v)}`;
|
|
1774
2036
|
}
|
|
1775
|
-
|
|
2037
|
+
const rows = await table.findMany({
|
|
2038
|
+
filter: { $or: dedupedIds },
|
|
2039
|
+
controls: { $select: [...fields] }
|
|
2040
|
+
});
|
|
2041
|
+
const rowByKey = /* @__PURE__ */ new Map();
|
|
2042
|
+
for (const row of rows) for (const sortedFields of shapes.values()) {
|
|
2043
|
+
let key = "";
|
|
2044
|
+
let ok = true;
|
|
2045
|
+
for (const f of sortedFields) {
|
|
2046
|
+
const v = row[f];
|
|
2047
|
+
if (v === void 0) {
|
|
2048
|
+
ok = false;
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
key += `${f}\x1f${stringifyScalar(v)}\x1e`;
|
|
2052
|
+
}
|
|
2053
|
+
if (ok && !rowByKey.has(key)) rowByKey.set(key, row);
|
|
2054
|
+
}
|
|
2055
|
+
return ids.map((_, i) => rowByKey.get(idKeys[i]));
|
|
1776
2056
|
}
|
|
1777
2057
|
const dbActionRowSlot = cached((ctx) => loadRow(ctx));
|
|
1778
2058
|
const dbActionRowsSlot = cached((ctx) => loadRows(ctx));
|
|
@@ -1797,28 +2077,38 @@ function buildGateInterceptor(opts) {
|
|
|
1797
2077
|
injectBoundTable(table);
|
|
1798
2078
|
const ctx = current();
|
|
1799
2079
|
if (level === "row") {
|
|
1800
|
-
|
|
2080
|
+
const verdicts = disabled([await ctx.get(dbActionRowSlot)]);
|
|
2081
|
+
assertVerdictLength(action, verdicts, 1);
|
|
2082
|
+
if (verdicts[0]) throw new ActionDisabledError(action, await ctx.get(dbActionIdSlot));
|
|
1801
2083
|
return;
|
|
1802
2084
|
}
|
|
1803
|
-
const
|
|
2085
|
+
const ids = await ctx.get(dbActionIdsSlot);
|
|
1804
2086
|
const rows = await ctx.get(dbActionRowsSlot);
|
|
1805
|
-
const
|
|
2087
|
+
const existingRows = [];
|
|
2088
|
+
for (const row of rows) if (row !== void 0) existingRows.push(row);
|
|
2089
|
+
const verdicts = disabled(existingRows);
|
|
2090
|
+
assertVerdictLength(action, verdicts, existingRows.length);
|
|
2091
|
+
const failingIds = [];
|
|
1806
2092
|
const passingRows = [];
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
2093
|
+
const passingIds = [];
|
|
2094
|
+
let verdictIndex = 0;
|
|
2095
|
+
for (let i = 0; i < ids.length; i++) {
|
|
2096
|
+
const row = rows[i];
|
|
2097
|
+
if (row === void 0 || verdicts[verdictIndex++]) failingIds.push(ids[i]);
|
|
2098
|
+
else {
|
|
2099
|
+
passingRows.push(row);
|
|
2100
|
+
passingIds.push(ids[i]);
|
|
2101
|
+
}
|
|
1812
2102
|
}
|
|
1813
2103
|
if (onDisabledRows === "skip") {
|
|
1814
|
-
if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...
|
|
1815
|
-
if (
|
|
2104
|
+
if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
|
|
2105
|
+
if (failingIds.length > 0) {
|
|
1816
2106
|
ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
|
|
1817
|
-
ctx.set(
|
|
2107
|
+
ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
|
|
1818
2108
|
}
|
|
1819
2109
|
return;
|
|
1820
2110
|
}
|
|
1821
|
-
if (
|
|
2111
|
+
if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
|
|
1822
2112
|
}, GATE_PRIORITY);
|
|
1823
2113
|
}
|
|
1824
2114
|
/** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
|
|
@@ -1836,6 +2126,11 @@ function buildThinInterceptor(opts) {
|
|
|
1836
2126
|
* (gate when `disabled` is set, thin bound-table injector when only
|
|
1837
2127
|
* `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
|
|
1838
2128
|
* is undefined and emits a warning.
|
|
2129
|
+
*
|
|
2130
|
+
* Generic over `TRow` (annotate at the call site: `@DbAction<Order>(...)`)
|
|
2131
|
+
* and `R` (the literal `requiredFields` tuple, inferred via `const R`).
|
|
2132
|
+
* The `disabled` predicate's `rows` argument is type-narrowed to
|
|
2133
|
+
* `Pick<FlatOf<TRow>, R[number]>[]`.
|
|
1839
2134
|
*/
|
|
1840
2135
|
function DbAction(name, opts = {}) {
|
|
1841
2136
|
const mate = getMoostMate();
|
|
@@ -1854,14 +2149,15 @@ function DbAction(name, opts = {}) {
|
|
|
1854
2149
|
})(target, propertyKey, descriptor);
|
|
1855
2150
|
if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
|
|
1856
2151
|
const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
|
|
1857
|
-
|
|
2152
|
+
const rawOpts = opts;
|
|
2153
|
+
if (typeof rawOpts.disabled === "function" && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
|
|
1858
2154
|
action: name,
|
|
1859
2155
|
level: scan.level,
|
|
1860
|
-
disabled:
|
|
1861
|
-
onDisabledRows:
|
|
1862
|
-
table:
|
|
2156
|
+
disabled: rawOpts.disabled,
|
|
2157
|
+
onDisabledRows: rawOpts.onDisabledRows ?? "reject",
|
|
2158
|
+
table: rawOpts.table
|
|
1863
2159
|
}))(target, propertyKey, descriptor);
|
|
1864
|
-
else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table:
|
|
2160
|
+
else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: rawOpts.table }))(target, propertyKey, descriptor);
|
|
1865
2161
|
return descriptor;
|
|
1866
2162
|
});
|
|
1867
2163
|
}
|
|
@@ -1881,62 +2177,70 @@ function DbActionDefault() {
|
|
|
1881
2177
|
});
|
|
1882
2178
|
}
|
|
1883
2179
|
//#endregion
|
|
1884
|
-
//#region src/actions/
|
|
2180
|
+
//#region src/actions/id-source.ts
|
|
1885
2181
|
/**
|
|
1886
|
-
* Build a parameter decorator that reads its value from the cached
|
|
2182
|
+
* Build a parameter decorator that reads its value from the cached ID wook
|
|
1887
2183
|
* (single or multi). Validation runs inside the wook factory exactly once
|
|
1888
|
-
* per request, regardless of how many readers consume the value (`@
|
|
2184
|
+
* per request, regardless of how many readers consume the value (`@DbActionID*`
|
|
1889
2185
|
* resolver, gate interceptor, cached row wook, in-handler composables).
|
|
1890
2186
|
*
|
|
1891
2187
|
* Marks the param so {@link discoverActions} can infer the action's `level`.
|
|
1892
2188
|
*/
|
|
1893
|
-
function
|
|
2189
|
+
function createIdParamDecorator(kind) {
|
|
1894
2190
|
const mate = getMoostMate();
|
|
1895
|
-
const
|
|
1896
|
-
const
|
|
1897
|
-
return ApplyDecorators(mate.decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(
|
|
2191
|
+
const resolverName = kind === "id" ? "dbActionId" : "dbActionIds";
|
|
2192
|
+
const resolver = kind === "id" ? async () => current().get(dbActionIdSlot) : async () => current().get(dbActionIdsSlot);
|
|
2193
|
+
return ApplyDecorators(mate.decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(resolver, resolverName));
|
|
1898
2194
|
}
|
|
1899
2195
|
//#endregion
|
|
1900
|
-
//#region src/actions/db-action-
|
|
2196
|
+
//#region src/actions/db-action-id.decorator.ts
|
|
1901
2197
|
/**
|
|
1902
|
-
* Parameter resolver that reads
|
|
1903
|
-
* and validates it against the bound table's
|
|
2198
|
+
* Parameter resolver that reads a row identifier from the JSON request body
|
|
2199
|
+
* and validates it against the bound table's legitimate identifiers.
|
|
2200
|
+
*
|
|
2201
|
+
* Body shape is always a JSON object — no scalar form. The object's key set
|
|
2202
|
+
* MUST exactly match one of the table's legitimate identifications:
|
|
1904
2203
|
*
|
|
1905
|
-
* - Single-field PK →
|
|
1906
|
-
* - Composite PK →
|
|
2204
|
+
* - Single-field PK → `{ id: "abc" }` (or whatever the PK prop is named).
|
|
2205
|
+
* - Composite PK → `{ tenantId: "...", userId: "..." }`.
|
|
2206
|
+
* - Single-field unique index → `{ slug: "alpha" }`.
|
|
2207
|
+
* - Compound unique index → `{ tenantId: "...", slug: "..." }`.
|
|
1907
2208
|
*
|
|
1908
|
-
*
|
|
2209
|
+
* Strict — unknown fields are rejected, no type coercion. Mismatches throw a
|
|
1909
2210
|
* `ValidatorError` which the existing validation interceptor surfaces as
|
|
1910
2211
|
* HTTP 400 with the same envelope as DTO failures.
|
|
1911
2212
|
*
|
|
1912
2213
|
* Marks the param so {@link discoverActions} can infer the action's `level`
|
|
1913
2214
|
* as `'row'`.
|
|
1914
2215
|
*
|
|
1915
|
-
* Implementation note: the resolver is a thin reader of the cached
|
|
2216
|
+
* Implementation note: the resolver is a thin reader of the cached ID wook
|
|
1916
2217
|
* — validation logic lives in the wook factory, which runs once per request
|
|
1917
2218
|
* regardless of how many readers consume the value.
|
|
1918
2219
|
*/
|
|
1919
|
-
function
|
|
1920
|
-
return
|
|
2220
|
+
function DbActionID() {
|
|
2221
|
+
return createIdParamDecorator("id");
|
|
1921
2222
|
}
|
|
1922
2223
|
//#endregion
|
|
1923
|
-
//#region src/actions/db-action-
|
|
2224
|
+
//#region src/actions/db-action-ids.decorator.ts
|
|
1924
2225
|
/**
|
|
1925
|
-
* Parameter resolver that reads a JSON array of
|
|
1926
|
-
* body and validates each entry against the bound table
|
|
2226
|
+
* Parameter resolver that reads a JSON array of row identifiers from the
|
|
2227
|
+
* request body and validates each entry against the bound table.
|
|
1927
2228
|
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
2229
|
+
* Body shape is always a JSON array of objects — no scalar form. Each
|
|
2230
|
+
* element's key set MUST exactly match one of the table's legitimate
|
|
2231
|
+
* identifications (PK or any unique index). Elements MAY mix shapes:
|
|
2232
|
+
* `[{ id: "1" }, { slug: "alpha" }]` is valid when both `id` is the PK
|
|
2233
|
+
* and `slug` is a unique index.
|
|
1930
2234
|
*
|
|
1931
|
-
*
|
|
1932
|
-
* {@link discoverActions} can infer the action's `level` as `'rows'`.
|
|
2235
|
+
* Strict — unknown fields are rejected, no type coercion. Marks the param
|
|
2236
|
+
* so {@link discoverActions} can infer the action's `level` as `'rows'`.
|
|
1933
2237
|
*
|
|
1934
2238
|
* In `'rows'` skip mode the resolved value reflects the gate interceptor's
|
|
1935
|
-
* filtered subset (the cached
|
|
1936
|
-
* {@link
|
|
2239
|
+
* filtered subset (the cached ID slot is overwritten in place); see
|
|
2240
|
+
* {@link dbActionIdsSlot} for precedence details.
|
|
1937
2241
|
*/
|
|
1938
|
-
function
|
|
1939
|
-
return
|
|
2242
|
+
function DbActionIDs() {
|
|
2243
|
+
return createIdParamDecorator("ids");
|
|
1940
2244
|
}
|
|
1941
2245
|
//#endregion
|
|
1942
2246
|
//#region src/actions/db-action-row.decorator.ts
|
|
@@ -1944,9 +2248,8 @@ function createRowParamDecorator(metaKey, slot, resolverName) {
|
|
|
1944
2248
|
return ApplyDecorators(getMoostMate().decorate(metaKey, true), Resolve(async () => current().get(slot), resolverName));
|
|
1945
2249
|
}
|
|
1946
2250
|
/**
|
|
1947
|
-
* Parameter decorator that injects the row whose
|
|
1948
|
-
* request body.
|
|
1949
|
-
* shared with the gate interceptor when `disabled` is set).
|
|
2251
|
+
* Parameter decorator that injects the row whose identifier was supplied in
|
|
2252
|
+
* the request body.
|
|
1950
2253
|
*
|
|
1951
2254
|
* Marks the param so {@link discoverActions} infers the action's `level` as
|
|
1952
2255
|
* `'row'`. Co-occurrence with `@DbActionRows()` (or any multi-cardinality
|
|
@@ -1959,8 +2262,8 @@ function DbActionRow() {
|
|
|
1959
2262
|
return createRowParamDecorator(MOOST_DB_ACTION_ROW, dbActionRowSlot, "dbActionRow");
|
|
1960
2263
|
}
|
|
1961
2264
|
/**
|
|
1962
|
-
* Parameter decorator that injects the rows
|
|
1963
|
-
*
|
|
2265
|
+
* Parameter decorator that injects the rows fetched by the identifiers
|
|
2266
|
+
* supplied in the request body.
|
|
1964
2267
|
*
|
|
1965
2268
|
* Marks the param so {@link discoverActions} infers the action's `level` as
|
|
1966
2269
|
* `'rows'`. In `'rows'` + `'skip'` mode the resolved value contains only the
|
|
@@ -1978,10 +2281,9 @@ function DbActionRows() {
|
|
|
1978
2281
|
* the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
|
|
1979
2282
|
* `@DbRowsActions`) to avoid repeating `level`.
|
|
1980
2283
|
*
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
* `
|
|
1984
|
-
* `@Post`-bound endpoint accepting the level-determined body shape.
|
|
2284
|
+
* Generic over `TRow` (annotate at the call site: `@DbActions<Order>(...)`)
|
|
2285
|
+
* and `D` (the literal dict, captured via `const D`). Each entry's
|
|
2286
|
+
* `disabled` predicate is type-narrowed by its own `requiredFields` literal.
|
|
1985
2287
|
*
|
|
1986
2288
|
* Multiple `@DbActions` (and shortcut) decorators on the same class
|
|
1987
2289
|
* accumulate.
|
|
@@ -2022,4 +2324,19 @@ function classLevelActions(dict, forcedLevel) {
|
|
|
2022
2324
|
});
|
|
2023
2325
|
}
|
|
2024
2326
|
//#endregion
|
|
2025
|
-
|
|
2327
|
+
//#region src/actions/per-row.ts
|
|
2328
|
+
/**
|
|
2329
|
+
* Lift a per-row predicate into the batch shape required by
|
|
2330
|
+
* `@DbAction` opts.`disabled` and class-level dict `disabled`. Polarity is
|
|
2331
|
+
* preserved — `true` from `fn` means the action is disabled for that row.
|
|
2332
|
+
*
|
|
2333
|
+
* ```ts
|
|
2334
|
+
* @DbAction<Order>('archive', {
|
|
2335
|
+
* requiredFields: ['status'],
|
|
2336
|
+
* disabled: perRow(r => r.status === 'archived'),
|
|
2337
|
+
* })
|
|
2338
|
+
* ```
|
|
2339
|
+
*/
|
|
2340
|
+
const perRow = (fn) => (rows) => rows.map(fn);
|
|
2341
|
+
//#endregion
|
|
2342
|
+
export { ActionDisabledError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActions, DbRowActions, DbRowsActions, DbTableActions, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, ViewController, discoverActions, perRow, useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows, validationErrorTransform };
|