@atscript/moost-db 0.1.132 → 0.1.134

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -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",
@@ -1184,6 +1180,13 @@ const OP_VERB = {
1184
1180
  aggregate: "aggregate over",
1185
1181
  bucket: "bucket"
1186
1182
  };
1183
+ /** The one "nonexistent path" verdict — hidden paths answer with it byte for byte. */
1184
+ function unknownField(path) {
1185
+ return {
1186
+ path,
1187
+ message: `Unknown field "${path}"`
1188
+ };
1189
+ }
1187
1190
  function leafHint(leaves) {
1188
1191
  if (leaves.length === 0) return "no leaf fields";
1189
1192
  const shown = leaves.slice(0, 5).join(", ");
@@ -1234,14 +1237,8 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1234
1237
  _entries = /* @__PURE__ */ new Map();
1235
1238
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1236
1239
  _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;
1240
+ /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
1241
+ _bucketTable;
1245
1242
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1246
1243
  get leaves() {
1247
1244
  return this._entries;
@@ -1258,7 +1255,6 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1258
1255
  this.signature = FieldCapabilityIndex.adapterSignature(source);
1259
1256
  const units = source.calendarBucketUnits();
1260
1257
  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
1258
  const physicalNames = /* @__PURE__ */ new Set();
1263
1259
  const jsonValueParents = /* @__PURE__ */ new Set();
1264
1260
  for (const fd of source.fieldDescriptors) {
@@ -1266,7 +1262,11 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1266
1262
  if (isJsonValueField(fd)) jsonValueParents.add(fd.path);
1267
1263
  }
1268
1264
  this.physicalNames = physicalNames;
1269
- this._jsonValueParents = jsonValueParents;
1265
+ this._bucketTable = {
1266
+ jsonValueParents,
1267
+ dimensions: source.dimensions,
1268
+ measures: source.measures
1269
+ };
1270
1270
  const nav = new Set(source.navFields);
1271
1271
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1272
1272
  this.navFields = nav;
@@ -1323,18 +1323,14 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1323
1323
  geo: verdict("geo", true)
1324
1324
  };
1325
1325
  const filterOps = filterBy.compare === REASON_ADAPTER_FILTER && !filterPolicyBlocked ? narrowerFilterOps(fd, source) : [];
1326
- if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER}${acceptedOperatorsHint(filterOps)}.`;
1326
+ if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER_REASON}${acceptedOperatorsHint(filterOps)}.`;
1327
1327
  const physicalReason = verdict("compare", false);
1328
1328
  let sortReason = source.canSortField(fd) ? void 0 : REASON_ADAPTER_SORT;
1329
1329
  if (fd.encrypted) sortReason = REASON_ENCRYPTED;
1330
1330
  if (isWriteOnly) sortReason = REASON_WRITE_ONLY;
1331
1331
  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;
1332
+ const bucket = isWriteOnly ? void 0 : bucketSourceVerdict(fd, this._bucketTable, source);
1333
+ const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
1338
1334
  const cap = {
1339
1335
  filterable: filterBy.compare === void 0,
1340
1336
  sortable: sortReason === void 0,
@@ -1368,21 +1364,29 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1368
1364
  }
1369
1365
  /**
1370
1366
  * 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.
1367
+ * path is accepted.
1376
1368
  *
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.
1369
+ * `exists` runs FIRST, for every path (since 0.1.133): it is the
1370
+ * controller's `hasField`, the visibility hook subclasses narrow per
1371
+ * request (e.g. a projection-scoped viewer). A path it rejects answers
1372
+ * `Unknown field "x"` exactly like a nonexistent one — never a capability
1373
+ * or navigation hint, which would reveal the field and let a filter or
1374
+ * sort on it act as a value oracle. Before 0.1.133 listed leaves and
1375
+ * navigation paths skipped it.
1376
+ *
1377
+ * Then: navigation paths (a nav path exists on the target table but is
1378
+ * never a column here), a listed leaf's capability, and for other paths
1379
+ * the storage classification. Existence also runs BEFORE the JSON /
1380
+ * encrypted classification: an untyped descendant of a JSON column
1381
+ * (`address.nope`) is reported as `Unknown field`, not as "inside
1382
+ * JSON-stored column" — clients pin that wording, so do not "align" it
1383
+ * with the core backstop's text.
1381
1384
  *
1382
1385
  * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1383
1386
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1384
1387
  */
1385
1388
  check(path, op, exists, predicate = "compare") {
1389
+ if (!exists(path)) return unknownField(path);
1386
1390
  const { kind, parent } = classifyQueryPath(this, path);
1387
1391
  if (kind === "nav") {
1388
1392
  if (parent === void 0) return {
@@ -1422,17 +1426,17 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1422
1426
  };
1423
1427
  }
1424
1428
  }
1425
- if (!exists(path)) return {
1426
- path,
1427
- message: `Unknown field "${path}"`
1428
- };
1429
1429
  switch (kind) {
1430
- case "objectParent":
1430
+ case "objectParent": {
1431
1431
  if (op === "select") return void 0;
1432
+ const all = this._objectParents.get(path);
1433
+ const leaves = all.filter(exists);
1434
+ if (leaves.length === 0 && all.length > 0) return unknownField(path);
1432
1435
  return {
1433
1436
  path,
1434
- message: `"${path}" is a nested object — filter or sort on one of its leaves (${leafHint(this._objectParents.get(path))})`
1437
+ message: `"${path}" is a nested object — filter or sort on one of its leaves (${leafHint(leaves)})`
1435
1438
  };
1439
+ }
1436
1440
  case "jsonDescendant": return {
1437
1441
  path,
1438
1442
  message: `"${path}" is inside JSON-stored column "${parent}" — this adapter cannot ${OP_VERB[op]} JSON paths; select "${parent}" and read the value client-side.`
@@ -1444,10 +1448,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1444
1448
  path,
1445
1449
  message: `Cannot ${OP_VERB[op]} encrypted field "${path}"`
1446
1450
  };
1447
- default: return {
1448
- path,
1449
- message: `Unknown field "${path}"`
1450
- };
1451
+ default: return unknownField(path);
1451
1452
  }
1452
1453
  }
1453
1454
  };
@@ -1489,6 +1490,7 @@ const GEO_CONTROLS = [
1489
1490
  ];
1490
1491
  //#endregion
1491
1492
  //#region src/as-db-readable.controller.ts
1493
+ var _AsDbReadableController;
1492
1494
  /** Gate positions checked after the filter entries, in order; `refs[op]` are their paths. */
1493
1495
  const PATH_OPS = [
1494
1496
  "sort",
@@ -1498,7 +1500,7 @@ const PATH_OPS = [
1498
1500
  "aggregate",
1499
1501
  "bucket"
1500
1502
  ];
1501
- let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1503
+ let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
1502
1504
  /** Reference to the underlying readable (table or view). */
1503
1505
  readable;
1504
1506
  /**
@@ -1536,8 +1538,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1536
1538
  metaCacheKey() {
1537
1539
  return this.capabilities;
1538
1540
  }
1539
- /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1541
+ /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1540
1542
  _exists = (path) => this.hasField(path);
1543
+ /**
1544
+ * Id-resolution options (since 0.1.134): `{ isFieldVisible: hasField }`
1545
+ * when a subclass overrides {@link hasField}, else `undefined` (the default
1546
+ * accepts every real path, so resolution stays unfiltered). A unique index
1547
+ * over a hidden field is never an identification.
1548
+ */
1549
+ _idOpts;
1550
+ /** Narrowed id sources, one stable object per distinct visible-identification set. */
1551
+ _idSources = /* @__PURE__ */ new Map();
1541
1552
  _preferredIdSet;
1542
1553
  _overlayIsNoOp;
1543
1554
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
@@ -1563,6 +1574,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1563
1574
  this._quantityRefByPath = this._collectQuantityRefs();
1564
1575
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1565
1576
  this._overlayIsNoOp = this.applyMetaOverlay === defaultOverlay;
1577
+ this._idOpts = this.hasField === _AsDbReadableController.prototype.hasField ? void 0 : { isFieldVisible: this._exists };
1578
+ }
1579
+ /**
1580
+ * The identifications this request may address rows through (since
1581
+ * 0.1.134): the readable's own, minus unique indexes over fields
1582
+ * {@link hasField} hides. Used by `/one?…`, `DELETE /?…` and action `ids`.
1583
+ * Stable per distinct outcome, so per-source caches keyed on it hit.
1584
+ */
1585
+ get idSource() {
1586
+ const opts = this._idOpts;
1587
+ if (!opts) return this.readable;
1588
+ const visible = this.readable.identificationsVisibleTo(opts.isFieldVisible);
1589
+ if (visible.length === this.readable.identifications.length) return this.readable;
1590
+ const key = visible.map((ident) => ident.source).join("");
1591
+ let source = this._idSources.get(key);
1592
+ if (!source) {
1593
+ source = {
1594
+ identifications: visible,
1595
+ fieldDescriptors: this.readable.fieldDescriptors
1596
+ };
1597
+ this._idSources.set(key, source);
1598
+ }
1599
+ return source;
1566
1600
  }
1567
1601
  _collectInvertibleFields() {
1568
1602
  const out = [];
@@ -1589,6 +1623,24 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1589
1623
  for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
1590
1624
  return out;
1591
1625
  }
1626
+ /**
1627
+ * THE field-visibility hook: every gated path consults it before any
1628
+ * capability check (since 0.1.133) — filter keys (inside `$and` / `$or` /
1629
+ * `$not`, existence predicates included), `$sort`, `$select`,
1630
+ * `$groupBy`, `$having` keys, aggregate and calendar-bucket `$field`s,
1631
+ * `$with` relation names and sub-query paths, and the `$search` fallback
1632
+ * fields. A path it rejects is answered exactly like a nonexistent one
1633
+ * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
1634
+ * fields per request (read scopes). Since 0.1.134 it also governs row
1635
+ * identification — a unique index over a hidden field is not an
1636
+ * identification for `/one/:id`, `/one?…`, `DELETE`, a PK-less `PATCH` or
1637
+ * an action id (primary key and `preferredId` always are) — and the
1638
+ * nested-object 400 hint lists visible leaves only. The default accepts every real path
1639
+ * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1640
+ * there with `applyMetaOverlay`. Native text search and vector search
1641
+ * (`$vector` names an index) run inside the engine over its indexes, out of
1642
+ * this hook's reach — keep hidden fields out of those indexes.
1643
+ */
1592
1644
  hasField(path) {
1593
1645
  if (typeof this.readable.isValidFieldPath === "function") return this.readable.isValidFieldPath(path);
1594
1646
  return this.readable.flatMap.has(path);
@@ -1676,7 +1728,13 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1676
1728
  const withRelations = parsed.controls.$with;
1677
1729
  if (withRelations?.length) {
1678
1730
  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)"}`);
1731
+ for (const rel of withRelations) {
1732
+ const dot = rel.name.indexOf(".");
1733
+ if (!(dot === -1 ? relations.has(rel.name) && this.hasField(rel.name) : this.hasField(rel.name.slice(0, dot)))) {
1734
+ const visible = [...relations.keys()].filter((name) => this.hasField(name));
1735
+ return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${visible.join(", ") || "(none)"}`);
1736
+ }
1737
+ }
1680
1738
  }
1681
1739
  }
1682
1740
  /**
@@ -1751,11 +1809,26 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1751
1809
  return widened;
1752
1810
  }
1753
1811
  const widened = {};
1754
- for (const path of this._invertibleFields) if (!excluded.has(path)) widened[path] = 1;
1812
+ for (const path of this._invertExclusion(excluded)) widened[path] = 1;
1755
1813
  for (const field of this._preferredIdSet) widened[field] = 1;
1756
1814
  return widened;
1757
1815
  }
1758
1816
  /**
1817
+ * The logical paths an exclusion keeps. A path goes when it, an ancestor
1818
+ * or a descendant is excluded: excluding an object parent excludes its
1819
+ * whole subtree, and a kept parent would carry an excluded child back
1820
+ * (its other leaves stay listed on their own). Before 0.1.134 only the
1821
+ * exact paths were dropped, so `$select=-a` still returned `a`'s leaves.
1822
+ */
1823
+ _invertExclusion(excluded) {
1824
+ return this._invertibleFields.filter((path) => {
1825
+ if (excluded.has(path) || findAncestorInSet(path, excluded) !== void 0) return false;
1826
+ const prefix = `${path}.`;
1827
+ for (const key of excluded) if (key.startsWith(prefix)) return false;
1828
+ return true;
1829
+ });
1830
+ }
1831
+ /**
1759
1832
  * Auto-includes the sibling-ref field whenever its `@db.amount.currency.ref`
1760
1833
  * / `@db.unit.ref` quantity is selected — UI must never get a value without
1761
1834
  * its dimension. No-op when `$select` is undefined (full row covers it).
@@ -1818,10 +1891,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1818
1891
  for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
1819
1892
  else if (v === 0 || v === false) excluded.push(k);
1820
1893
  if (included.length > 0 && excluded.length === 0) return included;
1821
- if (excluded.length > 0 && included.length === 0) {
1822
- const excludedSet = new Set(excluded);
1823
- return this._invertibleFields.filter((path) => !excludedSet.has(path));
1824
- }
1894
+ if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
1825
1895
  throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
1826
1896
  }
1827
1897
  /** WHY: filter row/rows envelopes by the per-request `applyMetaOverlay` action set; skip `meta()` when overlay is identity. */
@@ -1913,10 +1983,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1913
1983
  /** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
1914
1984
  _findWriteOnlyInAggregate(groupBy, select) {
1915
1985
  if (this._writeOnlySet.size === 0) return void 0;
1916
- for (const f of groupBy) if (this._writeOnlySet.has(f)) return f;
1986
+ const sealed = (f) => this._writeOnlySet.has(f) && this.hasField(f);
1987
+ for (const f of groupBy) if (sealed(f)) return f;
1917
1988
  if (Array.isArray(select)) for (const item of select) {
1918
1989
  const field = typeof item === "string" ? item : item.$field;
1919
- if (field && this._writeOnlySet.has(field)) return field;
1990
+ if (field && sealed(field)) return field;
1920
1991
  }
1921
1992
  }
1922
1993
  /**
@@ -1928,9 +1999,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1928
1999
  applySearchFallback(filter, controls) {
1929
2000
  const term = controls.$search;
1930
2001
  if (!term || controls.$vector !== void 0) return filter;
1931
- if (this.readable.isSearchable() || this._searchFallbackFields.length === 0) return filter;
2002
+ if (this.readable.isSearchable()) return filter;
2003
+ const fields = this._searchFallbackFields.filter((f) => this.hasField(f));
2004
+ if (fields.length === 0) return filter;
1932
2005
  const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
1933
- const fragment = { $or: this._searchFallbackFields.map((f) => ({ [f]: { $regex: rx } })) };
2006
+ const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
1934
2007
  return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
1935
2008
  }
1936
2009
  /**
@@ -1999,9 +2072,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1999
2072
  });
2000
2073
  return result;
2001
2074
  }
2002
- /** Pick the first identification (PK or unique index) whose fields are all present in the query. */
2075
+ /**
2076
+ * Pick the first identification (PK or unique index) whose fields are all
2077
+ * present in the query. A unique index over a field {@link hasField} hides
2078
+ * is not a candidate (since 0.1.134) — `?hidden=x` answers exactly like
2079
+ * `?nope=x`, so it cannot probe whether a row with that value exists.
2080
+ */
2003
2081
  extractIdShape(query) {
2004
- for (const id of this.readable.identifications) {
2082
+ for (const id of this.idSource.identifications) {
2005
2083
  const idObj = {};
2006
2084
  let allPresent = true;
2007
2085
  for (const field of id.fields) {
@@ -2238,7 +2316,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2238
2316
  ...parsedControls,
2239
2317
  $select: initialSelect
2240
2318
  };
2241
- const idFilter = this.readable.resolveIdFilter(id);
2319
+ const idFilter = this.readable.resolveIdFilter(id, this._idOpts);
2242
2320
  let row = null;
2243
2321
  if (idFilter) {
2244
2322
  const overlay = await this.transformOne({});
@@ -2361,7 +2439,7 @@ __decorate([
2361
2439
  __decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record, String]),
2362
2440
  __decorateMetadata("design:returntype", Promise)
2363
2441
  ], AsDbReadableController.prototype, "getOneComposite", null);
2364
- AsDbReadableController = __decorate([
2442
+ AsDbReadableController = _AsDbReadableController = __decorate([
2365
2443
  Inherit(),
2366
2444
  __decorateParam(1, Inject(READABLE_DEF)),
2367
2445
  __decorateParam(1, Optional()),
@@ -2393,6 +2471,9 @@ function hasWriteShape(data, many) {
2393
2471
  let AsDbController = _AsDbController = class AsDbController extends AsDbReadableController {
2394
2472
  constructor(app, table) {
2395
2473
  super(app, table);
2474
+ const proto = _AsDbController.prototype;
2475
+ this._writeArgs = this._hookArgs(this.guardWrite !== proto.guardWrite ? (ctx) => this.guardWrite(ctx) : void 0);
2476
+ this._removeArgs = this._hookArgs(this.guardRemove !== proto.guardRemove ? (ctx) => this.guardRemove(ctx) : void 0);
2396
2477
  }
2397
2478
  buildCrud() {
2398
2479
  return {
@@ -2460,19 +2541,20 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2460
2541
  withTransaction(fn) {
2461
2542
  return this.table.getAdapter().withTransaction(fn);
2462
2543
  }
2544
+ /** The table write call's trailing options — see {@link _hookArgs}. */
2545
+ _writeArgs;
2546
+ /** `deleteOne`'s trailing options — see {@link _hookArgs}. */
2547
+ _removeArgs;
2463
2548
  /**
2464
- * The table write call's trailing options: `[{ guard }]` only when
2465
- * `guardWrite` is overridden, else nothing (the table is called exactly as
2466
- * an unmodified controller always called it).
2549
+ * A table call's trailing options, built once: `guard` only when the guard
2550
+ * hook is overridden, `isFieldVisible` only when `hasField` is (an id or a
2551
+ * PK-less payload never resolves through a hidden unique key) — else
2552
+ * nothing, so an unmodified controller calls the table exactly as before.
2467
2553
  */
2468
- _writeArgs() {
2469
- if (this.guardWrite === _AsDbController.prototype.guardWrite) return [];
2470
- return [{ guard: (ctx) => this.guardWrite(ctx) }];
2471
- }
2472
- /** `deleteOne`'s trailing options: `[{ guard }]` only when `guardRemove` is overridden. */
2473
- _removeArgs() {
2474
- if (this.guardRemove === _AsDbController.prototype.guardRemove) return [];
2475
- return [{ guard: (ctx) => this.guardRemove(ctx) }];
2554
+ _hookArgs(guard) {
2555
+ const opts = { ...this._idOpts };
2556
+ if (guard) opts.guard = guard;
2557
+ return Object.keys(opts).length > 0 ? [opts] : [];
2476
2558
  }
2477
2559
  /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
2478
2560
  async _checkHook(pending, abortMessage) {
@@ -2522,7 +2604,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2522
2604
  }
2523
2605
  /** Deletes by id (guard forwarded when overridden) and maps "nothing deleted" to 404. */
2524
2606
  async _deleteOrThrow(id) {
2525
- const result = await this.table.deleteOne(id, ...this._removeArgs());
2607
+ const result = await this.table.deleteOne(id, ...this._removeArgs);
2526
2608
  if (result.deletedCount < 1) throw new HttpError(404);
2527
2609
  return result;
2528
2610
  }
@@ -2533,10 +2615,10 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2533
2615
  assertWriteShape(payload);
2534
2616
  if (Array.isArray(payload)) {
2535
2617
  const rows = await this._writeBody("insertMany", payload, true);
2536
- return this.table.insertMany(rows, ...this._writeArgs());
2618
+ return this.table.insertMany(rows, ...this._writeArgs);
2537
2619
  }
2538
2620
  const row = await this._writeBody("insert", payload, false);
2539
- return this.table.insertOne(row, ...this._writeArgs());
2621
+ return this.table.insertOne(row, ...this._writeArgs);
2540
2622
  }
2541
2623
  /**
2542
2624
  * **PUT /** — fully replaces one or many records matched by primary key.
@@ -2553,11 +2635,11 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2553
2635
  if (Array.isArray(payload)) {
2554
2636
  const rows = await this._writeBody("replaceMany", payload, true);
2555
2637
  this._resolveBulkCas(rows, versionColumn);
2556
- return this.table.bulkReplace(rows, ...this._writeArgs());
2638
+ return this.table.bulkReplace(rows, ...this._writeArgs);
2557
2639
  }
2558
2640
  const row = await this._writeBody("replace", payload, false);
2559
2641
  const hadCas = this._resolveCas(row, versionColumn);
2560
- const result = await this.table.replaceOne(row, ...this._writeArgs());
2642
+ const result = await this.table.replaceOne(row, ...this._writeArgs);
2561
2643
  if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
2562
2644
  return result;
2563
2645
  }
@@ -2574,11 +2656,11 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2574
2656
  if (Array.isArray(payload)) {
2575
2657
  const rows = await this._writeBody("updateMany", payload, true);
2576
2658
  this._resolveBulkCas(rows, versionColumn);
2577
- return this.table.bulkUpdate(rows, ...this._writeArgs());
2659
+ return this.table.bulkUpdate(rows, ...this._writeArgs);
2578
2660
  }
2579
2661
  const row = await this._writeBody("update", payload, false);
2580
2662
  const hadCas = this._resolveCas(row, versionColumn);
2581
- const result = await this.table.updateOne(row, ...this._writeArgs());
2663
+ const result = await this.table.updateOne(row, ...this._writeArgs);
2582
2664
  if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
2583
2665
  return result;
2584
2666
  }
@@ -2589,7 +2671,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2589
2671
  * but the supplied version is stale (§6.3). Callers throw the result.
2590
2672
  */
2591
2673
  async _disambiguateMismatch(data, versionColumn) {
2592
- const filter = this.table.resolveIdFilter(data);
2674
+ const filter = this.table.resolveIdFilter(data, this._idOpts);
2593
2675
  const row = filter ? await this.table.findOne({
2594
2676
  filter,
2595
2677
  controls: {}
@@ -3138,8 +3220,11 @@ function isPlainObject$1(value) {
3138
3220
  //#endregion
3139
3221
  //#region src/actions/id-cache.ts
3140
3222
  const boundTableKey = key("atscript_db_action_bound_table");
3223
+ function controllerOf(ctx) {
3224
+ return useControllerContext(ctx).getController();
3225
+ }
3141
3226
  function controllerTable(ctx) {
3142
- const ctrl = useControllerContext(ctx).getController();
3227
+ const ctrl = controllerOf(ctx);
3143
3228
  return ctrl?.readable ?? ctrl?.table ?? null;
3144
3229
  }
3145
3230
  function getActionTable(ctx) {
@@ -3160,11 +3245,22 @@ function noTableError(ctx) {
3160
3245
  code: "ACTION_TABLE_NOT_BOUND"
3161
3246
  });
3162
3247
  }
3248
+ /**
3249
+ * Validates the body's `ids` against the action table's identifications. For
3250
+ * the controller's own table that is its `idSource` (since 0.1.134): a unique
3251
+ * index over a field `hasField` hides neither addresses a row nor appears in
3252
+ * the "must exactly match one of" message. An `opts.table` binding has no
3253
+ * visibility hook.
3254
+ */
3163
3255
  async function resolveValidatedId(ctx, validate) {
3164
- const table = getActionTable(ctx);
3165
- if (!isIdValidationSource(table)) throw noTableError(ctx);
3256
+ let source = ctx.has(boundTableKey) ? ctx.get(boundTableKey) : void 0;
3257
+ if (!source) {
3258
+ const ctrl = controllerOf(ctx);
3259
+ source = ctrl?.idSource ?? ctrl?.readable ?? ctrl?.table ?? null;
3260
+ }
3261
+ if (!isIdValidationSource(source)) throw noTableError(ctx);
3166
3262
  const env = await ctx.get(dbActionBodySlot);
3167
- validate(env.ids, table);
3263
+ validate(env.ids, source);
3168
3264
  return env.ids;
3169
3265
  }
3170
3266
  const dbActionIdSlot = cached((ctx) => resolveValidatedId(ctx, validateSingleId));
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.134",
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.134"
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.134"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",