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