@atscript/moost-db 0.1.150 → 0.1.152

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.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { a as resolveDbSpace, i as provideDbSpace, n as clearDbSpaces, r as closeDbSpaces, t as DEFAULT_DB_SPACE } from "./db-space-registry-Cdb0FGCn.mjs";
2
2
  import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
3
- import { Body, Delete, Get, HttpError, MoostHttp, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
4
- import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Pipe, Provide, Resolve, TInterceptorPriority, TPipePriority, defineBeforeInterceptor, defineInterceptor, definePipeFn, getMoostMate, useControllerContext, withControllerContext } from "moost";
3
+ import { Body, Delete, Get, HttpError, MoostHttp, Patch, Post, Put, Query, Url, httpKind, prerenderJson } from "@moostjs/event-http";
4
+ import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Pipe, Provide, Resolve, TInterceptorPriority, TPipePriority, current, defineBeforeInterceptor, defineInterceptor, definePipeFn, getMoostMate, useControllerContext, withControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
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";
7
+ import { cached, current as current$1, 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";
@@ -468,7 +468,7 @@ const ACTION_GATE_PRIORITY = TInterceptorPriority.AFTER_GUARD;
468
468
  * controller's `prepareRequest` before the handler. No `prepareRequest` →
469
469
  * returns without awaiting.
470
470
  */
471
- const actionPrepareInterceptor = defineBeforeInterceptor(() => current().get(dbActionPreparedSlot), ACTION_GATE_PRIORITY);
471
+ const actionPrepareInterceptor = defineBeforeInterceptor(() => current$1().get(dbActionPreparedSlot), ACTION_GATE_PRIORITY);
472
472
  //#endregion
473
473
  //#region src/actions/controller-access.ts
474
474
  const boundTableKey = key("atscript_db_action_bound_table");
@@ -593,30 +593,83 @@ function identityOf(row, fields, read) {
593
593
  return id;
594
594
  }
595
595
  /**
596
+ * Sorted field lists per raw key order — every id of one shape sorts its keys
597
+ * once per call, not once per id.
598
+ */
599
+ var IdShapes = class {
600
+ /** Raw key order (`Object.keys` joined) → the sorted fields. */
601
+ byRaw = /* @__PURE__ */ new Map();
602
+ /** Sorted signature → the sorted fields (one entry per distinct shape). */
603
+ bySorted = /* @__PURE__ */ new Map();
604
+ fieldsOf(id) {
605
+ const keys = Object.keys(id);
606
+ const raw = `${keys.length}:${keys.join("")}`;
607
+ let sorted = this.byRaw.get(raw);
608
+ if (sorted === void 0) {
609
+ const own = keys.length > 1 ? keys.toSorted() : keys;
610
+ const sig = `${own.length}:${own.join("")}`;
611
+ sorted = this.bySorted.get(sig);
612
+ if (sorted === void 0) this.bySorted.set(sig, sorted = own);
613
+ this.byRaw.set(raw, sorted);
614
+ }
615
+ return sorted;
616
+ }
617
+ };
618
+ /** A value `$in` matches exactly like the equality `{ field: value }` on every adapter. */
619
+ function isInSafe(value) {
620
+ const type = typeof value;
621
+ return type === "string" || type === "bigint" || type === "boolean" || type === "number" && Number.isFinite(value);
622
+ }
623
+ /**
624
+ * The filter matching any of `ids` (deduped): `{ field: { $in } }` when they
625
+ * share one single-field shape with scalar values (one equality for a single
626
+ * id), else `{ $or: ids }`.
627
+ */
628
+ function idsFilter(ids, shapes) {
629
+ if (ids.length > 0 && shapes.bySorted.size === 1) {
630
+ const [fields] = shapes.bySorted.values();
631
+ if (fields.length === 1) {
632
+ const field = fields[0];
633
+ const values = [];
634
+ for (const id of ids) {
635
+ const value = id[field];
636
+ if (!isInSafe(value)) return { $or: ids };
637
+ values.push(value);
638
+ }
639
+ return values.length === 1 ? { [field]: values[0] } : { [field]: { $in: values } };
640
+ }
641
+ }
642
+ return { $or: ids };
643
+ }
644
+ /**
596
645
  * The rows `ids` address that also match `scope` (none = every row),
597
646
  * aligned with `ids` — `undefined` where nothing matched. One `findMany`
598
- * over the deduped ids (`{ $or: ids } AND scope`) selecting `select` plus
599
- * every id field; ids of different identification shapes may be mixed.
600
- * The action row loader and `$actions` scope check share it.
647
+ * over the deduped ids (`{ field: { $in } }` for single-field ids, else
648
+ * `{ $or: ids }`; AND `scope`) selecting `select` plus every id field; ids of
649
+ * different identification shapes may be mixed. The action row loader and
650
+ * `$actions` scope check share it.
601
651
  */
602
652
  async function findRowsByIds(source, ids, scope, select) {
603
653
  if (ids.length === 0) return [];
604
654
  const fields = new Set(select);
605
655
  const dedupedIds = [];
606
656
  const seenKeys = /* @__PURE__ */ new Set();
657
+ const shapes = new IdShapes();
658
+ const idKeys = [];
607
659
  for (const id of ids) {
608
- const sortedFields = Object.keys(id).toSorted();
609
- for (const f of sortedFields) fields.add(f);
610
- const key = idKey(id, sortedFields);
660
+ const key = idKey(id, shapes.fieldsOf(id));
661
+ idKeys.push(key);
611
662
  if (key !== void 0 && !seenKeys.has(key)) {
612
663
  seenKeys.add(key);
613
664
  dedupedIds.push(id);
614
665
  }
615
666
  }
616
- return alignRowsToIds(await source.findMany({
617
- filter: scope ? { $and: [{ $or: dedupedIds }, scope] } : { $or: dedupedIds },
667
+ for (const sortedFields of shapes.bySorted.values()) for (const f of sortedFields) fields.add(f);
668
+ const match = idsFilter(dedupedIds, shapes);
669
+ return alignByKeys(await source.findMany({
670
+ filter: scope ? { $and: [match, scope] } : match,
618
671
  controls: { $select: [...fields] }
619
- }), ids);
672
+ }), idKeys, shapes);
620
673
  }
621
674
  /**
622
675
  * `rows` aligned with `ids` — per id, the first row with its key values
@@ -624,16 +677,12 @@ async function findRowsByIds(source, ids, scope, select) {
624
677
  * identification shapes may be mixed.
625
678
  */
626
679
  function alignRowsToIds(rows, ids) {
627
- const shapes = /* @__PURE__ */ new Map();
628
- const idKeys = [];
629
- for (const id of ids) {
630
- const sortedFields = Object.keys(id).toSorted();
631
- const sig = sortedFields.join("");
632
- if (!shapes.has(sig)) shapes.set(sig, sortedFields);
633
- idKeys.push(idKey(id, sortedFields));
634
- }
680
+ const shapes = new IdShapes();
681
+ return alignByKeys(rows, ids.map((id) => idKey(id, shapes.fieldsOf(id))), shapes);
682
+ }
683
+ function alignByKeys(rows, idKeys, shapes) {
635
684
  const rowByKey = /* @__PURE__ */ new Map();
636
- for (const row of rows) for (const sortedFields of shapes.values()) {
685
+ for (const row of rows) for (const sortedFields of shapes.bySorted.values()) {
637
686
  const key = idKey(row, sortedFields);
638
687
  if (key !== void 0 && !rowByKey.has(key)) rowByKey.set(key, row);
639
688
  }
@@ -660,11 +709,43 @@ function exceedsFields(row, fields) {
660
709
  for (const key in row) if (!fields.has(key)) return true;
661
710
  return false;
662
711
  }
712
+ /**
713
+ * `row` without its own `keys` — a NEW object (same key order) when it has
714
+ * any of them, else `row` itself. Rows are rebuilt rather than `delete`d
715
+ * from: a deleted key turns an object into a slow dictionary-mode one for
716
+ * everything after (decoration, serialization).
717
+ */
718
+ function omitKeys(row, keys) {
719
+ let has = false;
720
+ for (const key of keys) if (Object.hasOwn(row, key)) {
721
+ has = true;
722
+ break;
723
+ }
724
+ if (!has) return row;
725
+ const out = {};
726
+ for (const key of Object.keys(row)) {
727
+ if (keys.has(key)) continue;
728
+ if (key === "__proto__") Object.defineProperty(out, key, {
729
+ value: row[key],
730
+ enumerable: true,
731
+ writable: true,
732
+ configurable: true
733
+ });
734
+ else out[key] = row[key];
735
+ }
736
+ return out;
737
+ }
738
+ /** `fields` split at the dots — pass to {@link projectRow} when projecting many rows. */
739
+ function splitPaths(fields) {
740
+ const out = [];
741
+ for (const path of fields) out.push(path.split("."));
742
+ return out;
743
+ }
663
744
  /** `row` narrowed to `fields` (dot paths copied into fresh nested objects). */
664
745
  function projectRow$1(row, fields) {
665
746
  const out = {};
666
747
  for (const path of fields) {
667
- const parts = path.split(".");
748
+ const parts = typeof path === "string" ? path.split(".") : path;
668
749
  let src = row;
669
750
  for (const p of parts) src = src?.[p];
670
751
  if (src === void 0) continue;
@@ -1844,13 +1925,13 @@ function copyClientFilter(filter, freeze = false) {
1844
1925
  } catch {
1845
1926
  throw badRequest("", "Malformed query: the filter cannot be copied");
1846
1927
  }
1847
- if (freeze) deepFreeze(copy);
1928
+ if (freeze) deepFreeze$1(copy);
1848
1929
  return copy;
1849
1930
  }
1850
- function deepFreeze(v) {
1931
+ function deepFreeze$1(v) {
1851
1932
  if (v === null || typeof v !== "object" || Object.isFrozen(v)) return;
1852
1933
  Object.freeze(v);
1853
- for (const child of Object.values(v)) deepFreeze(child);
1934
+ for (const child of Object.values(v)) deepFreeze$1(child);
1854
1935
  }
1855
1936
  /** The predicate operator maps of a filter's own level (through `$and` / `$or` / `$not`). */
1856
1937
  function collectPredicateMaps(filter, out) {
@@ -2107,6 +2188,129 @@ function walk(node, def, shallow) {
2107
2188
  }
2108
2189
  }
2109
2190
  //#endregion
2191
+ //#region src/meta/meta-cache.ts
2192
+ /**
2193
+ * `/meta` identity memo (since 0.1.151). Each layer that turns the cached
2194
+ * envelope into a request's `/meta` object memoizes its output per INPUT
2195
+ * identity and per the request-dependent answers it reads (the key), so
2196
+ * equal inputs give the same object across requests. The final object is
2197
+ * then registered with wooks' `prerenderJson` (JSON serialized once, weak
2198
+ * ETag from the bytes).
2199
+ *
2200
+ * Soundness: a memoized object is never mutated (the `applyMetaOverlay`
2201
+ * contract), so its identity implies its bytes; every key covers every
2202
+ * request-dependent input its layer reads. Inputs that are not themselves
2203
+ * stable (an overlay that builds a fresh object per request) are not memoized —
2204
+ * their output is computed per request, as before, and gets no ETag.
2205
+ */
2206
+ /** Objects whose identity implies their bytes: cached envelopes and memo outputs derived from one. */
2207
+ const stable = /* @__PURE__ */ new WeakSet();
2208
+ /**
2209
+ * Most memoized variants derived from one root envelope, over all chained
2210
+ * layers (planner → index visibility → delegated actions → own path); past it
2211
+ * a variant is computed per request (and is not stable).
2212
+ */
2213
+ const MAX_VARIANTS_PER_ROOT = 128;
2214
+ /** The root envelope each memo output was derived from (for the budget). */
2215
+ const rootOf = /* @__PURE__ */ new WeakMap();
2216
+ /** Variants memoized so far per root envelope. */
2217
+ const rootBudget = /* @__PURE__ */ new WeakMap();
2218
+ /** Marks `obj` as identity-stable (never mutated after this point). Returns it. */
2219
+ function markStableMeta(obj) {
2220
+ stable.add(obj);
2221
+ return obj;
2222
+ }
2223
+ /**
2224
+ * Marks a `/meta` payload your override memoizes (an `applyMetaOverlay`
2225
+ * variant, a `metaForm` projection) as reusable: it is deep-frozen and, when
2226
+ * served again, sent from its serialized-once JSON with an `ETag`. Return the
2227
+ * same object for the same inputs only — its bytes are fixed from now on.
2228
+ *
2229
+ * @since 0.1.151
2230
+ */
2231
+ function stableMeta(obj) {
2232
+ deepFreeze(obj);
2233
+ return markStableMeta(obj);
2234
+ }
2235
+ /**
2236
+ * `obj` may be memoized on and registered for prerendering: a cached envelope,
2237
+ * a memo output, or an object passed to {@link stableMeta}. (Frozenness alone
2238
+ * is not enough: `Object.freeze` is shallow.)
2239
+ */
2240
+ function isStableMeta(obj) {
2241
+ return stable.has(obj);
2242
+ }
2243
+ /**
2244
+ * The memoized variant of `input` for `key` in `variants` — `compute()` once
2245
+ * per (input, key). Keys of different layers must not collide (prefix them).
2246
+ * An unstable input is not memoized (computed every time); past
2247
+ * {@link MAX_VARIANTS_PER_ROOT} variants of one envelope a variant is computed
2248
+ * per request (and is not stable).
2249
+ */
2250
+ function metaVariant(variants, input, key, compute) {
2251
+ if (!isStableMeta(input)) return compute();
2252
+ let byKey = variants.get(input);
2253
+ const hit = byKey?.get(key);
2254
+ if (hit) return hit;
2255
+ const out = compute();
2256
+ const root = rootOf.get(input) ?? input;
2257
+ const used = rootBudget.get(root) ?? 0;
2258
+ if (used < MAX_VARIANTS_PER_ROOT) {
2259
+ rootBudget.set(root, used + 1);
2260
+ if (out !== input) {
2261
+ markStableMeta(out);
2262
+ rootOf.set(out, root);
2263
+ }
2264
+ if (!byKey) variants.set(input, byKey = /* @__PURE__ */ new Map());
2265
+ byKey.set(key, out);
2266
+ }
2267
+ return out;
2268
+ }
2269
+ /** Stable small ids for objects (memo keys over object identities). */
2270
+ const ids = /* @__PURE__ */ new WeakMap();
2271
+ let nextId = 1;
2272
+ /** A per-process id of `obj`'s identity. */
2273
+ function identityId(obj) {
2274
+ let id = ids.get(obj);
2275
+ if (id === void 0) ids.set(obj, id = nextId++);
2276
+ return id;
2277
+ }
2278
+ /** Bit string of `flags` (memo key). */
2279
+ function bits(flags) {
2280
+ let out = "";
2281
+ for (const flag of flags) out += flag ? "1" : "0";
2282
+ return out;
2283
+ }
2284
+ let freezeServed;
2285
+ const deepFrozen = /* @__PURE__ */ new WeakSet();
2286
+ /**
2287
+ * Shared objects are deep-frozen when `NODE_ENV` is `test` or
2288
+ * `development`, or `ATSCRIPT_DB_FREEZE_META=1` — an in-place mutation (which
2289
+ * would keep serving the old bytes) then throws instead.
2290
+ */
2291
+ function shouldFreeze() {
2292
+ if (freezeServed === void 0) {
2293
+ const env = typeof process === "undefined" ? void 0 : process.env;
2294
+ freezeServed = env?.NODE_ENV === "test" || env?.NODE_ENV === "development" || env?.ATSCRIPT_DB_FREEZE_META === "1";
2295
+ }
2296
+ return freezeServed;
2297
+ }
2298
+ /**
2299
+ * Deep-freezes `obj` in dev / test (see {@link shouldFreeze}); a no-op
2300
+ * otherwise. For objects shared across requests (served `/meta` payloads,
2301
+ * memoized projections), which must never be mutated.
2302
+ */
2303
+ function freezeShared(obj) {
2304
+ if (!shouldFreeze()) return;
2305
+ deepFreeze(obj);
2306
+ }
2307
+ function deepFreeze(value) {
2308
+ if (value === null || typeof value !== "object" || deepFrozen.has(value)) return;
2309
+ deepFrozen.add(value);
2310
+ Object.freeze(value);
2311
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
2312
+ }
2313
+ //#endregion
2110
2314
  //#region src/http-path-design-time.ts
2111
2315
  const KEY$1 = "db.http.path";
2112
2316
  /** The `@db.http.path` value a model carried before moost-db first wrote its published path. */
@@ -2515,7 +2719,11 @@ function __decorateParam(paramIndex, decorator) {
2515
2719
  }
2516
2720
  //#endregion
2517
2721
  //#region src/as-readable.controller.ts
2518
- let AsReadableController = class AsReadableController {
2722
+ var _AsReadableController;
2723
+ const DEFAULT_META_CACHE_CONTROL = "private, no-cache";
2724
+ const DEFAULT_META_VARY = ["Authorization", "Cookie"];
2725
+ const readHttpResponse = () => current().get(httpKind.keys.response);
2726
+ let AsReadableController = _AsReadableController = class AsReadableController {
2519
2727
  /** The Atscript interface this controller serves. */
2520
2728
  boundType;
2521
2729
  /** Short human-readable name for logging (usually the table/source name). */
@@ -2537,6 +2745,12 @@ let AsReadableController = class AsReadableController {
2537
2745
  /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
2538
2746
  _formSchemas = /* @__PURE__ */ new Map();
2539
2747
  /**
2748
+ * @internal Memoized `/meta` variants of this controller (own mount path,
2749
+ * index visibility, decorations, delegated actions) — per input object, then
2750
+ * per layer-prefixed key; see `meta/meta-cache.ts`.
2751
+ */
2752
+ _metaVariants = /* @__PURE__ */ new WeakMap();
2753
+ /**
2540
2754
  * @param opts.canonical Multi-mount models only: marks (`true`) or excludes
2541
2755
  * (`false`) this controller as the model's published value-help route
2542
2756
  * (since 0.1.150). Decorator-bound controllers use the decorator option.
@@ -2821,10 +3035,56 @@ let AsReadableController = class AsReadableController {
2821
3035
  * static envelope is cached (rebuilt when {@link metaCacheKey} changes);
2822
3036
  * {@link applyMetaOverlay} runs per request so subclasses can prune the
2823
3037
  * response by principal.
3038
+ *
3039
+ * Since 0.1.151 the response is HTTP-cacheable ({@link metaHttpCaching}):
3040
+ * `Cache-Control: private, no-cache`, `Vary: Authorization, Cookie` and —
3041
+ * when the payload is the same object as for an earlier request with the
3042
+ * same inputs — a weak `ETag` computed from the serialized bytes, so a
3043
+ * matching `If-None-Match` answers `304 Not Modified`. The JSON is then
3044
+ * serialized once, not per request.
2824
3045
  */
2825
3046
  async meta() {
2826
3047
  await this.parseRequest("meta");
2827
- return this._withOwnHttpPath(await this.resolveMeta());
3048
+ return this._serveMeta(this._withOwnHttpPath(await this.resolveMeta()), this.meta === _AsReadableController.prototype.meta);
3049
+ }
3050
+ /**
3051
+ * HTTP caching of `GET /meta` and `GET /meta/form/:name` (since 0.1.151).
3052
+ * Default `{}`: `Cache-Control: private, no-cache` (kept when the response
3053
+ * already carries one), `Vary: Authorization, Cookie` (merged into an
3054
+ * existing `Vary`), and a weak `ETag` from the response bytes with `304`
3055
+ * on a matching `If-None-Match`. Return `false` to send none of it (the
3056
+ * pre-0.1.151 response), or override the header values — e.g.
3057
+ * `{ cacheControl: "no-store" }` for deployments where no browser may keep
3058
+ * a copy.
3059
+ *
3060
+ * The `ETag` is a hash of the final bytes, computed after every overlay:
3061
+ * a request whose principal sees a different `/meta` gets a different tag
3062
+ * (a `200`), so a `304` only ever confirms the bytes the client already
3063
+ * holds. A refused request (`prepareRequest` throwing) is answered with its
3064
+ * error, never `304`.
3065
+ */
3066
+ metaHttpCaching() {
3067
+ return {};
3068
+ }
3069
+ /**
3070
+ * Applies {@link metaHttpCaching} to the current response and — when `body`
3071
+ * is identity-stable (a cached / memoized object) — registers it for
3072
+ * prerendering with an ETag (deep-frozen in dev / test).
3073
+ */
3074
+ _serveMeta(body, prerender = true) {
3075
+ const caching = this.metaHttpCaching();
3076
+ if (caching === false) return isStableMeta(body) ? { ...body } : body;
3077
+ let response;
3078
+ try {
3079
+ response = readHttpResponse();
3080
+ } catch {}
3081
+ if (!response) return body;
3082
+ applyMetaHeaders(response, caching);
3083
+ if (prerender && isStableMeta(body)) {
3084
+ freezeShared(body);
3085
+ prerenderJson(body, { etag: true });
3086
+ }
3087
+ return body;
2828
3088
  }
2829
3089
  /**
2830
3090
  * The root of a controller's own `/meta` carries its own mount as
@@ -2834,7 +3094,6 @@ let AsReadableController = class AsReadableController {
2834
3094
  * cached envelope.
2835
3095
  */
2836
3096
  _withOwnHttpPath(meta) {
2837
- const KEY = "db.http.path";
2838
3097
  let prefix;
2839
3098
  try {
2840
3099
  prefix = useControllerContext().getPrefix();
@@ -2844,6 +3103,11 @@ let AsReadableController = class AsReadableController {
2844
3103
  if (prefix === void 0 || prefix === null) return meta;
2845
3104
  const own = normalizeHttpPath(prefix);
2846
3105
  if (isParametricPath(own)) return meta;
3106
+ return this.getSerializeOptions === _AsReadableController.prototype.getSerializeOptions ? metaVariant(this._metaVariants, meta, `o:${own}`, () => this._ownHttpPathOf(meta, own)) : this._ownHttpPathOf(meta, own);
3107
+ }
3108
+ /** {@link _withOwnHttpPath} for the mount `own`. */
3109
+ _ownHttpPathOf(meta, own) {
3110
+ const KEY = "db.http.path";
2847
3111
  const options = this.getSerializeOptions();
2848
3112
  if (options.ignoreAnnotations?.includes(KEY)) return meta;
2849
3113
  let key = KEY;
@@ -2888,7 +3152,7 @@ let AsReadableController = class AsReadableController {
2888
3152
  this.enterScope(scope);
2889
3153
  const key = this.metaCacheKey();
2890
3154
  if (!this._metaResponse || key !== this._metaResponseKey) {
2891
- this._metaResponse = this.buildScoped(scope, () => this.buildMetaResponse());
3155
+ this._metaResponse = markStableMeta(this.buildScoped(scope, () => this.buildMetaResponse()));
2892
3156
  this._metaResponseKey = key;
2893
3157
  }
2894
3158
  return this.applyMetaOverlay(this._metaResponse);
@@ -2921,10 +3185,10 @@ let AsReadableController = class AsReadableController {
2921
3185
  this.enterScope(scope);
2922
3186
  let cached = this._formSchemas.get(name);
2923
3187
  if (!cached) {
2924
- cached = this.buildScoped(scope, () => this.serializeForMeta(formType));
3188
+ cached = markStableMeta(this.buildScoped(scope, () => this.serializeForMeta(formType)));
2925
3189
  this._formSchemas.set(name, cached);
2926
3190
  }
2927
- return cached;
3191
+ return this._serveMeta(cached);
2928
3192
  }
2929
3193
  /**
2930
3194
  * Per-request gate for `GET /meta/form/:name`: return `false` to refuse the
@@ -2981,6 +3245,12 @@ let AsReadableController = class AsReadableController {
2981
3245
  * composables). The cached envelope MUST NOT be mutated — see
2982
3246
  * `docs/http/permissions.md` for the full contract, including the
2983
3247
  * "discoverability only" caveat.
3248
+ *
3249
+ * `/meta`'s serialize-once memo and `ETag` (since 0.1.151) need a stable
3250
+ * object: return the envelope itself when nothing changes, or memoize your
3251
+ * outputs and return them through `stableMeta()` (deep-freezes and marks
3252
+ * them) — never mutate one afterwards. A fresh object per request is
3253
+ * correct, just served without an `ETag`.
2984
3254
  */
2985
3255
  applyMetaOverlay(meta) {
2986
3256
  return meta;
@@ -2999,13 +3269,36 @@ __decorate([
2999
3269
  __decorateMetadata("design:paramtypes", [String]),
3000
3270
  __decorateMetadata("design:returntype", Promise)
3001
3271
  ], AsReadableController.prototype, "metaForm", null);
3002
- AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
3272
+ AsReadableController = _AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
3003
3273
  Object,
3004
3274
  String,
3005
3275
  typeof Moost === "undefined" ? Object : Moost,
3006
3276
  Object,
3007
3277
  Object
3008
3278
  ])], AsReadableController);
3279
+ /** Sets `/meta`'s `Cache-Control` (unless present) and merges its `Vary` tokens. */
3280
+ function applyMetaHeaders(response, caching) {
3281
+ const headers = response.headers();
3282
+ let cacheControlSet = false;
3283
+ let varyKey = "vary";
3284
+ for (const key in headers) {
3285
+ const lower = key.toLowerCase();
3286
+ if (lower === "cache-control") cacheControlSet = true;
3287
+ else if (lower === "vary") varyKey = key;
3288
+ }
3289
+ if (!cacheControlSet) response.setHeader("cache-control", caching.cacheControl ?? DEFAULT_META_CACHE_CONTROL);
3290
+ const wanted = caching.vary ?? DEFAULT_META_VARY;
3291
+ if (wanted.length === 0) return;
3292
+ const existing = headers[varyKey];
3293
+ const tokens = (Array.isArray(existing) ? existing.join(",") : existing ?? "").split(",").map((t) => t.trim()).filter(Boolean);
3294
+ if (tokens.includes("*")) return;
3295
+ const seen = new Set(tokens.map((t) => t.toLowerCase()));
3296
+ for (const token of wanted) if (!seen.has(token.toLowerCase())) {
3297
+ seen.add(token.toLowerCase());
3298
+ tokens.push(token);
3299
+ }
3300
+ response.setHeader(varyKey, tokens.join(", "));
3301
+ }
3009
3302
  //#endregion
3010
3303
  //#region src/actions/verdict.ts
3011
3304
  /** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
@@ -3068,7 +3361,8 @@ function computeStripFields(candidates, resolvedProjection) {
3068
3361
  /**
3069
3362
  * Sets `$actions` on every row (plus `$disabledReasons` on rows where a
3070
3363
  * predicate returned a reason string) and strips the columns fetched only for an
3071
- * action's `requiredFields` — IN PLACE; returns the same array, typed as
3364
+ * action's `requiredFields` — IN PLACE in `rows` (a stripped row is replaced
3365
+ * by a rebuilt copy, see `omitKeys`); returns the same array, typed as
3072
3366
  * augmented.
3073
3367
  */
3074
3368
  function augmentRowsWithActions(args) {
@@ -3078,7 +3372,11 @@ function augmentRowsWithActions(args) {
3078
3372
  const verdicts = candidates.map((c) => {
3079
3373
  if (!c.disabledFn) return void 0;
3080
3374
  const fields = gateFields?.(c.envelope);
3081
- const input = fields && rows.some((row) => exceedsFields(row, fields)) ? rows.map((row) => projectRow$1(row, fields)) : rows;
3375
+ let input = rows;
3376
+ if (fields && rows.some((row) => exceedsFields(row, fields))) {
3377
+ const paths = splitPaths(fields);
3378
+ input = rows.map((row) => projectRow$1(row, paths));
3379
+ }
3082
3380
  return judgeRows(c.envelope.info.name, c.disabledFn, input);
3083
3381
  });
3084
3382
  const masks = outOfScope && candidates.map((c) => outOfScope.get(c.envelope.info.name));
@@ -3102,7 +3400,11 @@ function augmentRowsWithActions(args) {
3102
3400
  }
3103
3401
  if (resolvedProjection !== null) {
3104
3402
  const stripFields = computeStripFields(candidates, resolvedProjection);
3105
- if (stripFields !== null) for (const row of rows) for (const f of stripFields) delete row[f];
3403
+ if (stripFields !== null) for (let i = 0; i < rows.length; i++) {
3404
+ const row = rows[i];
3405
+ if (isPlainObject(row)) rows[i] = omitKeys(row, stripFields);
3406
+ else for (const key of stripFields) delete row[key];
3407
+ }
3106
3408
  }
3107
3409
  return rows;
3108
3410
  }
@@ -3126,7 +3428,7 @@ const dbActionArgsResolvedKey = key("atscript_db_action_args_resolved");
3126
3428
  const markHandlerStartPipe = definePipeFn((value, metas, level) => {
3127
3429
  const total = metas.methodMeta?.params?.length ?? 0;
3128
3430
  if (level !== "PARAM" || !metas.methodMeta?.atscript_db_action || metas.index === void 0) return value;
3129
- const ctx = current();
3431
+ const ctx = current$1();
3130
3432
  let seen = ctx.hasOwn(dbActionArgsResolvedKey) ? ctx.getOwn(dbActionArgsResolvedKey) : void 0;
3131
3433
  if (!seen) {
3132
3434
  seen = /* @__PURE__ */ new Set();
@@ -3140,7 +3442,7 @@ const markHandlerStartPipe = definePipeFn((value, metas, level) => {
3140
3442
  * `true` once the current action event's handler started (every `'rows'`
3141
3443
  * action has an argument, so its start is always observed).
3142
3444
  */
3143
- function actionHandlerStarted(ctx = current()) {
3445
+ function actionHandlerStarted(ctx = current$1()) {
3144
3446
  return ctx.hasOwn(dbActionHandlerStartedKey);
3145
3447
  }
3146
3448
  //#endregion
@@ -3472,7 +3774,7 @@ const dbActionAbortedKey = key("atscript_db_action_aborted");
3472
3774
  *
3473
3775
  * @since 0.1.147
3474
3776
  */
3475
- function useDbActionTarget(ctx = current()) {
3777
+ function useDbActionTarget(ctx = current$1()) {
3476
3778
  if (!ctx.has(dbActionTargetKey)) throw new Error("[moost-db actions] useDbActionTarget(): no action target in this event — it is available in the handler of a 'rows' level @DbAction");
3477
3779
  return ctx.get(dbActionTargetKey);
3478
3780
  }
@@ -4183,7 +4485,7 @@ function buildDecorationIndex(controller, meta, source) {
4183
4485
  requires,
4184
4486
  visibleOn,
4185
4487
  leavesOf,
4186
- memo: { meta: /* @__PURE__ */ new WeakMap() }
4488
+ memo: {}
4187
4489
  };
4188
4490
  }
4189
4491
  //#endregion
@@ -4374,45 +4676,46 @@ var DecorationPlanner = class {
4374
4676
  * `fields[key]` entry (from its virtual capability-index entry); the others
4375
4677
  * are pruned, and `decorations` is dropped when none is left. Runs after
4376
4678
  * `applyMetaOverlay` — an overlay never sees the decoration `fields`
4377
- * entries. Memoized per input while visibility is not request-scoped.
4679
+ * entries. Memoized per input and per the request's decoration visibility.
4378
4680
  */
4379
4681
  meta(meta) {
4380
- if (!meta.decorations) return meta;
4381
- const { memo, keySet } = this.index;
4382
- const memoize = !this.host.scoped;
4383
- const hit = memoize ? memo.meta.get(meta) : void 0;
4384
- if (hit) return hit;
4682
+ const decorations = meta.decorations;
4683
+ if (!decorations) return meta;
4684
+ const { keySet } = this.index;
4385
4685
  const capabilities = this.host.capabilities();
4386
- const props = meta.decorations.type.props ?? {};
4387
- const kept = {};
4388
- const fields = { ...meta.fields };
4389
- for (const [key, prop] of Object.entries(props)) {
4390
- const cap = keySet.has(key) && this.visible(key) ? capabilities.decorationCap(key) : void 0;
4391
- if (!cap) continue;
4392
- kept[key] = prop;
4393
- fields[key] = {
4394
- sortable: cap.sortable,
4395
- filterable: cap.filterable,
4396
- decoration: true
4397
- };
4398
- }
4399
- let out;
4400
- if (Object.keys(kept).length === 0) {
4401
- const { decorations: _dropped, ...rest } = meta;
4402
- out = rest;
4403
- } else out = {
4404
- ...meta,
4405
- fields,
4406
- decorations: {
4407
- ...meta.decorations,
4408
- type: {
4409
- ...meta.decorations.type,
4410
- props: kept
4411
- }
4686
+ const props = decorations.type.props ?? {};
4687
+ const entries = Object.entries(props);
4688
+ const visible = entries.map(([key]) => keySet.has(key) && this.visible(key));
4689
+ return metaVariant(this.host.metaVariants, meta, `p:${identityId(capabilities)}:${bits(visible)}`, () => {
4690
+ const kept = {};
4691
+ const fields = { ...meta.fields };
4692
+ for (let i = 0; i < entries.length; i++) {
4693
+ const [key, prop] = entries[i];
4694
+ const cap = visible[i] ? capabilities.decorationCap(key) : void 0;
4695
+ if (!cap) continue;
4696
+ kept[key] = prop;
4697
+ fields[key] = {
4698
+ sortable: cap.sortable,
4699
+ filterable: cap.filterable,
4700
+ decoration: true
4701
+ };
4412
4702
  }
4413
- };
4414
- if (memoize) memo.meta.set(meta, out);
4415
- return out;
4703
+ if (Object.keys(kept).length === 0) {
4704
+ const { decorations: _dropped, ...rest } = meta;
4705
+ return rest;
4706
+ }
4707
+ return {
4708
+ ...meta,
4709
+ fields,
4710
+ decorations: {
4711
+ ...decorations,
4712
+ type: {
4713
+ ...decorations.type,
4714
+ props: kept
4715
+ }
4716
+ }
4717
+ };
4718
+ });
4416
4719
  }
4417
4720
  /** The declared interface serialized for `/meta.decorations`, once per class and app scope. */
4418
4721
  serialized(scope, serialize) {
@@ -4424,18 +4727,31 @@ var DecorationPlanner = class {
4424
4727
  return memo.serialized;
4425
4728
  }
4426
4729
  };
4427
- /** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
4730
+ /**
4731
+ * Removes what a read must not carry — the unserved declared keys and the
4732
+ * hook-only paths — from `rows`, in place in the array: a row losing a
4733
+ * top-level key is replaced by a rebuilt copy (`omitKeys`); nested paths are
4734
+ * deleted inside it.
4735
+ */
4428
4736
  function stripDecorations(rows, read) {
4429
4737
  const { dropKeys, dropPaths, keepPaths, selectedPaths } = read;
4430
4738
  if (dropKeys.length === 0 && dropPaths.length === 0) return;
4431
4739
  const owned = (prefix) => selectedPaths !== void 0 && selectedPaths.some((sel) => prefix.every((part, i) => sel[i] === part));
4432
- for (const row of rows) {
4433
- for (const key of dropKeys) delete row[key];
4434
- dropPaths.forEach((parts, i) => {
4740
+ const topLevel = new Set(dropKeys);
4741
+ const nested = [];
4742
+ dropPaths.forEach((parts, i) => {
4743
+ if (parts.length === 1 && (keepPaths[i] ?? []).length === 0) topLevel.add(parts[0]);
4744
+ else nested.push(i);
4745
+ });
4746
+ for (let r = 0; r < rows.length; r++) {
4747
+ let row = rows[r];
4748
+ if (isPlainObject(row)) row = rows[r] = omitKeys(row, topLevel);
4749
+ else for (const key of dropKeys) delete row[key];
4750
+ for (const i of nested) {
4435
4751
  const keep = keepPaths[i] ?? [];
4436
- if (keep.length === 0) deleteDescending(row, parts, 0, selectedPaths !== void 0, owned);
4437
- else pruneExcept(row, parts, 0, keep);
4438
- });
4752
+ if (keep.length === 0) deleteDescending(row, dropPaths[i], 0, selectedPaths !== void 0, owned);
4753
+ else pruneExcept(row, dropPaths[i], 0, keep);
4754
+ }
4439
4755
  }
4440
4756
  }
4441
4757
  /** An object without keys, or an array (of any nesting) holding nothing but such values (what a strip left of a parent). */
@@ -4558,6 +4874,15 @@ function routedController(ctx) {
4558
4874
  return controllerOf(c);
4559
4875
  }
4560
4876
  }
4877
+ /**
4878
+ * `collectQueryPaths`' aggregate flag for a parsed query: `true` when its
4879
+ * `$select` carries computed entries (aggregates, calendar buckets), else
4880
+ * `undefined` (aggregate mode iff `$groupBy`).
4881
+ */
4882
+ function computedMode(parsed) {
4883
+ const select = parsed.controls?.$select;
4884
+ return Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0;
4885
+ }
4561
4886
  /** The 400 of a filter / sort on a `@db.writeOnly` field. */
4562
4887
  function writeOnlyError(path, op) {
4563
4888
  const verdict = writeOnlyVerdict(path, op);
@@ -4598,13 +4923,34 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4598
4923
  * would keep advertising (and gating) the pre-sync answer (since 0.1.132).
4599
4924
  */
4600
4925
  get capabilities() {
4926
+ const readable = this.readable;
4927
+ const geo = readable.isGeoSearchable();
4928
+ const units = readable.calendarBucketUnits();
4929
+ const fns = readable.aggregateFns();
4930
+ const expr = readable.supportsAggregateExpressions();
4601
4931
  const current = this._capabilities;
4602
- if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
4603
- const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet, this._decorations?.visibleOn);
4932
+ const seen = this._capabilityInputs;
4933
+ if (current && seen && seen.geo === geo && seen.units === units && seen.unitsSize === units.size && seen.fns === fns && seen.fnsSize === fns.size && seen.expr === expr) return current;
4934
+ const inputs = {
4935
+ geo,
4936
+ units,
4937
+ unitsSize: units.size,
4938
+ fns,
4939
+ fnsSize: fns.size,
4940
+ expr
4941
+ };
4942
+ if (current && current.signature === FieldCapabilityIndex.adapterSignature(readable)) {
4943
+ this._capabilityInputs = inputs;
4944
+ return current;
4945
+ }
4946
+ const index = new FieldCapabilityIndex(readable, this._writeOnlySet, this._decorations?.visibleOn);
4604
4947
  this._capabilities = index;
4948
+ this._capabilityInputs = inputs;
4605
4949
  return index;
4606
4950
  }
4607
4951
  _capabilities;
4952
+ /** The adapter answers {@link capabilities} last confirmed its index against (identity fast path). */
4953
+ _capabilityInputs;
4608
4954
  /** The client relational-predicate gate (since 0.1.147), built on first use — see {@link _relationGate}. */
4609
4955
  _relGate;
4610
4956
  /**
@@ -4637,6 +4983,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4637
4983
  _derivedSource;
4638
4984
  /** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
4639
4985
  _targetWriteOnly = /* @__PURE__ */ new WeakMap();
4986
+ /** The forced exclusion `$select` per sealed set (see {@link _sealSelect}). */
4987
+ _exclusionBySealed = /* @__PURE__ */ new WeakMap();
4988
+ /** The objects of {@link _exclusionBySealed}. */
4989
+ _forcedExclusions = /* @__PURE__ */ new WeakSet();
4990
+ /** {@link widenPreferredIdProjection} of each forced exclusion. */
4991
+ _widenedExclusions = /* @__PURE__ */ new WeakMap();
4640
4992
  /** Own leaf paths per readable (bound + `$with` targets), see {@link _leavesOf}. */
4641
4993
  _targetLeaves = /* @__PURE__ */ new WeakMap();
4642
4994
  _indexFieldPathsCache;
@@ -4722,7 +5074,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4722
5074
  this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
4723
5075
  this._planner = this._decorations && new DecorationPlanner(this._decorations, {
4724
5076
  isVisible,
4725
- scoped,
5077
+ metaVariants: this._metaVariants,
4726
5078
  preferred: this._preferredIdSet,
4727
5079
  capabilities: () => this.capabilities,
4728
5080
  firstVisibleField: () => this._invertibleFields.find((path) => isVisible(path))
@@ -4868,8 +5220,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4868
5220
  checkCapabilities(parsed) {
4869
5221
  const capabilities = this.capabilities;
4870
5222
  const isVisible = this.fieldVisibility.isVisible;
4871
- const select = parsed.controls?.$select;
4872
- const refs = collectQueryPaths$1(parsed, Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0);
5223
+ const shared = this._sharedRefs;
5224
+ const refs = shared?.parsed === parsed ? shared.refs : collectQueryPaths$1(parsed, computedMode(parsed));
4873
5225
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
4874
5226
  const relState = { nodes: 0 };
4875
5227
  for (const ref of refs.filter) {
@@ -4896,6 +5248,21 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4896
5248
  if (having) return badRequest(having.path, having.message);
4897
5249
  return this.checkGates(parsed) ?? this._checkIndexGate(parsed.controls ?? {});
4898
5250
  }
5251
+ /** {@link checkCapabilities} reusing `refs` (the walk it makes) when the method is not overridden. */
5252
+ _checkCapabilitiesWith(parsed, refs) {
5253
+ if (this.checkCapabilities !== _AsDbReadableController.prototype.checkCapabilities) return this.checkCapabilities(parsed);
5254
+ this._sharedRefs = {
5255
+ parsed,
5256
+ refs
5257
+ };
5258
+ try {
5259
+ return this.checkCapabilities(parsed);
5260
+ } finally {
5261
+ this._sharedRefs = void 0;
5262
+ }
5263
+ }
5264
+ /** The walk of the `parsed` in flight, handed to {@link checkCapabilities} (sync, cleared after). */
5265
+ _sharedRefs;
4899
5266
  /**
4900
5267
  * The core's shared normalizer of `$select` computed entries
4901
5268
  * (`normalizeComputedSelect`) as a 400 with the core's wording and `path`
@@ -5234,15 +5601,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5234
5601
  _applyIndexVisibility(meta) {
5235
5602
  if (!this._indexGateActive) return meta;
5236
5603
  const entries = this.indexFieldPaths();
5604
+ const indexVisible = entries.map((e) => this._indexVisible(e));
5605
+ const isSearchable = this.readable.isSearchable();
5606
+ const fallbackVisible = isSearchable ? [] : this._searchFallbackFields.map((f) => this.fieldVisibility.isVisible(f));
5607
+ return metaVariant(this._metaVariants, meta, `i:${bits(indexVisible)}:${isSearchable ? 1 : 0}:${bits(fallbackVisible)}`, () => this._narrowIndexVisibility(meta, entries, indexVisible, isSearchable, fallbackVisible));
5608
+ }
5609
+ /** {@link _applyIndexVisibility} for the given answers. */
5610
+ _narrowIndexVisibility(meta, entries, indexVisible, isSearchable, fallbackVisible) {
5237
5611
  const visibleDefault = (type) => {
5238
- const def = entries.find((e) => e.type === type && e.isDefault);
5239
- return def !== void 0 && this._indexVisible(def);
5612
+ const at = entries.findIndex((e) => e.type === type && e.isDefault);
5613
+ return at >= 0 && indexVisible[at];
5240
5614
  };
5241
5615
  const has = (type) => entries.some((e) => e.type === type);
5242
- const anyVisible = (type) => entries.some((e) => e.type === type && this._indexVisible(e));
5243
- const hidden = new Set(entries.filter((e) => e.type !== "geo" && !this._indexVisible(e)).map((e) => e.name));
5244
- const searchable = this.readable.isSearchable() ? visibleDefault("text") : this._searchFallbackFields.some((f) => this.fieldVisibility.isVisible(f));
5245
- const textUsable = searchable || this.readable.isSearchable() && anyVisible("text");
5616
+ const anyVisible = (type) => entries.some((e, i) => e.type === type && indexVisible[i]);
5617
+ const hidden = new Set(entries.filter((e, i) => e.type !== "geo" && !indexVisible[i]).map((e) => e.name));
5618
+ const searchable = isSearchable ? visibleDefault("text") : fallbackVisible.some(Boolean);
5619
+ const textUsable = searchable || isSearchable && anyVisible("text");
5246
5620
  const vectorUsable = anyVisible("vector");
5247
5621
  const unusable = /* @__PURE__ */ new Set();
5248
5622
  if (has("text") && !anyVisible("text")) unusable.add("index");
@@ -5476,7 +5850,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5476
5850
  const transformed = await this.transformProjection(plan ? plan.select : controls.$select);
5477
5851
  const sealed = this._sealControls(controls, transformed);
5478
5852
  const finish = () => {
5479
- const select = this.widenPreferredIdProjection(sealed.$select);
5853
+ const select = this._widenSealed(sealed.$select);
5480
5854
  if (select instanceof HttpError) return select;
5481
5855
  let kept;
5482
5856
  const keptPaths = () => kept === void 0 ? kept = this._resolveProjectionForAugmenter(select) : kept;
@@ -5491,6 +5865,23 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5491
5865
  finish
5492
5866
  };
5493
5867
  }
5868
+ /**
5869
+ * {@link widenPreferredIdProjection} of a sealed `$select` — memoized for the
5870
+ * forced exclusion of a read without `$select` (one object per sealed set,
5871
+ * see {@link _sealSelect}): its widening depends on the controller alone.
5872
+ */
5873
+ _widenSealed(select) {
5874
+ if (select === void 0 || !this._forcedExclusions.has(select)) return this.widenPreferredIdProjection(select);
5875
+ let widened = this._widenedExclusions.get(select);
5876
+ if (widened === void 0) {
5877
+ const out = this.widenPreferredIdProjection(select);
5878
+ if (out instanceof HttpError) return out;
5879
+ if (out !== void 0 && out !== select) freezeShared(out);
5880
+ this._widenedExclusions.set(select, out);
5881
+ widened = out;
5882
+ }
5883
+ return widened;
5884
+ }
5494
5885
  widenPreferredIdProjection(projection) {
5495
5886
  const widened = this.widenQuantityRefProjection(projection);
5496
5887
  if (widened instanceof HttpError) return widened;
@@ -5818,7 +6209,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5818
6209
  const plain = (q) => this.readable.findMany(q);
5819
6210
  return byIds(plain, ids, void 0, fields);
5820
6211
  }
5821
- return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
6212
+ const pathsByShape = /* @__PURE__ */ new Map();
6213
+ const pathsOf = (id) => {
6214
+ const keys = Object.keys(id);
6215
+ const shape = keys.join("");
6216
+ let paths = pathsByShape.get(shape);
6217
+ if (!paths) pathsByShape.set(shape, paths = splitPaths(new Set([...fields, ...keys])));
6218
+ return paths;
6219
+ };
6220
+ return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, pathsOf(ids[i])) : void 0));
5822
6221
  }
5823
6222
  };
5824
6223
  }
@@ -5968,7 +6367,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5968
6367
  async resolveQuery(q, opts = {}) {
5969
6368
  let caller;
5970
6369
  try {
5971
- caller = current();
6370
+ caller = current$1();
5972
6371
  } catch {
5973
6372
  throw new Error("[moost-db] resolveQuery must be awaited inside an event handler");
5974
6373
  }
@@ -6001,7 +6400,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6001
6400
  sortGate: ids.length > 0 ? void 0 : order,
6002
6401
  routeParams: sameRoute ? void 0 : {}
6003
6402
  });
6004
- return rows.map((row) => projectRow$1(row, sealedSelect));
6403
+ const paths = splitPaths(sealedSelect);
6404
+ return rows.map((row) => projectRow$1(row, paths));
6005
6405
  }
6006
6406
  /**
6007
6407
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
@@ -6013,7 +6413,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6013
6413
  */
6014
6414
  _asRead(controls, filter, fn, routeParams) {
6015
6415
  return runAsController(this, "query", async () => {
6016
- if (routeParams) current().set(routeParamsKey, routeParams);
6416
+ if (routeParams) current$1().set(routeParamsKey, routeParams);
6017
6417
  if (typeof this.prepareRequest === "function") await this.prepareRequest(readRequestContext("query", controls, filter));
6018
6418
  return fn();
6019
6419
  });
@@ -6045,8 +6445,18 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6045
6445
  _sealSelect(select, writeOnly, readable) {
6046
6446
  if (writeOnly.size === 0) return select;
6047
6447
  const exclusion = () => {
6048
- const out = {};
6049
- for (const f of writeOnly) out[f] = 0;
6448
+ const shared = writeOnly === this._writeOnlySet || writeOnly === this._targetWriteOnly.get(readable);
6449
+ let out = shared ? this._exclusionBySealed.get(writeOnly) : void 0;
6450
+ if (out === void 0) {
6451
+ const built = {};
6452
+ for (const f of writeOnly) built[f] = 0;
6453
+ out = built;
6454
+ if (shared) {
6455
+ freezeShared(out);
6456
+ this._exclusionBySealed.set(writeOnly, out);
6457
+ this._forcedExclusions.add(out);
6458
+ }
6459
+ }
6050
6460
  return out;
6051
6461
  };
6052
6462
  if (select === void 0) return exclusion();
@@ -6318,10 +6728,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6318
6728
  const [overlaid, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
6319
6729
  const meta = this._planner ? this._planner.meta(overlaid) : overlaid;
6320
6730
  const visible = this._applyIndexVisibility(meta);
6321
- return delegated.length > 0 ? {
6731
+ if (delegated.length === 0) return visible;
6732
+ return metaVariant(this._metaVariants, visible, `d:${delegated.map((info) => identityId(info)).join(",")}`, () => ({
6322
6733
  ...visible,
6323
6734
  actions: [...visible.actions, ...delegated]
6324
- } : visible;
6735
+ }));
6325
6736
  })();
6326
6737
  }
6327
6738
  /**
@@ -6587,8 +6998,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6587
6998
  if (groupBy?.length && controls.$vector !== void 0) return new HttpError(400, "Cannot combine $vector and $groupBy in the same query");
6588
6999
  const error = this.validateParsed(parsed, "query");
6589
7000
  if (error) return error;
7001
+ let gateRefs;
6590
7002
  if (groupBy?.length && this._writeOnlySet.size > 0) {
6591
- const refs = collectQueryPaths$1(parsed, true);
7003
+ gateRefs = collectQueryPaths$1(parsed, computedMode(parsed));
7004
+ const refs = gateRefs.aggregateMode ? gateRefs : collectQueryPaths$1(parsed, true);
6592
7005
  const sealed = [
6593
7006
  ...refs.groupBy,
6594
7007
  ...refs.aggregate,
@@ -6598,7 +7011,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6598
7011
  ].find((path) => this._writeOnlySet.has(path) && this.fieldVisibility.isVisible(path));
6599
7012
  if (sealed) return new HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
6600
7013
  }
6601
- const gateError = this.checkCapabilities(parsed);
7014
+ const gateError = gateRefs ? this._checkCapabilitiesWith(parsed, gateRefs) : this.checkCapabilities(parsed);
6602
7015
  if (gateError) return gateError;
6603
7016
  const clientFilter = await this._relationOverlay(parsed);
6604
7017
  if (groupBy?.length) {
@@ -6790,14 +7203,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6790
7203
  };
6791
7204
  const item = await this.returnOne(this._findRow(resolvedId, overlay, readControls));
6792
7205
  if (item instanceof HttpError) return item;
6793
- const pending = this._finishRows([item], prep, {
7206
+ const rows = [item];
7207
+ const pending = this._finishRows(rows, prep, {
6794
7208
  endpoint: "one",
6795
7209
  projection: projected.select,
6796
7210
  controls,
6797
7211
  decorations: projected.read?.served ?? NO_DECORATIONS
6798
7212
  }, projected.read);
6799
7213
  if (pending) await pending;
6800
- return item;
7214
+ return rows[0];
6801
7215
  }
6802
7216
  /**
6803
7217
  * **GET /meta/actions/:id** — the row-level actions the caller may run on
@@ -6916,7 +7330,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6916
7330
  throw new HttpError(404, `Unknown action "${name}"`);
6917
7331
  }
6918
7332
  const limits = delegation.queryTargets.get(name);
6919
- const env = await useBody(current()).parseBody() ?? {};
7333
+ const env = await useBody(current$1()).parseBody() ?? {};
6920
7334
  if (typeof env !== "object" || Array.isArray(env)) throw targetInvalid(name, "Action body must be an object of shape { query, input? }");
6921
7335
  if (env.ids !== void 0) throw targetInvalid(name, "This route takes a `query` — post `ids` to the action's own route");
6922
7336
  if (env.query === void 0) throw targetInvalid(name, "`query` is required");
@@ -7066,7 +7480,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
7066
7480
  for (const row of rows) if (row) present.push(row);
7067
7481
  const verdicts = envelopes.map((e, i) => {
7068
7482
  const disabled = getCandidate(e)?.disabledFn;
7069
- return disabled && present.length > 0 ? judgeRows(e.info.name, disabled, present.map((row) => projectRow$1(row, fieldsOf[i]))) : void 0;
7483
+ if (!disabled || present.length === 0) return void 0;
7484
+ const paths = splitPaths(fieldsOf[i]);
7485
+ return judgeRows(e.info.name, disabled, present.map((row) => projectRow$1(row, paths)));
7070
7486
  });
7071
7487
  let p = 0;
7072
7488
  return rows.map((row, r) => {
@@ -7874,22 +8290,29 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
7874
8290
  }
7875
8291
  async query(controls) {
7876
8292
  let rows = this.rows;
7877
- if (controls.filter && Object.keys(controls.filter).length > 0) {
7878
- const predicate = buildMemoryPredicate(controls.filter);
7879
- rows = rows.filter((row) => predicate(row));
7880
- }
7881
8293
  const search = controls.controls.$search;
7882
8294
  if (search) {
7883
8295
  const needle = search.toLowerCase();
7884
- const fields = this.searchableFields.filter((field) => this.hasField(field));
7885
- rows = rows.filter((row) => {
7886
- for (const field of fields) {
8296
+ const columns = this.searchableFields.filter((field) => this.hasField(field)).map((field) => [field, this._lowercased(field)]);
8297
+ rows = rows.filter((row, i) => {
8298
+ for (const [field, column] of columns) {
7887
8299
  const v = row[field];
7888
- if (typeof v === "string" && v.toLowerCase().includes(needle)) return true;
8300
+ if (typeof v !== "string") continue;
8301
+ let lower = column.lower[i];
8302
+ if (lower === void 0 || column.src[i] !== v) {
8303
+ lower = v.toLowerCase();
8304
+ column.src[i] = v;
8305
+ column.lower[i] = lower;
8306
+ }
8307
+ if (lower.includes(needle)) return true;
7889
8308
  }
7890
8309
  return false;
7891
8310
  });
7892
8311
  }
8312
+ if (controls.filter && Object.keys(controls.filter).length > 0) {
8313
+ const predicate = buildMemoryPredicate(controls.filter);
8314
+ rows = rows.filter((row) => predicate(row));
8315
+ }
7893
8316
  const sort = this.normalizeSort(controls.controls.$sort);
7894
8317
  if (sort) rows = sortRows(rows, sort);
7895
8318
  const total = rows.length;
@@ -7902,6 +8325,25 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
7902
8325
  count: total
7903
8326
  };
7904
8327
  }
8328
+ /**
8329
+ * `field`'s lowercased values by position in {@link rows} (the `$search`
8330
+ * haystack). An entry is used only while the row still holds the value it
8331
+ * was lowercased from (`src`), so rows edited in place stay correct.
8332
+ */
8333
+ _lowercased(field) {
8334
+ let haystack = this._haystack;
8335
+ if (haystack?.rows !== this.rows) haystack = this._haystack = {
8336
+ rows: this.rows,
8337
+ byField: /* @__PURE__ */ new Map()
8338
+ };
8339
+ let column = haystack.byField.get(field);
8340
+ if (!column) haystack.byField.set(field, column = {
8341
+ src: [],
8342
+ lower: []
8343
+ });
8344
+ return column;
8345
+ }
8346
+ _haystack;
7905
8347
  async getOne(id) {
7906
8348
  return this._pkIndex?.get(String(id)) ?? null;
7907
8349
  }
@@ -8052,7 +8494,7 @@ var ActionDisabledError = class extends HttpError {
8052
8494
  //#endregion
8053
8495
  //#region src/actions/gate-interceptor.ts
8054
8496
  function injectBoundTable(fallback) {
8055
- const ctx = current();
8497
+ const ctx = current$1();
8056
8498
  if (ctx.has(boundTableKey)) return;
8057
8499
  const t = controllerTable(ctx) ?? fallback;
8058
8500
  if (t != null) ctx.set(boundTableKey, t);
@@ -8076,7 +8518,7 @@ async function queryTargetReply(ctx, reply) {
8076
8518
  function buildGateInterceptor(opts) {
8077
8519
  const { action, level, disabled, onDisabledRows, table } = opts;
8078
8520
  return defineBeforeInterceptor(async (reply) => {
8079
- const ctx = current();
8521
+ const ctx = current$1();
8080
8522
  await awaitActionPrepared(ctx);
8081
8523
  injectBoundTable(table);
8082
8524
  await ctx.get(dbActionOverlaySlot);
@@ -8177,7 +8619,7 @@ async function needsScopeLoad(ctx, level) {
8177
8619
  function buildThinInterceptor(opts) {
8178
8620
  const { table, scope } = opts;
8179
8621
  return defineBeforeInterceptor(async (reply) => {
8180
- const ctx = current();
8622
+ const ctx = current$1();
8181
8623
  await awaitActionPrepared(ctx);
8182
8624
  injectBoundTable(table);
8183
8625
  if (!scope) return;
@@ -8206,7 +8648,7 @@ function buildTargetInterceptor(opts) {
8206
8648
  const { action, disabled, table } = opts;
8207
8649
  return defineInterceptor({
8208
8650
  async before(reply) {
8209
- const ctx = current();
8651
+ const ctx = current$1();
8210
8652
  await awaitActionPrepared(ctx);
8211
8653
  injectBoundTable(table);
8212
8654
  const { target, dryRun } = await setStreamedTarget(ctx, action, disabled);
@@ -8214,7 +8656,7 @@ function buildTargetInterceptor(opts) {
8214
8656
  else if (target.kind === "query" && target.matched === 0) reply(target.summary());
8215
8657
  },
8216
8658
  error(error, reply) {
8217
- const summary = abortStreamedTarget(current(), error);
8659
+ const summary = abortStreamedTarget(current$1(), error);
8218
8660
  if (summary) reply(summary);
8219
8661
  }
8220
8662
  }, ACTION_GATE_PRIORITY);
@@ -8308,7 +8750,7 @@ function DbActionDefault() {
8308
8750
  */
8309
8751
  function createIdParamDecorator(kind) {
8310
8752
  const resolverName = kind === "id" ? "dbActionId" : "dbActionIds";
8311
- const resolver = kind === "id" ? async () => current().get(dbActionIdSlot) : async () => current().get(dbActionIdsSlot);
8753
+ const resolver = kind === "id" ? async () => current$1().get(dbActionIdSlot) : async () => current$1().get(dbActionIdsSlot);
8312
8754
  return ApplyDecorators(getAtscriptDbMate().decorate("atscript_db_action_param", kind), Resolve(resolver, resolverName));
8313
8755
  }
8314
8756
  //#endregion
@@ -8364,7 +8806,7 @@ function DbActionIDs() {
8364
8806
  //#endregion
8365
8807
  //#region src/actions/db-action-row.decorator.ts
8366
8808
  function createRowParamDecorator(metaKey, slot, resolverName) {
8367
- return ApplyDecorators(getAtscriptDbMate().decorate(metaKey, true), Resolve(async () => current().get(slot), resolverName));
8809
+ return ApplyDecorators(getAtscriptDbMate().decorate(metaKey, true), Resolve(async () => current$1().get(slot), resolverName));
8368
8810
  }
8369
8811
  /**
8370
8812
  * Parameter decorator that injects the row whose identifier was supplied in
@@ -8517,7 +8959,7 @@ function InputForm(formType, validatorOpts) {
8517
8959
  atscript_type: resolved
8518
8960
  };
8519
8961
  }), Resolve(async () => {
8520
- const input = await current().get(dbActionInputSlot) ?? {};
8962
+ const input = await current$1().get(dbActionInputSlot) ?? {};
8521
8963
  if (resolved) resolved.validator(validatorOpts).validate(input);
8522
8964
  return input;
8523
8965
  }, "dbActionInputForm"));
@@ -8690,4 +9132,4 @@ const VALUE_HELP_CRUD_HANDLERS = Object.freeze({
8690
9132
  one: ["runGetOne", "runGetOneComposite"]
8691
9133
  });
8692
9134
  //#endregion
8693
- export { ActionDisabledError, ActionTargetError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
9135
+ export { ActionDisabledError, ActionTargetError, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, DbActionID, DbActionIDs, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, stableMeta, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };