@atscript/moost-db 0.1.57 → 0.1.59
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 +881 -245
- package/dist/index.d.cts +274 -71
- package/dist/index.d.mts +274 -71
- package/dist/index.mjs +873 -245
- package/package.json +5 -2
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ValidatorError, defineAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
|
|
2
2
|
import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
|
|
3
|
-
import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, Resolve, TInterceptorPriority, defineInterceptor, getMoostMate, useControllerContext } from "moost";
|
|
3
|
+
import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Param, Provide, Resolve, TInterceptorPriority, defineBeforeInterceptor, defineInterceptor, getMoostMate, useControllerContext } from "moost";
|
|
4
4
|
import { parseUrl } from "@uniqu/url";
|
|
5
5
|
import { DbError } from "@atscript/db";
|
|
6
|
+
import { cached, current, defineWook, key } from "@wooksjs/event-core";
|
|
6
7
|
import { useBody } from "@wooksjs/http-body";
|
|
7
8
|
//#region src/validation-interceptor.ts
|
|
8
9
|
const dbErrorCodeToStatus = { CONFLICT: 409 };
|
|
@@ -97,7 +98,7 @@ var SelectControlDto = class {
|
|
|
97
98
|
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
98
99
|
}
|
|
99
100
|
};
|
|
100
|
-
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);
|
|
101
102
|
defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedType().designType("string").tags("string").annotate("expect.pattern", {
|
|
102
103
|
pattern: "^\\d+$",
|
|
103
104
|
flags: "u",
|
|
@@ -106,8 +107,8 @@ defineAnnotatedType("object", PagesControlsDto).prop("$page", defineAnnotatedTyp
|
|
|
106
107
|
pattern: "^\\d+$",
|
|
107
108
|
flags: "u",
|
|
108
109
|
message: "Expected positive number"
|
|
109
|
-
}, 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);
|
|
110
|
-
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);
|
|
111
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);
|
|
112
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);
|
|
113
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);
|
|
@@ -180,13 +181,39 @@ function findSortOffender(sort, isAllowed) {
|
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
//#endregion
|
|
184
|
+
//#region src/actions/controller-registry.ts
|
|
185
|
+
let asDbReadableCtor = null;
|
|
186
|
+
let asValueHelpCtor = null;
|
|
187
|
+
function registerAsDbReadableController(ctor) {
|
|
188
|
+
asDbReadableCtor = ctor;
|
|
189
|
+
}
|
|
190
|
+
function registerAsValueHelpController(ctor) {
|
|
191
|
+
asValueHelpCtor = ctor;
|
|
192
|
+
}
|
|
193
|
+
function isAsDbReadableControllerSubclass(ctor) {
|
|
194
|
+
if (!asDbReadableCtor) return false;
|
|
195
|
+
return asDbReadableCtor.prototype.isPrototypeOf(ctor.prototype);
|
|
196
|
+
}
|
|
197
|
+
function isAsValueHelpControllerSubclass(ctor) {
|
|
198
|
+
if (!asValueHelpCtor) return false;
|
|
199
|
+
return asValueHelpCtor.prototype.isPrototypeOf(ctor.prototype);
|
|
200
|
+
}
|
|
201
|
+
function isAsDbReadableControllerInstance(value) {
|
|
202
|
+
return !!asDbReadableCtor && value instanceof asDbReadableCtor;
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
183
205
|
//#region src/actions/keys.ts
|
|
206
|
+
/** Log-message prefix for warnings emitted from the actions subsystem. */
|
|
207
|
+
const WARN_PREFIX = "[moost-db actions]";
|
|
184
208
|
/** Method-level metadata key — written by `@DbAction(name, opts)`. */
|
|
185
209
|
const MOOST_DB_ACTION = "atscript_db_action";
|
|
186
210
|
/** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
|
|
187
211
|
const MOOST_DB_ACTIONS = "atscript_db_actions";
|
|
188
|
-
/** Param-level metadata key — written by `@
|
|
212
|
+
/** Param-level metadata key — written by `@DbActionID()` / `@DbActionIDs()`. Drives level inference. */
|
|
189
213
|
const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
|
|
214
|
+
/** Param-level marker keys — written by `@DbActionRow()` / `@DbActionRows()`. */
|
|
215
|
+
const MOOST_DB_ACTION_ROW = "atscript_db_action_row";
|
|
216
|
+
const MOOST_DB_ACTION_ROWS = "atscript_db_action_rows";
|
|
190
217
|
/**
|
|
191
218
|
* Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
|
|
192
219
|
* read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
|
|
@@ -204,39 +231,70 @@ function mergeActionMeta(current, patch) {
|
|
|
204
231
|
};
|
|
205
232
|
}
|
|
206
233
|
//#endregion
|
|
234
|
+
//#region src/actions/param-level.ts
|
|
235
|
+
function scanParamLevel(params) {
|
|
236
|
+
let single = false;
|
|
237
|
+
let multi = false;
|
|
238
|
+
let hasRowParam = false;
|
|
239
|
+
let hasBody = false;
|
|
240
|
+
for (const p of params) {
|
|
241
|
+
const kind = p[MOOST_DB_ACTION_PARAM];
|
|
242
|
+
if (kind === "id") single = true;
|
|
243
|
+
else if (kind === "ids") multi = true;
|
|
244
|
+
if (p["atscript_db_action_row"]) {
|
|
245
|
+
single = true;
|
|
246
|
+
hasRowParam = true;
|
|
247
|
+
}
|
|
248
|
+
if (p["atscript_db_action_rows"]) {
|
|
249
|
+
multi = true;
|
|
250
|
+
hasRowParam = true;
|
|
251
|
+
}
|
|
252
|
+
if (p.paramSource === "BODY") hasBody = true;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
level: single && multi ? "table" : single ? "row" : multi ? "rows" : "table",
|
|
256
|
+
single,
|
|
257
|
+
multi,
|
|
258
|
+
hasRowParam,
|
|
259
|
+
hasBody
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
207
263
|
//#region src/actions/discover.ts
|
|
208
|
-
/**
|
|
264
|
+
/** Structural-copy fields; `disabled` is handled separately in {@link emitInfo} (function-to-string transform). */
|
|
209
265
|
const OPTIONAL_FIELDS = [
|
|
210
266
|
"icon",
|
|
211
267
|
"intent",
|
|
212
268
|
"description",
|
|
213
269
|
"order",
|
|
214
270
|
"default",
|
|
215
|
-
"promptText"
|
|
271
|
+
"promptText",
|
|
272
|
+
"shortcut"
|
|
216
273
|
];
|
|
217
|
-
const WARN_PREFIX = "[moost-db actions]";
|
|
218
274
|
const actionsCache = /* @__PURE__ */ new WeakMap();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
* Reads class + method metadata via `getMoostMate()` and resolves bound POST
|
|
222
|
-
* paths through the Moost controller overview.
|
|
223
|
-
*
|
|
224
|
-
* Result is memoized per controller constructor — discovery walks every
|
|
225
|
-
* handler entry and reads decorator metadata, which is wasted work to repeat
|
|
226
|
-
* across instances.
|
|
227
|
-
*/
|
|
275
|
+
const rowLevelActionsCache = /* @__PURE__ */ new WeakMap();
|
|
276
|
+
/** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
|
|
228
277
|
function discoverActions(controllerCtor, app, logger) {
|
|
229
278
|
const cached = actionsCache.get(controllerCtor);
|
|
230
279
|
if (cached) return cached;
|
|
231
280
|
const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
|
|
232
281
|
const out = [];
|
|
233
|
-
|
|
234
|
-
|
|
282
|
+
const seen = /* @__PURE__ */ new Set();
|
|
283
|
+
collectMethodActions(controllerCtor, overview, logger, out, seen);
|
|
284
|
+
collectClassActions(controllerCtor, logger, out, seen);
|
|
235
285
|
applyDefaultPerLevel(out, logger);
|
|
236
286
|
actionsCache.set(controllerCtor, out);
|
|
237
287
|
return out;
|
|
238
288
|
}
|
|
239
|
-
|
|
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) {
|
|
240
298
|
if (!overview) return;
|
|
241
299
|
const byMethod = /* @__PURE__ */ new Map();
|
|
242
300
|
for (const h of overview.handlers) {
|
|
@@ -255,9 +313,25 @@ function collectMethodActions(ctor, overview, logger, out) {
|
|
|
255
313
|
const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
|
|
256
314
|
if (!levelInfer) continue;
|
|
257
315
|
if (levelInfer.bodyConflict) {
|
|
258
|
-
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`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (levelInfer.level === "table" && action.opts.disabled !== void 0) {
|
|
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`);
|
|
321
|
+
continue;
|
|
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`);
|
|
259
325
|
continue;
|
|
260
326
|
}
|
|
327
|
+
if (action.opts.disabled !== void 0 || levelInfer.hasRowParam) {
|
|
328
|
+
const extendsReadable = isAsDbReadableControllerSubclass(ctor);
|
|
329
|
+
const hasOptsTable = action.opts.table != null;
|
|
330
|
+
if (!extendsReadable && !hasOptsTable) {
|
|
331
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" declares a gate or row injection but the controller does not extend AsDbReadableController and \`opts.table\` is not provided. Either extend AsDbReadableController / AsDbController or pass \`opts.table\` on @DbAction — dropping`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
261
335
|
const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
|
|
262
336
|
if (!postEntry) {
|
|
263
337
|
logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
|
|
@@ -273,6 +347,10 @@ function collectMethodActions(ctor, overview, logger, out) {
|
|
|
273
347
|
logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
|
|
274
348
|
continue;
|
|
275
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
|
+
}
|
|
276
354
|
const info = {
|
|
277
355
|
name: action.name,
|
|
278
356
|
label,
|
|
@@ -280,40 +358,46 @@ function collectMethodActions(ctor, overview, logger, out) {
|
|
|
280
358
|
processor: "backend",
|
|
281
359
|
value: path
|
|
282
360
|
};
|
|
283
|
-
|
|
284
|
-
|
|
361
|
+
emitInfo(info, action.opts);
|
|
362
|
+
seen.add(action.name);
|
|
363
|
+
out.push({
|
|
364
|
+
info,
|
|
365
|
+
raw: action.opts
|
|
366
|
+
});
|
|
285
367
|
}
|
|
286
368
|
}
|
|
287
369
|
function inferMethodLevel(params, actionName, logger) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
for (const p of params) {
|
|
292
|
-
const kind = p[MOOST_DB_ACTION_PARAM];
|
|
293
|
-
if (kind === "pk") hasPk = true;
|
|
294
|
-
else if (kind === "pks") hasPks = true;
|
|
295
|
-
if (p.paramSource === "BODY") hasBody = true;
|
|
296
|
-
}
|
|
297
|
-
if (hasPk && hasPks) {
|
|
298
|
-
logger.warn(`${WARN_PREFIX} action "${actionName}" has both @DbActionPK and @DbActionPKs — dropping`);
|
|
370
|
+
const scan = scanParamLevel(params);
|
|
371
|
+
if (scan.single && scan.multi) {
|
|
372
|
+
logger.warn(`${WARN_PREFIX} action "${actionName}" mixes single-cardinality and multi-cardinality decorators (@DbActionID / @DbActionRow vs @DbActionIDs / @DbActionRows) — dropping`);
|
|
299
373
|
return null;
|
|
300
374
|
}
|
|
301
|
-
const level = hasPk ? "row" : hasPks ? "rows" : "table";
|
|
302
375
|
return {
|
|
303
|
-
level,
|
|
304
|
-
bodyConflict: hasBody && level !== "table"
|
|
376
|
+
level: scan.level,
|
|
377
|
+
bodyConflict: scan.hasBody && scan.level !== "table",
|
|
378
|
+
hasRowParam: scan.hasRowParam
|
|
305
379
|
};
|
|
306
380
|
}
|
|
307
|
-
function collectClassActions(ctor, logger, out) {
|
|
381
|
+
function collectClassActions(ctor, logger, out, seen) {
|
|
308
382
|
const list = getMoostMate().read(ctor)?.[MOOST_DB_ACTIONS];
|
|
309
383
|
if (!list) return;
|
|
310
|
-
for (const { name, entry
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
}
|
|
389
|
+
const built = buildClassEntry(name, entry, logger);
|
|
390
|
+
if (built) {
|
|
391
|
+
seen.add(name);
|
|
392
|
+
out.push({
|
|
393
|
+
info: built,
|
|
394
|
+
raw: entry
|
|
395
|
+
});
|
|
396
|
+
}
|
|
313
397
|
}
|
|
314
398
|
}
|
|
315
|
-
function buildClassEntry(name, entry,
|
|
316
|
-
const level =
|
|
399
|
+
function buildClassEntry(name, entry, logger) {
|
|
400
|
+
const level = entry.level;
|
|
317
401
|
if (!level) {
|
|
318
402
|
logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
|
|
319
403
|
return null;
|
|
@@ -322,6 +406,14 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
|
|
|
322
406
|
logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
|
|
323
407
|
return null;
|
|
324
408
|
}
|
|
409
|
+
if (level === "table" && entry.disabled !== void 0) {
|
|
410
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" — \`disabled\` is not allowed at the 'table' level — dropping`);
|
|
411
|
+
return null;
|
|
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
|
+
}
|
|
325
417
|
const processor = entry.processor;
|
|
326
418
|
let value;
|
|
327
419
|
if (processor === "navigate" || processor === "backend") {
|
|
@@ -349,26 +441,37 @@ function buildClassEntry(name, entry, forcedLevel, logger) {
|
|
|
349
441
|
processor,
|
|
350
442
|
value
|
|
351
443
|
};
|
|
352
|
-
|
|
444
|
+
emitInfo(info, entry);
|
|
353
445
|
return info;
|
|
354
446
|
}
|
|
355
|
-
function applyDefaultPerLevel(
|
|
447
|
+
function applyDefaultPerLevel(envelopes, logger) {
|
|
356
448
|
const winners = /* @__PURE__ */ new Map();
|
|
357
|
-
for (const
|
|
358
|
-
if (!
|
|
359
|
-
const existing = winners.get(
|
|
449
|
+
for (const { info } of envelopes) {
|
|
450
|
+
if (!info.default) continue;
|
|
451
|
+
const existing = winners.get(info.level);
|
|
360
452
|
if (existing) {
|
|
361
|
-
|
|
362
|
-
logger.warn(`${WARN_PREFIX} duplicate default action at level "${
|
|
363
|
-
} 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);
|
|
364
456
|
}
|
|
365
457
|
}
|
|
458
|
+
/** Emit structural-copy fields plus stringified `disabled`. `requiredFields` is server-internal (never on the wire). */
|
|
459
|
+
function emitInfo(info, source) {
|
|
460
|
+
const disabled = source.disabled;
|
|
461
|
+
const hasDisabled = typeof disabled === "function";
|
|
462
|
+
copyOptionalFields(info, source);
|
|
463
|
+
if (Array.isArray(info.promptText)) info.promptText = info.promptText.slice();
|
|
464
|
+
if (hasDisabled) info.disabled = disabled.toString();
|
|
465
|
+
}
|
|
366
466
|
function copyOptionalFields(info, source) {
|
|
367
467
|
for (const key of OPTIONAL_FIELDS) {
|
|
368
468
|
const value = source[key];
|
|
369
469
|
if (value !== void 0) info[key] = value;
|
|
370
470
|
}
|
|
371
471
|
}
|
|
472
|
+
function isNonEmptyStringArray(value) {
|
|
473
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string");
|
|
474
|
+
}
|
|
372
475
|
//#endregion
|
|
373
476
|
//#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
|
|
374
477
|
function __decorateMetadata(k, v) {
|
|
@@ -551,6 +654,7 @@ let AsReadableController = class AsReadableController {
|
|
|
551
654
|
vectorSearchable: false,
|
|
552
655
|
searchIndexes: [],
|
|
553
656
|
primaryKeys: [],
|
|
657
|
+
preferredId: [],
|
|
554
658
|
relations: [],
|
|
555
659
|
fields: {},
|
|
556
660
|
type: this.getSerializedType(),
|
|
@@ -564,7 +668,7 @@ let AsReadableController = class AsReadableController {
|
|
|
564
668
|
* controllers — see {@link AsValueHelpController#buildMetaResponse}.
|
|
565
669
|
*/
|
|
566
670
|
buildActions() {
|
|
567
|
-
return discoverActions(this.constructor, this.app, this.logger);
|
|
671
|
+
return discoverActions(this.constructor, this.app, this.logger).map((e) => e.info);
|
|
568
672
|
}
|
|
569
673
|
/**
|
|
570
674
|
* Declares the built-in CRUD operations this controller exposes. Subclasses
|
|
@@ -599,6 +703,80 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
|
|
|
599
703
|
Object
|
|
600
704
|
])], AsReadableController);
|
|
601
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
|
|
602
780
|
//#region src/decorators.ts
|
|
603
781
|
/**
|
|
604
782
|
* DI token under which the {@link AtscriptDbReadable} instance
|
|
@@ -693,10 +871,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
693
871
|
/** Reference to the underlying readable (table or view). */
|
|
694
872
|
readable;
|
|
695
873
|
_gates;
|
|
874
|
+
_preferredIdSet;
|
|
875
|
+
_compositeIdShapes;
|
|
876
|
+
_overlayIsNoOp;
|
|
696
877
|
constructor(readable, app) {
|
|
697
878
|
super(readable.type, readable.tableName, app, readable.isView ? "view" : "table");
|
|
698
879
|
this.readable = readable;
|
|
699
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;
|
|
700
885
|
}
|
|
701
886
|
_buildGates() {
|
|
702
887
|
const meta = this.readable.type.metadata;
|
|
@@ -764,16 +949,169 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
764
949
|
transformProjection(projection) {
|
|
765
950
|
return projection;
|
|
766
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
|
+
}
|
|
767
1106
|
/**
|
|
768
1107
|
* Extracts a composite identifier object from query params.
|
|
769
1108
|
* Tries composite primary key first, then compound unique indexes.
|
|
770
1109
|
*/
|
|
771
1110
|
extractCompositeId(query) {
|
|
772
|
-
const
|
|
773
|
-
if (pkFields.length > 1) {
|
|
1111
|
+
for (const id of this._compositeIdShapes) {
|
|
774
1112
|
const idObj = {};
|
|
775
1113
|
let allPresent = true;
|
|
776
|
-
for (const field of
|
|
1114
|
+
for (const field of id.fields) {
|
|
777
1115
|
if (query[field] === void 0) {
|
|
778
1116
|
allPresent = false;
|
|
779
1117
|
break;
|
|
@@ -782,19 +1120,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
782
1120
|
}
|
|
783
1121
|
if (allPresent) return idObj;
|
|
784
1122
|
}
|
|
785
|
-
for (const index of this.readable.indexes.values()) {
|
|
786
|
-
if (index.type !== "unique" || index.fields.length < 2) continue;
|
|
787
|
-
const idObj = {};
|
|
788
|
-
let allPresent = true;
|
|
789
|
-
for (const indexField of index.fields) {
|
|
790
|
-
if (query[indexField.name] === void 0) {
|
|
791
|
-
allPresent = false;
|
|
792
|
-
break;
|
|
793
|
-
}
|
|
794
|
-
idObj[indexField.name] = query[indexField.name];
|
|
795
|
-
}
|
|
796
|
-
if (allPresent) return idObj;
|
|
797
|
-
}
|
|
798
1123
|
return new HttpError(400, "Query params do not match any composite primary key or compound unique index");
|
|
799
1124
|
}
|
|
800
1125
|
/**
|
|
@@ -803,6 +1128,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
803
1128
|
async query(url) {
|
|
804
1129
|
const parsed = this.parseQueryString(url);
|
|
805
1130
|
const controls = parsed.controls;
|
|
1131
|
+
this._coerceActionsControl(controls);
|
|
806
1132
|
if (controls.$groupBy?.length) {
|
|
807
1133
|
if (controls.$with?.length) return new HttpError(400, "Cannot combine $with and $groupBy in the same query");
|
|
808
1134
|
if (parsed.insights) {
|
|
@@ -820,17 +1146,16 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
820
1146
|
if (error) return error;
|
|
821
1147
|
const gateError = this.checkGates(parsed.filter, controls, this._gates);
|
|
822
1148
|
if (gateError) return gateError;
|
|
823
|
-
const [filter,
|
|
1149
|
+
const [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
824
1150
|
if (controls.$count) return this.readable.count({
|
|
825
1151
|
filter,
|
|
826
1152
|
controls: {
|
|
827
1153
|
...controls,
|
|
828
|
-
$select:
|
|
1154
|
+
$select: rawSelect
|
|
829
1155
|
}
|
|
830
1156
|
});
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
const vectorField = controls.$vector;
|
|
1157
|
+
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1158
|
+
if (select instanceof HttpError) return select;
|
|
834
1159
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
835
1160
|
const queryObj = {
|
|
836
1161
|
filter,
|
|
@@ -841,19 +1166,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
841
1166
|
$threshold: threshold
|
|
842
1167
|
}
|
|
843
1168
|
};
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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;
|
|
851
1176
|
}
|
|
852
1177
|
/**
|
|
853
1178
|
* **GET /pages** — returns paginated records with metadata.
|
|
854
1179
|
*/
|
|
855
1180
|
async pages(url) {
|
|
856
1181
|
const parsed = this.parseQueryString(url);
|
|
1182
|
+
this._coerceActionsControl(parsed.controls);
|
|
857
1183
|
const error = this.validateParsed(parsed, "pages");
|
|
858
1184
|
if (error) return error;
|
|
859
1185
|
const controls = parsed.controls;
|
|
@@ -862,10 +1188,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
862
1188
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
863
1189
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
864
1190
|
const skip = (page - 1) * size;
|
|
865
|
-
const [filter,
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
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;
|
|
869
1194
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
870
1195
|
const query = {
|
|
871
1196
|
filter,
|
|
@@ -877,13 +1202,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
877
1202
|
$threshold: threshold
|
|
878
1203
|
}
|
|
879
1204
|
};
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
+
});
|
|
887
1212
|
return {
|
|
888
1213
|
data: result.data,
|
|
889
1214
|
page,
|
|
@@ -897,15 +1222,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
897
1222
|
*/
|
|
898
1223
|
async getOne(id, url) {
|
|
899
1224
|
const parsed = this.parseQueryString(url);
|
|
1225
|
+
this._coerceActionsControl(parsed.controls);
|
|
900
1226
|
if (Object.keys(parsed.filter).length > 0) return new HttpError(400, "Filtering is not allowed for \"one\" endpoint");
|
|
901
1227
|
const error = this.validateParsed(parsed, "getOne");
|
|
902
1228
|
if (error) return error;
|
|
903
|
-
const
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
};
|
|
908
|
-
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);
|
|
909
1233
|
}
|
|
910
1234
|
/**
|
|
911
1235
|
* **GET /one?field1=val1&field2=val2** — retrieves a single record by composite key
|
|
@@ -915,12 +1239,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
915
1239
|
const idObj = this.extractCompositeId(query);
|
|
916
1240
|
if (idObj instanceof HttpError) return idObj;
|
|
917
1241
|
const parsed = this.parseQueryString(url);
|
|
918
|
-
|
|
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;
|
|
919
1251
|
const controls = {
|
|
920
|
-
...
|
|
921
|
-
$select:
|
|
1252
|
+
...parsedControls,
|
|
1253
|
+
$select: initialSelect
|
|
922
1254
|
};
|
|
923
|
-
|
|
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;
|
|
924
1265
|
}
|
|
925
1266
|
/**
|
|
926
1267
|
* **GET /meta** — returns table/view metadata for UI.
|
|
@@ -954,6 +1295,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
954
1295
|
vectorSearchable: this.readable.isVectorSearchable(),
|
|
955
1296
|
searchIndexes: this.readable.getSearchIndexes(),
|
|
956
1297
|
primaryKeys: [...this.readable.primaryKeys],
|
|
1298
|
+
preferredId: [...this.readable.preferredId],
|
|
957
1299
|
relations,
|
|
958
1300
|
fields,
|
|
959
1301
|
type: this.getSerializedType(),
|
|
@@ -1005,6 +1347,7 @@ AsDbReadableController = __decorate([
|
|
|
1005
1347
|
__decorateParam(0, Inject(READABLE_DEF)),
|
|
1006
1348
|
__decorateMetadata("design:paramtypes", [Object, typeof (_ref$3 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$3 : Object])
|
|
1007
1349
|
], AsDbReadableController);
|
|
1350
|
+
registerAsDbReadableController(AsDbReadableController);
|
|
1008
1351
|
//#endregion
|
|
1009
1352
|
//#region src/as-db.controller.ts
|
|
1010
1353
|
var _ref$2, _ref2$1;
|
|
@@ -1251,6 +1594,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
1251
1594
|
vectorSearchable: false,
|
|
1252
1595
|
searchIndexes: [],
|
|
1253
1596
|
primaryKeys: this.primaryKey ? [this.primaryKey] : [],
|
|
1597
|
+
preferredId: this.primaryKey ? [this.primaryKey] : [],
|
|
1254
1598
|
relations: [],
|
|
1255
1599
|
fields,
|
|
1256
1600
|
type: this.getSerializedType(),
|
|
@@ -1303,6 +1647,7 @@ AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramt
|
|
|
1303
1647
|
String,
|
|
1304
1648
|
typeof (_ref$1 = typeof Moost !== "undefined" && Moost) === "function" ? _ref$1 : Object
|
|
1305
1649
|
])], AsValueHelpController);
|
|
1650
|
+
registerAsValueHelpController(AsValueHelpController);
|
|
1306
1651
|
//#endregion
|
|
1307
1652
|
//#region src/as-json-value-help.controller.ts
|
|
1308
1653
|
var _ref;
|
|
@@ -1463,151 +1808,120 @@ function applySelect(rows, select) {
|
|
|
1463
1808
|
});
|
|
1464
1809
|
}
|
|
1465
1810
|
//#endregion
|
|
1466
|
-
//#region src/actions/
|
|
1811
|
+
//#region src/actions/action-disabled-error.ts
|
|
1812
|
+
function buildMessage(action, ids) {
|
|
1813
|
+
if (ids !== void 0) return `Action "${action}" is disabled for ${ids.length} of the selected rows`;
|
|
1814
|
+
return `Action "${action}" is disabled for this row`;
|
|
1815
|
+
}
|
|
1467
1816
|
/**
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1471
|
-
* meta builder reads this metadata plus the bound POST path lazily and
|
|
1472
|
-
* emits the action with `processor: 'backend'`. Order vs.
|
|
1473
|
-
* `@DbActionDefault()` does not matter — both merge into the same slot.
|
|
1817
|
+
* Thrown by the gate interceptor when `disabled` returns truthy. Composes
|
|
1818
|
+
* with Moost's existing error mapper to produce HTTP 409 with the wire body
|
|
1819
|
+
* defined by {@link ActionDisabledErrorBody}.
|
|
1474
1820
|
*
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
* async blockUser(@DbActionPK() id: string) { ... }
|
|
1480
|
-
* ```
|
|
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).
|
|
1481
1825
|
*/
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
})
|
|
1826
|
+
var ActionDisabledError = class extends HttpError {
|
|
1827
|
+
name = "ActionDisabledError";
|
|
1828
|
+
constructor(action, id, ids) {
|
|
1829
|
+
const body = {
|
|
1830
|
+
name: "ActionDisabledError",
|
|
1831
|
+
message: buildMessage(action, ids),
|
|
1832
|
+
statusCode: 409,
|
|
1833
|
+
action
|
|
1491
1834
|
};
|
|
1492
|
-
|
|
1493
|
-
|
|
1835
|
+
if (ids !== void 0) body.ids = ids;
|
|
1836
|
+
else if (id !== void 0) body.id = id;
|
|
1837
|
+
super(409, body);
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1494
1840
|
//#endregion
|
|
1495
|
-
//#region src/actions/
|
|
1496
|
-
/**
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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];
|
|
1508
1855
|
}
|
|
1509
1856
|
//#endregion
|
|
1510
|
-
//#region src/actions/
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
return candidate;
|
|
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;
|
|
1528
1874
|
}
|
|
1529
|
-
function
|
|
1875
|
+
function fieldsSig(fields) {
|
|
1876
|
+
return fields.toSorted().join("");
|
|
1877
|
+
}
|
|
1878
|
+
function isIdValidationSource(value) {
|
|
1530
1879
|
if (!value || typeof value !== "object") return false;
|
|
1531
1880
|
const v = value;
|
|
1532
|
-
return
|
|
1533
|
-
}
|
|
1534
|
-
/**
|
|
1535
|
-
* Build a parameter decorator that parses the JSON request body, validates
|
|
1536
|
-
* it against the bound table's PK schema with `validate`, and tags the param
|
|
1537
|
-
* so {@link discoverActions} can infer the action's `level`.
|
|
1538
|
-
*/
|
|
1539
|
-
function createPkParamDecorator(kind, validate, resolverName) {
|
|
1540
|
-
return ApplyDecorators(getMoostMate().decorate(MOOST_DB_ACTION_PARAM, kind), Resolve(async () => {
|
|
1541
|
-
const body = await useBody().parseBody();
|
|
1542
|
-
validate(body, resolvePkSource(useControllerContext().getController()));
|
|
1543
|
-
return body;
|
|
1544
|
-
}, resolverName));
|
|
1881
|
+
return typeof v.getIdentifications === "function" && Array.isArray(v.fieldDescriptors);
|
|
1545
1882
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
/**
|
|
1549
|
-
* Validate a JSON-decoded body against a single-row PK shape (scalar or
|
|
1550
|
-
* composite). Throws {@link ValidatorError} with structured `errors` so the
|
|
1551
|
-
* existing validation interceptor returns HTTP 400.
|
|
1552
|
-
*/
|
|
1553
|
-
function validateSinglePk(body, source, path = "") {
|
|
1554
|
-
const errors = collectPkErrors(body, source, path);
|
|
1883
|
+
function validateSingleId(body, source, path = "") {
|
|
1884
|
+
const errors = collectIdErrors(body, source, path);
|
|
1555
1885
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
1886
|
+
return body;
|
|
1556
1887
|
}
|
|
1557
|
-
|
|
1558
|
-
* Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
|
|
1559
|
-
* The body MUST be an array; each element is validated against the PK schema.
|
|
1560
|
-
*/
|
|
1561
|
-
function validateMultiPk(body, source) {
|
|
1888
|
+
function validateMultiId(body, source) {
|
|
1562
1889
|
if (!Array.isArray(body)) throw new ValidatorError([{
|
|
1563
1890
|
path: "",
|
|
1564
|
-
message: "Expected JSON array of
|
|
1891
|
+
message: "Expected JSON array of identifier objects",
|
|
1565
1892
|
details: []
|
|
1566
1893
|
}]);
|
|
1567
1894
|
const errors = [];
|
|
1568
|
-
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}]`));
|
|
1569
1896
|
if (errors.length > 0) throw new ValidatorError(errors);
|
|
1897
|
+
return body;
|
|
1570
1898
|
}
|
|
1571
|
-
function
|
|
1572
|
-
|
|
1573
|
-
|
|
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 [{
|
|
1574
1913
|
path: pathPrefix,
|
|
1575
|
-
message:
|
|
1914
|
+
message: `Identifier fields must exactly match one of: ${cache.formatted}`,
|
|
1576
1915
|
details: []
|
|
1577
1916
|
}];
|
|
1578
1917
|
const errors = [];
|
|
1579
|
-
|
|
1580
|
-
const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
|
|
1581
|
-
if (err) errors.push(err);
|
|
1582
|
-
return errors;
|
|
1583
|
-
}
|
|
1584
|
-
if (!isPlainObject(value)) {
|
|
1585
|
-
errors.push({
|
|
1586
|
-
path: pathPrefix,
|
|
1587
|
-
message: "Expected JSON object for composite primary key",
|
|
1588
|
-
details: []
|
|
1589
|
-
});
|
|
1590
|
-
return errors;
|
|
1591
|
-
}
|
|
1592
|
-
for (const fieldName of pkFields) {
|
|
1918
|
+
for (const fieldName of match.fields) {
|
|
1593
1919
|
const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
|
|
1594
|
-
|
|
1595
|
-
errors.push({
|
|
1596
|
-
path: sub,
|
|
1597
|
-
message: `Missing primary-key field "${fieldName}"`,
|
|
1598
|
-
details: []
|
|
1599
|
-
});
|
|
1600
|
-
continue;
|
|
1601
|
-
}
|
|
1602
|
-
const fd = findFieldDescriptor(source, fieldName);
|
|
1603
|
-
const err = checkScalar(value[fieldName], fd, sub);
|
|
1920
|
+
const err = checkScalar(value[fieldName], cache.fieldByName.get(fieldName), sub);
|
|
1604
1921
|
if (err) errors.push(err);
|
|
1605
1922
|
}
|
|
1606
1923
|
return errors;
|
|
1607
1924
|
}
|
|
1608
|
-
function findFieldDescriptor(source, name) {
|
|
1609
|
-
for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
|
|
1610
|
-
}
|
|
1611
1925
|
function checkScalar(value, fd, path) {
|
|
1612
1926
|
const expected = fd?.designType ?? "string";
|
|
1613
1927
|
if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
|
|
@@ -1617,7 +1931,7 @@ function checkScalar(value, fd, path) {
|
|
|
1617
1931
|
function scalarMismatch(path, expected, value) {
|
|
1618
1932
|
return {
|
|
1619
1933
|
path,
|
|
1620
|
-
message: `Expected
|
|
1934
|
+
message: `Expected identifier value to be ${expected}, got ${describe(value)}`,
|
|
1621
1935
|
details: []
|
|
1622
1936
|
};
|
|
1623
1937
|
}
|
|
@@ -1630,38 +1944,333 @@ function isPlainObject(value) {
|
|
|
1630
1944
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1631
1945
|
}
|
|
1632
1946
|
//#endregion
|
|
1633
|
-
//#region src/actions/
|
|
1947
|
+
//#region src/actions/id-cache.ts
|
|
1948
|
+
const boundTableKey = key("atscript_db_action_bound_table");
|
|
1949
|
+
function getActionTable(ctx) {
|
|
1950
|
+
const fromSlot = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
|
|
1951
|
+
if (fromSlot) return fromSlot;
|
|
1952
|
+
const ctrl = useControllerContext(ctx).getController();
|
|
1953
|
+
return ctrl?.readable ?? ctrl?.table ?? null;
|
|
1954
|
+
}
|
|
1955
|
+
function noTableError(ctx) {
|
|
1956
|
+
const actionName = readCurrentActionMeta(ctx)?.name;
|
|
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.`);
|
|
1958
|
+
}
|
|
1959
|
+
async function resolveValidatedId(ctx, validate) {
|
|
1960
|
+
const table = getActionTable(ctx);
|
|
1961
|
+
if (!isIdValidationSource(table)) throw noTableError(ctx);
|
|
1962
|
+
const body = await useBody(ctx).parseBody();
|
|
1963
|
+
validate(body, table);
|
|
1964
|
+
return body;
|
|
1965
|
+
}
|
|
1966
|
+
const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
|
|
1967
|
+
const dbActionIdsSlot = cached(async (ctx) => {
|
|
1968
|
+
return await resolveValidatedId(ctx, validateMultiId);
|
|
1969
|
+
});
|
|
1970
|
+
const useDbActionId = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdSlot) }));
|
|
1971
|
+
const useDbActionIds = defineWook((ctx) => ({ load: () => ctx.get(dbActionIdsSlot) }));
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region src/actions/row-cache.ts
|
|
1974
|
+
function asFetchTable(value) {
|
|
1975
|
+
if (!value || typeof value !== "object") return null;
|
|
1976
|
+
const v = value;
|
|
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;
|
|
1991
|
+
}
|
|
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;
|
|
1998
|
+
}
|
|
1999
|
+
async function loadRow(ctx) {
|
|
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");
|
|
2010
|
+
return row;
|
|
2011
|
+
}
|
|
2012
|
+
async function loadRows(ctx) {
|
|
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);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
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]));
|
|
2056
|
+
}
|
|
2057
|
+
const dbActionRowSlot = cached((ctx) => loadRow(ctx));
|
|
2058
|
+
const dbActionRowsSlot = cached((ctx) => loadRows(ctx));
|
|
2059
|
+
const useDbActionRow = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowSlot) }));
|
|
2060
|
+
const useDbActionRows = defineWook((ctx) => ({ load: () => ctx.get(dbActionRowsSlot) }));
|
|
2061
|
+
//#endregion
|
|
2062
|
+
//#region src/actions/gate-interceptor.ts
|
|
2063
|
+
const GATE_PRIORITY = TInterceptorPriority.AFTER_GUARD;
|
|
2064
|
+
function injectBoundTable(table) {
|
|
2065
|
+
const ctx = current();
|
|
2066
|
+
if (ctx.has(boundTableKey)) return;
|
|
2067
|
+
const controller = useControllerContext(ctx).getController();
|
|
2068
|
+
if (isAsDbReadableControllerInstance(controller)) {
|
|
2069
|
+
ctx.set(boundTableKey, controller.readable);
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
if (table != null) ctx.set(boundTableKey, table);
|
|
2073
|
+
}
|
|
2074
|
+
function buildGateInterceptor(opts) {
|
|
2075
|
+
const { action, level, disabled, onDisabledRows, table } = opts;
|
|
2076
|
+
return defineBeforeInterceptor(async () => {
|
|
2077
|
+
injectBoundTable(table);
|
|
2078
|
+
const ctx = current();
|
|
2079
|
+
if (level === "row") {
|
|
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));
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const ids = await ctx.get(dbActionIdsSlot);
|
|
2086
|
+
const rows = await ctx.get(dbActionRowsSlot);
|
|
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 = [];
|
|
2092
|
+
const passingRows = [];
|
|
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
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
if (onDisabledRows === "skip") {
|
|
2104
|
+
if (passingRows.length === 0) throw new ActionDisabledError(action, void 0, [...ids]);
|
|
2105
|
+
if (failingIds.length > 0) {
|
|
2106
|
+
ctx.set(dbActionRowsSlot, Promise.resolve(passingRows));
|
|
2107
|
+
ctx.set(dbActionIdsSlot, Promise.resolve(passingIds));
|
|
2108
|
+
}
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (failingIds.length > 0) throw new ActionDisabledError(action, void 0, failingIds);
|
|
2112
|
+
}, GATE_PRIORITY);
|
|
2113
|
+
}
|
|
2114
|
+
/** Thin interceptor for `@DbActionRow*` without `disabled` — injects only the bound table. */
|
|
2115
|
+
function buildThinInterceptor(opts) {
|
|
2116
|
+
const { table } = opts;
|
|
2117
|
+
return defineBeforeInterceptor(() => {
|
|
2118
|
+
injectBoundTable(table);
|
|
2119
|
+
}, GATE_PRIORITY);
|
|
2120
|
+
}
|
|
2121
|
+
//#endregion
|
|
2122
|
+
//#region src/actions/db-action.decorator.ts
|
|
2123
|
+
/**
|
|
2124
|
+
* Mark a controller method as a database action surfaced via `/meta`. Writes
|
|
2125
|
+
* `MOOST_DB_ACTION` metadata and registers a Moost interceptor when needed
|
|
2126
|
+
* (gate when `disabled` is set, thin bound-table injector when only
|
|
2127
|
+
* `@DbActionRow*` is present). Stacking two `@DbAction` on the same method
|
|
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]>[]`.
|
|
2134
|
+
*/
|
|
2135
|
+
function DbAction(name, opts = {}) {
|
|
2136
|
+
const mate = getMoostMate();
|
|
2137
|
+
return ((target, propertyKey, descriptor) => {
|
|
2138
|
+
const priorName = mate.read(target, propertyKey)?.[MOOST_DB_ACTION]?.name;
|
|
2139
|
+
if (priorName) console.warn(`${WARN_PREFIX} stacking @DbAction on the same method is undefined; declare one per method. Detected: "${priorName}" and "${name}".`);
|
|
2140
|
+
mate.decorate((current) => {
|
|
2141
|
+
const meta = current;
|
|
2142
|
+
return {
|
|
2143
|
+
...current,
|
|
2144
|
+
[MOOST_DB_ACTION]: mergeActionMeta(meta, {
|
|
2145
|
+
name,
|
|
2146
|
+
opts
|
|
2147
|
+
})
|
|
2148
|
+
};
|
|
2149
|
+
})(target, propertyKey, descriptor);
|
|
2150
|
+
if (isAsValueHelpControllerSubclass(typeof target === "function" ? target : target.constructor)) return descriptor;
|
|
2151
|
+
const scan = scanParamLevel(mate.read(target, propertyKey)?.params ?? []);
|
|
2152
|
+
const rawOpts = opts;
|
|
2153
|
+
if (typeof rawOpts.disabled === "function" && (scan.level === "row" || scan.level === "rows")) Intercept(buildGateInterceptor({
|
|
2154
|
+
action: name,
|
|
2155
|
+
level: scan.level,
|
|
2156
|
+
disabled: rawOpts.disabled,
|
|
2157
|
+
onDisabledRows: rawOpts.onDisabledRows ?? "reject",
|
|
2158
|
+
table: rawOpts.table
|
|
2159
|
+
}))(target, propertyKey, descriptor);
|
|
2160
|
+
else if (scan.hasRowParam) Intercept(buildThinInterceptor({ table: rawOpts.table }))(target, propertyKey, descriptor);
|
|
2161
|
+
return descriptor;
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
//#endregion
|
|
2165
|
+
//#region src/actions/db-action-default.decorator.ts
|
|
2166
|
+
/**
|
|
2167
|
+
* Sugar that flips `default: true` on the same method's `@DbAction` metadata.
|
|
2168
|
+
* Equivalent to passing `opts.default = true`. Decorator order does not matter.
|
|
2169
|
+
*/
|
|
2170
|
+
function DbActionDefault() {
|
|
2171
|
+
return getMoostMate().decorate((current) => {
|
|
2172
|
+
const meta = current;
|
|
2173
|
+
return {
|
|
2174
|
+
...current,
|
|
2175
|
+
[MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
|
|
2176
|
+
};
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
//#endregion
|
|
2180
|
+
//#region src/actions/id-source.ts
|
|
1634
2181
|
/**
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
2182
|
+
* Build a parameter decorator that reads its value from the cached ID wook
|
|
2183
|
+
* (single or multi). Validation runs inside the wook factory exactly once
|
|
2184
|
+
* per request, regardless of how many readers consume the value (`@DbActionID*`
|
|
2185
|
+
* resolver, gate interceptor, cached row wook, in-handler composables).
|
|
2186
|
+
*
|
|
2187
|
+
* Marks the param so {@link discoverActions} can infer the action's `level`.
|
|
2188
|
+
*/
|
|
2189
|
+
function createIdParamDecorator(kind) {
|
|
2190
|
+
const mate = getMoostMate();
|
|
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));
|
|
2194
|
+
}
|
|
2195
|
+
//#endregion
|
|
2196
|
+
//#region src/actions/db-action-id.decorator.ts
|
|
2197
|
+
/**
|
|
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:
|
|
1637
2203
|
*
|
|
1638
|
-
* - Single-field PK →
|
|
1639
|
-
* - 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: "..." }`.
|
|
1640
2208
|
*
|
|
1641
|
-
*
|
|
2209
|
+
* Strict — unknown fields are rejected, no type coercion. Mismatches throw a
|
|
1642
2210
|
* `ValidatorError` which the existing validation interceptor surfaces as
|
|
1643
2211
|
* HTTP 400 with the same envelope as DTO failures.
|
|
1644
2212
|
*
|
|
1645
2213
|
* Marks the param so {@link discoverActions} can infer the action's `level`
|
|
1646
2214
|
* as `'row'`.
|
|
2215
|
+
*
|
|
2216
|
+
* Implementation note: the resolver is a thin reader of the cached ID wook
|
|
2217
|
+
* — validation logic lives in the wook factory, which runs once per request
|
|
2218
|
+
* regardless of how many readers consume the value.
|
|
1647
2219
|
*/
|
|
1648
|
-
function
|
|
1649
|
-
return
|
|
2220
|
+
function DbActionID() {
|
|
2221
|
+
return createIdParamDecorator("id");
|
|
1650
2222
|
}
|
|
1651
2223
|
//#endregion
|
|
1652
|
-
//#region src/actions/db-action-
|
|
2224
|
+
//#region src/actions/db-action-ids.decorator.ts
|
|
1653
2225
|
/**
|
|
1654
|
-
* Parameter resolver that reads a JSON array of
|
|
1655
|
-
* 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.
|
|
2228
|
+
*
|
|
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.
|
|
1656
2234
|
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
2235
|
+
* Strict — unknown fields are rejected, no type coercion. Marks the param
|
|
2236
|
+
* so {@link discoverActions} can infer the action's `level` as `'rows'`.
|
|
1659
2237
|
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
2238
|
+
* In `'rows'` skip mode the resolved value reflects the gate interceptor's
|
|
2239
|
+
* filtered subset (the cached ID slot is overwritten in place); see
|
|
2240
|
+
* {@link dbActionIdsSlot} for precedence details.
|
|
1662
2241
|
*/
|
|
1663
|
-
function
|
|
1664
|
-
return
|
|
2242
|
+
function DbActionIDs() {
|
|
2243
|
+
return createIdParamDecorator("ids");
|
|
2244
|
+
}
|
|
2245
|
+
//#endregion
|
|
2246
|
+
//#region src/actions/db-action-row.decorator.ts
|
|
2247
|
+
function createRowParamDecorator(metaKey, slot, resolverName) {
|
|
2248
|
+
return ApplyDecorators(getMoostMate().decorate(metaKey, true), Resolve(async () => current().get(slot), resolverName));
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Parameter decorator that injects the row whose identifier was supplied in
|
|
2252
|
+
* the request body.
|
|
2253
|
+
*
|
|
2254
|
+
* Marks the param so {@link discoverActions} infers the action's `level` as
|
|
2255
|
+
* `'row'`. Co-occurrence with `@DbActionRows()` (or any multi-cardinality
|
|
2256
|
+
* decorator) drops the action with a warning.
|
|
2257
|
+
*
|
|
2258
|
+
* In `'skip'` mode this returns the gate's filtered row; the original
|
|
2259
|
+
* request-body row is not retrievable.
|
|
2260
|
+
*/
|
|
2261
|
+
function DbActionRow() {
|
|
2262
|
+
return createRowParamDecorator(MOOST_DB_ACTION_ROW, dbActionRowSlot, "dbActionRow");
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Parameter decorator that injects the rows fetched by the identifiers
|
|
2266
|
+
* supplied in the request body.
|
|
2267
|
+
*
|
|
2268
|
+
* Marks the param so {@link discoverActions} infers the action's `level` as
|
|
2269
|
+
* `'rows'`. In `'rows'` + `'skip'` mode the resolved value contains only the
|
|
2270
|
+
* gate's surviving rows.
|
|
2271
|
+
*/
|
|
2272
|
+
function DbActionRows() {
|
|
2273
|
+
return createRowParamDecorator(MOOST_DB_ACTION_ROWS, dbActionRowsSlot, "dbActionRows");
|
|
1665
2274
|
}
|
|
1666
2275
|
//#endregion
|
|
1667
2276
|
//#region src/actions/db-actions.decorator.ts
|
|
@@ -1672,10 +2281,9 @@ function DbActionPKs() {
|
|
|
1672
2281
|
* the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
|
|
1673
2282
|
* `@DbRowsActions`) to avoid repeating `level`.
|
|
1674
2283
|
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
* `
|
|
1678
|
-
* `@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.
|
|
1679
2287
|
*
|
|
1680
2288
|
* Multiple `@DbActions` (and shortcut) decorators on the same class
|
|
1681
2289
|
* accumulate.
|
|
@@ -1697,11 +2305,16 @@ function DbRowsActions(dict) {
|
|
|
1697
2305
|
}
|
|
1698
2306
|
function classLevelActions(dict, forcedLevel) {
|
|
1699
2307
|
const entries = [];
|
|
1700
|
-
for (const [name, entry] of Object.entries(dict))
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
2308
|
+
for (const [name, entry] of Object.entries(dict)) {
|
|
2309
|
+
const merged = forcedLevel ? {
|
|
2310
|
+
...entry,
|
|
2311
|
+
level: forcedLevel
|
|
2312
|
+
} : entry;
|
|
2313
|
+
entries.push({
|
|
2314
|
+
name,
|
|
2315
|
+
entry: merged
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
1705
2318
|
return getMoostMate().decorate((current) => {
|
|
1706
2319
|
const existing = current["atscript_db_actions"] ?? [];
|
|
1707
2320
|
return {
|
|
@@ -1711,4 +2324,19 @@ function classLevelActions(dict, forcedLevel) {
|
|
|
1711
2324
|
});
|
|
1712
2325
|
}
|
|
1713
2326
|
//#endregion
|
|
1714
|
-
|
|
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 };
|