@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.cjs CHANGED
@@ -594,30 +594,83 @@ function identityOf(row, fields, read) {
594
594
  return id;
595
595
  }
596
596
  /**
597
+ * Sorted field lists per raw key order — every id of one shape sorts its keys
598
+ * once per call, not once per id.
599
+ */
600
+ var IdShapes = class {
601
+ /** Raw key order (`Object.keys` joined) → the sorted fields. */
602
+ byRaw = /* @__PURE__ */ new Map();
603
+ /** Sorted signature → the sorted fields (one entry per distinct shape). */
604
+ bySorted = /* @__PURE__ */ new Map();
605
+ fieldsOf(id) {
606
+ const keys = Object.keys(id);
607
+ const raw = `${keys.length}:${keys.join("")}`;
608
+ let sorted = this.byRaw.get(raw);
609
+ if (sorted === void 0) {
610
+ const own = keys.length > 1 ? keys.toSorted() : keys;
611
+ const sig = `${own.length}:${own.join("")}`;
612
+ sorted = this.bySorted.get(sig);
613
+ if (sorted === void 0) this.bySorted.set(sig, sorted = own);
614
+ this.byRaw.set(raw, sorted);
615
+ }
616
+ return sorted;
617
+ }
618
+ };
619
+ /** A value `$in` matches exactly like the equality `{ field: value }` on every adapter. */
620
+ function isInSafe(value) {
621
+ const type = typeof value;
622
+ return type === "string" || type === "bigint" || type === "boolean" || type === "number" && Number.isFinite(value);
623
+ }
624
+ /**
625
+ * The filter matching any of `ids` (deduped): `{ field: { $in } }` when they
626
+ * share one single-field shape with scalar values (one equality for a single
627
+ * id), else `{ $or: ids }`.
628
+ */
629
+ function idsFilter(ids, shapes) {
630
+ if (ids.length > 0 && shapes.bySorted.size === 1) {
631
+ const [fields] = shapes.bySorted.values();
632
+ if (fields.length === 1) {
633
+ const field = fields[0];
634
+ const values = [];
635
+ for (const id of ids) {
636
+ const value = id[field];
637
+ if (!isInSafe(value)) return { $or: ids };
638
+ values.push(value);
639
+ }
640
+ return values.length === 1 ? { [field]: values[0] } : { [field]: { $in: values } };
641
+ }
642
+ }
643
+ return { $or: ids };
644
+ }
645
+ /**
597
646
  * The rows `ids` address that also match `scope` (none = every row),
598
647
  * aligned with `ids` — `undefined` where nothing matched. One `findMany`
599
- * over the deduped ids (`{ $or: ids } AND scope`) selecting `select` plus
600
- * every id field; ids of different identification shapes may be mixed.
601
- * The action row loader and `$actions` scope check share it.
648
+ * over the deduped ids (`{ field: { $in } }` for single-field ids, else
649
+ * `{ $or: ids }`; AND `scope`) selecting `select` plus every id field; ids of
650
+ * different identification shapes may be mixed. The action row loader and
651
+ * `$actions` scope check share it.
602
652
  */
603
653
  async function findRowsByIds(source, ids, scope, select) {
604
654
  if (ids.length === 0) return [];
605
655
  const fields = new Set(select);
606
656
  const dedupedIds = [];
607
657
  const seenKeys = /* @__PURE__ */ new Set();
658
+ const shapes = new IdShapes();
659
+ const idKeys = [];
608
660
  for (const id of ids) {
609
- const sortedFields = Object.keys(id).toSorted();
610
- for (const f of sortedFields) fields.add(f);
611
- const key = idKey(id, sortedFields);
661
+ const key = idKey(id, shapes.fieldsOf(id));
662
+ idKeys.push(key);
612
663
  if (key !== void 0 && !seenKeys.has(key)) {
613
664
  seenKeys.add(key);
614
665
  dedupedIds.push(id);
615
666
  }
616
667
  }
617
- return alignRowsToIds(await source.findMany({
618
- filter: scope ? { $and: [{ $or: dedupedIds }, scope] } : { $or: dedupedIds },
668
+ for (const sortedFields of shapes.bySorted.values()) for (const f of sortedFields) fields.add(f);
669
+ const match = idsFilter(dedupedIds, shapes);
670
+ return alignByKeys(await source.findMany({
671
+ filter: scope ? { $and: [match, scope] } : match,
619
672
  controls: { $select: [...fields] }
620
- }), ids);
673
+ }), idKeys, shapes);
621
674
  }
622
675
  /**
623
676
  * `rows` aligned with `ids` — per id, the first row with its key values
@@ -625,16 +678,12 @@ async function findRowsByIds(source, ids, scope, select) {
625
678
  * identification shapes may be mixed.
626
679
  */
627
680
  function alignRowsToIds(rows, ids) {
628
- const shapes = /* @__PURE__ */ new Map();
629
- const idKeys = [];
630
- for (const id of ids) {
631
- const sortedFields = Object.keys(id).toSorted();
632
- const sig = sortedFields.join("");
633
- if (!shapes.has(sig)) shapes.set(sig, sortedFields);
634
- idKeys.push(idKey(id, sortedFields));
635
- }
681
+ const shapes = new IdShapes();
682
+ return alignByKeys(rows, ids.map((id) => idKey(id, shapes.fieldsOf(id))), shapes);
683
+ }
684
+ function alignByKeys(rows, idKeys, shapes) {
636
685
  const rowByKey = /* @__PURE__ */ new Map();
637
- for (const row of rows) for (const sortedFields of shapes.values()) {
686
+ for (const row of rows) for (const sortedFields of shapes.bySorted.values()) {
638
687
  const key = idKey(row, sortedFields);
639
688
  if (key !== void 0 && !rowByKey.has(key)) rowByKey.set(key, row);
640
689
  }
@@ -661,11 +710,43 @@ function exceedsFields(row, fields) {
661
710
  for (const key in row) if (!fields.has(key)) return true;
662
711
  return false;
663
712
  }
713
+ /**
714
+ * `row` without its own `keys` — a NEW object (same key order) when it has
715
+ * any of them, else `row` itself. Rows are rebuilt rather than `delete`d
716
+ * from: a deleted key turns an object into a slow dictionary-mode one for
717
+ * everything after (decoration, serialization).
718
+ */
719
+ function omitKeys(row, keys) {
720
+ let has = false;
721
+ for (const key of keys) if (Object.hasOwn(row, key)) {
722
+ has = true;
723
+ break;
724
+ }
725
+ if (!has) return row;
726
+ const out = {};
727
+ for (const key of Object.keys(row)) {
728
+ if (keys.has(key)) continue;
729
+ if (key === "__proto__") Object.defineProperty(out, key, {
730
+ value: row[key],
731
+ enumerable: true,
732
+ writable: true,
733
+ configurable: true
734
+ });
735
+ else out[key] = row[key];
736
+ }
737
+ return out;
738
+ }
739
+ /** `fields` split at the dots — pass to {@link projectRow} when projecting many rows. */
740
+ function splitPaths(fields) {
741
+ const out = [];
742
+ for (const path of fields) out.push(path.split("."));
743
+ return out;
744
+ }
664
745
  /** `row` narrowed to `fields` (dot paths copied into fresh nested objects). */
665
746
  function projectRow$2(row, fields) {
666
747
  const out = {};
667
748
  for (const path of fields) {
668
- const parts = path.split(".");
749
+ const parts = typeof path === "string" ? path.split(".") : path;
669
750
  let src = row;
670
751
  for (const p of parts) src = src?.[p];
671
752
  if (src === void 0) continue;
@@ -1845,13 +1926,13 @@ function copyClientFilter(filter, freeze = false) {
1845
1926
  } catch {
1846
1927
  throw badRequest("", "Malformed query: the filter cannot be copied");
1847
1928
  }
1848
- if (freeze) deepFreeze(copy);
1929
+ if (freeze) deepFreeze$1(copy);
1849
1930
  return copy;
1850
1931
  }
1851
- function deepFreeze(v) {
1932
+ function deepFreeze$1(v) {
1852
1933
  if (v === null || typeof v !== "object" || Object.isFrozen(v)) return;
1853
1934
  Object.freeze(v);
1854
- for (const child of Object.values(v)) deepFreeze(child);
1935
+ for (const child of Object.values(v)) deepFreeze$1(child);
1855
1936
  }
1856
1937
  /** The predicate operator maps of a filter's own level (through `$and` / `$or` / `$not`). */
1857
1938
  function collectPredicateMaps(filter, out) {
@@ -2108,6 +2189,129 @@ function walk(node, def, shallow) {
2108
2189
  }
2109
2190
  }
2110
2191
  //#endregion
2192
+ //#region src/meta/meta-cache.ts
2193
+ /**
2194
+ * `/meta` identity memo (since 0.1.151). Each layer that turns the cached
2195
+ * envelope into a request's `/meta` object memoizes its output per INPUT
2196
+ * identity and per the request-dependent answers it reads (the key), so
2197
+ * equal inputs give the same object across requests. The final object is
2198
+ * then registered with wooks' `prerenderJson` (JSON serialized once, weak
2199
+ * ETag from the bytes).
2200
+ *
2201
+ * Soundness: a memoized object is never mutated (the `applyMetaOverlay`
2202
+ * contract), so its identity implies its bytes; every key covers every
2203
+ * request-dependent input its layer reads. Inputs that are not themselves
2204
+ * stable (an overlay that builds a fresh object per request) are not memoized —
2205
+ * their output is computed per request, as before, and gets no ETag.
2206
+ */
2207
+ /** Objects whose identity implies their bytes: cached envelopes and memo outputs derived from one. */
2208
+ const stable = /* @__PURE__ */ new WeakSet();
2209
+ /**
2210
+ * Most memoized variants derived from one root envelope, over all chained
2211
+ * layers (planner → index visibility → delegated actions → own path); past it
2212
+ * a variant is computed per request (and is not stable).
2213
+ */
2214
+ const MAX_VARIANTS_PER_ROOT = 128;
2215
+ /** The root envelope each memo output was derived from (for the budget). */
2216
+ const rootOf = /* @__PURE__ */ new WeakMap();
2217
+ /** Variants memoized so far per root envelope. */
2218
+ const rootBudget = /* @__PURE__ */ new WeakMap();
2219
+ /** Marks `obj` as identity-stable (never mutated after this point). Returns it. */
2220
+ function markStableMeta(obj) {
2221
+ stable.add(obj);
2222
+ return obj;
2223
+ }
2224
+ /**
2225
+ * Marks a `/meta` payload your override memoizes (an `applyMetaOverlay`
2226
+ * variant, a `metaForm` projection) as reusable: it is deep-frozen and, when
2227
+ * served again, sent from its serialized-once JSON with an `ETag`. Return the
2228
+ * same object for the same inputs only — its bytes are fixed from now on.
2229
+ *
2230
+ * @since 0.1.151
2231
+ */
2232
+ function stableMeta(obj) {
2233
+ deepFreeze(obj);
2234
+ return markStableMeta(obj);
2235
+ }
2236
+ /**
2237
+ * `obj` may be memoized on and registered for prerendering: a cached envelope,
2238
+ * a memo output, or an object passed to {@link stableMeta}. (Frozenness alone
2239
+ * is not enough: `Object.freeze` is shallow.)
2240
+ */
2241
+ function isStableMeta(obj) {
2242
+ return stable.has(obj);
2243
+ }
2244
+ /**
2245
+ * The memoized variant of `input` for `key` in `variants` — `compute()` once
2246
+ * per (input, key). Keys of different layers must not collide (prefix them).
2247
+ * An unstable input is not memoized (computed every time); past
2248
+ * {@link MAX_VARIANTS_PER_ROOT} variants of one envelope a variant is computed
2249
+ * per request (and is not stable).
2250
+ */
2251
+ function metaVariant(variants, input, key, compute) {
2252
+ if (!isStableMeta(input)) return compute();
2253
+ let byKey = variants.get(input);
2254
+ const hit = byKey?.get(key);
2255
+ if (hit) return hit;
2256
+ const out = compute();
2257
+ const root = rootOf.get(input) ?? input;
2258
+ const used = rootBudget.get(root) ?? 0;
2259
+ if (used < MAX_VARIANTS_PER_ROOT) {
2260
+ rootBudget.set(root, used + 1);
2261
+ if (out !== input) {
2262
+ markStableMeta(out);
2263
+ rootOf.set(out, root);
2264
+ }
2265
+ if (!byKey) variants.set(input, byKey = /* @__PURE__ */ new Map());
2266
+ byKey.set(key, out);
2267
+ }
2268
+ return out;
2269
+ }
2270
+ /** Stable small ids for objects (memo keys over object identities). */
2271
+ const ids = /* @__PURE__ */ new WeakMap();
2272
+ let nextId = 1;
2273
+ /** A per-process id of `obj`'s identity. */
2274
+ function identityId(obj) {
2275
+ let id = ids.get(obj);
2276
+ if (id === void 0) ids.set(obj, id = nextId++);
2277
+ return id;
2278
+ }
2279
+ /** Bit string of `flags` (memo key). */
2280
+ function bits(flags) {
2281
+ let out = "";
2282
+ for (const flag of flags) out += flag ? "1" : "0";
2283
+ return out;
2284
+ }
2285
+ let freezeServed;
2286
+ const deepFrozen = /* @__PURE__ */ new WeakSet();
2287
+ /**
2288
+ * Shared objects are deep-frozen when `NODE_ENV` is `test` or
2289
+ * `development`, or `ATSCRIPT_DB_FREEZE_META=1` — an in-place mutation (which
2290
+ * would keep serving the old bytes) then throws instead.
2291
+ */
2292
+ function shouldFreeze() {
2293
+ if (freezeServed === void 0) {
2294
+ const env = typeof process === "undefined" ? void 0 : process.env;
2295
+ freezeServed = env?.NODE_ENV === "test" || env?.NODE_ENV === "development" || env?.ATSCRIPT_DB_FREEZE_META === "1";
2296
+ }
2297
+ return freezeServed;
2298
+ }
2299
+ /**
2300
+ * Deep-freezes `obj` in dev / test (see {@link shouldFreeze}); a no-op
2301
+ * otherwise. For objects shared across requests (served `/meta` payloads,
2302
+ * memoized projections), which must never be mutated.
2303
+ */
2304
+ function freezeShared(obj) {
2305
+ if (!shouldFreeze()) return;
2306
+ deepFreeze(obj);
2307
+ }
2308
+ function deepFreeze(value) {
2309
+ if (value === null || typeof value !== "object" || deepFrozen.has(value)) return;
2310
+ deepFrozen.add(value);
2311
+ Object.freeze(value);
2312
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
2313
+ }
2314
+ //#endregion
2111
2315
  //#region src/http-path-design-time.ts
2112
2316
  const KEY$1 = "db.http.path";
2113
2317
  /** The `@db.http.path` value a model carried before moost-db first wrote its published path. */
@@ -2516,7 +2720,11 @@ function __decorateParam(paramIndex, decorator) {
2516
2720
  }
2517
2721
  //#endregion
2518
2722
  //#region src/as-readable.controller.ts
2519
- let AsReadableController = class AsReadableController {
2723
+ var _AsReadableController;
2724
+ const DEFAULT_META_CACHE_CONTROL = "private, no-cache";
2725
+ const DEFAULT_META_VARY = ["Authorization", "Cookie"];
2726
+ const readHttpResponse = () => (0, moost.current)().get(_moostjs_event_http.httpKind.keys.response);
2727
+ let AsReadableController = _AsReadableController = class AsReadableController {
2520
2728
  /** The Atscript interface this controller serves. */
2521
2729
  boundType;
2522
2730
  /** Short human-readable name for logging (usually the table/source name). */
@@ -2538,6 +2746,12 @@ let AsReadableController = class AsReadableController {
2538
2746
  /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
2539
2747
  _formSchemas = /* @__PURE__ */ new Map();
2540
2748
  /**
2749
+ * @internal Memoized `/meta` variants of this controller (own mount path,
2750
+ * index visibility, decorations, delegated actions) — per input object, then
2751
+ * per layer-prefixed key; see `meta/meta-cache.ts`.
2752
+ */
2753
+ _metaVariants = /* @__PURE__ */ new WeakMap();
2754
+ /**
2541
2755
  * @param opts.canonical Multi-mount models only: marks (`true`) or excludes
2542
2756
  * (`false`) this controller as the model's published value-help route
2543
2757
  * (since 0.1.150). Decorator-bound controllers use the decorator option.
@@ -2822,10 +3036,56 @@ let AsReadableController = class AsReadableController {
2822
3036
  * static envelope is cached (rebuilt when {@link metaCacheKey} changes);
2823
3037
  * {@link applyMetaOverlay} runs per request so subclasses can prune the
2824
3038
  * response by principal.
3039
+ *
3040
+ * Since 0.1.151 the response is HTTP-cacheable ({@link metaHttpCaching}):
3041
+ * `Cache-Control: private, no-cache`, `Vary: Authorization, Cookie` and —
3042
+ * when the payload is the same object as for an earlier request with the
3043
+ * same inputs — a weak `ETag` computed from the serialized bytes, so a
3044
+ * matching `If-None-Match` answers `304 Not Modified`. The JSON is then
3045
+ * serialized once, not per request.
2825
3046
  */
2826
3047
  async meta() {
2827
3048
  await this.parseRequest("meta");
2828
- return this._withOwnHttpPath(await this.resolveMeta());
3049
+ return this._serveMeta(this._withOwnHttpPath(await this.resolveMeta()), this.meta === _AsReadableController.prototype.meta);
3050
+ }
3051
+ /**
3052
+ * HTTP caching of `GET /meta` and `GET /meta/form/:name` (since 0.1.151).
3053
+ * Default `{}`: `Cache-Control: private, no-cache` (kept when the response
3054
+ * already carries one), `Vary: Authorization, Cookie` (merged into an
3055
+ * existing `Vary`), and a weak `ETag` from the response bytes with `304`
3056
+ * on a matching `If-None-Match`. Return `false` to send none of it (the
3057
+ * pre-0.1.151 response), or override the header values — e.g.
3058
+ * `{ cacheControl: "no-store" }` for deployments where no browser may keep
3059
+ * a copy.
3060
+ *
3061
+ * The `ETag` is a hash of the final bytes, computed after every overlay:
3062
+ * a request whose principal sees a different `/meta` gets a different tag
3063
+ * (a `200`), so a `304` only ever confirms the bytes the client already
3064
+ * holds. A refused request (`prepareRequest` throwing) is answered with its
3065
+ * error, never `304`.
3066
+ */
3067
+ metaHttpCaching() {
3068
+ return {};
3069
+ }
3070
+ /**
3071
+ * Applies {@link metaHttpCaching} to the current response and — when `body`
3072
+ * is identity-stable (a cached / memoized object) — registers it for
3073
+ * prerendering with an ETag (deep-frozen in dev / test).
3074
+ */
3075
+ _serveMeta(body, prerender = true) {
3076
+ const caching = this.metaHttpCaching();
3077
+ if (caching === false) return isStableMeta(body) ? { ...body } : body;
3078
+ let response;
3079
+ try {
3080
+ response = readHttpResponse();
3081
+ } catch {}
3082
+ if (!response) return body;
3083
+ applyMetaHeaders(response, caching);
3084
+ if (prerender && isStableMeta(body)) {
3085
+ freezeShared(body);
3086
+ (0, _moostjs_event_http.prerenderJson)(body, { etag: true });
3087
+ }
3088
+ return body;
2829
3089
  }
2830
3090
  /**
2831
3091
  * The root of a controller's own `/meta` carries its own mount as
@@ -2835,7 +3095,6 @@ let AsReadableController = class AsReadableController {
2835
3095
  * cached envelope.
2836
3096
  */
2837
3097
  _withOwnHttpPath(meta) {
2838
- const KEY = "db.http.path";
2839
3098
  let prefix;
2840
3099
  try {
2841
3100
  prefix = (0, moost.useControllerContext)().getPrefix();
@@ -2845,6 +3104,11 @@ let AsReadableController = class AsReadableController {
2845
3104
  if (prefix === void 0 || prefix === null) return meta;
2846
3105
  const own = normalizeHttpPath(prefix);
2847
3106
  if (isParametricPath(own)) return meta;
3107
+ return this.getSerializeOptions === _AsReadableController.prototype.getSerializeOptions ? metaVariant(this._metaVariants, meta, `o:${own}`, () => this._ownHttpPathOf(meta, own)) : this._ownHttpPathOf(meta, own);
3108
+ }
3109
+ /** {@link _withOwnHttpPath} for the mount `own`. */
3110
+ _ownHttpPathOf(meta, own) {
3111
+ const KEY = "db.http.path";
2848
3112
  const options = this.getSerializeOptions();
2849
3113
  if (options.ignoreAnnotations?.includes(KEY)) return meta;
2850
3114
  let key = KEY;
@@ -2889,7 +3153,7 @@ let AsReadableController = class AsReadableController {
2889
3153
  this.enterScope(scope);
2890
3154
  const key = this.metaCacheKey();
2891
3155
  if (!this._metaResponse || key !== this._metaResponseKey) {
2892
- this._metaResponse = this.buildScoped(scope, () => this.buildMetaResponse());
3156
+ this._metaResponse = markStableMeta(this.buildScoped(scope, () => this.buildMetaResponse()));
2893
3157
  this._metaResponseKey = key;
2894
3158
  }
2895
3159
  return this.applyMetaOverlay(this._metaResponse);
@@ -2922,10 +3186,10 @@ let AsReadableController = class AsReadableController {
2922
3186
  this.enterScope(scope);
2923
3187
  let cached = this._formSchemas.get(name);
2924
3188
  if (!cached) {
2925
- cached = this.buildScoped(scope, () => this.serializeForMeta(formType));
3189
+ cached = markStableMeta(this.buildScoped(scope, () => this.serializeForMeta(formType)));
2926
3190
  this._formSchemas.set(name, cached);
2927
3191
  }
2928
- return cached;
3192
+ return this._serveMeta(cached);
2929
3193
  }
2930
3194
  /**
2931
3195
  * Per-request gate for `GET /meta/form/:name`: return `false` to refuse the
@@ -2982,6 +3246,12 @@ let AsReadableController = class AsReadableController {
2982
3246
  * composables). The cached envelope MUST NOT be mutated — see
2983
3247
  * `docs/http/permissions.md` for the full contract, including the
2984
3248
  * "discoverability only" caveat.
3249
+ *
3250
+ * `/meta`'s serialize-once memo and `ETag` (since 0.1.151) need a stable
3251
+ * object: return the envelope itself when nothing changes, or memoize your
3252
+ * outputs and return them through `stableMeta()` (deep-freezes and marks
3253
+ * them) — never mutate one afterwards. A fresh object per request is
3254
+ * correct, just served without an `ETag`.
2985
3255
  */
2986
3256
  applyMetaOverlay(meta) {
2987
3257
  return meta;
@@ -3000,13 +3270,36 @@ __decorate([
3000
3270
  __decorateMetadata("design:paramtypes", [String]),
3001
3271
  __decorateMetadata("design:returntype", Promise)
3002
3272
  ], AsReadableController.prototype, "metaForm", null);
3003
- AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
3273
+ AsReadableController = _AsReadableController = __decorate([UseValidationErrorTransform(), __decorateMetadata("design:paramtypes", [
3004
3274
  Object,
3005
3275
  String,
3006
3276
  typeof moost.Moost === "undefined" ? Object : moost.Moost,
3007
3277
  Object,
3008
3278
  Object
3009
3279
  ])], AsReadableController);
3280
+ /** Sets `/meta`'s `Cache-Control` (unless present) and merges its `Vary` tokens. */
3281
+ function applyMetaHeaders(response, caching) {
3282
+ const headers = response.headers();
3283
+ let cacheControlSet = false;
3284
+ let varyKey = "vary";
3285
+ for (const key in headers) {
3286
+ const lower = key.toLowerCase();
3287
+ if (lower === "cache-control") cacheControlSet = true;
3288
+ else if (lower === "vary") varyKey = key;
3289
+ }
3290
+ if (!cacheControlSet) response.setHeader("cache-control", caching.cacheControl ?? DEFAULT_META_CACHE_CONTROL);
3291
+ const wanted = caching.vary ?? DEFAULT_META_VARY;
3292
+ if (wanted.length === 0) return;
3293
+ const existing = headers[varyKey];
3294
+ const tokens = (Array.isArray(existing) ? existing.join(",") : existing ?? "").split(",").map((t) => t.trim()).filter(Boolean);
3295
+ if (tokens.includes("*")) return;
3296
+ const seen = new Set(tokens.map((t) => t.toLowerCase()));
3297
+ for (const token of wanted) if (!seen.has(token.toLowerCase())) {
3298
+ seen.add(token.toLowerCase());
3299
+ tokens.push(token);
3300
+ }
3301
+ response.setHeader(varyKey, tokens.join(", "));
3302
+ }
3010
3303
  //#endregion
3011
3304
  //#region src/actions/verdict.ts
3012
3305
  /** Assert that a `disabled` predicate returned an array of the expected length; throws HTTP 500 otherwise. */
@@ -3069,7 +3362,8 @@ function computeStripFields(candidates, resolvedProjection) {
3069
3362
  /**
3070
3363
  * Sets `$actions` on every row (plus `$disabledReasons` on rows where a
3071
3364
  * predicate returned a reason string) and strips the columns fetched only for an
3072
- * action's `requiredFields` — IN PLACE; returns the same array, typed as
3365
+ * action's `requiredFields` — IN PLACE in `rows` (a stripped row is replaced
3366
+ * by a rebuilt copy, see `omitKeys`); returns the same array, typed as
3073
3367
  * augmented.
3074
3368
  */
3075
3369
  function augmentRowsWithActions(args) {
@@ -3079,7 +3373,11 @@ function augmentRowsWithActions(args) {
3079
3373
  const verdicts = candidates.map((c) => {
3080
3374
  if (!c.disabledFn) return void 0;
3081
3375
  const fields = gateFields?.(c.envelope);
3082
- const input = fields && rows.some((row) => exceedsFields(row, fields)) ? rows.map((row) => projectRow$2(row, fields)) : rows;
3376
+ let input = rows;
3377
+ if (fields && rows.some((row) => exceedsFields(row, fields))) {
3378
+ const paths = splitPaths(fields);
3379
+ input = rows.map((row) => projectRow$2(row, paths));
3380
+ }
3083
3381
  return judgeRows(c.envelope.info.name, c.disabledFn, input);
3084
3382
  });
3085
3383
  const masks = outOfScope && candidates.map((c) => outOfScope.get(c.envelope.info.name));
@@ -3103,7 +3401,11 @@ function augmentRowsWithActions(args) {
3103
3401
  }
3104
3402
  if (resolvedProjection !== null) {
3105
3403
  const stripFields = computeStripFields(candidates, resolvedProjection);
3106
- if (stripFields !== null) for (const row of rows) for (const f of stripFields) delete row[f];
3404
+ if (stripFields !== null) for (let i = 0; i < rows.length; i++) {
3405
+ const row = rows[i];
3406
+ if ((0, _atscript_db.isPlainObject)(row)) rows[i] = omitKeys(row, stripFields);
3407
+ else for (const key of stripFields) delete row[key];
3408
+ }
3107
3409
  }
3108
3410
  return rows;
3109
3411
  }
@@ -4186,7 +4488,7 @@ function buildDecorationIndex(controller, meta, source) {
4186
4488
  requires,
4187
4489
  visibleOn,
4188
4490
  leavesOf,
4189
- memo: { meta: /* @__PURE__ */ new WeakMap() }
4491
+ memo: {}
4190
4492
  };
4191
4493
  }
4192
4494
  //#endregion
@@ -4377,45 +4679,46 @@ var DecorationPlanner = class {
4377
4679
  * `fields[key]` entry (from its virtual capability-index entry); the others
4378
4680
  * are pruned, and `decorations` is dropped when none is left. Runs after
4379
4681
  * `applyMetaOverlay` — an overlay never sees the decoration `fields`
4380
- * entries. Memoized per input while visibility is not request-scoped.
4682
+ * entries. Memoized per input and per the request's decoration visibility.
4381
4683
  */
4382
4684
  meta(meta) {
4383
- if (!meta.decorations) return meta;
4384
- const { memo, keySet } = this.index;
4385
- const memoize = !this.host.scoped;
4386
- const hit = memoize ? memo.meta.get(meta) : void 0;
4387
- if (hit) return hit;
4685
+ const decorations = meta.decorations;
4686
+ if (!decorations) return meta;
4687
+ const { keySet } = this.index;
4388
4688
  const capabilities = this.host.capabilities();
4389
- const props = meta.decorations.type.props ?? {};
4390
- const kept = {};
4391
- const fields = { ...meta.fields };
4392
- for (const [key, prop] of Object.entries(props)) {
4393
- const cap = keySet.has(key) && this.visible(key) ? capabilities.decorationCap(key) : void 0;
4394
- if (!cap) continue;
4395
- kept[key] = prop;
4396
- fields[key] = {
4397
- sortable: cap.sortable,
4398
- filterable: cap.filterable,
4399
- decoration: true
4400
- };
4401
- }
4402
- let out;
4403
- if (Object.keys(kept).length === 0) {
4404
- const { decorations: _dropped, ...rest } = meta;
4405
- out = rest;
4406
- } else out = {
4407
- ...meta,
4408
- fields,
4409
- decorations: {
4410
- ...meta.decorations,
4411
- type: {
4412
- ...meta.decorations.type,
4413
- props: kept
4414
- }
4689
+ const props = decorations.type.props ?? {};
4690
+ const entries = Object.entries(props);
4691
+ const visible = entries.map(([key]) => keySet.has(key) && this.visible(key));
4692
+ return metaVariant(this.host.metaVariants, meta, `p:${identityId(capabilities)}:${bits(visible)}`, () => {
4693
+ const kept = {};
4694
+ const fields = { ...meta.fields };
4695
+ for (let i = 0; i < entries.length; i++) {
4696
+ const [key, prop] = entries[i];
4697
+ const cap = visible[i] ? capabilities.decorationCap(key) : void 0;
4698
+ if (!cap) continue;
4699
+ kept[key] = prop;
4700
+ fields[key] = {
4701
+ sortable: cap.sortable,
4702
+ filterable: cap.filterable,
4703
+ decoration: true
4704
+ };
4415
4705
  }
4416
- };
4417
- if (memoize) memo.meta.set(meta, out);
4418
- return out;
4706
+ if (Object.keys(kept).length === 0) {
4707
+ const { decorations: _dropped, ...rest } = meta;
4708
+ return rest;
4709
+ }
4710
+ return {
4711
+ ...meta,
4712
+ fields,
4713
+ decorations: {
4714
+ ...decorations,
4715
+ type: {
4716
+ ...decorations.type,
4717
+ props: kept
4718
+ }
4719
+ }
4720
+ };
4721
+ });
4419
4722
  }
4420
4723
  /** The declared interface serialized for `/meta.decorations`, once per class and app scope. */
4421
4724
  serialized(scope, serialize) {
@@ -4427,18 +4730,31 @@ var DecorationPlanner = class {
4427
4730
  return memo.serialized;
4428
4731
  }
4429
4732
  };
4430
- /** Removes what a read must not carry — the unserved declared keys and the hook-only paths — from `rows`. */
4733
+ /**
4734
+ * Removes what a read must not carry — the unserved declared keys and the
4735
+ * hook-only paths — from `rows`, in place in the array: a row losing a
4736
+ * top-level key is replaced by a rebuilt copy (`omitKeys`); nested paths are
4737
+ * deleted inside it.
4738
+ */
4431
4739
  function stripDecorations(rows, read) {
4432
4740
  const { dropKeys, dropPaths, keepPaths, selectedPaths } = read;
4433
4741
  if (dropKeys.length === 0 && dropPaths.length === 0) return;
4434
4742
  const owned = (prefix) => selectedPaths !== void 0 && selectedPaths.some((sel) => prefix.every((part, i) => sel[i] === part));
4435
- for (const row of rows) {
4436
- for (const key of dropKeys) delete row[key];
4437
- dropPaths.forEach((parts, i) => {
4743
+ const topLevel = new Set(dropKeys);
4744
+ const nested = [];
4745
+ dropPaths.forEach((parts, i) => {
4746
+ if (parts.length === 1 && (keepPaths[i] ?? []).length === 0) topLevel.add(parts[0]);
4747
+ else nested.push(i);
4748
+ });
4749
+ for (let r = 0; r < rows.length; r++) {
4750
+ let row = rows[r];
4751
+ if ((0, _atscript_db.isPlainObject)(row)) row = rows[r] = omitKeys(row, topLevel);
4752
+ else for (const key of dropKeys) delete row[key];
4753
+ for (const i of nested) {
4438
4754
  const keep = keepPaths[i] ?? [];
4439
- if (keep.length === 0) deleteDescending(row, parts, 0, selectedPaths !== void 0, owned);
4440
- else pruneExcept(row, parts, 0, keep);
4441
- });
4755
+ if (keep.length === 0) deleteDescending(row, dropPaths[i], 0, selectedPaths !== void 0, owned);
4756
+ else pruneExcept(row, dropPaths[i], 0, keep);
4757
+ }
4442
4758
  }
4443
4759
  }
4444
4760
  /** An object without keys, or an array (of any nesting) holding nothing but such values (what a strip left of a parent). */
@@ -4561,6 +4877,15 @@ function routedController(ctx) {
4561
4877
  return controllerOf(c);
4562
4878
  }
4563
4879
  }
4880
+ /**
4881
+ * `collectQueryPaths`' aggregate flag for a parsed query: `true` when its
4882
+ * `$select` carries computed entries (aggregates, calendar buckets), else
4883
+ * `undefined` (aggregate mode iff `$groupBy`).
4884
+ */
4885
+ function computedMode(parsed) {
4886
+ const select = parsed.controls?.$select;
4887
+ return Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0;
4888
+ }
4564
4889
  /** The 400 of a filter / sort on a `@db.writeOnly` field. */
4565
4890
  function writeOnlyError(path, op) {
4566
4891
  const verdict = writeOnlyVerdict(path, op);
@@ -4601,13 +4926,34 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4601
4926
  * would keep advertising (and gating) the pre-sync answer (since 0.1.132).
4602
4927
  */
4603
4928
  get capabilities() {
4929
+ const readable = this.readable;
4930
+ const geo = readable.isGeoSearchable();
4931
+ const units = readable.calendarBucketUnits();
4932
+ const fns = readable.aggregateFns();
4933
+ const expr = readable.supportsAggregateExpressions();
4604
4934
  const current = this._capabilities;
4605
- if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
4606
- const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet, this._decorations?.visibleOn);
4935
+ const seen = this._capabilityInputs;
4936
+ 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;
4937
+ const inputs = {
4938
+ geo,
4939
+ units,
4940
+ unitsSize: units.size,
4941
+ fns,
4942
+ fnsSize: fns.size,
4943
+ expr
4944
+ };
4945
+ if (current && current.signature === FieldCapabilityIndex.adapterSignature(readable)) {
4946
+ this._capabilityInputs = inputs;
4947
+ return current;
4948
+ }
4949
+ const index = new FieldCapabilityIndex(readable, this._writeOnlySet, this._decorations?.visibleOn);
4607
4950
  this._capabilities = index;
4951
+ this._capabilityInputs = inputs;
4608
4952
  return index;
4609
4953
  }
4610
4954
  _capabilities;
4955
+ /** The adapter answers {@link capabilities} last confirmed its index against (identity fast path). */
4956
+ _capabilityInputs;
4611
4957
  /** The client relational-predicate gate (since 0.1.147), built on first use — see {@link _relationGate}. */
4612
4958
  _relGate;
4613
4959
  /**
@@ -4640,6 +4986,12 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4640
4986
  _derivedSource;
4641
4987
  /** `@db.writeOnly` paths of `$with` target readables, collected once per target. */
4642
4988
  _targetWriteOnly = /* @__PURE__ */ new WeakMap();
4989
+ /** The forced exclusion `$select` per sealed set (see {@link _sealSelect}). */
4990
+ _exclusionBySealed = /* @__PURE__ */ new WeakMap();
4991
+ /** The objects of {@link _exclusionBySealed}. */
4992
+ _forcedExclusions = /* @__PURE__ */ new WeakSet();
4993
+ /** {@link widenPreferredIdProjection} of each forced exclusion. */
4994
+ _widenedExclusions = /* @__PURE__ */ new WeakMap();
4643
4995
  /** Own leaf paths per readable (bound + `$with` targets), see {@link _leavesOf}. */
4644
4996
  _targetLeaves = /* @__PURE__ */ new WeakMap();
4645
4997
  _indexFieldPathsCache;
@@ -4725,7 +5077,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4725
5077
  this._idOpts = scoped ? { isFieldVisible: isVisible } : void 0;
4726
5078
  this._planner = this._decorations && new DecorationPlanner(this._decorations, {
4727
5079
  isVisible,
4728
- scoped,
5080
+ metaVariants: this._metaVariants,
4729
5081
  preferred: this._preferredIdSet,
4730
5082
  capabilities: () => this.capabilities,
4731
5083
  firstVisibleField: () => this._invertibleFields.find((path) => isVisible(path))
@@ -4871,8 +5223,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4871
5223
  checkCapabilities(parsed) {
4872
5224
  const capabilities = this.capabilities;
4873
5225
  const isVisible = this.fieldVisibility.isVisible;
4874
- const select = parsed.controls?.$select;
4875
- const refs = (0, _atscript_db.collectQueryPaths)(parsed, Array.isArray(select) && select.some((item) => typeof item !== "string") || void 0);
5226
+ const shared = this._sharedRefs;
5227
+ const refs = shared?.parsed === parsed ? shared.refs : (0, _atscript_db.collectQueryPaths)(parsed, computedMode(parsed));
4876
5228
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, (0, _atscript_db.unsupportedOperatorMessage)(refs.unsupportedOperator));
4877
5229
  const relState = { nodes: 0 };
4878
5230
  for (const ref of refs.filter) {
@@ -4899,6 +5251,21 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4899
5251
  if (having) return badRequest(having.path, having.message);
4900
5252
  return this.checkGates(parsed) ?? this._checkIndexGate(parsed.controls ?? {});
4901
5253
  }
5254
+ /** {@link checkCapabilities} reusing `refs` (the walk it makes) when the method is not overridden. */
5255
+ _checkCapabilitiesWith(parsed, refs) {
5256
+ if (this.checkCapabilities !== _AsDbReadableController.prototype.checkCapabilities) return this.checkCapabilities(parsed);
5257
+ this._sharedRefs = {
5258
+ parsed,
5259
+ refs
5260
+ };
5261
+ try {
5262
+ return this.checkCapabilities(parsed);
5263
+ } finally {
5264
+ this._sharedRefs = void 0;
5265
+ }
5266
+ }
5267
+ /** The walk of the `parsed` in flight, handed to {@link checkCapabilities} (sync, cleared after). */
5268
+ _sharedRefs;
4902
5269
  /**
4903
5270
  * The core's shared normalizer of `$select` computed entries
4904
5271
  * (`normalizeComputedSelect`) as a 400 with the core's wording and `path`
@@ -5237,15 +5604,22 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5237
5604
  _applyIndexVisibility(meta) {
5238
5605
  if (!this._indexGateActive) return meta;
5239
5606
  const entries = this.indexFieldPaths();
5607
+ const indexVisible = entries.map((e) => this._indexVisible(e));
5608
+ const isSearchable = this.readable.isSearchable();
5609
+ const fallbackVisible = isSearchable ? [] : this._searchFallbackFields.map((f) => this.fieldVisibility.isVisible(f));
5610
+ return metaVariant(this._metaVariants, meta, `i:${bits(indexVisible)}:${isSearchable ? 1 : 0}:${bits(fallbackVisible)}`, () => this._narrowIndexVisibility(meta, entries, indexVisible, isSearchable, fallbackVisible));
5611
+ }
5612
+ /** {@link _applyIndexVisibility} for the given answers. */
5613
+ _narrowIndexVisibility(meta, entries, indexVisible, isSearchable, fallbackVisible) {
5240
5614
  const visibleDefault = (type) => {
5241
- const def = entries.find((e) => e.type === type && e.isDefault);
5242
- return def !== void 0 && this._indexVisible(def);
5615
+ const at = entries.findIndex((e) => e.type === type && e.isDefault);
5616
+ return at >= 0 && indexVisible[at];
5243
5617
  };
5244
5618
  const has = (type) => entries.some((e) => e.type === type);
5245
- const anyVisible = (type) => entries.some((e) => e.type === type && this._indexVisible(e));
5246
- const hidden = new Set(entries.filter((e) => e.type !== "geo" && !this._indexVisible(e)).map((e) => e.name));
5247
- const searchable = this.readable.isSearchable() ? visibleDefault("text") : this._searchFallbackFields.some((f) => this.fieldVisibility.isVisible(f));
5248
- const textUsable = searchable || this.readable.isSearchable() && anyVisible("text");
5619
+ const anyVisible = (type) => entries.some((e, i) => e.type === type && indexVisible[i]);
5620
+ const hidden = new Set(entries.filter((e, i) => e.type !== "geo" && !indexVisible[i]).map((e) => e.name));
5621
+ const searchable = isSearchable ? visibleDefault("text") : fallbackVisible.some(Boolean);
5622
+ const textUsable = searchable || isSearchable && anyVisible("text");
5249
5623
  const vectorUsable = anyVisible("vector");
5250
5624
  const unusable = /* @__PURE__ */ new Set();
5251
5625
  if (has("text") && !anyVisible("text")) unusable.add("index");
@@ -5479,7 +5853,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5479
5853
  const transformed = await this.transformProjection(plan ? plan.select : controls.$select);
5480
5854
  const sealed = this._sealControls(controls, transformed);
5481
5855
  const finish = () => {
5482
- const select = this.widenPreferredIdProjection(sealed.$select);
5856
+ const select = this._widenSealed(sealed.$select);
5483
5857
  if (select instanceof _moostjs_event_http.HttpError) return select;
5484
5858
  let kept;
5485
5859
  const keptPaths = () => kept === void 0 ? kept = this._resolveProjectionForAugmenter(select) : kept;
@@ -5494,6 +5868,23 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5494
5868
  finish
5495
5869
  };
5496
5870
  }
5871
+ /**
5872
+ * {@link widenPreferredIdProjection} of a sealed `$select` — memoized for the
5873
+ * forced exclusion of a read without `$select` (one object per sealed set,
5874
+ * see {@link _sealSelect}): its widening depends on the controller alone.
5875
+ */
5876
+ _widenSealed(select) {
5877
+ if (select === void 0 || !this._forcedExclusions.has(select)) return this.widenPreferredIdProjection(select);
5878
+ let widened = this._widenedExclusions.get(select);
5879
+ if (widened === void 0) {
5880
+ const out = this.widenPreferredIdProjection(select);
5881
+ if (out instanceof _moostjs_event_http.HttpError) return out;
5882
+ if (out !== void 0 && out !== select) freezeShared(out);
5883
+ this._widenedExclusions.set(select, out);
5884
+ widened = out;
5885
+ }
5886
+ return widened;
5887
+ }
5497
5888
  widenPreferredIdProjection(projection) {
5498
5889
  const widened = this.widenQuantityRefProjection(projection);
5499
5890
  if (widened instanceof _moostjs_event_http.HttpError) return widened;
@@ -5821,7 +6212,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5821
6212
  const plain = (q) => this.readable.findMany(q);
5822
6213
  return byIds(plain, ids, void 0, fields);
5823
6214
  }
5824
- return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$2(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
6215
+ const pathsByShape = /* @__PURE__ */ new Map();
6216
+ const pathsOf = (id) => {
6217
+ const keys = Object.keys(id);
6218
+ const shape = keys.join("");
6219
+ let paths = pathsByShape.get(shape);
6220
+ if (!paths) pathsByShape.set(shape, paths = splitPaths(new Set([...fields, ...keys])));
6221
+ return paths;
6222
+ };
6223
+ return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$2(row, pathsOf(ids[i])) : void 0));
5825
6224
  }
5826
6225
  };
5827
6226
  }
@@ -6004,7 +6403,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6004
6403
  sortGate: ids.length > 0 ? void 0 : order,
6005
6404
  routeParams: sameRoute ? void 0 : {}
6006
6405
  });
6007
- return rows.map((row) => projectRow$2(row, sealedSelect));
6406
+ const paths = splitPaths(sealedSelect);
6407
+ return rows.map((row) => projectRow$2(row, paths));
6008
6408
  }
6009
6409
  /**
6010
6410
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
@@ -6048,8 +6448,18 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6048
6448
  _sealSelect(select, writeOnly, readable) {
6049
6449
  if (writeOnly.size === 0) return select;
6050
6450
  const exclusion = () => {
6051
- const out = {};
6052
- for (const f of writeOnly) out[f] = 0;
6451
+ const shared = writeOnly === this._writeOnlySet || writeOnly === this._targetWriteOnly.get(readable);
6452
+ let out = shared ? this._exclusionBySealed.get(writeOnly) : void 0;
6453
+ if (out === void 0) {
6454
+ const built = {};
6455
+ for (const f of writeOnly) built[f] = 0;
6456
+ out = built;
6457
+ if (shared) {
6458
+ freezeShared(out);
6459
+ this._exclusionBySealed.set(writeOnly, out);
6460
+ this._forcedExclusions.add(out);
6461
+ }
6462
+ }
6053
6463
  return out;
6054
6464
  };
6055
6465
  if (select === void 0) return exclusion();
@@ -6321,10 +6731,11 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6321
6731
  const [overlaid, delegated] = await Promise.all([own, this._hasDelegations ? this._delegatedInfos() : []]);
6322
6732
  const meta = this._planner ? this._planner.meta(overlaid) : overlaid;
6323
6733
  const visible = this._applyIndexVisibility(meta);
6324
- return delegated.length > 0 ? {
6734
+ if (delegated.length === 0) return visible;
6735
+ return metaVariant(this._metaVariants, visible, `d:${delegated.map((info) => identityId(info)).join(",")}`, () => ({
6325
6736
  ...visible,
6326
6737
  actions: [...visible.actions, ...delegated]
6327
- } : visible;
6738
+ }));
6328
6739
  })();
6329
6740
  }
6330
6741
  /**
@@ -6590,8 +7001,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6590
7001
  if (groupBy?.length && controls.$vector !== void 0) return new _moostjs_event_http.HttpError(400, "Cannot combine $vector and $groupBy in the same query");
6591
7002
  const error = this.validateParsed(parsed, "query");
6592
7003
  if (error) return error;
7004
+ let gateRefs;
6593
7005
  if (groupBy?.length && this._writeOnlySet.size > 0) {
6594
- const refs = (0, _atscript_db.collectQueryPaths)(parsed, true);
7006
+ gateRefs = (0, _atscript_db.collectQueryPaths)(parsed, computedMode(parsed));
7007
+ const refs = gateRefs.aggregateMode ? gateRefs : (0, _atscript_db.collectQueryPaths)(parsed, true);
6595
7008
  const sealed = [
6596
7009
  ...refs.groupBy,
6597
7010
  ...refs.aggregate,
@@ -6601,7 +7014,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6601
7014
  ].find((path) => this._writeOnlySet.has(path) && this.fieldVisibility.isVisible(path));
6602
7015
  if (sealed) return new _moostjs_event_http.HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
6603
7016
  }
6604
- const gateError = this.checkCapabilities(parsed);
7017
+ const gateError = gateRefs ? this._checkCapabilitiesWith(parsed, gateRefs) : this.checkCapabilities(parsed);
6605
7018
  if (gateError) return gateError;
6606
7019
  const clientFilter = await this._relationOverlay(parsed);
6607
7020
  if (groupBy?.length) {
@@ -6793,14 +7206,15 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6793
7206
  };
6794
7207
  const item = await this.returnOne(this._findRow(resolvedId, overlay, readControls));
6795
7208
  if (item instanceof _moostjs_event_http.HttpError) return item;
6796
- const pending = this._finishRows([item], prep, {
7209
+ const rows = [item];
7210
+ const pending = this._finishRows(rows, prep, {
6797
7211
  endpoint: "one",
6798
7212
  projection: projected.select,
6799
7213
  controls,
6800
7214
  decorations: projected.read?.served ?? NO_DECORATIONS
6801
7215
  }, projected.read);
6802
7216
  if (pending) await pending;
6803
- return item;
7217
+ return rows[0];
6804
7218
  }
6805
7219
  /**
6806
7220
  * **GET /meta/actions/:id** — the row-level actions the caller may run on
@@ -7069,7 +7483,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
7069
7483
  for (const row of rows) if (row) present.push(row);
7070
7484
  const verdicts = envelopes.map((e, i) => {
7071
7485
  const disabled = getCandidate(e)?.disabledFn;
7072
- return disabled && present.length > 0 ? judgeRows(e.info.name, disabled, present.map((row) => projectRow$2(row, fieldsOf[i]))) : void 0;
7486
+ if (!disabled || present.length === 0) return void 0;
7487
+ const paths = splitPaths(fieldsOf[i]);
7488
+ return judgeRows(e.info.name, disabled, present.map((row) => projectRow$2(row, paths)));
7073
7489
  });
7074
7490
  let p = 0;
7075
7491
  return rows.map((row, r) => {
@@ -7877,22 +8293,29 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
7877
8293
  }
7878
8294
  async query(controls) {
7879
8295
  let rows = this.rows;
7880
- if (controls.filter && Object.keys(controls.filter).length > 0) {
7881
- const predicate = (0, _atscript_db_memory.buildMemoryPredicate)(controls.filter);
7882
- rows = rows.filter((row) => predicate(row));
7883
- }
7884
8296
  const search = controls.controls.$search;
7885
8297
  if (search) {
7886
8298
  const needle = search.toLowerCase();
7887
- const fields = this.searchableFields.filter((field) => this.hasField(field));
7888
- rows = rows.filter((row) => {
7889
- for (const field of fields) {
8299
+ const columns = this.searchableFields.filter((field) => this.hasField(field)).map((field) => [field, this._lowercased(field)]);
8300
+ rows = rows.filter((row, i) => {
8301
+ for (const [field, column] of columns) {
7890
8302
  const v = row[field];
7891
- if (typeof v === "string" && v.toLowerCase().includes(needle)) return true;
8303
+ if (typeof v !== "string") continue;
8304
+ let lower = column.lower[i];
8305
+ if (lower === void 0 || column.src[i] !== v) {
8306
+ lower = v.toLowerCase();
8307
+ column.src[i] = v;
8308
+ column.lower[i] = lower;
8309
+ }
8310
+ if (lower.includes(needle)) return true;
7892
8311
  }
7893
8312
  return false;
7894
8313
  });
7895
8314
  }
8315
+ if (controls.filter && Object.keys(controls.filter).length > 0) {
8316
+ const predicate = (0, _atscript_db_memory.buildMemoryPredicate)(controls.filter);
8317
+ rows = rows.filter((row) => predicate(row));
8318
+ }
7896
8319
  const sort = this.normalizeSort(controls.controls.$sort);
7897
8320
  if (sort) rows = (0, _atscript_db_memory.sortRows)(rows, sort);
7898
8321
  const total = rows.length;
@@ -7905,6 +8328,25 @@ let AsJsonValueHelpController = class AsJsonValueHelpController extends AsValueH
7905
8328
  count: total
7906
8329
  };
7907
8330
  }
8331
+ /**
8332
+ * `field`'s lowercased values by position in {@link rows} (the `$search`
8333
+ * haystack). An entry is used only while the row still holds the value it
8334
+ * was lowercased from (`src`), so rows edited in place stay correct.
8335
+ */
8336
+ _lowercased(field) {
8337
+ let haystack = this._haystack;
8338
+ if (haystack?.rows !== this.rows) haystack = this._haystack = {
8339
+ rows: this.rows,
8340
+ byField: /* @__PURE__ */ new Map()
8341
+ };
8342
+ let column = haystack.byField.get(field);
8343
+ if (!column) haystack.byField.set(field, column = {
8344
+ src: [],
8345
+ lower: []
8346
+ });
8347
+ return column;
8348
+ }
8349
+ _haystack;
7908
8350
  async getOne(id) {
7909
8351
  return this._pkIndex?.get(String(id)) ?? null;
7910
8352
  }
@@ -8786,6 +9228,7 @@ exports.resolveBoundReadable = resolveBoundReadable;
8786
9228
  exports.resolveDbSpace = require_db_space_registry.resolveDbSpace;
8787
9229
  exports.resolveProp = resolveProp;
8788
9230
  exports.resolveTerminalRef = resolveTerminalRef;
9231
+ exports.stableMeta = stableMeta;
8789
9232
  exports.unknownRelationError = unknownRelationError;
8790
9233
  exports.useDbActionId = useDbActionId;
8791
9234
  exports.useDbActionIds = useDbActionIds;