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