@atscript/moost-db 0.1.148 → 0.1.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +843 -269
- package/dist/index.d.cts +178 -19
- package/dist/index.d.mts +178 -19
- package/dist/index.mjs +845 -271
- package/package.json +12 -12
package/dist/index.mjs
CHANGED
|
@@ -3,8 +3,8 @@ import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotate
|
|
|
3
3
|
import { Body, Delete, Get, HttpError, MoostHttp, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
|
|
4
4
|
import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Pipe, Provide, Resolve, TInterceptorPriority, TPipePriority, defineBeforeInterceptor, defineInterceptor, definePipeFn, getMoostMate, useControllerContext, withControllerContext } from "moost";
|
|
5
5
|
import { parseUrl } from "@uniqu/url";
|
|
6
|
-
import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, groupSourceVerdict, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, numericOperandProblem, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
7
|
-
import { cached, current, defineWook, key, useRouteParams } from "@wooksjs/event-core";
|
|
6
|
+
import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, groupSourceVerdict, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, numericOperandProblem, reconcileCas, searchIndexNotFoundMessage, searchMemberKind, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
7
|
+
import { cached, current, defineWook, key, routeParamsKey, useRouteParams } from "@wooksjs/event-core";
|
|
8
8
|
import { useBody } from "@wooksjs/http-body";
|
|
9
9
|
import { BUCKET_UNITS } from "@uniqu/core";
|
|
10
10
|
import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
|
|
@@ -70,6 +70,13 @@ function insightError(insights, message) {
|
|
|
70
70
|
const entry = insightPaths.get(insights);
|
|
71
71
|
return entry?.message === message ? badRequest(entry.path, message) : new HttpError(400, message);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The 400 envelope of a `ValidatorError`; `undefined` for any other error.
|
|
75
|
+
* @internal Not part of the public API (not re-exported from the barrel).
|
|
76
|
+
*/
|
|
77
|
+
function validatorErrorToHttp(error) {
|
|
78
|
+
return error instanceof ValidatorError ? errorEnvelope(400, error.message, error.errors) : void 0;
|
|
79
|
+
}
|
|
73
80
|
//#endregion
|
|
74
81
|
//#region src/validation-interceptor.ts
|
|
75
82
|
const dbErrorCodeToStatus = {
|
|
@@ -80,7 +87,8 @@ const dbErrorCodeToStatus = {
|
|
|
80
87
|
BUCKET_TZ_UNAVAILABLE: 501
|
|
81
88
|
};
|
|
82
89
|
function transformValidationError(error, reply) {
|
|
83
|
-
|
|
90
|
+
const validation = validatorErrorToHttp(error);
|
|
91
|
+
if (validation) reply(validation);
|
|
84
92
|
else if (error instanceof DbError) reply(errorEnvelope(dbErrorCodeToStatus[error.code] ?? 400, error.message, error.errors));
|
|
85
93
|
}
|
|
86
94
|
const validationErrorTransform = () => defineInterceptor({ error: transformValidationError }, TInterceptorPriority.BEFORE_ALL);
|
|
@@ -2099,6 +2107,393 @@ function walk(node, def, shallow) {
|
|
|
2099
2107
|
}
|
|
2100
2108
|
}
|
|
2101
2109
|
//#endregion
|
|
2110
|
+
//#region src/http-path-design-time.ts
|
|
2111
|
+
const KEY$1 = "db.http.path";
|
|
2112
|
+
/** The `@db.http.path` value a model carried before moost-db first wrote its published path. */
|
|
2113
|
+
const designTime = /* @__PURE__ */ new WeakMap();
|
|
2114
|
+
/**
|
|
2115
|
+
* Remembers the model's own `@db.http.path` (or its absence) before the first
|
|
2116
|
+
* published path is mirrored into its runtime metadata. Idempotent.
|
|
2117
|
+
*
|
|
2118
|
+
* @since 0.1.150
|
|
2119
|
+
*/
|
|
2120
|
+
function captureDesignTime(type) {
|
|
2121
|
+
if (!designTime.has(type)) designTime.set(type, type.metadata.get(KEY$1));
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* The route hint a model declares with `@db.http.path` — never the path
|
|
2125
|
+
* moost-db mirrored into the runtime metadata afterwards. Decorators and
|
|
2126
|
+
* `assertExposed` read this.
|
|
2127
|
+
*
|
|
2128
|
+
* @since 0.1.150
|
|
2129
|
+
*/
|
|
2130
|
+
function designTimeHttpPath(type) {
|
|
2131
|
+
return designTime.has(type) ? designTime.get(type) : type.metadata.get(KEY$1);
|
|
2132
|
+
}
|
|
2133
|
+
/** The captured design-time value (`undefined` when none was captured or declared). */
|
|
2134
|
+
function getDesignTime(type) {
|
|
2135
|
+
return designTime.get(type);
|
|
2136
|
+
}
|
|
2137
|
+
//#endregion
|
|
2138
|
+
//#region src/decorators.ts
|
|
2139
|
+
/**
|
|
2140
|
+
* DI token under which the {@link AtscriptDbReadable} instance
|
|
2141
|
+
* is exposed to the readable controller's constructor via `@Inject`.
|
|
2142
|
+
*/
|
|
2143
|
+
const READABLE_DEF = "__atscript_db_readable_def";
|
|
2144
|
+
/**
|
|
2145
|
+
* DI token under which the {@link AtscriptDbTable} instance
|
|
2146
|
+
* is exposed to the controller's constructor via `@Inject`.
|
|
2147
|
+
* Points to the same token as READABLE_DEF for backward compatibility.
|
|
2148
|
+
*/
|
|
2149
|
+
const TABLE_DEF = READABLE_DEF;
|
|
2150
|
+
function normalizeOptions(prefixOrOptions) {
|
|
2151
|
+
if (typeof prefixOrOptions === "string") return { prefix: prefixOrOptions };
|
|
2152
|
+
return prefixOrOptions ?? {};
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Builds the shared binding metadata for a decorator invocation: classifies
|
|
2156
|
+
* the binding form, computes the static route prefix, and packages a uniform
|
|
2157
|
+
* `resolve()` used by both the DI provide factory and the base controller's
|
|
2158
|
+
* `super(app)` fallback.
|
|
2159
|
+
*/
|
|
2160
|
+
function buildBinding(binding, options, decoratorName) {
|
|
2161
|
+
const canonical = options.canonical;
|
|
2162
|
+
if (isAnnotatedType(binding)) {
|
|
2163
|
+
const model = binding;
|
|
2164
|
+
const space = options.space ?? model.metadata.get("db.space");
|
|
2165
|
+
const hinted = designTimeHttpPath(model);
|
|
2166
|
+
const prefix = options.prefix || hinted || model.metadata.get("db.table") || model.metadata.get("db.view") || model.id || "";
|
|
2167
|
+
const prefixSource = options.prefix ? "option" : hinted ? "annotation" : "name";
|
|
2168
|
+
if (!prefix) throw new Error(`[moost-db] @${decoratorName}: cannot derive a route prefix from the model token (no @db.http.path / @db.table / @db.view and no type id). Pass an explicit prefix.`);
|
|
2169
|
+
return {
|
|
2170
|
+
meta: {
|
|
2171
|
+
model,
|
|
2172
|
+
resolve: () => resolveDbSpace(space).get(model),
|
|
2173
|
+
prefix,
|
|
2174
|
+
prefixSource,
|
|
2175
|
+
canonical
|
|
2176
|
+
},
|
|
2177
|
+
prefix
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
if (typeof binding === "function") {
|
|
2181
|
+
if (!options.prefix) throw new Error(`[moost-db] @${decoratorName}: the lazy factory form needs an explicit route prefix (the readable is not created until app.init()). Pass a prefix, or use the model token form which derives it from @db.http.path / @db.table.`);
|
|
2182
|
+
return {
|
|
2183
|
+
meta: {
|
|
2184
|
+
resolve: binding,
|
|
2185
|
+
prefix: options.prefix,
|
|
2186
|
+
prefixSource: "option",
|
|
2187
|
+
canonical
|
|
2188
|
+
},
|
|
2189
|
+
prefix: options.prefix
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
const readable = binding;
|
|
2193
|
+
const hinted = designTimeHttpPath(readable.type);
|
|
2194
|
+
const prefix = options.prefix || hinted || readable.tableName;
|
|
2195
|
+
return {
|
|
2196
|
+
meta: {
|
|
2197
|
+
model: readable.type,
|
|
2198
|
+
resolve: () => readable,
|
|
2199
|
+
prefix,
|
|
2200
|
+
prefixSource: options.prefix ? "option" : hinted ? "annotation" : "name",
|
|
2201
|
+
canonical
|
|
2202
|
+
},
|
|
2203
|
+
prefix
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
function bindReadableController(binding, prefixOrOptions, decoratorName) {
|
|
2207
|
+
const { meta, prefix } = buildBinding(binding, normalizeOptions(prefixOrOptions), decoratorName);
|
|
2208
|
+
return ApplyDecorators(getAtscriptDbMate().decorate((classMeta) => {
|
|
2209
|
+
classMeta.atscript_db_readable_binding = meta;
|
|
2210
|
+
return classMeta;
|
|
2211
|
+
}), Provide(READABLE_DEF, () => meta.resolve()), Controller(prefix), Inherit());
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Combines the boilerplate needed to turn an {@link AsDbController}
|
|
2215
|
+
* subclass into a fully wired HTTP controller for a given `@db.table` model.
|
|
2216
|
+
*
|
|
2217
|
+
* Internally applies three decorators:
|
|
2218
|
+
* 1. **Provide** — registers the readable resolver under {@link TABLE_DEF}.
|
|
2219
|
+
* 2. **Controller** — registers the class as a Moost HTTP controller
|
|
2220
|
+
* with an optional route prefix (defaults to `@db.http.path`, then the
|
|
2221
|
+
* table name).
|
|
2222
|
+
* 3. **Inherit** — copies metadata (routes, guards, etc.) from the
|
|
2223
|
+
* parent class so they stay active in the derived controller.
|
|
2224
|
+
*
|
|
2225
|
+
* All three binding forms are supported (see {@link TReadableBinding}):
|
|
2226
|
+
*
|
|
2227
|
+
* ```ts
|
|
2228
|
+
* @TableController(User) // model token (preferred)
|
|
2229
|
+
* @TableController(User, { space: "analytics" })
|
|
2230
|
+
* @TableController(() => db.getTable(User), "users") // lazy factory
|
|
2231
|
+
* @TableController(usersTable) // instance (legacy)
|
|
2232
|
+
* export class UsersController extends AsDbController<typeof User> {}
|
|
2233
|
+
* ```
|
|
2234
|
+
*
|
|
2235
|
+
* Token and factory forms resolve lazily at first controller instantiation
|
|
2236
|
+
* (during `app.init()`), so the `DbSpace` does not have to exist when the
|
|
2237
|
+
* controller module is imported. For the token form, register the space with
|
|
2238
|
+
* `provideDbSpace(db)` before `app.init()`.
|
|
2239
|
+
*
|
|
2240
|
+
* @param binding Model token, lazy factory, or {@link AtscriptDbTable} instance.
|
|
2241
|
+
* @param prefixOrOptions Route prefix string, or {@link TControllerBindingOptions}.
|
|
2242
|
+
*/
|
|
2243
|
+
const TableController = (binding, prefixOrOptions) => bindReadableController(binding, prefixOrOptions, "TableController");
|
|
2244
|
+
/**
|
|
2245
|
+
* Combines the boilerplate needed to turn an {@link AsDbReadableController}
|
|
2246
|
+
* subclass into a fully wired HTTP controller for a given `@db.view` or `@db.table` model.
|
|
2247
|
+
*
|
|
2248
|
+
* Accepts the same three binding forms as {@link TableController}.
|
|
2249
|
+
*
|
|
2250
|
+
* @param binding Model token, lazy factory, or {@link AtscriptDbReadable} instance.
|
|
2251
|
+
* @param prefixOrOptions Route prefix string, or {@link TControllerBindingOptions}.
|
|
2252
|
+
*
|
|
2253
|
+
* @example
|
|
2254
|
+
* ```ts
|
|
2255
|
+
* @ReadableController(ActiveTasks)
|
|
2256
|
+
* export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
|
|
2257
|
+
* ```
|
|
2258
|
+
*/
|
|
2259
|
+
const ReadableController = (binding, prefixOrOptions) => bindReadableController(binding, prefixOrOptions, "ReadableController");
|
|
2260
|
+
/**
|
|
2261
|
+
* Alias for {@link ReadableController} — use with view-backed controllers.
|
|
2262
|
+
*
|
|
2263
|
+
* @example
|
|
2264
|
+
* ```ts
|
|
2265
|
+
* @ViewController(ActiveTasks)
|
|
2266
|
+
* export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
|
|
2267
|
+
* ```
|
|
2268
|
+
*/
|
|
2269
|
+
const ViewController = ReadableController;
|
|
2270
|
+
/**
|
|
2271
|
+
* Finds the readable binding written by `@TableController` /
|
|
2272
|
+
* `@ReadableController` / `@ViewController` on a controller class, walking the
|
|
2273
|
+
* prototype chain so intermediate undecorated classes don't hide the binding
|
|
2274
|
+
* (nearest decorated ancestor wins).
|
|
2275
|
+
*/
|
|
2276
|
+
function findReadableBinding(ctor) {
|
|
2277
|
+
const mate = getAtscriptDbMate();
|
|
2278
|
+
let current = ctor;
|
|
2279
|
+
while (typeof current === "function") {
|
|
2280
|
+
const binding = mate.read(current)?.atscript_db_readable_binding;
|
|
2281
|
+
if (binding) return binding;
|
|
2282
|
+
current = Object.getPrototypeOf(current);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Resolves the readable bound to a controller class via
|
|
2287
|
+
* {@link findReadableBinding}, throwing with wiring guidance when none is
|
|
2288
|
+
* found.
|
|
2289
|
+
*
|
|
2290
|
+
* Used by {@link AsDbReadableController}'s constructor when `readable` is
|
|
2291
|
+
* `undefined` — i.e. a subclass with its own constructor called
|
|
2292
|
+
* `super(app)` instead of forwarding an injected instance.
|
|
2293
|
+
*/
|
|
2294
|
+
function resolveBoundReadable(ctor) {
|
|
2295
|
+
const binding = findReadableBinding(ctor);
|
|
2296
|
+
if (binding) return binding.resolve();
|
|
2297
|
+
throw new Error(`[moost-db] ${ctor?.name || "controller"}: no readable bound. Either pass a table/view to super(...), or decorate the class with @TableController / @ReadableController (model token, lazy factory, or instance form).`);
|
|
2298
|
+
}
|
|
2299
|
+
//#endregion
|
|
2300
|
+
//#region src/http-path.ts
|
|
2301
|
+
const KEY = "db.http.path";
|
|
2302
|
+
/**
|
|
2303
|
+
* Normalizes a controller prefix into a public path: one leading slash, no
|
|
2304
|
+
* empty segments, no trailing slash.
|
|
2305
|
+
*
|
|
2306
|
+
* @since 0.1.150
|
|
2307
|
+
*/
|
|
2308
|
+
function normalizeHttpPath(prefix) {
|
|
2309
|
+
return `/${prefix.split("/").filter(Boolean).join("/")}`;
|
|
2310
|
+
}
|
|
2311
|
+
/** A path with a route parameter or a wildcard cannot be a static picker URL. */
|
|
2312
|
+
function isParametricPath(path) {
|
|
2313
|
+
return path.split("/").some((s) => s.startsWith(":") || s.includes("*"));
|
|
2314
|
+
}
|
|
2315
|
+
/** Per app (the constructor's `app` argument): another app's construction never alters this app's records. */
|
|
2316
|
+
const bound = /* @__PURE__ */ new WeakMap();
|
|
2317
|
+
/**
|
|
2318
|
+
* Process-wide records per ctor: the fallback for an app that never
|
|
2319
|
+
* constructed the class itself (a DI singleton reused across apps), read only
|
|
2320
|
+
* when it holds exactly one model.
|
|
2321
|
+
*/
|
|
2322
|
+
const boundAny = /* @__PURE__ */ new WeakMap();
|
|
2323
|
+
let boundVersion = 0;
|
|
2324
|
+
/**
|
|
2325
|
+
* Records that `ctor` serves `type` inside `app` (called by the readable base constructor).
|
|
2326
|
+
* Pure bookkeeping — nothing is written to the model. Only a NEW pair bumps
|
|
2327
|
+
* the version that invalidates per-app scopes.
|
|
2328
|
+
*/
|
|
2329
|
+
function recordBoundType(app, ctor, type, canonical) {
|
|
2330
|
+
let ofApp = bound.get(app);
|
|
2331
|
+
if (!ofApp) {
|
|
2332
|
+
ofApp = /* @__PURE__ */ new WeakMap();
|
|
2333
|
+
bound.set(app, ofApp);
|
|
2334
|
+
}
|
|
2335
|
+
let models = ofApp.get(ctor);
|
|
2336
|
+
if (!models) {
|
|
2337
|
+
models = /* @__PURE__ */ new Map();
|
|
2338
|
+
ofApp.set(ctor, models);
|
|
2339
|
+
}
|
|
2340
|
+
let any = boundAny.get(ctor);
|
|
2341
|
+
if (!any) {
|
|
2342
|
+
any = /* @__PURE__ */ new Map();
|
|
2343
|
+
boundAny.set(ctor, any);
|
|
2344
|
+
}
|
|
2345
|
+
any.set(type, canonical);
|
|
2346
|
+
if (!models.has(type) || models.get(type) !== canonical) {
|
|
2347
|
+
models.set(type, canonical);
|
|
2348
|
+
boundVersion++;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
const apps = /* @__PURE__ */ new WeakMap();
|
|
2352
|
+
function stateOf(app) {
|
|
2353
|
+
let s = apps.get(app);
|
|
2354
|
+
if (!s) {
|
|
2355
|
+
s = {
|
|
2356
|
+
version: -1,
|
|
2357
|
+
published: false,
|
|
2358
|
+
hookAdded: false,
|
|
2359
|
+
warned: /* @__PURE__ */ new Set()
|
|
2360
|
+
};
|
|
2361
|
+
apps.set(app, s);
|
|
2362
|
+
}
|
|
2363
|
+
return s;
|
|
2364
|
+
}
|
|
2365
|
+
const idOf = (type) => type.id ?? "(unnamed model)";
|
|
2366
|
+
const hasOverview = (app) => !!app && typeof app.getControllersOverview === "function";
|
|
2367
|
+
function endsWithSegments(path, suffix) {
|
|
2368
|
+
const a = path.split("/").filter(Boolean);
|
|
2369
|
+
const b = suffix.split("/").filter(Boolean);
|
|
2370
|
+
if (b.length > a.length) return false;
|
|
2371
|
+
return b.every((seg, i) => a[a.length - b.length + i] === seg);
|
|
2372
|
+
}
|
|
2373
|
+
function warnOnce(app, s, key, message) {
|
|
2374
|
+
if (s.warned.has(key)) return;
|
|
2375
|
+
s.warned.add(key);
|
|
2376
|
+
try {
|
|
2377
|
+
app.getLogger?.("moost-db")?.warn(message);
|
|
2378
|
+
} catch {}
|
|
2379
|
+
}
|
|
2380
|
+
function buildScope(app, s) {
|
|
2381
|
+
const mounts = /* @__PURE__ */ new Map();
|
|
2382
|
+
for (const o of app.getControllersOverview()) {
|
|
2383
|
+
if (typeof o.computedPrefix !== "string") continue;
|
|
2384
|
+
const ctor = o.type;
|
|
2385
|
+
const binding = findReadableBinding(ctor);
|
|
2386
|
+
let recorded = bound.get(app)?.get(ctor);
|
|
2387
|
+
if (!recorded) {
|
|
2388
|
+
const any = boundAny.get(ctor);
|
|
2389
|
+
if (any?.size === 1) recorded = any;
|
|
2390
|
+
}
|
|
2391
|
+
const models = recorded?.size ? [...recorded] : binding?.model ? [[binding.model, void 0]] : [];
|
|
2392
|
+
if (models.length === 0) continue;
|
|
2393
|
+
if (models.length > 1) {
|
|
2394
|
+
warnOnce(app, s, `multi-model|${ctor.name}`, `[moost-db] ${ctor.name} serves several models; none of them gets a published value-help path (db.http.path).`);
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
const [model, recordedCanonical] = models[0];
|
|
2398
|
+
const path = normalizeHttpPath(o.computedPrefix);
|
|
2399
|
+
const ownPrefix = o.meta?.controller?.prefix ?? "";
|
|
2400
|
+
const fromAnnotation = binding?.prefixSource === "annotation" && ownPrefix === binding.prefix && endsWithSegments(path, binding.prefix ?? "");
|
|
2401
|
+
const list = mounts.get(model) ?? [];
|
|
2402
|
+
list.push({
|
|
2403
|
+
ctor,
|
|
2404
|
+
path,
|
|
2405
|
+
canonical: binding?.canonical ?? recordedCanonical,
|
|
2406
|
+
fromAnnotation,
|
|
2407
|
+
parametric: isParametricPath(path)
|
|
2408
|
+
});
|
|
2409
|
+
mounts.set(model, list);
|
|
2410
|
+
}
|
|
2411
|
+
const paths = /* @__PURE__ */ new Map();
|
|
2412
|
+
for (const [model, list] of mounts) paths.set(model, resolveCanonical(app, s, model, list));
|
|
2413
|
+
return { paths };
|
|
2414
|
+
}
|
|
2415
|
+
const distinct = (list) => [...new Set(list.map((m) => m.path))];
|
|
2416
|
+
function resolveCanonical(app, s, model, list) {
|
|
2417
|
+
const cands = list.filter((m) => !m.parametric && m.canonical !== false);
|
|
2418
|
+
const marked = distinct(cands.filter((m) => m.canonical === true));
|
|
2419
|
+
if (marked.length === 1) return marked[0];
|
|
2420
|
+
if (marked.length > 1) return ambiguous(app, s, model, cands, "has several canonical mounts");
|
|
2421
|
+
const all = distinct(cands);
|
|
2422
|
+
if (all.length === 0) return null;
|
|
2423
|
+
if (all.length === 1) return all[0];
|
|
2424
|
+
const derived = distinct(cands.filter((m) => m.fromAnnotation));
|
|
2425
|
+
if (derived.length === 1) return derived[0];
|
|
2426
|
+
return ambiguous(app, s, model, cands, "is served by several controllers on different routes");
|
|
2427
|
+
}
|
|
2428
|
+
function ambiguous(app, s, model, cands, what) {
|
|
2429
|
+
const where = cands.map((m) => `${m.ctor.name} at ${m.path}`);
|
|
2430
|
+
const id = idOf(model);
|
|
2431
|
+
warnOnce(app, s, `${id}|${distinct(cands).toSorted().join(",")}|${what}`, `[moost-db] Model "${id}" ${what}:\n ${where.join(", ")}.\nIts value-help path (db.http.path) is left unset, so references to it render no picker.\nMark the controller that serves its value help with \`canonical: true\`\n(@TableController(${id}, { canonical: true }), or a value-help controller's \`{ canonical: true }\`\nconstructor option) or \`canonical: false\` on the others, or declare @db.http.path on the\nmodel and mount that controller without an explicit prefix.`);
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* The scope of `app`: built lazily, rebuilt on publish or when a new
|
|
2436
|
+
* controller → model pair was recorded. A hit never polls the overview.
|
|
2437
|
+
* `undefined` when `app` has no controller overview (mock apps).
|
|
2438
|
+
*/
|
|
2439
|
+
function httpPathScopeFor(app) {
|
|
2440
|
+
if (!hasOverview(app)) return void 0;
|
|
2441
|
+
const s = stateOf(app);
|
|
2442
|
+
if (!s.scope || s.version !== boundVersion) {
|
|
2443
|
+
s.scope = buildScope(app, s);
|
|
2444
|
+
s.version = boundVersion;
|
|
2445
|
+
}
|
|
2446
|
+
return s.scope;
|
|
2447
|
+
}
|
|
2448
|
+
/** Models whose runtime metadata a publish wrote, with the value written. */
|
|
2449
|
+
const mirrored = /* @__PURE__ */ new Map();
|
|
2450
|
+
/** (Re)builds the scope of `app`, warns on ambiguity and writes the global compat mirror. */
|
|
2451
|
+
function publishDbHttpPaths(app) {
|
|
2452
|
+
if (!hasOverview(app)) return;
|
|
2453
|
+
const s = stateOf(app);
|
|
2454
|
+
s.published = true;
|
|
2455
|
+
s.scope = buildScope(app, s);
|
|
2456
|
+
s.version = boundVersion;
|
|
2457
|
+
const { paths } = s.scope;
|
|
2458
|
+
for (const [type, value] of paths) {
|
|
2459
|
+
captureDesignTime(type);
|
|
2460
|
+
if (value === null) type.metadata.delete(KEY);
|
|
2461
|
+
else type.metadata.set(KEY, value);
|
|
2462
|
+
mirrored.set(type, value ?? void 0);
|
|
2463
|
+
}
|
|
2464
|
+
for (const [type, written] of mirrored) {
|
|
2465
|
+
if (paths.has(type)) continue;
|
|
2466
|
+
if (type.metadata.get(KEY) === written) {
|
|
2467
|
+
const original = getDesignTime(type);
|
|
2468
|
+
if (original === void 0) type.metadata.delete(KEY);
|
|
2469
|
+
else type.metadata.set(KEY, original);
|
|
2470
|
+
}
|
|
2471
|
+
mirrored.delete(type);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
/** Lazy fallback for apps that never ran the hook (singletons reused across apps). */
|
|
2475
|
+
function ensurePublished(app) {
|
|
2476
|
+
if (!hasOverview(app)) return;
|
|
2477
|
+
if (!stateOf(app).published) publishDbHttpPaths(app);
|
|
2478
|
+
}
|
|
2479
|
+
/** Registers the publish hook once per app (no-op on mock apps without `addInitHook`). */
|
|
2480
|
+
function addPublishHook(app) {
|
|
2481
|
+
if (!app || typeof app.addInitHook !== "function") return;
|
|
2482
|
+
const s = stateOf(app);
|
|
2483
|
+
if (s.hookAdded) return;
|
|
2484
|
+
s.hookAdded = true;
|
|
2485
|
+
app.addInitHook((a) => publishDbHttpPaths(a), { priority: Number.MIN_SAFE_INTEGER });
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* The `annotationOverrides` contribution for `scope`: the only way a path
|
|
2489
|
+
* reaches the wire. Models the app does not serve pass through at their
|
|
2490
|
+
* design-time value (never another app's mirror).
|
|
2491
|
+
*/
|
|
2492
|
+
function httpPathOverrides(scope, type) {
|
|
2493
|
+
if (scope.paths.has(type)) return { [KEY]: scope.paths.get(type) ?? void 0 };
|
|
2494
|
+
if (mirrored.has(type) && type.metadata.get(KEY) === mirrored.get(type)) return { [KEY]: getDesignTime(type) };
|
|
2495
|
+
}
|
|
2496
|
+
//#endregion
|
|
2102
2497
|
//#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateMetadata.js
|
|
2103
2498
|
function __decorateMetadata(k, v) {
|
|
2104
2499
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
@@ -2131,19 +2526,29 @@ let AsReadableController = class AsReadableController {
|
|
|
2131
2526
|
app;
|
|
2132
2527
|
/** Cached serialized type definition (lazy, computed on first access). */
|
|
2133
2528
|
_serializedType;
|
|
2529
|
+
/** App scope every cache below was built for; a different scope drops them all ({@link enterScope}). */
|
|
2530
|
+
_cacheScope;
|
|
2531
|
+
/** Scope of the synchronous build in flight — instance-local, cleared in `finally`. */
|
|
2532
|
+
_buildScope;
|
|
2134
2533
|
/** Cached full meta response (computed lazily on first meta() call). */
|
|
2135
2534
|
_metaResponse;
|
|
2136
2535
|
/** {@link metaCacheKey} the cached response was built for. */
|
|
2137
2536
|
_metaResponseKey;
|
|
2138
2537
|
/** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
|
|
2139
2538
|
_formSchemas = /* @__PURE__ */ new Map();
|
|
2140
|
-
|
|
2539
|
+
/**
|
|
2540
|
+
* @param opts.canonical Multi-mount models only: marks (`true`) or excludes
|
|
2541
|
+
* (`false`) this controller as the model's published value-help route
|
|
2542
|
+
* (since 0.1.150). Decorator-bound controllers use the decorator option.
|
|
2543
|
+
*/
|
|
2544
|
+
constructor(boundType, controllerName, app, kindTag = "readable", opts) {
|
|
2141
2545
|
this.boundType = boundType;
|
|
2142
2546
|
this.controllerName = controllerName;
|
|
2143
2547
|
this.app = app;
|
|
2144
2548
|
this.logger = app.getLogger(`db [${controllerName}]`);
|
|
2145
2549
|
this.logger.info(`Initializing ${kindTag} controller`);
|
|
2146
|
-
this.
|
|
2550
|
+
recordBoundType(app, this.constructor, boundType, opts?.canonical);
|
|
2551
|
+
addPublishHook(app);
|
|
2147
2552
|
try {
|
|
2148
2553
|
const p = this.init();
|
|
2149
2554
|
if (p instanceof Promise) p.catch((error) => {
|
|
@@ -2154,22 +2559,40 @@ let AsReadableController = class AsReadableController {
|
|
|
2154
2559
|
throw error;
|
|
2155
2560
|
}
|
|
2156
2561
|
}
|
|
2157
|
-
/**
|
|
2158
|
-
|
|
2159
|
-
|
|
2562
|
+
/**
|
|
2563
|
+
* The app of the current event, through DI — never the one this
|
|
2564
|
+
* (singleton) instance was constructed in, which may be gone (a re-booted
|
|
2565
|
+
* app, a hot reload). Outside an event it is {@link app}.
|
|
2566
|
+
*
|
|
2567
|
+
* @since 0.1.150
|
|
2568
|
+
*/
|
|
2569
|
+
async currentApp() {
|
|
2160
2570
|
try {
|
|
2161
|
-
|
|
2162
|
-
} catch {
|
|
2163
|
-
|
|
2164
|
-
if (prefix) {
|
|
2165
|
-
if (!prefix.startsWith("/")) prefix = `/${prefix}`;
|
|
2166
|
-
this.boundType.metadata.set("db.http.path", prefix);
|
|
2571
|
+
return await useControllerContext().instantiate(Moost);
|
|
2572
|
+
} catch {
|
|
2573
|
+
return this.app;
|
|
2167
2574
|
}
|
|
2168
2575
|
}
|
|
2169
|
-
/** Lazily serializes the bound type (
|
|
2576
|
+
/** Lazily serializes the bound type (dropped with the other caches on a scope change). */
|
|
2170
2577
|
getSerializedType() {
|
|
2171
|
-
|
|
2172
|
-
|
|
2578
|
+
return this._serializedType ??= this.serializeForMeta(this.boundType);
|
|
2579
|
+
}
|
|
2580
|
+
/** Drops every scope-dependent cache when `scope` is not the one they were built for. */
|
|
2581
|
+
enterScope(scope) {
|
|
2582
|
+
if (scope === this._cacheScope) return;
|
|
2583
|
+
this._cacheScope = scope;
|
|
2584
|
+
this._serializedType = void 0;
|
|
2585
|
+
this._metaResponse = void 0;
|
|
2586
|
+
this._formSchemas.clear();
|
|
2587
|
+
}
|
|
2588
|
+
/** Runs a synchronous build with `scope`'s `db.http.path` overrides applied. */
|
|
2589
|
+
buildScoped(scope, build) {
|
|
2590
|
+
this._buildScope = scope;
|
|
2591
|
+
try {
|
|
2592
|
+
return build();
|
|
2593
|
+
} finally {
|
|
2594
|
+
this._buildScope = void 0;
|
|
2595
|
+
}
|
|
2173
2596
|
}
|
|
2174
2597
|
/**
|
|
2175
2598
|
* Serializes a type for the meta surfaces (`/meta`, `/meta/form/:name`)
|
|
@@ -2179,10 +2602,31 @@ let AsReadableController = class AsReadableController {
|
|
|
2179
2602
|
* serialize exactly as before.
|
|
2180
2603
|
*/
|
|
2181
2604
|
serializeForMeta(type) {
|
|
2182
|
-
const options = this.
|
|
2605
|
+
const options = this._effectiveSerializeOptions();
|
|
2183
2606
|
return applyTerminalRefs(serializeAnnotatedType(type, options), type, options);
|
|
2184
2607
|
}
|
|
2185
2608
|
/**
|
|
2609
|
+
* {@link getSerializeOptions} plus the current build's per-app `db.http.path`
|
|
2610
|
+
* overrides (a subclass's own `annotationOverrides` entry wins for the same key).
|
|
2611
|
+
*/
|
|
2612
|
+
_effectiveSerializeOptions() {
|
|
2613
|
+
const base = this.getSerializeOptions();
|
|
2614
|
+
const scope = this._buildScope;
|
|
2615
|
+
if (!scope) return base;
|
|
2616
|
+
const own = base.annotationOverrides;
|
|
2617
|
+
return {
|
|
2618
|
+
...base,
|
|
2619
|
+
annotationOverrides: (t) => {
|
|
2620
|
+
const ours = httpPathOverrides(scope, t);
|
|
2621
|
+
const theirs = own?.(t);
|
|
2622
|
+
return ours || theirs ? {
|
|
2623
|
+
...ours,
|
|
2624
|
+
...theirs
|
|
2625
|
+
} : void 0;
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2186
2630
|
* One-time initialization hook. Override to seed data, register watchers, etc.
|
|
2187
2631
|
*/
|
|
2188
2632
|
init() {}
|
|
@@ -2380,7 +2824,53 @@ let AsReadableController = class AsReadableController {
|
|
|
2380
2824
|
*/
|
|
2381
2825
|
async meta() {
|
|
2382
2826
|
await this.parseRequest("meta");
|
|
2383
|
-
return this.resolveMeta();
|
|
2827
|
+
return this._withOwnHttpPath(await this.resolveMeta());
|
|
2828
|
+
}
|
|
2829
|
+
/**
|
|
2830
|
+
* The root of a controller's own `/meta` carries its own mount as
|
|
2831
|
+
* `db.http.path` (a secondary mount answers with its own route; references to
|
|
2832
|
+
* the model elsewhere carry the canonical one). Left alone for parametric
|
|
2833
|
+
* mounts and when the serialize options strip the key. Never mutates the
|
|
2834
|
+
* cached envelope.
|
|
2835
|
+
*/
|
|
2836
|
+
_withOwnHttpPath(meta) {
|
|
2837
|
+
const KEY = "db.http.path";
|
|
2838
|
+
let prefix;
|
|
2839
|
+
try {
|
|
2840
|
+
prefix = useControllerContext().getPrefix();
|
|
2841
|
+
} catch {
|
|
2842
|
+
return meta;
|
|
2843
|
+
}
|
|
2844
|
+
if (prefix === void 0 || prefix === null) return meta;
|
|
2845
|
+
const own = normalizeHttpPath(prefix);
|
|
2846
|
+
if (isParametricPath(own)) return meta;
|
|
2847
|
+
const options = this.getSerializeOptions();
|
|
2848
|
+
if (options.ignoreAnnotations?.includes(KEY)) return meta;
|
|
2849
|
+
let key = KEY;
|
|
2850
|
+
let value = own;
|
|
2851
|
+
if (options.processAnnotation) {
|
|
2852
|
+
const out = options.processAnnotation({
|
|
2853
|
+
key: KEY,
|
|
2854
|
+
value: own,
|
|
2855
|
+
path: [],
|
|
2856
|
+
kind: "object"
|
|
2857
|
+
});
|
|
2858
|
+
if (!out) return meta;
|
|
2859
|
+
key = out.key;
|
|
2860
|
+
value = out.value;
|
|
2861
|
+
}
|
|
2862
|
+
const type = meta.type;
|
|
2863
|
+
if (!type || type.metadata?.[key] === value) return meta;
|
|
2864
|
+
return {
|
|
2865
|
+
...meta,
|
|
2866
|
+
type: {
|
|
2867
|
+
...meta.type,
|
|
2868
|
+
metadata: {
|
|
2869
|
+
...type.metadata,
|
|
2870
|
+
[key]: value
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
};
|
|
2384
2874
|
}
|
|
2385
2875
|
/**
|
|
2386
2876
|
* The `/meta` payload for the current request — the cached envelope
|
|
@@ -2392,12 +2882,17 @@ let AsReadableController = class AsReadableController {
|
|
|
2392
2882
|
* @since 0.1.143
|
|
2393
2883
|
*/
|
|
2394
2884
|
resolveMeta() {
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
this.
|
|
2399
|
-
|
|
2400
|
-
|
|
2885
|
+
return this.currentApp().then((app) => {
|
|
2886
|
+
ensurePublished(app);
|
|
2887
|
+
const scope = httpPathScopeFor(app);
|
|
2888
|
+
this.enterScope(scope);
|
|
2889
|
+
const key = this.metaCacheKey();
|
|
2890
|
+
if (!this._metaResponse || key !== this._metaResponseKey) {
|
|
2891
|
+
this._metaResponse = this.buildScoped(scope, () => this.buildMetaResponse());
|
|
2892
|
+
this._metaResponseKey = key;
|
|
2893
|
+
}
|
|
2894
|
+
return this.applyMetaOverlay(this._metaResponse);
|
|
2895
|
+
});
|
|
2401
2896
|
}
|
|
2402
2897
|
/**
|
|
2403
2898
|
* Identity of the inputs the cached `/meta` envelope is built from — a new
|
|
@@ -2420,9 +2915,13 @@ let AsReadableController = class AsReadableController {
|
|
|
2420
2915
|
const envelopes = discoverActions(this.constructor, this.app, this.logger);
|
|
2421
2916
|
const formType = getControllerFormType(this.constructor, name);
|
|
2422
2917
|
if (!formType || !await this.authorizeForm(name, envelopes.filter((e) => e.info.inputForm === name).map((e) => e.info.name))) throw new HttpError(404, `Unknown form "${name}"`);
|
|
2918
|
+
const app = await this.currentApp();
|
|
2919
|
+
ensurePublished(app);
|
|
2920
|
+
const scope = httpPathScopeFor(app);
|
|
2921
|
+
this.enterScope(scope);
|
|
2423
2922
|
let cached = this._formSchemas.get(name);
|
|
2424
2923
|
if (!cached) {
|
|
2425
|
-
cached = this.serializeForMeta(formType);
|
|
2924
|
+
cached = this.buildScoped(scope, () => this.serializeForMeta(formType));
|
|
2426
2925
|
this._formSchemas.set(name, cached);
|
|
2427
2926
|
}
|
|
2428
2927
|
return cached;
|
|
@@ -2504,6 +3003,7 @@ AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMeta
|
|
|
2504
3003
|
Object,
|
|
2505
3004
|
String,
|
|
2506
3005
|
typeof Moost === "undefined" ? Object : Moost,
|
|
3006
|
+
Object,
|
|
2507
3007
|
Object
|
|
2508
3008
|
])], AsReadableController);
|
|
2509
3009
|
//#endregion
|
|
@@ -3914,9 +4414,14 @@ var DecorationPlanner = class {
|
|
|
3914
4414
|
if (memoize) memo.meta.set(meta, out);
|
|
3915
4415
|
return out;
|
|
3916
4416
|
}
|
|
3917
|
-
/** The declared interface serialized for `/meta.decorations`, once per class. */
|
|
3918
|
-
serialized(serialize) {
|
|
3919
|
-
|
|
4417
|
+
/** The declared interface serialized for `/meta.decorations`, once per class and app scope. */
|
|
4418
|
+
serialized(scope, serialize) {
|
|
4419
|
+
const { memo } = this.index;
|
|
4420
|
+
if (!memo.serialized || memo.serializedScope !== scope) {
|
|
4421
|
+
memo.serialized = serialize();
|
|
4422
|
+
memo.serializedScope = scope;
|
|
4423
|
+
}
|
|
4424
|
+
return memo.serialized;
|
|
3920
4425
|
}
|
|
3921
4426
|
};
|
|
3922
4427
|
/** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
|
|
@@ -4003,153 +4508,6 @@ function stripsNothing(read) {
|
|
|
4003
4508
|
return read === void 0 || read.dropKeys.length === 0 && read.dropPaths.length === 0;
|
|
4004
4509
|
}
|
|
4005
4510
|
//#endregion
|
|
4006
|
-
//#region src/decorators.ts
|
|
4007
|
-
/**
|
|
4008
|
-
* DI token under which the {@link AtscriptDbReadable} instance
|
|
4009
|
-
* is exposed to the readable controller's constructor via `@Inject`.
|
|
4010
|
-
*/
|
|
4011
|
-
const READABLE_DEF = "__atscript_db_readable_def";
|
|
4012
|
-
/**
|
|
4013
|
-
* DI token under which the {@link AtscriptDbTable} instance
|
|
4014
|
-
* is exposed to the controller's constructor via `@Inject`.
|
|
4015
|
-
* Points to the same token as READABLE_DEF for backward compatibility.
|
|
4016
|
-
*/
|
|
4017
|
-
const TABLE_DEF = READABLE_DEF;
|
|
4018
|
-
function normalizeOptions(prefixOrOptions) {
|
|
4019
|
-
if (typeof prefixOrOptions === "string") return { prefix: prefixOrOptions };
|
|
4020
|
-
return prefixOrOptions ?? {};
|
|
4021
|
-
}
|
|
4022
|
-
/**
|
|
4023
|
-
* Builds the shared binding metadata for a decorator invocation: classifies
|
|
4024
|
-
* the binding form, computes the static route prefix, and packages a uniform
|
|
4025
|
-
* `resolve()` used by both the DI provide factory and the base controller's
|
|
4026
|
-
* `super(app)` fallback.
|
|
4027
|
-
*/
|
|
4028
|
-
function buildBinding(binding, options, decoratorName) {
|
|
4029
|
-
if (isAnnotatedType(binding)) {
|
|
4030
|
-
const model = binding;
|
|
4031
|
-
const space = options.space ?? model.metadata.get("db.space");
|
|
4032
|
-
const prefix = options.prefix || model.metadata.get("db.http.path") || model.metadata.get("db.table") || model.metadata.get("db.view") || model.id || "";
|
|
4033
|
-
if (!prefix) throw new Error(`[moost-db] @${decoratorName}: cannot derive a route prefix from the model token (no @db.http.path / @db.table / @db.view and no type id). Pass an explicit prefix.`);
|
|
4034
|
-
return {
|
|
4035
|
-
meta: {
|
|
4036
|
-
model,
|
|
4037
|
-
resolve: () => resolveDbSpace(space).get(model)
|
|
4038
|
-
},
|
|
4039
|
-
prefix
|
|
4040
|
-
};
|
|
4041
|
-
}
|
|
4042
|
-
if (typeof binding === "function") {
|
|
4043
|
-
if (!options.prefix) throw new Error(`[moost-db] @${decoratorName}: the lazy factory form needs an explicit route prefix (the readable is not created until app.init()). Pass a prefix, or use the model token form which derives it from @db.http.path / @db.table.`);
|
|
4044
|
-
return {
|
|
4045
|
-
meta: { resolve: binding },
|
|
4046
|
-
prefix: options.prefix
|
|
4047
|
-
};
|
|
4048
|
-
}
|
|
4049
|
-
const readable = binding;
|
|
4050
|
-
const prefix = options.prefix || readable.type.metadata.get("db.http.path") || readable.tableName;
|
|
4051
|
-
return {
|
|
4052
|
-
meta: {
|
|
4053
|
-
model: readable.type,
|
|
4054
|
-
resolve: () => readable
|
|
4055
|
-
},
|
|
4056
|
-
prefix
|
|
4057
|
-
};
|
|
4058
|
-
}
|
|
4059
|
-
function bindReadableController(binding, prefixOrOptions, decoratorName) {
|
|
4060
|
-
const { meta, prefix } = buildBinding(binding, normalizeOptions(prefixOrOptions), decoratorName);
|
|
4061
|
-
return ApplyDecorators(getAtscriptDbMate().decorate((classMeta) => {
|
|
4062
|
-
classMeta.atscript_db_readable_binding = meta;
|
|
4063
|
-
return classMeta;
|
|
4064
|
-
}), Provide(READABLE_DEF, () => meta.resolve()), Controller(prefix), Inherit());
|
|
4065
|
-
}
|
|
4066
|
-
/**
|
|
4067
|
-
* Combines the boilerplate needed to turn an {@link AsDbController}
|
|
4068
|
-
* subclass into a fully wired HTTP controller for a given `@db.table` model.
|
|
4069
|
-
*
|
|
4070
|
-
* Internally applies three decorators:
|
|
4071
|
-
* 1. **Provide** — registers the readable resolver under {@link TABLE_DEF}.
|
|
4072
|
-
* 2. **Controller** — registers the class as a Moost HTTP controller
|
|
4073
|
-
* with an optional route prefix (defaults to `@db.http.path`, then the
|
|
4074
|
-
* table name).
|
|
4075
|
-
* 3. **Inherit** — copies metadata (routes, guards, etc.) from the
|
|
4076
|
-
* parent class so they stay active in the derived controller.
|
|
4077
|
-
*
|
|
4078
|
-
* All three binding forms are supported (see {@link TReadableBinding}):
|
|
4079
|
-
*
|
|
4080
|
-
* ```ts
|
|
4081
|
-
* @TableController(User) // model token (preferred)
|
|
4082
|
-
* @TableController(User, { space: "analytics" })
|
|
4083
|
-
* @TableController(() => db.getTable(User), "users") // lazy factory
|
|
4084
|
-
* @TableController(usersTable) // instance (legacy)
|
|
4085
|
-
* export class UsersController extends AsDbController<typeof User> {}
|
|
4086
|
-
* ```
|
|
4087
|
-
*
|
|
4088
|
-
* Token and factory forms resolve lazily at first controller instantiation
|
|
4089
|
-
* (during `app.init()`), so the `DbSpace` does not have to exist when the
|
|
4090
|
-
* controller module is imported. For the token form, register the space with
|
|
4091
|
-
* `provideDbSpace(db)` before `app.init()`.
|
|
4092
|
-
*
|
|
4093
|
-
* @param binding Model token, lazy factory, or {@link AtscriptDbTable} instance.
|
|
4094
|
-
* @param prefixOrOptions Route prefix string, or {@link TControllerBindingOptions}.
|
|
4095
|
-
*/
|
|
4096
|
-
const TableController = (binding, prefixOrOptions) => bindReadableController(binding, prefixOrOptions, "TableController");
|
|
4097
|
-
/**
|
|
4098
|
-
* Combines the boilerplate needed to turn an {@link AsDbReadableController}
|
|
4099
|
-
* subclass into a fully wired HTTP controller for a given `@db.view` or `@db.table` model.
|
|
4100
|
-
*
|
|
4101
|
-
* Accepts the same three binding forms as {@link TableController}.
|
|
4102
|
-
*
|
|
4103
|
-
* @param binding Model token, lazy factory, or {@link AtscriptDbReadable} instance.
|
|
4104
|
-
* @param prefixOrOptions Route prefix string, or {@link TControllerBindingOptions}.
|
|
4105
|
-
*
|
|
4106
|
-
* @example
|
|
4107
|
-
* ```ts
|
|
4108
|
-
* @ReadableController(ActiveTasks)
|
|
4109
|
-
* export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
|
|
4110
|
-
* ```
|
|
4111
|
-
*/
|
|
4112
|
-
const ReadableController = (binding, prefixOrOptions) => bindReadableController(binding, prefixOrOptions, "ReadableController");
|
|
4113
|
-
/**
|
|
4114
|
-
* Alias for {@link ReadableController} — use with view-backed controllers.
|
|
4115
|
-
*
|
|
4116
|
-
* @example
|
|
4117
|
-
* ```ts
|
|
4118
|
-
* @ViewController(ActiveTasks)
|
|
4119
|
-
* export class ActiveTasksController extends AsDbReadableController<typeof ActiveTasks> {}
|
|
4120
|
-
* ```
|
|
4121
|
-
*/
|
|
4122
|
-
const ViewController = ReadableController;
|
|
4123
|
-
/**
|
|
4124
|
-
* Finds the readable binding written by `@TableController` /
|
|
4125
|
-
* `@ReadableController` / `@ViewController` on a controller class, walking the
|
|
4126
|
-
* prototype chain so intermediate undecorated classes don't hide the binding
|
|
4127
|
-
* (nearest decorated ancestor wins).
|
|
4128
|
-
*/
|
|
4129
|
-
function findReadableBinding(ctor) {
|
|
4130
|
-
const mate = getAtscriptDbMate();
|
|
4131
|
-
let current = ctor;
|
|
4132
|
-
while (typeof current === "function") {
|
|
4133
|
-
const binding = mate.read(current)?.atscript_db_readable_binding;
|
|
4134
|
-
if (binding) return binding;
|
|
4135
|
-
current = Object.getPrototypeOf(current);
|
|
4136
|
-
}
|
|
4137
|
-
}
|
|
4138
|
-
/**
|
|
4139
|
-
* Resolves the readable bound to a controller class via
|
|
4140
|
-
* {@link findReadableBinding}, throwing with wiring guidance when none is
|
|
4141
|
-
* found.
|
|
4142
|
-
*
|
|
4143
|
-
* Used by {@link AsDbReadableController}'s constructor when `readable` is
|
|
4144
|
-
* `undefined` — i.e. a subclass with its own constructor called
|
|
4145
|
-
* `super(app)` instead of forwarding an injected instance.
|
|
4146
|
-
*/
|
|
4147
|
-
function resolveBoundReadable(ctor) {
|
|
4148
|
-
const binding = findReadableBinding(ctor);
|
|
4149
|
-
if (binding) return binding.resolve();
|
|
4150
|
-
throw new Error(`[moost-db] ${ctor?.name || "controller"}: no readable bound. Either pass a table/view to super(...), or decorate the class with @TableController / @ReadableController (model token, lazy factory, or instance form).`);
|
|
4151
|
-
}
|
|
4152
|
-
//#endregion
|
|
4153
4511
|
//#region src/permissions/crud-controls.ts
|
|
4154
4512
|
/**
|
|
4155
4513
|
* Static control whitelists per read op. Each list is the matching DTO's
|
|
@@ -4189,6 +4547,17 @@ const PATH_OPS = [
|
|
|
4189
4547
|
"aggregate",
|
|
4190
4548
|
"bucket"
|
|
4191
4549
|
];
|
|
4550
|
+
/** The controller of the event that owns the route params (nearest ancestor that set them). */
|
|
4551
|
+
function routedController(ctx) {
|
|
4552
|
+
for (let c = ctx; c; c = c.parent) {
|
|
4553
|
+
try {
|
|
4554
|
+
c.getOwn(routeParamsKey);
|
|
4555
|
+
} catch {
|
|
4556
|
+
continue;
|
|
4557
|
+
}
|
|
4558
|
+
return controllerOf(c);
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4192
4561
|
/** The 400 of a filter / sort on a `@db.writeOnly` field. */
|
|
4193
4562
|
function writeOnlyError(path, op) {
|
|
4194
4563
|
const verdict = writeOnlyVerdict(path, op);
|
|
@@ -4464,7 +4833,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4464
4833
|
* (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
|
|
4465
4834
|
* answers exactly like a nonexistent index (400); a hidden DEFAULT text
|
|
4466
4835
|
* index falls back to the `@db.column.searchable` substring search over
|
|
4467
|
-
* visible fields (or ignores the term when there are none
|
|
4836
|
+
* visible fields (or ignores the term when there are none — on list
|
|
4837
|
+
* endpoints; query targets, delegated targets and {@link resolveQuery}
|
|
4838
|
+
* answer 400 `TARGET_INVALID` instead). A
|
|
4468
4839
|
* `@db.column.derived` field is visible only while its source path is,
|
|
4469
4840
|
* and one whose source is hidden is sealed out of every read projection
|
|
4470
4841
|
* for the request, like a `@db.writeOnly` field. The same holds for a
|
|
@@ -4787,9 +5158,16 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4787
5158
|
}
|
|
4788
5159
|
return out;
|
|
4789
5160
|
}
|
|
5161
|
+
/**
|
|
5162
|
+
* The index visibility gate applies: {@link hasField} is overridden, or the
|
|
5163
|
+
* model has `@db.writeOnly` fields (never readable through a search hit).
|
|
5164
|
+
*/
|
|
5165
|
+
get _indexGateActive() {
|
|
5166
|
+
return this._hasFieldOverridden || this._writeOnlySet.size > 0;
|
|
5167
|
+
}
|
|
4790
5168
|
/** Every path `entry` reads is visible to this request. */
|
|
4791
5169
|
_indexVisible(entry) {
|
|
4792
|
-
return entry.fields.every(this.fieldVisibility.isVisible);
|
|
5170
|
+
return entry.fields.every((path) => this.fieldVisibility.isVisible(path) && selfOrAncestor(path, this._writeOnlySet) === void 0);
|
|
4793
5171
|
}
|
|
4794
5172
|
/**
|
|
4795
5173
|
* Native text search serves this request: the adapter searches natively
|
|
@@ -4808,7 +5186,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4808
5186
|
}
|
|
4809
5187
|
_resolveNativeSearch(controls) {
|
|
4810
5188
|
if (!this.readable.isSearchable()) return false;
|
|
4811
|
-
if (!this.
|
|
5189
|
+
if (!this._indexGateActive) return true;
|
|
4812
5190
|
if (typeof controls.$index === "string" && controls.$index) return true;
|
|
4813
5191
|
const def = this.indexFieldPaths().find((e) => e.type === "text" && e.isDefault);
|
|
4814
5192
|
return def === void 0 || this._indexVisible(def);
|
|
@@ -4822,7 +5200,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4822
5200
|
* nonexistent index (the core's wording).
|
|
4823
5201
|
*/
|
|
4824
5202
|
_checkIndexGate(controls) {
|
|
4825
|
-
if (!this.
|
|
5203
|
+
if (!this._indexGateActive) return void 0;
|
|
4826
5204
|
const name = typeof controls.$index === "string" ? controls.$index : void 0;
|
|
4827
5205
|
if (controls.$center !== void 0) {
|
|
4828
5206
|
const geoIndexes = this.indexFieldPaths().filter((e) => e.type === "geo");
|
|
@@ -4854,7 +5232,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
4854
5232
|
* `@db.column.searchable` fallback when any of its fields is visible).
|
|
4855
5233
|
*/
|
|
4856
5234
|
_applyIndexVisibility(meta) {
|
|
4857
|
-
if (!this.
|
|
5235
|
+
if (!this._indexGateActive) return meta;
|
|
4858
5236
|
const entries = this.indexFieldPaths();
|
|
4859
5237
|
const visibleDefault = (type) => {
|
|
4860
5238
|
const def = entries.find((e) => e.type === type && e.isDefault);
|
|
@@ -5067,6 +5445,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5067
5445
|
* {@link checkCapabilities}, {@link hasField}), where the client
|
|
5068
5446
|
* predicates' {@link transformRelationFilter} also runs: a query target
|
|
5069
5447
|
* never filters on, nor counts by, a field the caller can't read.
|
|
5448
|
+
* {@link resolveQuery} does not call it (there is no action of this
|
|
5449
|
+
* controller to scope).
|
|
5070
5450
|
*
|
|
5071
5451
|
* @since 0.1.147
|
|
5072
5452
|
*/
|
|
@@ -5403,18 +5783,76 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5403
5783
|
async [RESOLVE_TARGET](req) {
|
|
5404
5784
|
const { action } = req;
|
|
5405
5785
|
const body = parseQueryTargetBody(action, req.query);
|
|
5786
|
+
const { rows, filter, findMany, exclude, visibleOf, cap } = await this._resolveMatching({
|
|
5787
|
+
label: action,
|
|
5788
|
+
body,
|
|
5789
|
+
cap: req.cap,
|
|
5790
|
+
maxExclude: req.maxExclude,
|
|
5791
|
+
overlay: req.overlay,
|
|
5792
|
+
scopeByAction: true,
|
|
5793
|
+
select: [...new Set(req.select)],
|
|
5794
|
+
sortBy: req.select,
|
|
5795
|
+
excludeShapes: req.excludeShapes,
|
|
5796
|
+
visibleOf: req.visibleOf
|
|
5797
|
+
});
|
|
5798
|
+
const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
|
|
5799
|
+
...q,
|
|
5800
|
+
controls: {
|
|
5801
|
+
...q.controls,
|
|
5802
|
+
$limit: Math.max(ids.length, cap + 1)
|
|
5803
|
+
}
|
|
5804
|
+
}) }, ids, scope, select);
|
|
5805
|
+
const snapshotFields = new Set(req.select);
|
|
5806
|
+
let first = true;
|
|
5807
|
+
return {
|
|
5808
|
+
matched: rows.length,
|
|
5809
|
+
rows,
|
|
5810
|
+
dryRun: body.dryRun === true,
|
|
5811
|
+
exclude,
|
|
5812
|
+
visibleOf,
|
|
5813
|
+
load: (ids, select) => {
|
|
5814
|
+
if (!first) return byIds(findMany, ids, filter, select);
|
|
5815
|
+
first = false;
|
|
5816
|
+
const fields = [...select];
|
|
5817
|
+
if (!fields.every((f) => snapshotFields.has(f))) {
|
|
5818
|
+
const plain = (q) => this.readable.findMany(q);
|
|
5819
|
+
return byIds(plain, ids, void 0, fields);
|
|
5820
|
+
}
|
|
5821
|
+
return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
|
|
5822
|
+
}
|
|
5823
|
+
};
|
|
5824
|
+
}
|
|
5825
|
+
/**
|
|
5826
|
+
* The shared resolver behind query targets and {@link resolveQuery}: the
|
|
5827
|
+
* query body validated, checked and run as a READ of this controller, then
|
|
5828
|
+
* ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
|
|
5829
|
+
* scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
|
|
5830
|
+
* `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
|
|
5831
|
+
* `TARGET_CHANGED`.
|
|
5832
|
+
*
|
|
5833
|
+
* Everything that depends on the read's visibility — the `$search`
|
|
5834
|
+
* fallback, the native-search memo, the un-appliable-term refusal and the
|
|
5835
|
+
* delegated identity — is computed INSIDE the read child, where the
|
|
5836
|
+
* permission layer's per-request state is the read's.
|
|
5837
|
+
*/
|
|
5838
|
+
async _resolveMatching(spec) {
|
|
5839
|
+
const { label, body } = spec;
|
|
5840
|
+
const cap = Math.min(spec.cap, body.maxRows ?? Infinity);
|
|
5406
5841
|
const parsed = this.parseUrlOr400(body.q.startsWith("?") ? body.q.slice(1) : body.q);
|
|
5407
5842
|
const controls = {};
|
|
5408
5843
|
for (const [k, v] of Object.entries(parsed.controls ?? {})) {
|
|
5409
5844
|
if (v === void 0) continue;
|
|
5410
|
-
if (k !== "$search" && k !== "$index") throw targetInvalid(
|
|
5411
|
-
if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(
|
|
5845
|
+
if (k !== "$search" && k !== "$index") throw targetInvalid(label, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
|
|
5846
|
+
if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(label, "$search must be a search term");
|
|
5412
5847
|
controls[k] = k === "$search" ? `${v}` : v;
|
|
5413
5848
|
}
|
|
5414
|
-
if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(
|
|
5849
|
+
if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(label, "$index must be an index name");
|
|
5415
5850
|
const exclude = body.exclude ?? [];
|
|
5416
|
-
const shapes =
|
|
5851
|
+
const shapes = spec.excludeShapes ?? [];
|
|
5852
|
+
const sealedSet = () => new Set([...this.fieldVisibility.sealedFor(this.readable), ...this._leavesOf(this.readable).filter((leaf) => !this.fieldVisibility.isVisible(leaf))]);
|
|
5417
5853
|
const check = () => {
|
|
5854
|
+
let sealed;
|
|
5855
|
+
const readableLeaf = (leaf) => !(sealed ??= sealedSet()).has(leaf);
|
|
5418
5856
|
const controlsError = this.validateControls(controls, "query");
|
|
5419
5857
|
if (controlsError) throw new HttpError(400, controlsError);
|
|
5420
5858
|
const gateError = this.checkCapabilities({
|
|
@@ -5422,71 +5860,160 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5422
5860
|
controls
|
|
5423
5861
|
});
|
|
5424
5862
|
if (gateError) throw gateError;
|
|
5425
|
-
if (exclude.length > 0)
|
|
5426
|
-
|
|
5427
|
-
fields
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5863
|
+
if (exclude.length > 0) {
|
|
5864
|
+
const source = shapes.length === 0 ? this.idSource : {
|
|
5865
|
+
identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
|
|
5866
|
+
fields,
|
|
5867
|
+
source: "target"
|
|
5868
|
+
}))],
|
|
5869
|
+
fieldDescriptors: this.readable.fieldDescriptors
|
|
5870
|
+
};
|
|
5871
|
+
try {
|
|
5872
|
+
validateMultiId(exclude, source, spec.maxExclude);
|
|
5873
|
+
} catch (error) {
|
|
5874
|
+
throw validatorErrorToHttp(error) ?? error;
|
|
5875
|
+
}
|
|
5876
|
+
}
|
|
5877
|
+
const nav = FieldCapabilityIndex.navPathsOf(this.readable);
|
|
5878
|
+
for (const path of spec.selectGate ?? []) {
|
|
5879
|
+
if (this.capabilities.decorationCap(path) || selfOrAncestor(path, nav) !== void 0) throw badRequest(path, `Unknown field "${path}"`);
|
|
5880
|
+
const verdict = this.capabilities.check(path, "select", this.fieldVisibility.isVisible);
|
|
5881
|
+
if (verdict) throw badRequest(verdict.path, verdict.message);
|
|
5882
|
+
if (this._writeOnlySet.has(path)) throw badRequest(path, `Field "${path}" is @db.writeOnly`);
|
|
5883
|
+
const prefix = `${path}.`;
|
|
5884
|
+
const leaves = this._leavesOf(this.readable).filter((leaf) => leaf.startsWith(prefix));
|
|
5885
|
+
if (leaves.length > 0 && !leaves.some((leaf) => readableLeaf(leaf))) throw badRequest(path, `Unknown field "${path}"`);
|
|
5886
|
+
}
|
|
5887
|
+
for (const path of spec.sortGate ?? []) {
|
|
5888
|
+
const verdict = this.capabilities.check(path, "sort", this.fieldVisibility.isVisible);
|
|
5889
|
+
if (verdict) throw targetInvalid(label, verdict.message);
|
|
5890
|
+
}
|
|
5432
5891
|
};
|
|
5433
|
-
const ownScope =
|
|
5434
|
-
const [
|
|
5892
|
+
const ownScope = spec.scopeByAction === true && (spec.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope);
|
|
5893
|
+
const [read, overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
|
|
5435
5894
|
check();
|
|
5436
|
-
const [clientFilter, readScope] = await Promise.all([
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5895
|
+
const [clientFilter, readScope, strategy] = await Promise.all([
|
|
5896
|
+
this._relationOverlay(parsed),
|
|
5897
|
+
ownScope ? this.queryTargetScope(label) : void 0,
|
|
5898
|
+
this._resolveReadStrategy(controls)
|
|
5899
|
+
]);
|
|
5900
|
+
const base = spec.overlay === "action" ? clientFilter : await this.transformFilter(clientFilter ?? {});
|
|
5901
|
+
const searched = this.applySearchFallback(base, controls);
|
|
5902
|
+
if (controls.$search && strategy.kind !== "search" && searched === base) throw targetInvalid(label, "$search is not available here");
|
|
5903
|
+
const visibleOf = spec.visibleOf?.filter((f) => this.fieldVisibility.isVisible(f));
|
|
5904
|
+
let select = spec.select;
|
|
5905
|
+
if (spec.selectGate) {
|
|
5906
|
+
const sealed = sealedSet();
|
|
5907
|
+
for (const id of spec.identity ?? []) sealed.delete(id);
|
|
5908
|
+
select = this._sealSelect([...spec.select], sealed, this.readable);
|
|
5909
|
+
}
|
|
5910
|
+
return {
|
|
5911
|
+
searched,
|
|
5912
|
+
readScope,
|
|
5913
|
+
strategy,
|
|
5914
|
+
visibleOf,
|
|
5915
|
+
select
|
|
5916
|
+
};
|
|
5917
|
+
}, spec.routeParams), spec.overlay === "action" ? this.rowOverlay() : void 0]);
|
|
5918
|
+
const { strategy } = read;
|
|
5919
|
+
const filter = andFilters(read.searched, overlay, read.readScope, spec.scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
|
|
5441
5920
|
const findMany = (q) => strategy.kind === "search" ? this.readable.search(strategy.term, q, strategy.index) : this.readable.findMany(q);
|
|
5442
|
-
const cap = Math.min(req.cap, body.maxRows ?? Infinity);
|
|
5443
|
-
const sort = {};
|
|
5444
|
-
for (const f of req.select) sort[f] = 1;
|
|
5445
5921
|
const rows = await findMany({
|
|
5446
5922
|
filter,
|
|
5447
5923
|
controls: {
|
|
5448
|
-
$select: [...
|
|
5449
|
-
$sort:
|
|
5924
|
+
$select: [...read.select],
|
|
5925
|
+
$sort: Object.fromEntries(spec.sortBy.map((f) => [f, 1])),
|
|
5450
5926
|
$limit: cap + 1
|
|
5451
5927
|
}
|
|
5452
5928
|
});
|
|
5453
|
-
if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE",
|
|
5454
|
-
if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED",
|
|
5455
|
-
const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
|
|
5456
|
-
...q,
|
|
5457
|
-
controls: {
|
|
5458
|
-
...q.controls,
|
|
5459
|
-
$limit: Math.max(ids.length, cap + 1)
|
|
5460
|
-
}
|
|
5461
|
-
}) }, ids, scope, select);
|
|
5462
|
-
const snapshotFields = new Set(req.select);
|
|
5463
|
-
let first = true;
|
|
5929
|
+
if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", label, `The query matches more than ${cap} rows`, { cap });
|
|
5930
|
+
if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", label, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
|
|
5464
5931
|
return {
|
|
5465
|
-
matched: rows.length,
|
|
5466
5932
|
rows,
|
|
5467
|
-
|
|
5933
|
+
filter,
|
|
5934
|
+
findMany,
|
|
5468
5935
|
exclude,
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
const fields = [...select];
|
|
5473
|
-
if (!fields.every((f) => snapshotFields.has(f))) {
|
|
5474
|
-
const plain = (q) => this.readable.findMany(q);
|
|
5475
|
-
return byIds(plain, ids, void 0, fields);
|
|
5476
|
-
}
|
|
5477
|
-
return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
|
|
5478
|
-
}
|
|
5936
|
+
visibleOf: read.visibleOf,
|
|
5937
|
+
cap,
|
|
5938
|
+
select: read.select
|
|
5479
5939
|
};
|
|
5480
5940
|
}
|
|
5481
5941
|
/**
|
|
5942
|
+
* Rows of THIS controller matching `q`, resolved as a READ of it for the
|
|
5943
|
+
* current event's caller (since 0.1.149) — from your own command, e.g. to
|
|
5944
|
+
* act on "every issue matching this search". `q` is a `GET /query` string
|
|
5945
|
+
* (`$search` / `$index` and a filter only) or a query-target envelope
|
|
5946
|
+
* `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
|
|
5947
|
+
*
|
|
5948
|
+
* The read runs under this controller's full read policy
|
|
5949
|
+
* ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
|
|
5950
|
+
* {@link validateControls}, the capability / index gate and the
|
|
5951
|
+
* {@link transformFilter} overlay) with the current event's identity.
|
|
5952
|
+
* Route interceptors and guards of the `query` route do not run; read
|
|
5953
|
+
* authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
|
|
5954
|
+
* is not called. Hooks see no route params of the caller (only a call from
|
|
5955
|
+
* the routed event's own controller instance keeps its params); pass route-derived
|
|
5956
|
+
* restrictions as `opts.scope`. Joins the caller's open transaction.
|
|
5957
|
+
*
|
|
5958
|
+
* Rows are ordered by identity (`preferredId`, else the primary key) and
|
|
5959
|
+
* carry the identity fields plus `opts.select` (gated like `/query`
|
|
5960
|
+
* `$select`; `transformProjection` is not applied — hide fields with
|
|
5961
|
+
* {@link hasField}; decoration keys and navigation paths are refused). An
|
|
5962
|
+
* identity-less readable is ordered by `select` (each path sortable, else
|
|
5963
|
+
* `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
|
|
5964
|
+
* `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
|
|
5965
|
+
* `TARGET_CHANGED`; a `$search` that can't be applied → 400
|
|
5966
|
+
* `TARGET_INVALID`. Must be awaited inside a running event handler.
|
|
5967
|
+
*/
|
|
5968
|
+
async resolveQuery(q, opts = {}) {
|
|
5969
|
+
let caller;
|
|
5970
|
+
try {
|
|
5971
|
+
caller = current();
|
|
5972
|
+
} catch {
|
|
5973
|
+
throw new Error("[moost-db] resolveQuery must be awaited inside an event handler");
|
|
5974
|
+
}
|
|
5975
|
+
const cap = opts.cap ?? 1e3;
|
|
5976
|
+
if (!Number.isInteger(cap) || cap < 1) throw new Error("[moost-db] resolveQuery: `cap` must be a positive integer");
|
|
5977
|
+
const select = opts.select ?? [];
|
|
5978
|
+
if (!Array.isArray(select) || select.some((p) => typeof p !== "string")) throw new Error("[moost-db] resolveQuery: `select` must be an array of field paths");
|
|
5979
|
+
const ids = this.readable.preferredId?.length ? this.readable.preferredId : this.readable.primaryKeys;
|
|
5980
|
+
const order = ids.length > 0 ? ids : select;
|
|
5981
|
+
if (order.length === 0) throw new Error("[moost-db] resolveQuery: this readable has no identity — pass `select` (rows are ordered by it)");
|
|
5982
|
+
const label = readCurrentActionMeta(caller)?.name ?? "";
|
|
5983
|
+
const body = parseQueryTargetBody(label, typeof q === "string" ? { q } : q);
|
|
5984
|
+
if (body.dryRun !== void 0) throw targetInvalid(label, "resolveQuery takes no `dryRun` — read `query.dryRun` yourself");
|
|
5985
|
+
let sameRoute = false;
|
|
5986
|
+
try {
|
|
5987
|
+
sameRoute = controllerOf(caller) === this && routedController(caller) === this;
|
|
5988
|
+
} catch {}
|
|
5989
|
+
const fields = [...new Set([...ids, ...select])];
|
|
5990
|
+
const { rows, select: sealedSelect } = await this._resolveMatching({
|
|
5991
|
+
label,
|
|
5992
|
+
body,
|
|
5993
|
+
cap,
|
|
5994
|
+
maxExclude: DEFAULT_MAX_ACTION_IDS,
|
|
5995
|
+
overlay: "read",
|
|
5996
|
+
scope: opts.scope,
|
|
5997
|
+
select: fields,
|
|
5998
|
+
sortBy: order,
|
|
5999
|
+
selectGate: select,
|
|
6000
|
+
identity: ids,
|
|
6001
|
+
sortGate: ids.length > 0 ? void 0 : order,
|
|
6002
|
+
routeParams: sameRoute ? void 0 : {}
|
|
6003
|
+
});
|
|
6004
|
+
return rows.map((row) => projectRow$1(row, sealedSelect));
|
|
6005
|
+
}
|
|
6006
|
+
/**
|
|
5482
6007
|
* Runs `fn` as a READ of this controller (since 0.1.147): in a child of
|
|
5483
6008
|
* the current event whose controller context is this controller's `query`
|
|
5484
6009
|
* handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
|
|
5485
6010
|
* request-scoped state a permission layer builds there (read grant, field
|
|
5486
|
-
* visibility) is the read's and stays in the child.
|
|
6011
|
+
* visibility) is the read's and stays in the child. `routeParams` (since
|
|
6012
|
+
* 0.1.149) replaces the route params the child's hooks read.
|
|
5487
6013
|
*/
|
|
5488
|
-
_asRead(controls, filter, fn) {
|
|
6014
|
+
_asRead(controls, filter, fn, routeParams) {
|
|
5489
6015
|
return runAsController(this, "query", async () => {
|
|
6016
|
+
if (routeParams) current().set(routeParamsKey, routeParams);
|
|
5490
6017
|
if (typeof this.prepareRequest === "function") await this.prepareRequest(readRequestContext("query", controls, filter));
|
|
5491
6018
|
return fn();
|
|
5492
6019
|
});
|
|
@@ -5502,6 +6029,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5502
6029
|
for (const fd of this.readable.fieldDescriptors) {
|
|
5503
6030
|
if (fd.ignored) continue;
|
|
5504
6031
|
if (!fd.type?.metadata?.has?.("db.column.searchable")) continue;
|
|
6032
|
+
if (typeof searchMemberKind(fd.type) === "object") continue;
|
|
5505
6033
|
if (!this.capabilities.isPhysicallyFilterable(fd.path)) continue;
|
|
5506
6034
|
out.push(fd.path);
|
|
5507
6035
|
}
|
|
@@ -5570,7 +6098,18 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5570
6098
|
* with the existing filter. Applies only when native search does not serve
|
|
5571
6099
|
* the request (no native search, or — since 0.1.143 — its default index
|
|
5572
6100
|
* reads a field {@link hasField} hides) and the request isn't a vector
|
|
5573
|
-
* search (`$vector` consumes the term).
|
|
6101
|
+
* search (`$vector` consumes the term). Lenient on list endpoints: a term
|
|
6102
|
+
* nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
|
|
6103
|
+
* refuse it — a subclass override that applies the term must return a new
|
|
6104
|
+
* filter object.
|
|
6105
|
+
*
|
|
6106
|
+
* A string field matches when the term is a substring of its text; an
|
|
6107
|
+
* INTEGER field (`number.int` and its sizes, `@expect.int`,
|
|
6108
|
+
* `@db.default.increment`) when it is a substring of the number's decimal
|
|
6109
|
+
* text — `2946` finds `29461277` (since 0.1.150). To search numeric IDs
|
|
6110
|
+
* declare `@db.column.searchable` (fallback) or `@db.index.fulltext`
|
|
6111
|
+
* (native, exact number) on the integer field — override this hook only for
|
|
6112
|
+
* search rules the annotations cannot express.
|
|
5574
6113
|
*/
|
|
5575
6114
|
applySearchFallback(filter, controls) {
|
|
5576
6115
|
const term = controls.$search;
|
|
@@ -5609,6 +6148,51 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5609
6148
|
delete rest.$index;
|
|
5610
6149
|
return rest;
|
|
5611
6150
|
}
|
|
6151
|
+
/**
|
|
6152
|
+
* `$count` of a native text / vector search: the adapter's own count, so it
|
|
6153
|
+
* agrees with `/query` and `/pages`. A text search counts every match (the
|
|
6154
|
+
* single returned row is discarded); a vector search counts the nearest
|
|
6155
|
+
* neighbours up to `$limit` (default 1000), as `/query` returns them.
|
|
6156
|
+
*/
|
|
6157
|
+
async _countSearched(strategy, filter, controls, sealedControls) {
|
|
6158
|
+
const { $count: _count, ...sealed } = sealedControls;
|
|
6159
|
+
if (strategy.kind === "search") {
|
|
6160
|
+
const q = {
|
|
6161
|
+
filter,
|
|
6162
|
+
controls: this._searchReadControls(controls, sealed, sealed.$select, {
|
|
6163
|
+
$skip: void 0,
|
|
6164
|
+
$limit: 1
|
|
6165
|
+
})
|
|
6166
|
+
};
|
|
6167
|
+
const { count } = await this.readable.searchWithCount(strategy.term, q, strategy.index);
|
|
6168
|
+
return count;
|
|
6169
|
+
}
|
|
6170
|
+
const q = {
|
|
6171
|
+
filter,
|
|
6172
|
+
controls: this._searchReadControls(controls, sealed, sealed.$select, {
|
|
6173
|
+
$skip: void 0,
|
|
6174
|
+
$limit: controls.$limit || 1e3
|
|
6175
|
+
})
|
|
6176
|
+
};
|
|
6177
|
+
const { count } = await (strategy.vectorField ? this.readable.vectorSearchWithCount(strategy.vectorField, strategy.vector, q) : this.readable.vectorSearchWithCount(strategy.vector, q));
|
|
6178
|
+
return count;
|
|
6179
|
+
}
|
|
6180
|
+
/**
|
|
6181
|
+
* The controls a native search / vector read is run with: the request's
|
|
6182
|
+
* sealed controls (so every search control — `$fuzzy`, … — reaches the
|
|
6183
|
+
* adapter) plus the select, the threshold and the caller's paging. ONE
|
|
6184
|
+
* builder for `/query`, `/pages` and the native `$count`, so they can't
|
|
6185
|
+
* disagree about what a search matches.
|
|
6186
|
+
*/
|
|
6187
|
+
_searchReadControls(controls, sealed, select, paging) {
|
|
6188
|
+
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
6189
|
+
return {
|
|
6190
|
+
...sealed,
|
|
6191
|
+
$select: select,
|
|
6192
|
+
...paging,
|
|
6193
|
+
$threshold: threshold
|
|
6194
|
+
};
|
|
6195
|
+
}
|
|
5612
6196
|
async _resolveReadStrategy(controls) {
|
|
5613
6197
|
const searchTerm = controls.$search;
|
|
5614
6198
|
const indexName = controls.$index;
|
|
@@ -5663,21 +6247,13 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
5663
6247
|
if (decorated === void 0) return strip();
|
|
5664
6248
|
return Promise.resolve(decorated).then(strip);
|
|
5665
6249
|
}
|
|
5666
|
-
/**
|
|
5667
|
-
* The app of the current event, through DI — never the one this
|
|
5668
|
-
* (singleton) instance was constructed in, which may be gone (a re-booted
|
|
5669
|
-
* app, a hot reload).
|
|
5670
|
-
*/
|
|
5671
|
-
_currentApp() {
|
|
5672
|
-
return useControllerContext().instantiate(Moost);
|
|
5673
|
-
}
|
|
5674
6250
|
/** The class's `@DbActionsFrom` delegations, validated on first use (per app). */
|
|
5675
6251
|
async _delegations() {
|
|
5676
6252
|
if (!this._hasDelegations) return [];
|
|
5677
6253
|
return discoverDelegations({
|
|
5678
6254
|
ctor: this.constructor,
|
|
5679
6255
|
readable: this.readable,
|
|
5680
|
-
app: await this.
|
|
6256
|
+
app: await this.currentApp(),
|
|
5681
6257
|
logger: this.logger,
|
|
5682
6258
|
instantiate: (ctor) => useControllerContext().instantiate(ctor)
|
|
5683
6259
|
});
|
|
@@ -6035,24 +6611,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
6035
6611
|
}
|
|
6036
6612
|
const [transformedFilter, { sealed, finish }] = await Promise.all([this.transformFilter(clientFilter), this._projectRead(controls)]);
|
|
6037
6613
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
6038
|
-
if (controls.$count)
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6614
|
+
if (controls.$count) {
|
|
6615
|
+
const strategy = await this._resolveReadStrategy(controls);
|
|
6616
|
+
if (strategy.kind === "plain") return this.readable.count({
|
|
6617
|
+
filter,
|
|
6618
|
+
controls: {
|
|
6619
|
+
...controls,
|
|
6620
|
+
$select: sealed.$select
|
|
6621
|
+
}
|
|
6622
|
+
});
|
|
6623
|
+
return this._countSearched(strategy, filter, controls, sealed);
|
|
6624
|
+
}
|
|
6045
6625
|
const projected = finish();
|
|
6046
6626
|
if (projected instanceof HttpError) return projected;
|
|
6047
|
-
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
6048
6627
|
const queryObj = {
|
|
6049
6628
|
filter,
|
|
6050
|
-
controls: {
|
|
6051
|
-
...sealed,
|
|
6052
|
-
$select: projected.select,
|
|
6053
|
-
$limit: controls.$limit || 1e3,
|
|
6054
|
-
$threshold: threshold
|
|
6055
|
-
}
|
|
6629
|
+
controls: this._searchReadControls(controls, sealed, projected.select, { $limit: controls.$limit || 1e3 })
|
|
6056
6630
|
};
|
|
6057
6631
|
return (await this._runReadWithActions("query", queryObj, controls, projected, async (q, strategy) => {
|
|
6058
6632
|
switch (strategy.kind) {
|
|
@@ -6079,16 +6653,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
6079
6653
|
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
6080
6654
|
const projected = finish();
|
|
6081
6655
|
if (projected instanceof HttpError) return projected;
|
|
6082
|
-
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
6083
6656
|
const query = {
|
|
6084
6657
|
filter,
|
|
6085
|
-
controls: {
|
|
6086
|
-
...sealed,
|
|
6087
|
-
$select: projected.select,
|
|
6658
|
+
controls: this._searchReadControls(controls, sealed, projected.select, {
|
|
6088
6659
|
$skip: skip,
|
|
6089
|
-
$limit: size
|
|
6090
|
-
|
|
6091
|
-
}
|
|
6660
|
+
$limit: size
|
|
6661
|
+
})
|
|
6092
6662
|
};
|
|
6093
6663
|
const result = await this._runReadWithActions("pages", query, controls, projected, async (q, strategy) => {
|
|
6094
6664
|
switch (strategy.kind) {
|
|
@@ -6360,7 +6930,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
6360
6930
|
maxExclude: limits.maxIds,
|
|
6361
6931
|
overlay: "read",
|
|
6362
6932
|
select: [...new Set([...identity, ...delegation.paths])],
|
|
6363
|
-
excludeShapes: [delegation.paths]
|
|
6933
|
+
excludeShapes: [delegation.paths],
|
|
6934
|
+
visibleOf: identity
|
|
6364
6935
|
});
|
|
6365
6936
|
if (resolved.dryRun) return { matched: resolved.matched };
|
|
6366
6937
|
const summary = {
|
|
@@ -6370,7 +6941,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
6370
6941
|
failed: []
|
|
6371
6942
|
};
|
|
6372
6943
|
const { ids, index } = mapToSourceIds(resolved.rows, delegation.idMap);
|
|
6373
|
-
const visibleIdentity =
|
|
6944
|
+
const visibleIdentity = resolved.visibleOf ?? [];
|
|
6374
6945
|
for (let i = 0; i < index.length; i++) {
|
|
6375
6946
|
if (index[i] >= 0) continue;
|
|
6376
6947
|
const row = resolved.rows[i];
|
|
@@ -6568,7 +7139,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
|
|
|
6568
7139
|
relations,
|
|
6569
7140
|
fields,
|
|
6570
7141
|
type: this.getSerializedType(),
|
|
6571
|
-
...this._decorations && { decorations: this._planner.serialized(() => this.serializeForMeta(this._decorations.type)) },
|
|
7142
|
+
...this._decorations && { decorations: this._planner.serialized(this._buildScope, () => this.serializeForMeta(this._decorations.type)) },
|
|
6572
7143
|
actions: this.buildActions(),
|
|
6573
7144
|
crud: this.buildCrud(),
|
|
6574
7145
|
versionColumn: this.readable.versionColumn,
|
|
@@ -6851,7 +7422,8 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
|
|
|
6851
7422
|
* Bulk auto-lift: each item carries its own `version` → `$cas`.
|
|
6852
7423
|
* NOTE: per-item conflict disambiguation in the response body is deferred
|
|
6853
7424
|
* (§6.4) — the aggregate `{ matchedCount, modifiedCount }` surfaces partial
|
|
6854
|
-
* application; callers can detect mismatches via `
|
|
7425
|
+
* application; callers can detect mismatches via `matchedCount < N` (`modifiedCount` may
|
|
7426
|
+
* be lower for unchanged values, e.g. a version-exempt patch on MySQL / Mongo).
|
|
6855
7427
|
*/
|
|
6856
7428
|
_resolveBulkCas(rows, versionColumn) {
|
|
6857
7429
|
if (versionColumn === void 0) return;
|
|
@@ -7063,8 +7635,8 @@ let AsValueHelpController = class AsValueHelpController extends AsReadableContro
|
|
|
7063
7635
|
searchableFields;
|
|
7064
7636
|
/** The `@meta.id` field name on the bound interface, if any. */
|
|
7065
7637
|
primaryKey;
|
|
7066
|
-
constructor(boundType, controllerName, app) {
|
|
7067
|
-
super(boundType, controllerName, app, "value-help");
|
|
7638
|
+
constructor(boundType, controllerName, app, opts) {
|
|
7639
|
+
super(boundType, controllerName, app, "value-help", opts);
|
|
7068
7640
|
assertNoValueHelpActions(this.constructor);
|
|
7069
7641
|
const fieldMeta = /* @__PURE__ */ new Map();
|
|
7070
7642
|
const explicitlySearchable = [];
|
|
@@ -7280,7 +7852,8 @@ __decorate([
|
|
|
7280
7852
|
AsValueHelpController = __decorate([Inherit(), __decorateMetadata("design:paramtypes", [
|
|
7281
7853
|
Object,
|
|
7282
7854
|
String,
|
|
7283
|
-
typeof Moost === "undefined" ? Object : Moost
|
|
7855
|
+
typeof Moost === "undefined" ? Object : Moost,
|
|
7856
|
+
Object
|
|
7284
7857
|
])], AsValueHelpController);
|
|
7285
7858
|
registerAsValueHelpController(AsValueHelpController);
|
|
7286
7859
|
//#endregion
|
|
@@ -7288,9 +7861,9 @@ registerAsValueHelpController(AsValueHelpController);
|
|
|
7288
7861
|
let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueHelpController {
|
|
7289
7862
|
rows;
|
|
7290
7863
|
_pkIndex;
|
|
7291
|
-
constructor(boundType, rows, app, controllerName) {
|
|
7864
|
+
constructor(boundType, rows, app, controllerName, opts) {
|
|
7292
7865
|
const name = controllerName || boundType.metadata.get("db.table") || "value-help";
|
|
7293
|
-
super(boundType, name, app);
|
|
7866
|
+
super(boundType, name, app, opts);
|
|
7294
7867
|
this.rows = rows;
|
|
7295
7868
|
if (this.primaryKey) {
|
|
7296
7869
|
const pk = this.primaryKey;
|
|
@@ -7365,7 +7938,8 @@ AsJsonValueHelpController = __decorate([Inherit(), __decorateMetadata("design:pa
|
|
|
7365
7938
|
Object,
|
|
7366
7939
|
Array,
|
|
7367
7940
|
typeof Moost === "undefined" ? Object : Moost,
|
|
7368
|
-
String
|
|
7941
|
+
String,
|
|
7942
|
+
Object
|
|
7369
7943
|
])], AsJsonValueHelpController);
|
|
7370
7944
|
//#endregion
|
|
7371
7945
|
//#region src/assert-exposed.ts
|
|
@@ -7409,7 +7983,7 @@ function assertExposed(app, models, options) {
|
|
|
7409
7983
|
const missing = [];
|
|
7410
7984
|
for (const model of models) {
|
|
7411
7985
|
if (aliasTargetOf(model)) continue;
|
|
7412
|
-
const httpPath = model
|
|
7986
|
+
const httpPath = designTimeHttpPath(model);
|
|
7413
7987
|
if (!auditAll && httpPath === void 0) continue;
|
|
7414
7988
|
if (excluded.has(model) || exposed.has(model)) continue;
|
|
7415
7989
|
missing.push(model);
|