@atscript/moost-db 0.1.55 → 0.1.57
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 +3 -1
- package/dist/index.cjs +542 -30
- package/dist/index.d.cts +195 -19
- package/dist/index.d.mts +195 -19
- package/dist/index.mjs +532 -32
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -22,7 +22,9 @@ Generic database controller for the [Moost](https://moost.org) framework. Expose
|
|
|
22
22
|
pnpm add @atscript/moost-db
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Peer dependencies: `moost`, `@moostjs/event-http`, `@atscript/db`, `@atscript/typescript`.
|
|
25
|
+
Peer dependencies: `moost`, `@moostjs/event-http`, `@wooksjs/http-body`, `@atscript/db`, `@atscript/typescript`.
|
|
26
|
+
|
|
27
|
+
`@wooksjs/http-body` is required by the `@DbActionPK` / `@DbActionPKs` parameter resolvers (used by the action layer) to read the parsed JSON request body.
|
|
26
28
|
|
|
27
29
|
## Quick Start
|
|
28
30
|
|
package/dist/index.cjs
CHANGED
|
@@ -4,6 +4,7 @@ let _moostjs_event_http = require("@moostjs/event-http");
|
|
|
4
4
|
let moost = require("moost");
|
|
5
5
|
let _uniqu_url = require("@uniqu/url");
|
|
6
6
|
let _atscript_db = require("@atscript/db");
|
|
7
|
+
let _wooksjs_http_body = require("@wooksjs/http-body");
|
|
7
8
|
//#region src/validation-interceptor.ts
|
|
8
9
|
const dbErrorCodeToStatus = { CONFLICT: 409 };
|
|
9
10
|
function transformValidationError(error, reply) {
|
|
@@ -180,6 +181,196 @@ function findSortOffender(sort, isAllowed) {
|
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
//#endregion
|
|
184
|
+
//#region src/actions/keys.ts
|
|
185
|
+
/** Method-level metadata key — written by `@DbAction(name, opts)`. */
|
|
186
|
+
const MOOST_DB_ACTION = "atscript_db_action";
|
|
187
|
+
/** Class-level metadata key — written by `@DbActions` and the level-pinned shortcuts. Stored as an array; decorators accumulate. */
|
|
188
|
+
const MOOST_DB_ACTIONS = "atscript_db_actions";
|
|
189
|
+
/** Param-level metadata key — written by `@DbActionPK()` / `@DbActionPKs()`. Drives level inference. */
|
|
190
|
+
const MOOST_DB_ACTION_PARAM = "atscript_db_action_param";
|
|
191
|
+
/**
|
|
192
|
+
* Shared method-decorator update used by `@DbAction` and `@DbActionDefault`:
|
|
193
|
+
* read the existing `MOOST_DB_ACTION` slot, merge the patch (later-applied
|
|
194
|
+
* fields win), and write it back. `name` is empty until `@DbAction` provides
|
|
195
|
+
* one — `discoverActions` warns and drops actions with no name.
|
|
196
|
+
*/
|
|
197
|
+
function mergeActionMeta(current, patch) {
|
|
198
|
+
const existing = current[MOOST_DB_ACTION];
|
|
199
|
+
return {
|
|
200
|
+
name: patch.name ?? existing?.name ?? "",
|
|
201
|
+
opts: {
|
|
202
|
+
...existing?.opts,
|
|
203
|
+
...patch.opts
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/actions/discover.ts
|
|
209
|
+
/** Optional fields shared between method opts and class-level entries. */
|
|
210
|
+
const OPTIONAL_FIELDS = [
|
|
211
|
+
"icon",
|
|
212
|
+
"intent",
|
|
213
|
+
"description",
|
|
214
|
+
"order",
|
|
215
|
+
"default",
|
|
216
|
+
"promptText"
|
|
217
|
+
];
|
|
218
|
+
const WARN_PREFIX = "[moost-db actions]";
|
|
219
|
+
const actionsCache = /* @__PURE__ */ new WeakMap();
|
|
220
|
+
/**
|
|
221
|
+
* Discover all actions declared on a controller and produce the `/meta` array.
|
|
222
|
+
* Reads class + method metadata via `getMoostMate()` and resolves bound POST
|
|
223
|
+
* paths through the Moost controller overview.
|
|
224
|
+
*
|
|
225
|
+
* Result is memoized per controller constructor — discovery walks every
|
|
226
|
+
* handler entry and reads decorator metadata, which is wasted work to repeat
|
|
227
|
+
* across instances.
|
|
228
|
+
*/
|
|
229
|
+
function discoverActions(controllerCtor, app, logger) {
|
|
230
|
+
const cached = actionsCache.get(controllerCtor);
|
|
231
|
+
if (cached) return cached;
|
|
232
|
+
const overview = app.getControllersOverview?.()?.find((o) => o.type === controllerCtor);
|
|
233
|
+
const out = [];
|
|
234
|
+
collectMethodActions(controllerCtor, overview, logger, out);
|
|
235
|
+
collectClassActions(controllerCtor, logger, out);
|
|
236
|
+
applyDefaultPerLevel(out, logger);
|
|
237
|
+
actionsCache.set(controllerCtor, out);
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
function collectMethodActions(ctor, overview, logger, out) {
|
|
241
|
+
if (!overview) return;
|
|
242
|
+
const byMethod = /* @__PURE__ */ new Map();
|
|
243
|
+
for (const h of overview.handlers) {
|
|
244
|
+
const list = byMethod.get(h.method);
|
|
245
|
+
if (list) list.push(h);
|
|
246
|
+
else byMethod.set(h.method, [h]);
|
|
247
|
+
}
|
|
248
|
+
for (const [methodName, handlers] of byMethod) {
|
|
249
|
+
const methodMeta = handlers[0].meta;
|
|
250
|
+
const action = methodMeta[MOOST_DB_ACTION];
|
|
251
|
+
if (!action) continue;
|
|
252
|
+
if (!action.name) {
|
|
253
|
+
logger.warn(`${WARN_PREFIX} method "${methodName}" has @DbActionDefault() but no @DbAction(name) — dropping`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const levelInfer = inferMethodLevel(methodMeta.params ?? [], action.name, logger);
|
|
257
|
+
if (!levelInfer) continue;
|
|
258
|
+
if (levelInfer.bodyConflict) {
|
|
259
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" cannot mix @DbActionPK*/@DbActionPKs with @Body() — dropping`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const postEntry = handlers.find((h) => h.handler.type === "HTTP" && h.handler.method === "POST");
|
|
263
|
+
if (!postEntry) {
|
|
264
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" requires @Post(...); no POST handler bound to ${methodName} — dropping`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const path = postEntry.registeredAs[0]?.path;
|
|
268
|
+
if (!path) {
|
|
269
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" — POST handler ${methodName} has no registered path — dropping`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const label = action.opts.label ?? methodMeta.label;
|
|
273
|
+
if (!label) {
|
|
274
|
+
logger.warn(`${WARN_PREFIX} action "${action.name}" requires a label (opts.label or @Label) — dropping`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const info = {
|
|
278
|
+
name: action.name,
|
|
279
|
+
label,
|
|
280
|
+
level: levelInfer.level,
|
|
281
|
+
processor: "backend",
|
|
282
|
+
value: path
|
|
283
|
+
};
|
|
284
|
+
copyOptionalFields(info, action.opts);
|
|
285
|
+
out.push(info);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function inferMethodLevel(params, actionName, logger) {
|
|
289
|
+
let hasPk = false;
|
|
290
|
+
let hasPks = false;
|
|
291
|
+
let hasBody = false;
|
|
292
|
+
for (const p of params) {
|
|
293
|
+
const kind = p[MOOST_DB_ACTION_PARAM];
|
|
294
|
+
if (kind === "pk") hasPk = true;
|
|
295
|
+
else if (kind === "pks") hasPks = true;
|
|
296
|
+
if (p.paramSource === "BODY") hasBody = true;
|
|
297
|
+
}
|
|
298
|
+
if (hasPk && hasPks) {
|
|
299
|
+
logger.warn(`${WARN_PREFIX} action "${actionName}" has both @DbActionPK and @DbActionPKs — dropping`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const level = hasPk ? "row" : hasPks ? "rows" : "table";
|
|
303
|
+
return {
|
|
304
|
+
level,
|
|
305
|
+
bodyConflict: hasBody && level !== "table"
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function collectClassActions(ctor, logger, out) {
|
|
309
|
+
const list = (0, moost.getMoostMate)().read(ctor)?.[MOOST_DB_ACTIONS];
|
|
310
|
+
if (!list) return;
|
|
311
|
+
for (const { name, entry, forcedLevel } of list) {
|
|
312
|
+
const built = buildClassEntry(name, entry, forcedLevel, logger);
|
|
313
|
+
if (built) out.push(built);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function buildClassEntry(name, entry, forcedLevel, logger) {
|
|
317
|
+
const level = forcedLevel ?? entry.level;
|
|
318
|
+
if (!level) {
|
|
319
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a level — dropping. Use @DbTableActions/@DbRowActions/@DbRowsActions or set "level" explicitly.`);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
if (!entry.label) {
|
|
323
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" requires a label — dropping`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const processor = entry.processor;
|
|
327
|
+
let value;
|
|
328
|
+
if (processor === "navigate" || processor === "backend") {
|
|
329
|
+
const v = entry.value;
|
|
330
|
+
if (typeof v !== "string" || v === "") {
|
|
331
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" with processor "${processor}" requires a non-empty "value" — dropping`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
value = v;
|
|
335
|
+
} else if (processor === "custom") {
|
|
336
|
+
const v = entry.value;
|
|
337
|
+
if (v !== void 0 && v !== null) {
|
|
338
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" with processor "custom" forbids "value" (always derived from the dict key) — dropping`);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
value = name;
|
|
342
|
+
} else {
|
|
343
|
+
logger.warn(`${WARN_PREFIX} class-level action "${name}" has unknown processor "${String(processor)}" — dropping`);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const info = {
|
|
347
|
+
name,
|
|
348
|
+
label: entry.label,
|
|
349
|
+
level,
|
|
350
|
+
processor,
|
|
351
|
+
value
|
|
352
|
+
};
|
|
353
|
+
copyOptionalFields(info, entry);
|
|
354
|
+
return info;
|
|
355
|
+
}
|
|
356
|
+
function applyDefaultPerLevel(actions, logger) {
|
|
357
|
+
const winners = /* @__PURE__ */ new Map();
|
|
358
|
+
for (const a of actions) {
|
|
359
|
+
if (!a.default) continue;
|
|
360
|
+
const existing = winners.get(a.level);
|
|
361
|
+
if (existing) {
|
|
362
|
+
a.default = false;
|
|
363
|
+
logger.warn(`${WARN_PREFIX} duplicate default action at level "${a.level}": "${existing}" wins, "${a.name}" demoted`);
|
|
364
|
+
} else winners.set(a.level, a.name);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function copyOptionalFields(info, source) {
|
|
368
|
+
for (const key of OPTIONAL_FIELDS) {
|
|
369
|
+
const value = source[key];
|
|
370
|
+
if (value !== void 0) info[key] = value;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
183
374
|
//#region \0@oxc-project+runtime@0.120.0/helpers/decorateMetadata.js
|
|
184
375
|
function __decorateMetadata(k, v) {
|
|
185
376
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
@@ -282,13 +473,6 @@ let AsReadableController = class AsReadableController {
|
|
|
282
473
|
}
|
|
283
474
|
};
|
|
284
475
|
}
|
|
285
|
-
/**
|
|
286
|
-
* Whether this controller is read-only (no write endpoints).
|
|
287
|
-
* Returns `true` by default; {@link AsDbController} overrides to `false`.
|
|
288
|
-
*/
|
|
289
|
-
_isReadOnly() {
|
|
290
|
-
return true;
|
|
291
|
-
}
|
|
292
476
|
_queryControlsValidator;
|
|
293
477
|
_pagesControlsValidator;
|
|
294
478
|
_getOneControlsValidator;
|
|
@@ -348,36 +532,60 @@ let AsReadableController = class AsReadableController {
|
|
|
348
532
|
return item;
|
|
349
533
|
}
|
|
350
534
|
/**
|
|
351
|
-
* **GET /meta** — returns the bound interface's metadata envelope.
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* override to add source-specific fields (relations, searchable flags, etc.).
|
|
355
|
-
* The response is cached on the instance; async overrides must cache any
|
|
356
|
-
* extra enrichment themselves.
|
|
535
|
+
* **GET /meta** — returns the bound interface's metadata envelope. The
|
|
536
|
+
* static envelope is cached; {@link applyMetaOverlay} runs per request so
|
|
537
|
+
* subclasses can prune the response by principal.
|
|
357
538
|
*/
|
|
358
539
|
async meta() {
|
|
359
|
-
if (this._metaResponse)
|
|
360
|
-
|
|
361
|
-
this._metaResponse = response;
|
|
362
|
-
return response;
|
|
540
|
+
if (!this._metaResponse) this._metaResponse = this.buildMetaResponse();
|
|
541
|
+
return this.applyMetaOverlay(this._metaResponse);
|
|
363
542
|
}
|
|
364
543
|
/**
|
|
365
544
|
* Builds the `/meta` payload. Override in subclasses to populate source-specific
|
|
366
|
-
* fields.
|
|
367
|
-
*
|
|
545
|
+
* fields. Subclasses that fully replace the envelope must call
|
|
546
|
+
* {@link buildActions} and {@link buildCrud} directly so `@DbAction*`
|
|
547
|
+
* decorators and CRUD permissions still surface.
|
|
368
548
|
*/
|
|
369
|
-
|
|
549
|
+
buildMetaResponse() {
|
|
370
550
|
return {
|
|
371
551
|
searchable: false,
|
|
372
552
|
vectorSearchable: false,
|
|
373
553
|
searchIndexes: [],
|
|
374
554
|
primaryKeys: [],
|
|
375
|
-
readOnly: this._isReadOnly(),
|
|
376
555
|
relations: [],
|
|
377
556
|
fields: {},
|
|
378
|
-
type: this.getSerializedType()
|
|
557
|
+
type: this.getSerializedType(),
|
|
558
|
+
actions: this.buildActions(),
|
|
559
|
+
crud: this.buildCrud()
|
|
379
560
|
};
|
|
380
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Discovers `@DbAction*` and `@DbActions`-style class metadata on this
|
|
564
|
+
* controller and produces the `actions` array. Returns `[]` for value-help
|
|
565
|
+
* controllers — see {@link AsValueHelpController#buildMetaResponse}.
|
|
566
|
+
*/
|
|
567
|
+
buildActions() {
|
|
568
|
+
return discoverActions(this.constructor, this.app, this.logger);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Declares the built-in CRUD operations this controller exposes. Subclasses
|
|
572
|
+
* override to add their keys; the bare base only exposes `/meta`. See
|
|
573
|
+
* `docs/http/permissions.md` for the wire shape and overlay rules.
|
|
574
|
+
*/
|
|
575
|
+
buildCrud() {
|
|
576
|
+
return {};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Per-request overlay applied to the cached `/meta` envelope. Default no-op.
|
|
580
|
+
* Subclasses may shallow-clone and prune `crud` keys, `crud[op]` arrays, or
|
|
581
|
+
* `actions[]` based on the current request principal (read via Moost
|
|
582
|
+
* composables). The cached envelope MUST NOT be mutated — see
|
|
583
|
+
* `docs/http/permissions.md` for the full contract, including the
|
|
584
|
+
* "discoverability only" caveat.
|
|
585
|
+
*/
|
|
586
|
+
applyMetaOverlay(meta) {
|
|
587
|
+
return meta;
|
|
588
|
+
}
|
|
381
589
|
};
|
|
382
590
|
__decorate([
|
|
383
591
|
(0, _moostjs_event_http.Get)("meta"),
|
|
@@ -456,6 +664,23 @@ const ReadableController = (readable, prefix) => {
|
|
|
456
664
|
*/
|
|
457
665
|
const ViewController = ReadableController;
|
|
458
666
|
//#endregion
|
|
667
|
+
//#region src/permissions/crud-controls.ts
|
|
668
|
+
/**
|
|
669
|
+
* Static control whitelists per read op. Each list is the matching DTO's
|
|
670
|
+
* `$`-properties (stripped) plus the URL-grammar extras (`filter`,
|
|
671
|
+
* `insights`, `groupBy`) that bypass the DTO. The DTO is the single source of
|
|
672
|
+
* truth — adding a `$control` there auto-extends the whitelist here.
|
|
673
|
+
*/
|
|
674
|
+
const dtoControls = (Dto) => [...Dto.type.props.keys()].map((k) => k.startsWith("$") ? k.slice(1) : k);
|
|
675
|
+
const QUERY_CONTROLS = [
|
|
676
|
+
"filter",
|
|
677
|
+
"insights",
|
|
678
|
+
...dtoControls(QueryControlsDto),
|
|
679
|
+
"groupBy"
|
|
680
|
+
];
|
|
681
|
+
const PAGES_CONTROLS = ["filter", ...dtoControls(PagesControlsDto)];
|
|
682
|
+
const ONE_CONTROLS = dtoControls(GetOneControlsDto);
|
|
683
|
+
//#endregion
|
|
459
684
|
//#region \0@oxc-project+runtime@0.120.0/helpers/decorateParam.js
|
|
460
685
|
function __decorateParam(paramIndex, decorator) {
|
|
461
686
|
return function(target, key) {
|
|
@@ -705,7 +930,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
705
930
|
* vector-searchable flags, field-descriptor-derived filter/sort hints, and
|
|
706
931
|
* the configured primary keys.
|
|
707
932
|
*/
|
|
708
|
-
|
|
933
|
+
buildMetaResponse() {
|
|
709
934
|
const relations = [];
|
|
710
935
|
for (const [name, rel] of this.readable.relations) relations.push({
|
|
711
936
|
name,
|
|
@@ -730,10 +955,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
730
955
|
vectorSearchable: this.readable.isVectorSearchable(),
|
|
731
956
|
searchIndexes: this.readable.getSearchIndexes(),
|
|
732
957
|
primaryKeys: [...this.readable.primaryKeys],
|
|
733
|
-
readOnly: this._isReadOnly(),
|
|
734
958
|
relations,
|
|
735
959
|
fields,
|
|
736
|
-
type: this.getSerializedType()
|
|
960
|
+
type: this.getSerializedType(),
|
|
961
|
+
actions: this.buildActions(),
|
|
962
|
+
crud: this.buildCrud()
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
buildCrud() {
|
|
966
|
+
return {
|
|
967
|
+
...super.buildCrud(),
|
|
968
|
+
query: [...QUERY_CONTROLS],
|
|
969
|
+
pages: [...PAGES_CONTROLS],
|
|
970
|
+
one: [...ONE_CONTROLS]
|
|
737
971
|
};
|
|
738
972
|
}
|
|
739
973
|
};
|
|
@@ -783,8 +1017,14 @@ let AsDbController = class AsDbController extends AsDbReadableController {
|
|
|
783
1017
|
constructor(table, app) {
|
|
784
1018
|
super(table, app);
|
|
785
1019
|
}
|
|
786
|
-
|
|
787
|
-
return
|
|
1020
|
+
buildCrud() {
|
|
1021
|
+
return {
|
|
1022
|
+
...super.buildCrud(),
|
|
1023
|
+
insert: [],
|
|
1024
|
+
update: [],
|
|
1025
|
+
replace: [],
|
|
1026
|
+
remove: []
|
|
1027
|
+
};
|
|
788
1028
|
}
|
|
789
1029
|
/**
|
|
790
1030
|
* Intercepts write operations. Return `undefined` to abort.
|
|
@@ -1001,7 +1241,7 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
1001
1241
|
* client picker UI (which controls to render); the server does not enforce
|
|
1002
1242
|
* these flags at request time.
|
|
1003
1243
|
*/
|
|
1004
|
-
|
|
1244
|
+
buildMetaResponse() {
|
|
1005
1245
|
const fields = {};
|
|
1006
1246
|
for (const [path, meta] of this.fieldMeta) fields[path] = {
|
|
1007
1247
|
sortable: meta.has("ui.dict.sortable"),
|
|
@@ -1012,10 +1252,22 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
1012
1252
|
vectorSearchable: false,
|
|
1013
1253
|
searchIndexes: [],
|
|
1014
1254
|
primaryKeys: this.primaryKey ? [this.primaryKey] : [],
|
|
1015
|
-
readOnly: this._isReadOnly(),
|
|
1016
1255
|
relations: [],
|
|
1017
1256
|
fields,
|
|
1018
|
-
type: this.getSerializedType()
|
|
1257
|
+
type: this.getSerializedType(),
|
|
1258
|
+
actions: [],
|
|
1259
|
+
crud: this.buildCrud()
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
buildActions() {
|
|
1263
|
+
return [];
|
|
1264
|
+
}
|
|
1265
|
+
buildCrud() {
|
|
1266
|
+
return {
|
|
1267
|
+
...super.buildCrud(),
|
|
1268
|
+
query: [...QUERY_CONTROLS],
|
|
1269
|
+
pages: [...PAGES_CONTROLS],
|
|
1270
|
+
one: [...ONE_CONTROLS]
|
|
1019
1271
|
};
|
|
1020
1272
|
}
|
|
1021
1273
|
};
|
|
@@ -1212,6 +1464,254 @@ function applySelect(rows, select) {
|
|
|
1212
1464
|
});
|
|
1213
1465
|
}
|
|
1214
1466
|
//#endregion
|
|
1467
|
+
//#region src/actions/db-action.decorator.ts
|
|
1468
|
+
/**
|
|
1469
|
+
* Mark a controller method as a database action surfaced via `/meta`.
|
|
1470
|
+
*
|
|
1471
|
+
* Metadata-only — pair with `@Post(...)` for Moost to bind the route. The
|
|
1472
|
+
* meta builder reads this metadata plus the bound POST path lazily and
|
|
1473
|
+
* emits the action with `processor: 'backend'`. Order vs.
|
|
1474
|
+
* `@DbActionDefault()` does not matter — both merge into the same slot.
|
|
1475
|
+
*
|
|
1476
|
+
* @example
|
|
1477
|
+
* ```ts
|
|
1478
|
+
* @Post('actions/block')
|
|
1479
|
+
* @DbAction('block', { label: 'Block', icon: 'i-as-block', intent: 'negative' })
|
|
1480
|
+
* async blockUser(@DbActionPK() id: string) { ... }
|
|
1481
|
+
* ```
|
|
1482
|
+
*/
|
|
1483
|
+
function DbAction(name, opts = {}) {
|
|
1484
|
+
return (0, moost.getMoostMate)().decorate((current) => {
|
|
1485
|
+
const meta = current;
|
|
1486
|
+
return {
|
|
1487
|
+
...current,
|
|
1488
|
+
[MOOST_DB_ACTION]: mergeActionMeta(meta, {
|
|
1489
|
+
name,
|
|
1490
|
+
opts
|
|
1491
|
+
})
|
|
1492
|
+
};
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/actions/db-action-default.decorator.ts
|
|
1497
|
+
/**
|
|
1498
|
+
* Sugar that flips `default: true` on the same method's `@DbAction` metadata.
|
|
1499
|
+
* Equivalent to passing `opts.default = true`. Decorator order does not matter.
|
|
1500
|
+
*/
|
|
1501
|
+
function DbActionDefault() {
|
|
1502
|
+
return (0, moost.getMoostMate)().decorate((current) => {
|
|
1503
|
+
const meta = current;
|
|
1504
|
+
return {
|
|
1505
|
+
...current,
|
|
1506
|
+
[MOOST_DB_ACTION]: mergeActionMeta(meta, { opts: { default: true } })
|
|
1507
|
+
};
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/actions/pk-source.ts
|
|
1512
|
+
/**
|
|
1513
|
+
* Extract the PK validation source from a controller instance. Looks for
|
|
1514
|
+
* `readable` (set by {@link AsDbReadableController}) or `table` (set by
|
|
1515
|
+
* {@link AsDbController}).
|
|
1516
|
+
*
|
|
1517
|
+
* If the controller has no typed table attached (e.g. a value-help
|
|
1518
|
+
* controller, or a plain Moost controller without `@TableController`),
|
|
1519
|
+
* throws an HTTP 500 — this is a **server misconfiguration**, not a client
|
|
1520
|
+
* error. The body parser has nothing to validate against, so the request
|
|
1521
|
+
* cannot proceed. Use `@Body()` and parse the PK manually if you need to
|
|
1522
|
+
* accept PK-shaped bodies on a controller without an attached table.
|
|
1523
|
+
*/
|
|
1524
|
+
function resolvePkSource(controller) {
|
|
1525
|
+
const c = controller;
|
|
1526
|
+
const candidate = c.readable ?? c.table;
|
|
1527
|
+
if (!isPkValidationSource(candidate)) throw new _moostjs_event_http.HttpError(500, "@DbActionPK/@DbActionPKs requires a controller with an attached table (via @TableController / @ReadableController). Use @Body() instead if your controller has no typed table.");
|
|
1528
|
+
return candidate;
|
|
1529
|
+
}
|
|
1530
|
+
function isPkValidationSource(value) {
|
|
1531
|
+
if (!value || typeof value !== "object") return false;
|
|
1532
|
+
const v = value;
|
|
1533
|
+
return Array.isArray(v.primaryKeys) && Array.isArray(v.fieldDescriptors);
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Build a parameter decorator that parses the JSON request body, validates
|
|
1537
|
+
* it against the bound table's PK schema with `validate`, and tags the param
|
|
1538
|
+
* so {@link discoverActions} can infer the action's `level`.
|
|
1539
|
+
*/
|
|
1540
|
+
function createPkParamDecorator(kind, validate, resolverName) {
|
|
1541
|
+
return (0, moost.ApplyDecorators)((0, moost.getMoostMate)().decorate(MOOST_DB_ACTION_PARAM, kind), (0, moost.Resolve)(async () => {
|
|
1542
|
+
const body = await (0, _wooksjs_http_body.useBody)().parseBody();
|
|
1543
|
+
validate(body, resolvePkSource((0, moost.useControllerContext)().getController()));
|
|
1544
|
+
return body;
|
|
1545
|
+
}, resolverName));
|
|
1546
|
+
}
|
|
1547
|
+
//#endregion
|
|
1548
|
+
//#region src/actions/pk-validation.ts
|
|
1549
|
+
/**
|
|
1550
|
+
* Validate a JSON-decoded body against a single-row PK shape (scalar or
|
|
1551
|
+
* composite). Throws {@link ValidatorError} with structured `errors` so the
|
|
1552
|
+
* existing validation interceptor returns HTTP 400.
|
|
1553
|
+
*/
|
|
1554
|
+
function validateSinglePk(body, source, path = "") {
|
|
1555
|
+
const errors = collectPkErrors(body, source, path);
|
|
1556
|
+
if (errors.length > 0) throw new _atscript_typescript_utils.ValidatorError(errors);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Validate a JSON-decoded body against an array of PK shapes (`@DbActionPKs`).
|
|
1560
|
+
* The body MUST be an array; each element is validated against the PK schema.
|
|
1561
|
+
*/
|
|
1562
|
+
function validateMultiPk(body, source) {
|
|
1563
|
+
if (!Array.isArray(body)) throw new _atscript_typescript_utils.ValidatorError([{
|
|
1564
|
+
path: "",
|
|
1565
|
+
message: "Expected JSON array of primary keys",
|
|
1566
|
+
details: []
|
|
1567
|
+
}]);
|
|
1568
|
+
const errors = [];
|
|
1569
|
+
for (let i = 0; i < body.length; i++) errors.push(...collectPkErrors(body[i], source, `[${i}]`));
|
|
1570
|
+
if (errors.length > 0) throw new _atscript_typescript_utils.ValidatorError(errors);
|
|
1571
|
+
}
|
|
1572
|
+
function collectPkErrors(value, source, pathPrefix) {
|
|
1573
|
+
const pkFields = source.primaryKeys;
|
|
1574
|
+
if (pkFields.length === 0) return [{
|
|
1575
|
+
path: pathPrefix,
|
|
1576
|
+
message: "Table has no primary key configured",
|
|
1577
|
+
details: []
|
|
1578
|
+
}];
|
|
1579
|
+
const errors = [];
|
|
1580
|
+
if (pkFields.length === 1) {
|
|
1581
|
+
const err = checkScalar(value, findFieldDescriptor(source, pkFields[0]), pathPrefix);
|
|
1582
|
+
if (err) errors.push(err);
|
|
1583
|
+
return errors;
|
|
1584
|
+
}
|
|
1585
|
+
if (!isPlainObject(value)) {
|
|
1586
|
+
errors.push({
|
|
1587
|
+
path: pathPrefix,
|
|
1588
|
+
message: "Expected JSON object for composite primary key",
|
|
1589
|
+
details: []
|
|
1590
|
+
});
|
|
1591
|
+
return errors;
|
|
1592
|
+
}
|
|
1593
|
+
for (const fieldName of pkFields) {
|
|
1594
|
+
const sub = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName;
|
|
1595
|
+
if (!(fieldName in value)) {
|
|
1596
|
+
errors.push({
|
|
1597
|
+
path: sub,
|
|
1598
|
+
message: `Missing primary-key field "${fieldName}"`,
|
|
1599
|
+
details: []
|
|
1600
|
+
});
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
const fd = findFieldDescriptor(source, fieldName);
|
|
1604
|
+
const err = checkScalar(value[fieldName], fd, sub);
|
|
1605
|
+
if (err) errors.push(err);
|
|
1606
|
+
}
|
|
1607
|
+
return errors;
|
|
1608
|
+
}
|
|
1609
|
+
function findFieldDescriptor(source, name) {
|
|
1610
|
+
for (const fd of source.fieldDescriptors) if (fd.path === name) return fd;
|
|
1611
|
+
}
|
|
1612
|
+
function checkScalar(value, fd, path) {
|
|
1613
|
+
const expected = fd?.designType ?? "string";
|
|
1614
|
+
if (expected === "string" && typeof value !== "string") return scalarMismatch(path, expected, value);
|
|
1615
|
+
if (expected === "number" && typeof value !== "number") return scalarMismatch(path, expected, value);
|
|
1616
|
+
if (expected === "boolean" && typeof value !== "boolean") return scalarMismatch(path, expected, value);
|
|
1617
|
+
}
|
|
1618
|
+
function scalarMismatch(path, expected, value) {
|
|
1619
|
+
return {
|
|
1620
|
+
path,
|
|
1621
|
+
message: `Expected primary-key value to be ${expected}, got ${describe(value)}`,
|
|
1622
|
+
details: []
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
function describe(value) {
|
|
1626
|
+
if (value === null) return "null";
|
|
1627
|
+
if (Array.isArray(value)) return "array";
|
|
1628
|
+
return typeof value;
|
|
1629
|
+
}
|
|
1630
|
+
function isPlainObject(value) {
|
|
1631
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1632
|
+
}
|
|
1633
|
+
//#endregion
|
|
1634
|
+
//#region src/actions/db-action-pk.decorator.ts
|
|
1635
|
+
/**
|
|
1636
|
+
* Parameter resolver that reads the primary key from the JSON request body
|
|
1637
|
+
* and validates it against the bound table's PK schema.
|
|
1638
|
+
*
|
|
1639
|
+
* - Single-field PK → JSON-encoded scalar (`"abc"`, `42`, `true`).
|
|
1640
|
+
* - Composite PK → JSON object with all PK fields.
|
|
1641
|
+
*
|
|
1642
|
+
* Validation is strict — no type coercion. Mismatches throw a
|
|
1643
|
+
* `ValidatorError` which the existing validation interceptor surfaces as
|
|
1644
|
+
* HTTP 400 with the same envelope as DTO failures.
|
|
1645
|
+
*
|
|
1646
|
+
* Marks the param so {@link discoverActions} can infer the action's `level`
|
|
1647
|
+
* as `'row'`.
|
|
1648
|
+
*/
|
|
1649
|
+
function DbActionPK() {
|
|
1650
|
+
return createPkParamDecorator("pk", validateSinglePk, "dbActionPk");
|
|
1651
|
+
}
|
|
1652
|
+
//#endregion
|
|
1653
|
+
//#region src/actions/db-action-pks.decorator.ts
|
|
1654
|
+
/**
|
|
1655
|
+
* Parameter resolver that reads a JSON array of primary keys from the request
|
|
1656
|
+
* body and validates each entry against the bound table's PK schema.
|
|
1657
|
+
*
|
|
1658
|
+
* - Scalar PK → JSON array of scalars (`["a","b","c"]`).
|
|
1659
|
+
* - Composite PK → JSON array of objects.
|
|
1660
|
+
*
|
|
1661
|
+
* Validation is strict — no type coercion. Marks the param so
|
|
1662
|
+
* {@link discoverActions} can infer the action's `level` as `'rows'`.
|
|
1663
|
+
*/
|
|
1664
|
+
function DbActionPKs() {
|
|
1665
|
+
return createPkParamDecorator("pks", validateMultiPk, "dbActionPks");
|
|
1666
|
+
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
//#region src/actions/db-actions.decorator.ts
|
|
1669
|
+
/**
|
|
1670
|
+
* Declare class-level actions on a controller. Entries are flat dicts with
|
|
1671
|
+
* `processor: 'navigate' | 'custom' | 'backend'` matching the `/meta` wire
|
|
1672
|
+
* shape (see {@link TDbActionsEntry}). Each entry MUST specify `level`. Use
|
|
1673
|
+
* the level-pinned shortcuts (`@DbTableActions`, `@DbRowActions`,
|
|
1674
|
+
* `@DbRowsActions`) to avoid repeating `level`.
|
|
1675
|
+
*
|
|
1676
|
+
* The dictionary key serves as the action `name`. Entries do NOT bind any
|
|
1677
|
+
* HTTP route — the meta builder surfaces them in `/meta` only. For
|
|
1678
|
+
* `processor: 'backend'`, the dev-supplied `value` MUST point to a real
|
|
1679
|
+
* `@Post`-bound endpoint accepting the level-determined body shape.
|
|
1680
|
+
*
|
|
1681
|
+
* Multiple `@DbActions` (and shortcut) decorators on the same class
|
|
1682
|
+
* accumulate.
|
|
1683
|
+
*/
|
|
1684
|
+
function DbActions(dict) {
|
|
1685
|
+
return classLevelActions(dict);
|
|
1686
|
+
}
|
|
1687
|
+
/** Sugar for `@DbActions` with `level: 'table'` injected into each entry. */
|
|
1688
|
+
function DbTableActions(dict) {
|
|
1689
|
+
return classLevelActions(dict, "table");
|
|
1690
|
+
}
|
|
1691
|
+
/** Sugar for `@DbActions` with `level: 'row'` injected into each entry. */
|
|
1692
|
+
function DbRowActions(dict) {
|
|
1693
|
+
return classLevelActions(dict, "row");
|
|
1694
|
+
}
|
|
1695
|
+
/** Sugar for `@DbActions` with `level: 'rows'` injected into each entry. */
|
|
1696
|
+
function DbRowsActions(dict) {
|
|
1697
|
+
return classLevelActions(dict, "rows");
|
|
1698
|
+
}
|
|
1699
|
+
function classLevelActions(dict, forcedLevel) {
|
|
1700
|
+
const entries = [];
|
|
1701
|
+
for (const [name, entry] of Object.entries(dict)) entries.push({
|
|
1702
|
+
name,
|
|
1703
|
+
entry,
|
|
1704
|
+
forcedLevel
|
|
1705
|
+
});
|
|
1706
|
+
return (0, moost.getMoostMate)().decorate((current) => {
|
|
1707
|
+
const existing = current["atscript_db_actions"] ?? [];
|
|
1708
|
+
return {
|
|
1709
|
+
...current,
|
|
1710
|
+
[MOOST_DB_ACTIONS]: [...existing, ...entries]
|
|
1711
|
+
};
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
//#endregion
|
|
1215
1715
|
Object.defineProperty(exports, "AsDbController", {
|
|
1216
1716
|
enumerable: true,
|
|
1217
1717
|
get: function() {
|
|
@@ -1242,10 +1742,22 @@ Object.defineProperty(exports, "AsValueHelpController", {
|
|
|
1242
1742
|
return AsValueHelpController;
|
|
1243
1743
|
}
|
|
1244
1744
|
});
|
|
1745
|
+
exports.DbAction = DbAction;
|
|
1746
|
+
exports.DbActionDefault = DbActionDefault;
|
|
1747
|
+
exports.DbActionPK = DbActionPK;
|
|
1748
|
+
exports.DbActionPKs = DbActionPKs;
|
|
1749
|
+
exports.DbActions = DbActions;
|
|
1750
|
+
exports.DbRowActions = DbRowActions;
|
|
1751
|
+
exports.DbRowsActions = DbRowsActions;
|
|
1752
|
+
exports.DbTableActions = DbTableActions;
|
|
1753
|
+
exports.ONE_CONTROLS = ONE_CONTROLS;
|
|
1754
|
+
exports.PAGES_CONTROLS = PAGES_CONTROLS;
|
|
1755
|
+
exports.QUERY_CONTROLS = QUERY_CONTROLS;
|
|
1245
1756
|
exports.READABLE_DEF = READABLE_DEF;
|
|
1246
1757
|
exports.ReadableController = ReadableController;
|
|
1247
1758
|
exports.TABLE_DEF = TABLE_DEF;
|
|
1248
1759
|
exports.TableController = TableController;
|
|
1249
1760
|
exports.UseValidationErrorTransform = UseValidationErrorTransform;
|
|
1250
1761
|
exports.ViewController = ViewController;
|
|
1762
|
+
exports.discoverActions = discoverActions;
|
|
1251
1763
|
exports.validationErrorTransform = validationErrorTransform;
|