@atscript/moost-db 0.1.132 → 0.1.133

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
@@ -1155,16 +1155,12 @@ function resolveBoundReadable(ctor) {
1155
1155
  }
1156
1156
  //#endregion
1157
1157
  //#region src/meta/field-capabilities.ts
1158
- const ADAPTER_FILTER = "adapter cannot filter on this storage type";
1159
- const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER}.`;
1158
+ const REASON_ADAPTER_FILTER = `${_atscript_db.ADAPTER_FILTER_REASON}.`;
1160
1159
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1161
1160
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
1162
- const REASON_ENCRYPTED = "field is @db.encrypted (ciphertext cannot be compared or ordered).";
1161
+ const REASON_ENCRYPTED = `${_atscript_db.ENCRYPTED_REASON}.`;
1163
1162
  const REASON_ANNOTATION_FILTER = "add @db.column.filterable to enable.";
1164
1163
  const REASON_ANNOTATION_SORT = "add @db.column.sortable to enable.";
1165
- const REASON_NOT_TIMESTAMP = "not a timestamp field (declare it number.timestamp).";
1166
- const REASON_NOT_DIMENSION = "not a dimension.";
1167
- const REASON_NO_BUCKETS = "adapter has no calendar buckets.";
1168
1164
  /** Sentence subject per op ("Filtering on field …"). */
1169
1165
  const OP_SUBJECT = {
1170
1166
  filter: "Filtering on",
@@ -1235,14 +1231,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1235
1231
  _entries = /* @__PURE__ */ new Map();
1236
1232
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1237
1233
  _objectParents = /* @__PURE__ */ new Map();
1238
- /**
1239
- * Declared dimensions when the table is strict (declares dimensions or
1240
- * measures), else `undefined` — the core rule: a grouping source, a
1241
- * bucketed field included, must then be a dimension.
1242
- */
1243
- _dimensions;
1244
- /** Paths of every JSON-value descriptor (`isJsonValueField`) — see `jsonValueAncestor`. */
1245
- _jsonValueParents;
1234
+ /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
1235
+ _bucketTable;
1246
1236
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1247
1237
  get leaves() {
1248
1238
  return this._entries;
@@ -1259,7 +1249,6 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1259
1249
  this.signature = FieldCapabilityIndex.adapterSignature(source);
1260
1250
  const units = source.calendarBucketUnits();
1261
1251
  this.bucketUnits = _uniqu_core.BUCKET_UNITS.filter((unit) => units.has(unit));
1262
- this._dimensions = source.dimensions.length > 0 || source.measures.length > 0 ? new Set(source.dimensions) : void 0;
1263
1252
  const physicalNames = /* @__PURE__ */ new Set();
1264
1253
  const jsonValueParents = /* @__PURE__ */ new Set();
1265
1254
  for (const fd of source.fieldDescriptors) {
@@ -1267,7 +1256,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1267
1256
  if ((0, _atscript_db.isJsonValueField)(fd)) jsonValueParents.add(fd.path);
1268
1257
  }
1269
1258
  this.physicalNames = physicalNames;
1270
- this._jsonValueParents = jsonValueParents;
1259
+ this._bucketTable = {
1260
+ jsonValueParents,
1261
+ dimensions: source.dimensions,
1262
+ measures: source.measures
1263
+ };
1271
1264
  const nav = new Set(source.navFields);
1272
1265
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1273
1266
  this.navFields = nav;
@@ -1324,18 +1317,14 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1324
1317
  geo: verdict("geo", true)
1325
1318
  };
1326
1319
  const filterOps = filterBy.compare === REASON_ADAPTER_FILTER && !filterPolicyBlocked ? (0, _atscript_db.narrowerFilterOps)(fd, source) : [];
1327
- if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER}${(0, _atscript_db.acceptedOperatorsHint)(filterOps)}.`;
1320
+ if (filterOps.length > 0) filterBy.compare = `${_atscript_db.ADAPTER_FILTER_REASON}${(0, _atscript_db.acceptedOperatorsHint)(filterOps)}.`;
1328
1321
  const physicalReason = verdict("compare", false);
1329
1322
  let sortReason = source.canSortField(fd) ? void 0 : REASON_ADAPTER_SORT;
1330
1323
  if (fd.encrypted) sortReason = REASON_ENCRYPTED;
1331
1324
  if (isWriteOnly) sortReason = REASON_WRITE_ONLY;
1332
1325
  if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1333
- let bucketReason = physicalReason;
1334
- const jsonAncestor = (0, _atscript_db.jsonValueAncestor)(fd.path, this._jsonValueParents);
1335
- if (!bucketReason && jsonAncestor !== void 0) bucketReason = `inside JSON-stored column "${jsonAncestor}".`;
1336
- if (!bucketReason && !(0, _atscript_db.isBucketableField)(fd)) bucketReason = REASON_NOT_TIMESTAMP;
1337
- if (!bucketReason && this._dimensions && !this._dimensions.has(fd.path)) bucketReason = REASON_NOT_DIMENSION;
1338
- if (!bucketReason && this.bucketUnits.length === 0) bucketReason = REASON_NO_BUCKETS;
1326
+ const bucket = isWriteOnly ? void 0 : (0, _atscript_db.bucketSourceVerdict)(fd, this._bucketTable, source);
1327
+ const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
1339
1328
  const cap = {
1340
1329
  filterable: filterBy.compare === void 0,
1341
1330
  sortable: sortReason === void 0,
@@ -1369,21 +1358,32 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1369
1358
  }
1370
1359
  /**
1371
1360
  * Gate check for one path in one position. Returns `undefined` when the
1372
- * path is accepted. Order: navigation paths first (a nav path "exists" on
1373
- * the target table but is never a column here), then a listed leaf's
1374
- * capability (no existence lookup needed — every listed leaf is a real
1375
- * field), then `exists` (the readable's `isValidFieldPath`) and, for paths
1376
- * that exist but are not leaves, the storage classification.
1361
+ * path is accepted.
1362
+ *
1363
+ * `exists` runs FIRST, for every path (since 0.1.133): it is the
1364
+ * controller's `hasField`, the visibility hook subclasses narrow per
1365
+ * request (e.g. a projection-scoped viewer). A path it rejects answers
1366
+ * `Unknown field "x"` exactly like a nonexistent one — never a capability
1367
+ * or navigation hint, which would reveal the field and let a filter or
1368
+ * sort on it act as a value oracle. Before 0.1.133 listed leaves and
1369
+ * navigation paths skipped it.
1377
1370
  *
1378
- * Existence deliberately runs BEFORE the JSON / encrypted classification:
1379
- * an untyped descendant of a JSON column (`address.nope`) is reported as
1380
- * `Unknown field`, not as "inside JSON-stored column" — clients pin that
1381
- * wording, so do not "align" it with the core backstop's text.
1371
+ * Then: navigation paths (a nav path exists on the target table but is
1372
+ * never a column here), a listed leaf's capability, and for other paths
1373
+ * the storage classification. Existence also runs BEFORE the JSON /
1374
+ * encrypted classification: an untyped descendant of a JSON column
1375
+ * (`address.nope`) is reported as `Unknown field`, not as "inside
1376
+ * JSON-stored column" — clients pin that wording, so do not "align" it
1377
+ * with the core backstop's text.
1382
1378
  *
1383
1379
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1384
1380
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1385
1381
  */
1386
1382
  check(path, op, exists, predicate = "compare") {
1383
+ if (!exists(path)) return {
1384
+ path,
1385
+ message: `Unknown field "${path}"`
1386
+ };
1387
1387
  const { kind, parent } = (0, _atscript_db.classifyQueryPath)(this, path);
1388
1388
  if (kind === "nav") {
1389
1389
  if (parent === void 0) return {
@@ -1423,10 +1423,6 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1423
1423
  };
1424
1424
  }
1425
1425
  }
1426
- if (!exists(path)) return {
1427
- path,
1428
- message: `Unknown field "${path}"`
1429
- };
1430
1426
  switch (kind) {
1431
1427
  case "objectParent":
1432
1428
  if (op === "select") return void 0;
@@ -1537,7 +1533,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1537
1533
  metaCacheKey() {
1538
1534
  return this.capabilities;
1539
1535
  }
1540
- /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1536
+ /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1541
1537
  _exists = (path) => this.hasField(path);
1542
1538
  _preferredIdSet;
1543
1539
  _overlayIsNoOp;
@@ -1590,6 +1586,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1590
1586
  for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
1591
1587
  return out;
1592
1588
  }
1589
+ /**
1590
+ * THE field-visibility hook: every gated path consults it before any
1591
+ * capability check (since 0.1.133) — filter keys (inside `$and` / `$or` /
1592
+ * `$not`, existence predicates included), `$sort`, `$select`,
1593
+ * `$groupBy`, `$having` keys, aggregate and calendar-bucket `$field`s,
1594
+ * `$with` relation names and sub-query paths, and the `$search` fallback
1595
+ * fields. A path it rejects is answered exactly like a nonexistent one
1596
+ * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
1597
+ * fields per request (read scopes). The default accepts every real path
1598
+ * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1599
+ * there with `applyMetaOverlay`. Native text search and vector search
1600
+ * (`$vector` names an index) run inside the engine over its indexes, out of
1601
+ * this hook's reach — keep hidden fields out of those indexes.
1602
+ */
1593
1603
  hasField(path) {
1594
1604
  if (typeof this.readable.isValidFieldPath === "function") return this.readable.isValidFieldPath(path);
1595
1605
  return this.readable.flatMap.has(path);
@@ -1677,7 +1687,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1677
1687
  const withRelations = parsed.controls.$with;
1678
1688
  if (withRelations?.length) {
1679
1689
  const relations = this.readable.relations;
1680
- for (const rel of withRelations) if (!rel.name.includes(".") && !relations.has(rel.name)) return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
1690
+ for (const rel of withRelations) {
1691
+ const dot = rel.name.indexOf(".");
1692
+ if (!(dot === -1 ? relations.has(rel.name) && this.hasField(rel.name) : this.hasField(rel.name.slice(0, dot)))) {
1693
+ const visible = [...relations.keys()].filter((name) => this.hasField(name));
1694
+ return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${visible.join(", ") || "(none)"}`);
1695
+ }
1696
+ }
1681
1697
  }
1682
1698
  }
1683
1699
  /**
@@ -1914,10 +1930,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1914
1930
  /** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
1915
1931
  _findWriteOnlyInAggregate(groupBy, select) {
1916
1932
  if (this._writeOnlySet.size === 0) return void 0;
1917
- for (const f of groupBy) if (this._writeOnlySet.has(f)) return f;
1933
+ const sealed = (f) => this._writeOnlySet.has(f) && this.hasField(f);
1934
+ for (const f of groupBy) if (sealed(f)) return f;
1918
1935
  if (Array.isArray(select)) for (const item of select) {
1919
1936
  const field = typeof item === "string" ? item : item.$field;
1920
- if (field && this._writeOnlySet.has(field)) return field;
1937
+ if (field && sealed(field)) return field;
1921
1938
  }
1922
1939
  }
1923
1940
  /**
@@ -1929,9 +1946,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1929
1946
  applySearchFallback(filter, controls) {
1930
1947
  const term = controls.$search;
1931
1948
  if (!term || controls.$vector !== void 0) return filter;
1932
- if (this.readable.isSearchable() || this._searchFallbackFields.length === 0) return filter;
1949
+ if (this.readable.isSearchable()) return filter;
1950
+ const fields = this._searchFallbackFields.filter((f) => this.hasField(f));
1951
+ if (fields.length === 0) return filter;
1933
1952
  const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
1934
- const fragment = { $or: this._searchFallbackFields.map((f) => ({ [f]: { $regex: rx } })) };
1953
+ const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
1935
1954
  return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
1936
1955
  }
1937
1956
  /**
package/dist/index.d.cts CHANGED
@@ -22,7 +22,9 @@ import { parseUrl } from "@uniqu/url";
22
22
  *
23
23
  * Subclass responsibilities:
24
24
  * - Pass the bound interface + logical name + (optional) kind tag through super().
25
- * - Implement {@link hasField} so insights validation can reject unknown keys.
25
+ * - Implement {@link hasField} — the field-visibility hook every validated
26
+ * path consults; a path it rejects gets the same `Unknown field` 400 as a
27
+ * nonexistent one, so overriding it hides fields per request.
26
28
  * - Register the `/query`, `/pages`, `/one(/:id)` routes with the concrete
27
29
  * handlers that match the data source's contract (DB readables route into
28
30
  * aggregate/vector/search; value-help controllers just filter/sort/paginate).
@@ -45,7 +47,11 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
45
47
  /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
46
48
  private _formSchemas;
47
49
  constructor(boundType: T, controllerName: string, app: Moost, kindTag?: string);
48
- /** Subclass contract: return `true` if `path` addresses a valid field on the bound source. */
50
+ /**
51
+ * Subclass contract: return `true` if `path` addresses a field that exists
52
+ * AND is visible to the current request — see the DB controller's override
53
+ * for the full list of positions that consult it.
54
+ */
49
55
  protected abstract hasField(path: string): boolean;
50
56
  /** Sets @db.http.path on the type metadata from the controller's computed prefix. */
51
57
  private _resolveHttpPath;
@@ -195,9 +201,9 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
195
201
  * `filterable` is the value-comparison verdict, `filterOps` the narrower
196
202
  * predicates that still pass where it is `false`.
197
203
  *
198
- * A calendar-bucket source (`bucketable`) needs the physical capability, a
199
- * `number.timestamp` type, dimension status on a strict (dimension / measure
200
- * declaring) table, and an adapter with calendar-bucket units.
204
+ * A calendar-bucket source (`bucketable`) is the core's `bucketSourceVerdict`
205
+ * (the same function the core path guard runs) under the HTTP-only
206
+ * `@db.writeOnly` veto.
201
207
  */
202
208
  interface TFieldCapability {
203
209
  /** A value-comparison filter on this path passes the gate (adapter ∧ ¬writeOnly ∧ ¬encrypted ∧ policy). */
@@ -215,8 +221,8 @@ interface TFieldCapability {
215
221
  /** Present when `sortable` is `false` — the reason clause appended to the HTTP 400 message. */
216
222
  sortReason?: string;
217
223
  /**
218
- * A calendar bucket over this path passes the gate (physical ∧ timestamp ∧
219
- * (¬strict ∨ dimension) ∧ adapter has calendar buckets). Since 0.1.132.
224
+ * A calendar bucket over this path passes the gate (¬writeOnly ∧ the core's
225
+ * `bucketSourceVerdict`). Since 0.1.132.
220
226
  */
221
227
  bucketable: boolean;
222
228
  /** Present when `bucketable` is `false` — the reason clause appended to the HTTP 400 message. */
@@ -272,14 +278,8 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
272
278
  private readonly _entries;
273
279
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
274
280
  private readonly _objectParents;
275
- /**
276
- * Declared dimensions when the table is strict (declares dimensions or
277
- * measures), else `undefined` — the core rule: a grouping source, a
278
- * bucketed field included, must then be a dimension.
279
- */
280
- private readonly _dimensions;
281
- /** Paths of every JSON-value descriptor (`isJsonValueField`) — see `jsonValueAncestor`. */
282
- private readonly _jsonValueParents;
281
+ /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
282
+ private readonly _bucketTable;
283
283
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
284
284
  get leaves(): ReadonlyMap<string, unknown>;
285
285
  /** Nested-object parents — the {@link TQueryPathSource} view for `classifyQueryPath`. */
@@ -292,16 +292,23 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
292
292
  isPhysicallyFilterable(path: string): boolean;
293
293
  /**
294
294
  * Gate check for one path in one position. Returns `undefined` when the
295
- * path is accepted. Order: navigation paths first (a nav path "exists" on
296
- * the target table but is never a column here), then a listed leaf's
297
- * capability (no existence lookup needed — every listed leaf is a real
298
- * field), then `exists` (the readable's `isValidFieldPath`) and, for paths
299
- * that exist but are not leaves, the storage classification.
295
+ * path is accepted.
300
296
  *
301
- * Existence deliberately runs BEFORE the JSON / encrypted classification:
302
- * an untyped descendant of a JSON column (`address.nope`) is reported as
303
- * `Unknown field`, not as "inside JSON-stored column" — clients pin that
304
- * wording, so do not "align" it with the core backstop's text.
297
+ * `exists` runs FIRST, for every path (since 0.1.133): it is the
298
+ * controller's `hasField`, the visibility hook subclasses narrow per
299
+ * request (e.g. a projection-scoped viewer). A path it rejects answers
300
+ * `Unknown field "x"` exactly like a nonexistent one — never a capability
301
+ * or navigation hint, which would reveal the field and let a filter or
302
+ * sort on it act as a value oracle. Before 0.1.133 listed leaves and
303
+ * navigation paths skipped it.
304
+ *
305
+ * Then: navigation paths (a nav path exists on the target table but is
306
+ * never a column here), a listed leaf's capability, and for other paths
307
+ * the storage classification. Existence also runs BEFORE the JSON /
308
+ * encrypted classification: an untyped descendant of a JSON column
309
+ * (`address.nope`) is reported as `Unknown field`, not as "inside
310
+ * JSON-stored column" — clients pin that wording, so do not "align" it
311
+ * with the core backstop's text.
305
312
  *
306
313
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
307
314
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
@@ -343,7 +350,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
343
350
  private _capabilities?;
344
351
  /** `/meta` is a projection of {@link capabilities}: a rebuilt index rebuilds the cached envelope. */
345
352
  protected metaCacheKey(): unknown;
346
- /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
353
+ /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
347
354
  private readonly _exists;
348
355
  private readonly _preferredIdSet;
349
356
  private readonly _overlayIsNoOp;
@@ -363,6 +370,20 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
363
370
  private _collectInvertibleFields;
364
371
  private _collectQuantityRefs;
365
372
  private _collectAnnotated;
373
+ /**
374
+ * THE field-visibility hook: every gated path consults it before any
375
+ * capability check (since 0.1.133) — filter keys (inside `$and` / `$or` /
376
+ * `$not`, existence predicates included), `$sort`, `$select`,
377
+ * `$groupBy`, `$having` keys, aggregate and calendar-bucket `$field`s,
378
+ * `$with` relation names and sub-query paths, and the `$search` fallback
379
+ * fields. A path it rejects is answered exactly like a nonexistent one
380
+ * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
381
+ * fields per request (read scopes). The default accepts every real path
382
+ * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
383
+ * there with `applyMetaOverlay`. Native text search and vector search
384
+ * (`$vector` names an index) run inside the engine over its indexes, out of
385
+ * this hook's reach — keep hidden fields out of those indexes.
386
+ */
366
387
  protected hasField(path: string): boolean;
367
388
  /**
368
389
  * Structural capability gate (since 0.1.128): walks the PARSED query —
package/dist/index.d.mts CHANGED
@@ -22,7 +22,9 @@ import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, FilterExpr, FlatOf, TC
22
22
  *
23
23
  * Subclass responsibilities:
24
24
  * - Pass the bound interface + logical name + (optional) kind tag through super().
25
- * - Implement {@link hasField} so insights validation can reject unknown keys.
25
+ * - Implement {@link hasField} — the field-visibility hook every validated
26
+ * path consults; a path it rejects gets the same `Unknown field` 400 as a
27
+ * nonexistent one, so overriding it hides fields per request.
26
28
  * - Register the `/query`, `/pages`, `/one(/:id)` routes with the concrete
27
29
  * handlers that match the data source's contract (DB readables route into
28
30
  * aggregate/vector/search; value-help controllers just filter/sort/paginate).
@@ -45,7 +47,11 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
45
47
  /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
46
48
  private _formSchemas;
47
49
  constructor(boundType: T, controllerName: string, app: Moost, kindTag?: string);
48
- /** Subclass contract: return `true` if `path` addresses a valid field on the bound source. */
50
+ /**
51
+ * Subclass contract: return `true` if `path` addresses a field that exists
52
+ * AND is visible to the current request — see the DB controller's override
53
+ * for the full list of positions that consult it.
54
+ */
49
55
  protected abstract hasField(path: string): boolean;
50
56
  /** Sets @db.http.path on the type metadata from the controller's computed prefix. */
51
57
  private _resolveHttpPath;
@@ -195,9 +201,9 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
195
201
  * `filterable` is the value-comparison verdict, `filterOps` the narrower
196
202
  * predicates that still pass where it is `false`.
197
203
  *
198
- * A calendar-bucket source (`bucketable`) needs the physical capability, a
199
- * `number.timestamp` type, dimension status on a strict (dimension / measure
200
- * declaring) table, and an adapter with calendar-bucket units.
204
+ * A calendar-bucket source (`bucketable`) is the core's `bucketSourceVerdict`
205
+ * (the same function the core path guard runs) under the HTTP-only
206
+ * `@db.writeOnly` veto.
201
207
  */
202
208
  interface TFieldCapability {
203
209
  /** A value-comparison filter on this path passes the gate (adapter ∧ ¬writeOnly ∧ ¬encrypted ∧ policy). */
@@ -215,8 +221,8 @@ interface TFieldCapability {
215
221
  /** Present when `sortable` is `false` — the reason clause appended to the HTTP 400 message. */
216
222
  sortReason?: string;
217
223
  /**
218
- * A calendar bucket over this path passes the gate (physical ∧ timestamp ∧
219
- * (¬strict ∨ dimension) ∧ adapter has calendar buckets). Since 0.1.132.
224
+ * A calendar bucket over this path passes the gate (¬writeOnly ∧ the core's
225
+ * `bucketSourceVerdict`). Since 0.1.132.
220
226
  */
221
227
  bucketable: boolean;
222
228
  /** Present when `bucketable` is `false` — the reason clause appended to the HTTP 400 message. */
@@ -272,14 +278,8 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
272
278
  private readonly _entries;
273
279
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
274
280
  private readonly _objectParents;
275
- /**
276
- * Declared dimensions when the table is strict (declares dimensions or
277
- * measures), else `undefined` — the core rule: a grouping source, a
278
- * bucketed field included, must then be a dimension.
279
- */
280
- private readonly _dimensions;
281
- /** Paths of every JSON-value descriptor (`isJsonValueField`) — see `jsonValueAncestor`. */
282
- private readonly _jsonValueParents;
281
+ /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
282
+ private readonly _bucketTable;
283
283
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
284
284
  get leaves(): ReadonlyMap<string, unknown>;
285
285
  /** Nested-object parents — the {@link TQueryPathSource} view for `classifyQueryPath`. */
@@ -292,16 +292,23 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
292
292
  isPhysicallyFilterable(path: string): boolean;
293
293
  /**
294
294
  * Gate check for one path in one position. Returns `undefined` when the
295
- * path is accepted. Order: navigation paths first (a nav path "exists" on
296
- * the target table but is never a column here), then a listed leaf's
297
- * capability (no existence lookup needed — every listed leaf is a real
298
- * field), then `exists` (the readable's `isValidFieldPath`) and, for paths
299
- * that exist but are not leaves, the storage classification.
295
+ * path is accepted.
300
296
  *
301
- * Existence deliberately runs BEFORE the JSON / encrypted classification:
302
- * an untyped descendant of a JSON column (`address.nope`) is reported as
303
- * `Unknown field`, not as "inside JSON-stored column" — clients pin that
304
- * wording, so do not "align" it with the core backstop's text.
297
+ * `exists` runs FIRST, for every path (since 0.1.133): it is the
298
+ * controller's `hasField`, the visibility hook subclasses narrow per
299
+ * request (e.g. a projection-scoped viewer). A path it rejects answers
300
+ * `Unknown field "x"` exactly like a nonexistent one — never a capability
301
+ * or navigation hint, which would reveal the field and let a filter or
302
+ * sort on it act as a value oracle. Before 0.1.133 listed leaves and
303
+ * navigation paths skipped it.
304
+ *
305
+ * Then: navigation paths (a nav path exists on the target table but is
306
+ * never a column here), a listed leaf's capability, and for other paths
307
+ * the storage classification. Existence also runs BEFORE the JSON /
308
+ * encrypted classification: an untyped descendant of a JSON column
309
+ * (`address.nope`) is reported as `Unknown field`, not as "inside
310
+ * JSON-stored column" — clients pin that wording, so do not "align" it
311
+ * with the core backstop's text.
305
312
  *
306
313
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
307
314
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
@@ -343,7 +350,7 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
343
350
  private _capabilities?;
344
351
  /** `/meta` is a projection of {@link capabilities}: a rebuilt index rebuilds the cached envelope. */
345
352
  protected metaCacheKey(): unknown;
346
- /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
353
+ /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
347
354
  private readonly _exists;
348
355
  private readonly _preferredIdSet;
349
356
  private readonly _overlayIsNoOp;
@@ -363,6 +370,20 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
363
370
  private _collectInvertibleFields;
364
371
  private _collectQuantityRefs;
365
372
  private _collectAnnotated;
373
+ /**
374
+ * THE field-visibility hook: every gated path consults it before any
375
+ * capability check (since 0.1.133) — filter keys (inside `$and` / `$or` /
376
+ * `$not`, existence predicates included), `$sort`, `$select`,
377
+ * `$groupBy`, `$having` keys, aggregate and calendar-bucket `$field`s,
378
+ * `$with` relation names and sub-query paths, and the `$search` fallback
379
+ * fields. A path it rejects is answered exactly like a nonexistent one
380
+ * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
381
+ * fields per request (read scopes). The default accepts every real path
382
+ * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
383
+ * there with `applyMetaOverlay`. Native text search and vector search
384
+ * (`$vector` names an index) run inside the engine over its indexes, out of
385
+ * this hook's reach — keep hidden fields out of those indexes.
386
+ */
366
387
  protected hasField(path: string): boolean;
367
388
  /**
368
389
  * Structural capability gate (since 0.1.128): walks the PARSED query —
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotate
3
3
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
4
4
  import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Provide, Resolve, TInterceptorPriority, defineBeforeInterceptor, defineInterceptor, getMoostMate, useControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { DbError, acceptedOperatorsHint, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isBucketableField, isJsonValueField, isPlainObject, jsonValueAncestor, narrowerFilterOps, reconcileCas, resolveCalendarBuckets, unsupportedOperatorMessage } from "@atscript/db";
6
+ import { ADAPTER_FILTER_REASON, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isJsonValueField, isPlainObject, narrowerFilterOps, reconcileCas, resolveCalendarBuckets, unsupportedOperatorMessage } from "@atscript/db";
7
7
  import { BUCKET_UNITS } from "@uniqu/core";
8
8
  import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
9
9
  import { cached, current, defineWook, key } from "@wooksjs/event-core";
@@ -1154,16 +1154,12 @@ function resolveBoundReadable(ctor) {
1154
1154
  }
1155
1155
  //#endregion
1156
1156
  //#region src/meta/field-capabilities.ts
1157
- const ADAPTER_FILTER = "adapter cannot filter on this storage type";
1158
- const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER}.`;
1157
+ const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER_REASON}.`;
1159
1158
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1160
1159
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
1161
- const REASON_ENCRYPTED = "field is @db.encrypted (ciphertext cannot be compared or ordered).";
1160
+ const REASON_ENCRYPTED = `${ENCRYPTED_REASON}.`;
1162
1161
  const REASON_ANNOTATION_FILTER = "add @db.column.filterable to enable.";
1163
1162
  const REASON_ANNOTATION_SORT = "add @db.column.sortable to enable.";
1164
- const REASON_NOT_TIMESTAMP = "not a timestamp field (declare it number.timestamp).";
1165
- const REASON_NOT_DIMENSION = "not a dimension.";
1166
- const REASON_NO_BUCKETS = "adapter has no calendar buckets.";
1167
1163
  /** Sentence subject per op ("Filtering on field …"). */
1168
1164
  const OP_SUBJECT = {
1169
1165
  filter: "Filtering on",
@@ -1234,14 +1230,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1234
1230
  _entries = /* @__PURE__ */ new Map();
1235
1231
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1236
1232
  _objectParents = /* @__PURE__ */ new Map();
1237
- /**
1238
- * Declared dimensions when the table is strict (declares dimensions or
1239
- * measures), else `undefined` — the core rule: a grouping source, a
1240
- * bucketed field included, must then be a dimension.
1241
- */
1242
- _dimensions;
1243
- /** Paths of every JSON-value descriptor (`isJsonValueField`) — see `jsonValueAncestor`. */
1244
- _jsonValueParents;
1233
+ /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
1234
+ _bucketTable;
1245
1235
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1246
1236
  get leaves() {
1247
1237
  return this._entries;
@@ -1258,7 +1248,6 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1258
1248
  this.signature = FieldCapabilityIndex.adapterSignature(source);
1259
1249
  const units = source.calendarBucketUnits();
1260
1250
  this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
1261
- this._dimensions = source.dimensions.length > 0 || source.measures.length > 0 ? new Set(source.dimensions) : void 0;
1262
1251
  const physicalNames = /* @__PURE__ */ new Set();
1263
1252
  const jsonValueParents = /* @__PURE__ */ new Set();
1264
1253
  for (const fd of source.fieldDescriptors) {
@@ -1266,7 +1255,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1266
1255
  if (isJsonValueField(fd)) jsonValueParents.add(fd.path);
1267
1256
  }
1268
1257
  this.physicalNames = physicalNames;
1269
- this._jsonValueParents = jsonValueParents;
1258
+ this._bucketTable = {
1259
+ jsonValueParents,
1260
+ dimensions: source.dimensions,
1261
+ measures: source.measures
1262
+ };
1270
1263
  const nav = new Set(source.navFields);
1271
1264
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1272
1265
  this.navFields = nav;
@@ -1323,18 +1316,14 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1323
1316
  geo: verdict("geo", true)
1324
1317
  };
1325
1318
  const filterOps = filterBy.compare === REASON_ADAPTER_FILTER && !filterPolicyBlocked ? narrowerFilterOps(fd, source) : [];
1326
- if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER}${acceptedOperatorsHint(filterOps)}.`;
1319
+ if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER_REASON}${acceptedOperatorsHint(filterOps)}.`;
1327
1320
  const physicalReason = verdict("compare", false);
1328
1321
  let sortReason = source.canSortField(fd) ? void 0 : REASON_ADAPTER_SORT;
1329
1322
  if (fd.encrypted) sortReason = REASON_ENCRYPTED;
1330
1323
  if (isWriteOnly) sortReason = REASON_WRITE_ONLY;
1331
1324
  if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1332
- let bucketReason = physicalReason;
1333
- const jsonAncestor = jsonValueAncestor(fd.path, this._jsonValueParents);
1334
- if (!bucketReason && jsonAncestor !== void 0) bucketReason = `inside JSON-stored column "${jsonAncestor}".`;
1335
- if (!bucketReason && !isBucketableField(fd)) bucketReason = REASON_NOT_TIMESTAMP;
1336
- if (!bucketReason && this._dimensions && !this._dimensions.has(fd.path)) bucketReason = REASON_NOT_DIMENSION;
1337
- if (!bucketReason && this.bucketUnits.length === 0) bucketReason = REASON_NO_BUCKETS;
1325
+ const bucket = isWriteOnly ? void 0 : bucketSourceVerdict(fd, this._bucketTable, source);
1326
+ const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
1338
1327
  const cap = {
1339
1328
  filterable: filterBy.compare === void 0,
1340
1329
  sortable: sortReason === void 0,
@@ -1368,21 +1357,32 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1368
1357
  }
1369
1358
  /**
1370
1359
  * Gate check for one path in one position. Returns `undefined` when the
1371
- * path is accepted. Order: navigation paths first (a nav path "exists" on
1372
- * the target table but is never a column here), then a listed leaf's
1373
- * capability (no existence lookup needed — every listed leaf is a real
1374
- * field), then `exists` (the readable's `isValidFieldPath`) and, for paths
1375
- * that exist but are not leaves, the storage classification.
1360
+ * path is accepted.
1361
+ *
1362
+ * `exists` runs FIRST, for every path (since 0.1.133): it is the
1363
+ * controller's `hasField`, the visibility hook subclasses narrow per
1364
+ * request (e.g. a projection-scoped viewer). A path it rejects answers
1365
+ * `Unknown field "x"` exactly like a nonexistent one — never a capability
1366
+ * or navigation hint, which would reveal the field and let a filter or
1367
+ * sort on it act as a value oracle. Before 0.1.133 listed leaves and
1368
+ * navigation paths skipped it.
1376
1369
  *
1377
- * Existence deliberately runs BEFORE the JSON / encrypted classification:
1378
- * an untyped descendant of a JSON column (`address.nope`) is reported as
1379
- * `Unknown field`, not as "inside JSON-stored column" — clients pin that
1380
- * wording, so do not "align" it with the core backstop's text.
1370
+ * Then: navigation paths (a nav path exists on the target table but is
1371
+ * never a column here), a listed leaf's capability, and for other paths
1372
+ * the storage classification. Existence also runs BEFORE the JSON /
1373
+ * encrypted classification: an untyped descendant of a JSON column
1374
+ * (`address.nope`) is reported as `Unknown field`, not as "inside
1375
+ * JSON-stored column" — clients pin that wording, so do not "align" it
1376
+ * with the core backstop's text.
1381
1377
  *
1382
1378
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1383
1379
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1384
1380
  */
1385
1381
  check(path, op, exists, predicate = "compare") {
1382
+ if (!exists(path)) return {
1383
+ path,
1384
+ message: `Unknown field "${path}"`
1385
+ };
1386
1386
  const { kind, parent } = classifyQueryPath(this, path);
1387
1387
  if (kind === "nav") {
1388
1388
  if (parent === void 0) return {
@@ -1422,10 +1422,6 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1422
1422
  };
1423
1423
  }
1424
1424
  }
1425
- if (!exists(path)) return {
1426
- path,
1427
- message: `Unknown field "${path}"`
1428
- };
1429
1425
  switch (kind) {
1430
1426
  case "objectParent":
1431
1427
  if (op === "select") return void 0;
@@ -1536,7 +1532,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1536
1532
  metaCacheKey() {
1537
1533
  return this.capabilities;
1538
1534
  }
1539
- /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1535
+ /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1540
1536
  _exists = (path) => this.hasField(path);
1541
1537
  _preferredIdSet;
1542
1538
  _overlayIsNoOp;
@@ -1589,6 +1585,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1589
1585
  for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
1590
1586
  return out;
1591
1587
  }
1588
+ /**
1589
+ * THE field-visibility hook: every gated path consults it before any
1590
+ * capability check (since 0.1.133) — filter keys (inside `$and` / `$or` /
1591
+ * `$not`, existence predicates included), `$sort`, `$select`,
1592
+ * `$groupBy`, `$having` keys, aggregate and calendar-bucket `$field`s,
1593
+ * `$with` relation names and sub-query paths, and the `$search` fallback
1594
+ * fields. A path it rejects is answered exactly like a nonexistent one
1595
+ * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
1596
+ * fields per request (read scopes). The default accepts every real path
1597
+ * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1598
+ * there with `applyMetaOverlay`. Native text search and vector search
1599
+ * (`$vector` names an index) run inside the engine over its indexes, out of
1600
+ * this hook's reach — keep hidden fields out of those indexes.
1601
+ */
1592
1602
  hasField(path) {
1593
1603
  if (typeof this.readable.isValidFieldPath === "function") return this.readable.isValidFieldPath(path);
1594
1604
  return this.readable.flatMap.has(path);
@@ -1676,7 +1686,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1676
1686
  const withRelations = parsed.controls.$with;
1677
1687
  if (withRelations?.length) {
1678
1688
  const relations = this.readable.relations;
1679
- for (const rel of withRelations) if (!rel.name.includes(".") && !relations.has(rel.name)) return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
1689
+ for (const rel of withRelations) {
1690
+ const dot = rel.name.indexOf(".");
1691
+ if (!(dot === -1 ? relations.has(rel.name) && this.hasField(rel.name) : this.hasField(rel.name.slice(0, dot)))) {
1692
+ const visible = [...relations.keys()].filter((name) => this.hasField(name));
1693
+ return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${visible.join(", ") || "(none)"}`);
1694
+ }
1695
+ }
1680
1696
  }
1681
1697
  }
1682
1698
  /**
@@ -1913,10 +1929,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1913
1929
  /** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
1914
1930
  _findWriteOnlyInAggregate(groupBy, select) {
1915
1931
  if (this._writeOnlySet.size === 0) return void 0;
1916
- for (const f of groupBy) if (this._writeOnlySet.has(f)) return f;
1932
+ const sealed = (f) => this._writeOnlySet.has(f) && this.hasField(f);
1933
+ for (const f of groupBy) if (sealed(f)) return f;
1917
1934
  if (Array.isArray(select)) for (const item of select) {
1918
1935
  const field = typeof item === "string" ? item : item.$field;
1919
- if (field && this._writeOnlySet.has(field)) return field;
1936
+ if (field && sealed(field)) return field;
1920
1937
  }
1921
1938
  }
1922
1939
  /**
@@ -1928,9 +1945,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1928
1945
  applySearchFallback(filter, controls) {
1929
1946
  const term = controls.$search;
1930
1947
  if (!term || controls.$vector !== void 0) return filter;
1931
- if (this.readable.isSearchable() || this._searchFallbackFields.length === 0) return filter;
1948
+ if (this.readable.isSearchable()) return filter;
1949
+ const fields = this._searchFallbackFields.filter((f) => this.hasField(f));
1950
+ if (fields.length === 0) return filter;
1932
1951
  const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
1933
- const fragment = { $or: this._searchFallbackFields.map((f) => ({ [f]: { $regex: rx } })) };
1952
+ const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
1934
1953
  return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
1935
1954
  }
1936
1955
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.132",
3
+ "version": "0.1.133",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -43,14 +43,14 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@uniqu/url": "^0.1.9",
47
- "@atscript/db-memory": "^0.1.132"
46
+ "@uniqu/url": "^0.1.10",
47
+ "@atscript/db-memory": "^0.1.133"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@atscript/core": "^0.1.92",
51
51
  "@atscript/typescript": "^0.1.92",
52
52
  "@moostjs/event-http": "^0.6.37",
53
- "@uniqu/core": "^0.1.9",
53
+ "@uniqu/core": "^0.1.10",
54
54
  "@wooksjs/event-core": "^0.7.23",
55
55
  "@wooksjs/event-http": "^0.7.23",
56
56
  "@wooksjs/http-body": "^0.7.23",
@@ -60,11 +60,11 @@
60
60
  "peerDependencies": {
61
61
  "@atscript/typescript": "^0.1.92",
62
62
  "@moostjs/event-http": "^0.6.37",
63
- "@uniqu/core": "^0.1.9",
63
+ "@uniqu/core": "^0.1.10",
64
64
  "@wooksjs/event-core": "^0.7.23",
65
65
  "@wooksjs/http-body": "^0.7.23",
66
66
  "moost": "^0.6.37",
67
- "@atscript/db": "^0.1.132"
67
+ "@atscript/db": "^0.1.133"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",