@atscript/moost-db 0.1.133 → 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
@@ -1181,6 +1181,13 @@ const OP_VERB = {
1181
1181
  aggregate: "aggregate over",
1182
1182
  bucket: "bucket"
1183
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
+ }
1184
1191
  function leafHint(leaves) {
1185
1192
  if (leaves.length === 0) return "no leaf fields";
1186
1193
  const shown = leaves.slice(0, 5).join(", ");
@@ -1380,10 +1387,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1380
1387
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1381
1388
  */
1382
1389
  check(path, op, exists, predicate = "compare") {
1383
- if (!exists(path)) return {
1384
- path,
1385
- message: `Unknown field "${path}"`
1386
- };
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 {
@@ -1424,12 +1428,16 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1424
1428
  }
1425
1429
  }
1426
1430
  switch (kind) {
1427
- case "objectParent":
1431
+ case "objectParent": {
1428
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);
1429
1436
  return {
1430
1437
  path,
1431
- 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)})`
1432
1439
  };
1440
+ }
1433
1441
  case "jsonDescendant": return {
1434
1442
  path,
1435
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.`
@@ -1441,10 +1449,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1441
1449
  path,
1442
1450
  message: `Cannot ${OP_VERB[op]} encrypted field "${path}"`
1443
1451
  };
1444
- default: return {
1445
- path,
1446
- message: `Unknown field "${path}"`
1447
- };
1452
+ default: return unknownField(path);
1448
1453
  }
1449
1454
  }
1450
1455
  };
@@ -1486,6 +1491,7 @@ const GEO_CONTROLS = [
1486
1491
  ];
1487
1492
  //#endregion
1488
1493
  //#region src/as-db-readable.controller.ts
1494
+ var _AsDbReadableController;
1489
1495
  /** Gate positions checked after the filter entries, in order; `refs[op]` are their paths. */
1490
1496
  const PATH_OPS = [
1491
1497
  "sort",
@@ -1495,7 +1501,7 @@ const PATH_OPS = [
1495
1501
  "aggregate",
1496
1502
  "bucket"
1497
1503
  ];
1498
- let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1504
+ let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
1499
1505
  /** Reference to the underlying readable (table or view). */
1500
1506
  readable;
1501
1507
  /**
@@ -1535,6 +1541,15 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1535
1541
  }
1536
1542
  /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1537
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();
1538
1553
  _preferredIdSet;
1539
1554
  _overlayIsNoOp;
1540
1555
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
@@ -1560,6 +1575,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1560
1575
  this._quantityRefByPath = this._collectQuantityRefs();
1561
1576
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1562
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;
1563
1601
  }
1564
1602
  _collectInvertibleFields() {
1565
1603
  const out = [];
@@ -1594,7 +1632,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1594
1632
  * `$with` relation names and sub-query paths, and the `$search` fallback
1595
1633
  * fields. A path it rejects is answered exactly like a nonexistent one
1596
1634
  * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
1597
- * fields per request (read scopes). The default accepts every real path
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
1598
1640
  * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1599
1641
  * there with `applyMetaOverlay`. Native text search and vector search
1600
1642
  * (`$vector` names an index) run inside the engine over its indexes, out of
@@ -1768,11 +1810,26 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1768
1810
  return widened;
1769
1811
  }
1770
1812
  const widened = {};
1771
- for (const path of this._invertibleFields) if (!excluded.has(path)) widened[path] = 1;
1813
+ for (const path of this._invertExclusion(excluded)) widened[path] = 1;
1772
1814
  for (const field of this._preferredIdSet) widened[field] = 1;
1773
1815
  return widened;
1774
1816
  }
1775
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
+ /**
1776
1833
  * Auto-includes the sibling-ref field whenever its `@db.amount.currency.ref`
1777
1834
  * / `@db.unit.ref` quantity is selected — UI must never get a value without
1778
1835
  * its dimension. No-op when `$select` is undefined (full row covers it).
@@ -1835,10 +1892,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1835
1892
  for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
1836
1893
  else if (v === 0 || v === false) excluded.push(k);
1837
1894
  if (included.length > 0 && excluded.length === 0) return included;
1838
- if (excluded.length > 0 && included.length === 0) {
1839
- const excludedSet = new Set(excluded);
1840
- return this._invertibleFields.filter((path) => !excludedSet.has(path));
1841
- }
1895
+ if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
1842
1896
  throw new _moostjs_event_http.HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
1843
1897
  }
1844
1898
  /** WHY: filter row/rows envelopes by the per-request `applyMetaOverlay` action set; skip `meta()` when overlay is identity. */
@@ -2019,9 +2073,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2019
2073
  });
2020
2074
  return result;
2021
2075
  }
2022
- /** 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
+ */
2023
2082
  extractIdShape(query) {
2024
- for (const id of this.readable.identifications) {
2083
+ for (const id of this.idSource.identifications) {
2025
2084
  const idObj = {};
2026
2085
  let allPresent = true;
2027
2086
  for (const field of id.fields) {
@@ -2258,7 +2317,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2258
2317
  ...parsedControls,
2259
2318
  $select: initialSelect
2260
2319
  };
2261
- const idFilter = this.readable.resolveIdFilter(id);
2320
+ const idFilter = this.readable.resolveIdFilter(id, this._idOpts);
2262
2321
  let row = null;
2263
2322
  if (idFilter) {
2264
2323
  const overlay = await this.transformOne({});
@@ -2381,7 +2440,7 @@ __decorate([
2381
2440
  __decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record, String]),
2382
2441
  __decorateMetadata("design:returntype", Promise)
2383
2442
  ], AsDbReadableController.prototype, "getOneComposite", null);
2384
- AsDbReadableController = __decorate([
2443
+ AsDbReadableController = _AsDbReadableController = __decorate([
2385
2444
  (0, moost.Inherit)(),
2386
2445
  __decorateParam(1, (0, moost.Inject)(READABLE_DEF)),
2387
2446
  __decorateParam(1, (0, moost.Optional)()),
@@ -2413,6 +2472,9 @@ function hasWriteShape(data, many) {
2413
2472
  let AsDbController = _AsDbController = class AsDbController extends AsDbReadableController {
2414
2473
  constructor(app, table) {
2415
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);
2416
2478
  }
2417
2479
  buildCrud() {
2418
2480
  return {
@@ -2480,19 +2542,20 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2480
2542
  withTransaction(fn) {
2481
2543
  return this.table.getAdapter().withTransaction(fn);
2482
2544
  }
2545
+ /** The table write call's trailing options — see {@link _hookArgs}. */
2546
+ _writeArgs;
2547
+ /** `deleteOne`'s trailing options — see {@link _hookArgs}. */
2548
+ _removeArgs;
2483
2549
  /**
2484
- * The table write call's trailing options: `[{ guard }]` only when
2485
- * `guardWrite` is overridden, else nothing (the table is called exactly as
2486
- * 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.
2487
2554
  */
2488
- _writeArgs() {
2489
- if (this.guardWrite === _AsDbController.prototype.guardWrite) return [];
2490
- return [{ guard: (ctx) => this.guardWrite(ctx) }];
2491
- }
2492
- /** `deleteOne`'s trailing options: `[{ guard }]` only when `guardRemove` is overridden. */
2493
- _removeArgs() {
2494
- if (this.guardRemove === _AsDbController.prototype.guardRemove) return [];
2495
- 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] : [];
2496
2559
  }
2497
2560
  /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
2498
2561
  async _checkHook(pending, abortMessage) {
@@ -2542,7 +2605,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2542
2605
  }
2543
2606
  /** Deletes by id (guard forwarded when overridden) and maps "nothing deleted" to 404. */
2544
2607
  async _deleteOrThrow(id) {
2545
- const result = await this.table.deleteOne(id, ...this._removeArgs());
2608
+ const result = await this.table.deleteOne(id, ...this._removeArgs);
2546
2609
  if (result.deletedCount < 1) throw new _moostjs_event_http.HttpError(404);
2547
2610
  return result;
2548
2611
  }
@@ -2553,10 +2616,10 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2553
2616
  assertWriteShape(payload);
2554
2617
  if (Array.isArray(payload)) {
2555
2618
  const rows = await this._writeBody("insertMany", payload, true);
2556
- return this.table.insertMany(rows, ...this._writeArgs());
2619
+ return this.table.insertMany(rows, ...this._writeArgs);
2557
2620
  }
2558
2621
  const row = await this._writeBody("insert", payload, false);
2559
- return this.table.insertOne(row, ...this._writeArgs());
2622
+ return this.table.insertOne(row, ...this._writeArgs);
2560
2623
  }
2561
2624
  /**
2562
2625
  * **PUT /** — fully replaces one or many records matched by primary key.
@@ -2573,11 +2636,11 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2573
2636
  if (Array.isArray(payload)) {
2574
2637
  const rows = await this._writeBody("replaceMany", payload, true);
2575
2638
  this._resolveBulkCas(rows, versionColumn);
2576
- return this.table.bulkReplace(rows, ...this._writeArgs());
2639
+ return this.table.bulkReplace(rows, ...this._writeArgs);
2577
2640
  }
2578
2641
  const row = await this._writeBody("replace", payload, false);
2579
2642
  const hadCas = this._resolveCas(row, versionColumn);
2580
- const result = await this.table.replaceOne(row, ...this._writeArgs());
2643
+ const result = await this.table.replaceOne(row, ...this._writeArgs);
2581
2644
  if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
2582
2645
  return result;
2583
2646
  }
@@ -2594,11 +2657,11 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2594
2657
  if (Array.isArray(payload)) {
2595
2658
  const rows = await this._writeBody("updateMany", payload, true);
2596
2659
  this._resolveBulkCas(rows, versionColumn);
2597
- return this.table.bulkUpdate(rows, ...this._writeArgs());
2660
+ return this.table.bulkUpdate(rows, ...this._writeArgs);
2598
2661
  }
2599
2662
  const row = await this._writeBody("update", payload, false);
2600
2663
  const hadCas = this._resolveCas(row, versionColumn);
2601
- const result = await this.table.updateOne(row, ...this._writeArgs());
2664
+ const result = await this.table.updateOne(row, ...this._writeArgs);
2602
2665
  if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
2603
2666
  return result;
2604
2667
  }
@@ -2609,7 +2672,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2609
2672
  * but the supplied version is stale (§6.3). Callers throw the result.
2610
2673
  */
2611
2674
  async _disambiguateMismatch(data, versionColumn) {
2612
- const filter = this.table.resolveIdFilter(data);
2675
+ const filter = this.table.resolveIdFilter(data, this._idOpts);
2613
2676
  const row = filter ? await this.table.findOne({
2614
2677
  filter,
2615
2678
  controls: {}
@@ -3158,8 +3221,11 @@ function isPlainObject(value) {
3158
3221
  //#endregion
3159
3222
  //#region src/actions/id-cache.ts
3160
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
+ }
3161
3227
  function controllerTable(ctx) {
3162
- const ctrl = (0, moost.useControllerContext)(ctx).getController();
3228
+ const ctrl = controllerOf(ctx);
3163
3229
  return ctrl?.readable ?? ctrl?.table ?? null;
3164
3230
  }
3165
3231
  function getActionTable(ctx) {
@@ -3180,11 +3246,22 @@ function noTableError(ctx) {
3180
3246
  code: "ACTION_TABLE_NOT_BOUND"
3181
3247
  });
3182
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
+ */
3183
3256
  async function resolveValidatedId(ctx, validate) {
3184
- const table = getActionTable(ctx);
3185
- 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);
3186
3263
  const env = await ctx.get(dbActionBodySlot);
3187
- validate(env.ids, table);
3264
+ validate(env.ids, source);
3188
3265
  return env.ids;
3189
3266
  }
3190
3267
  const dbActionIdSlot = (0, _wooksjs_event_core.cached)((ctx) => resolveValidatedId(ctx, validateSingleId));
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { i as resolveDbSpace, n as clearDbSpaces, r as provideDbSpace, t as DEFAULT_DB_SPACE } from "./db-space-registry-CWpYwZ4R.cjs";
2
2
  import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, TSerializedAnnotatedType, TValidatorOptions, Validator } from "@atscript/typescript/utils";
3
- import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, FilterExpr, FlatOf, TCrudOp, TCrudPermissions, TCrudPermissions as TCrudPermissions$1, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionIntent as TDbActionIntent$1, TDbActionLevel, TDbActionLevel as TDbActionLevel$1, TDbActionProcessor, TDbFieldMeta, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
3
+ import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, FilterExpr, FlatOf, TCrudOp, TCrudPermissions, TCrudPermissions as TCrudPermissions$1, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionIntent as TDbActionIntent$1, TDbActionLevel, TDbActionLevel as TDbActionLevel$1, TDbActionProcessor, TDbFieldMeta, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdResolveOptions, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
4
4
  import { HttpError } from "@moostjs/event-http";
5
5
  import { Mate, Moost, TConsoleBase, TMateParamMeta, TMoostMetadata } from "moost";
6
6
  import { parseUrl } from "@uniqu/url";
@@ -185,6 +185,13 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
185
185
  protected applyMetaOverlay(meta: TMetaResponse): TMetaResponse | Promise<TMetaResponse>;
186
186
  }
187
187
  //#endregion
188
+ //#region src/actions/id-validation.d.ts
189
+ /** Duck-typed shape; matches `AtscriptDbReadable`'s public surface. */
190
+ interface IdValidationSource {
191
+ readonly identifications: readonly TIdentification[];
192
+ readonly fieldDescriptors: readonly TDbFieldMeta[];
193
+ }
194
+ //#endregion
188
195
  //#region src/meta/field-capabilities.d.ts
189
196
  /**
190
197
  * Per-path HTTP capability of a DB readable — the single source that both the
@@ -352,6 +359,15 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
352
359
  protected metaCacheKey(): unknown;
353
360
  /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
354
361
  private readonly _exists;
362
+ /**
363
+ * Id-resolution options (since 0.1.134): `{ isFieldVisible: hasField }`
364
+ * when a subclass overrides {@link hasField}, else `undefined` (the default
365
+ * accepts every real path, so resolution stays unfiltered). A unique index
366
+ * over a hidden field is never an identification.
367
+ */
368
+ protected readonly _idOpts: TIdResolveOptions | undefined;
369
+ /** Narrowed id sources, one stable object per distinct visible-identification set. */
370
+ private readonly _idSources;
355
371
  private readonly _preferredIdSet;
356
372
  private readonly _overlayIsNoOp;
357
373
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
@@ -367,6 +383,13 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
367
383
  */
368
384
  private readonly _invertibleFields;
369
385
  constructor(app: Moost, readable?: AtscriptDbReadable<T>);
386
+ /**
387
+ * The identifications this request may address rows through (since
388
+ * 0.1.134): the readable's own, minus unique indexes over fields
389
+ * {@link hasField} hides. Used by `/one?…`, `DELETE /?…` and action `ids`.
390
+ * Stable per distinct outcome, so per-source caches keyed on it hit.
391
+ */
392
+ get idSource(): IdValidationSource;
370
393
  private _collectInvertibleFields;
371
394
  private _collectQuantityRefs;
372
395
  private _collectAnnotated;
@@ -378,7 +401,11 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
378
401
  * `$with` relation names and sub-query paths, and the `$search` fallback
379
402
  * fields. A path it rejects is answered exactly like a nonexistent one
380
403
  * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
381
- * fields per request (read scopes). The default accepts every real path
404
+ * fields per request (read scopes). Since 0.1.134 it also governs row
405
+ * identification — a unique index over a hidden field is not an
406
+ * identification for `/one/:id`, `/one?…`, `DELETE`, a PK-less `PATCH` or
407
+ * an action id (primary key and `preferredId` always are) — and the
408
+ * nested-object 400 hint lists visible leaves only. The default accepts every real path
382
409
  * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
383
410
  * there with `applyMetaOverlay`. Native text search and vector search
384
411
  * (`$vector` names an index) run inside the engine over its indexes, out of
@@ -453,6 +480,14 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
453
480
  private widenPreferredIdProjection;
454
481
  private _widenArrayProjection;
455
482
  private _widenMapProjection;
483
+ /**
484
+ * The logical paths an exclusion keeps. A path goes when it, an ancestor
485
+ * or a descendant is excluded: excluding an object parent excludes its
486
+ * whole subtree, and a kept parent would carry an excluded child back
487
+ * (its other leaves stay listed on their own). Before 0.1.134 only the
488
+ * exact paths were dropped, so `$select=-a` still returned `a`'s leaves.
489
+ */
490
+ private _invertExclusion;
456
491
  /**
457
492
  * Auto-includes the sibling-ref field whenever its `@db.amount.currency.ref`
458
493
  * / `@db.unit.ref` quantity is selected — UI must never get a value without
@@ -523,7 +558,12 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
523
558
  * family (count vs no-count).
524
559
  */
525
560
  private _runReadWithActions;
526
- /** Pick the first identification (PK or unique index) whose fields are all present in the query. */
561
+ /**
562
+ * Pick the first identification (PK or unique index) whose fields are all
563
+ * present in the query. A unique index over a field {@link hasField} hides
564
+ * is not a candidate (since 0.1.134) — `?hidden=x` answers exactly like
565
+ * `?nope=x`, so it cannot probe whether a row with that value exists.
566
+ */
527
567
  protected extractIdShape(query: Record<string, string>): Record<string, unknown> | HttpError;
528
568
  /**
529
569
  * **GET /query** — returns an array of records or a count.
@@ -664,14 +704,17 @@ declare class AsDbController<T extends TAtscriptAnnotatedType = TAtscriptAnnotat
664
704
  * several table operations.
665
705
  */
666
706
  protected withTransaction<R>(fn: () => Promise<R>): Promise<R>;
707
+ /** The table write call's trailing options — see {@link _hookArgs}. */
708
+ private readonly _writeArgs;
709
+ /** `deleteOne`'s trailing options — see {@link _hookArgs}. */
710
+ private readonly _removeArgs;
667
711
  /**
668
- * The table write call's trailing options: `[{ guard }]` only when
669
- * `guardWrite` is overridden, else nothing (the table is called exactly as
670
- * an unmodified controller always called it).
712
+ * A table call's trailing options, built once: `guard` only when the guard
713
+ * hook is overridden, `isFieldVisible` only when `hasField` is (an id or a
714
+ * PK-less payload never resolves through a hidden unique key) — else
715
+ * nothing, so an unmodified controller calls the table exactly as before.
671
716
  */
672
- private _writeArgs;
673
- /** `deleteOne`'s trailing options: `[{ guard }]` only when `guardRemove` is overridden. */
674
- private _removeArgs;
717
+ private _hookArgs;
675
718
  /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
676
719
  private _checkHook;
677
720
  /** Runs `onWrite` and re-applies the shape gate to its output (a non-object is a 500 "Not saved"). */
@@ -1528,13 +1571,6 @@ declare function getControllerFormType(ctor: Function, name: string): TAtscriptA
1528
1571
  /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
1529
1572
  declare function discoverActions(controllerCtor: Function, app: Moost, logger: TConsoleBase): TDbActionEnvelope[];
1530
1573
  //#endregion
1531
- //#region src/actions/id-validation.d.ts
1532
- /** Duck-typed shape; matches `AtscriptDbReadable`'s public surface. */
1533
- interface IdValidationSource {
1534
- readonly identifications: readonly TIdentification[];
1535
- readonly fieldDescriptors: readonly TDbFieldMeta[];
1536
- }
1537
- //#endregion
1538
1574
  //#region src/actions/id-cache.d.ts
1539
1575
  declare const useDbActionId: import("@wooksjs/event-core").WookComposable<{
1540
1576
  load: () => Promise<Record<string, unknown>>;
package/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { TAtscriptAnnotatedType, TAtscriptDataType, TSerializeOptions, TSerializ
3
3
  import { HttpError } from "@moostjs/event-http";
4
4
  import { Mate, Moost, TConsoleBase, TMateParamMeta, TMoostMetadata } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, FilterExpr, FlatOf, TCrudOp, TCrudPermissions, TCrudPermissions as TCrudPermissions$1, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionIntent as TDbActionIntent$1, TDbActionLevel, TDbActionLevel as TDbActionLevel$1, TDbActionProcessor, TDbFieldMeta, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
6
+ import { AtscriptDbReadable, AtscriptDbTable, BucketUnit, FilterExpr, FlatOf, TCrudOp, TCrudPermissions, TCrudPermissions as TCrudPermissions$1, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionIntent as TDbActionIntent$1, TDbActionLevel, TDbActionLevel as TDbActionLevel$1, TDbActionProcessor, TDbFieldMeta, TDbRemoveGuardContext, TDbRemoveGuardContext as TDbRemoveGuardContext$1, TDbWriteAction, TDbWriteAction as TDbWriteAction$1, TDbWriteGuardContext, TDbWriteGuardContext as TDbWriteGuardContext$1, TFilterPredicate, TIdResolveOptions, TIdentification, TMetaResponse, TQueryPathOp, TQueryPathOp as TQueryPathOp$1, TQueryPathRefs, TQueryPathSource, Uniquery, UniqueryControls, collectQueryPaths } from "@atscript/db";
7
7
  //#region src/as-readable.controller.d.ts
8
8
  /**
9
9
  * Abstract base class for read-only HTTP controllers over an Atscript interface.
@@ -185,6 +185,13 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
185
185
  protected applyMetaOverlay(meta: TMetaResponse): TMetaResponse | Promise<TMetaResponse>;
186
186
  }
187
187
  //#endregion
188
+ //#region src/actions/id-validation.d.ts
189
+ /** Duck-typed shape; matches `AtscriptDbReadable`'s public surface. */
190
+ interface IdValidationSource {
191
+ readonly identifications: readonly TIdentification[];
192
+ readonly fieldDescriptors: readonly TDbFieldMeta[];
193
+ }
194
+ //#endregion
188
195
  //#region src/meta/field-capabilities.d.ts
189
196
  /**
190
197
  * Per-path HTTP capability of a DB readable — the single source that both the
@@ -352,6 +359,15 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
352
359
  protected metaCacheKey(): unknown;
353
360
  /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
354
361
  private readonly _exists;
362
+ /**
363
+ * Id-resolution options (since 0.1.134): `{ isFieldVisible: hasField }`
364
+ * when a subclass overrides {@link hasField}, else `undefined` (the default
365
+ * accepts every real path, so resolution stays unfiltered). A unique index
366
+ * over a hidden field is never an identification.
367
+ */
368
+ protected readonly _idOpts: TIdResolveOptions | undefined;
369
+ /** Narrowed id sources, one stable object per distinct visible-identification set. */
370
+ private readonly _idSources;
355
371
  private readonly _preferredIdSet;
356
372
  private readonly _overlayIsNoOp;
357
373
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
@@ -367,6 +383,13 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
367
383
  */
368
384
  private readonly _invertibleFields;
369
385
  constructor(app: Moost, readable?: AtscriptDbReadable<T>);
386
+ /**
387
+ * The identifications this request may address rows through (since
388
+ * 0.1.134): the readable's own, minus unique indexes over fields
389
+ * {@link hasField} hides. Used by `/one?…`, `DELETE /?…` and action `ids`.
390
+ * Stable per distinct outcome, so per-source caches keyed on it hit.
391
+ */
392
+ get idSource(): IdValidationSource;
370
393
  private _collectInvertibleFields;
371
394
  private _collectQuantityRefs;
372
395
  private _collectAnnotated;
@@ -378,7 +401,11 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
378
401
  * `$with` relation names and sub-query paths, and the `$search` fallback
379
402
  * fields. A path it rejects is answered exactly like a nonexistent one
380
403
  * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
381
- * fields per request (read scopes). The default accepts every real path
404
+ * fields per request (read scopes). Since 0.1.134 it also governs row
405
+ * identification — a unique index over a hidden field is not an
406
+ * identification for `/one/:id`, `/one?…`, `DELETE`, a PK-less `PATCH` or
407
+ * an action id (primary key and `preferredId` always are) — and the
408
+ * nested-object 400 hint lists visible leaves only. The default accepts every real path
382
409
  * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
383
410
  * there with `applyMetaOverlay`. Native text search and vector search
384
411
  * (`$vector` names an index) run inside the engine over its indexes, out of
@@ -453,6 +480,14 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
453
480
  private widenPreferredIdProjection;
454
481
  private _widenArrayProjection;
455
482
  private _widenMapProjection;
483
+ /**
484
+ * The logical paths an exclusion keeps. A path goes when it, an ancestor
485
+ * or a descendant is excluded: excluding an object parent excludes its
486
+ * whole subtree, and a kept parent would carry an excluded child back
487
+ * (its other leaves stay listed on their own). Before 0.1.134 only the
488
+ * exact paths were dropped, so `$select=-a` still returned `a`'s leaves.
489
+ */
490
+ private _invertExclusion;
456
491
  /**
457
492
  * Auto-includes the sibling-ref field whenever its `@db.amount.currency.ref`
458
493
  * / `@db.unit.ref` quantity is selected — UI must never get a value without
@@ -523,7 +558,12 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
523
558
  * family (count vs no-count).
524
559
  */
525
560
  private _runReadWithActions;
526
- /** Pick the first identification (PK or unique index) whose fields are all present in the query. */
561
+ /**
562
+ * Pick the first identification (PK or unique index) whose fields are all
563
+ * present in the query. A unique index over a field {@link hasField} hides
564
+ * is not a candidate (since 0.1.134) — `?hidden=x` answers exactly like
565
+ * `?nope=x`, so it cannot probe whether a row with that value exists.
566
+ */
527
567
  protected extractIdShape(query: Record<string, string>): Record<string, unknown> | HttpError;
528
568
  /**
529
569
  * **GET /query** — returns an array of records or a count.
@@ -664,14 +704,17 @@ declare class AsDbController<T extends TAtscriptAnnotatedType = TAtscriptAnnotat
664
704
  * several table operations.
665
705
  */
666
706
  protected withTransaction<R>(fn: () => Promise<R>): Promise<R>;
707
+ /** The table write call's trailing options — see {@link _hookArgs}. */
708
+ private readonly _writeArgs;
709
+ /** `deleteOne`'s trailing options — see {@link _hookArgs}. */
710
+ private readonly _removeArgs;
667
711
  /**
668
- * The table write call's trailing options: `[{ guard }]` only when
669
- * `guardWrite` is overridden, else nothing (the table is called exactly as
670
- * an unmodified controller always called it).
712
+ * A table call's trailing options, built once: `guard` only when the guard
713
+ * hook is overridden, `isFieldVisible` only when `hasField` is (an id or a
714
+ * PK-less payload never resolves through a hidden unique key) — else
715
+ * nothing, so an unmodified controller calls the table exactly as before.
671
716
  */
672
- private _writeArgs;
673
- /** `deleteOne`'s trailing options: `[{ guard }]` only when `guardRemove` is overridden. */
674
- private _removeArgs;
717
+ private _hookArgs;
675
718
  /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
676
719
  private _checkHook;
677
720
  /** Runs `onWrite` and re-applies the shape gate to its output (a non-object is a 500 "Not saved"). */
@@ -1528,13 +1571,6 @@ declare function getControllerFormType(ctor: Function, name: string): TAtscriptA
1528
1571
  /** Discover actions on a controller, memoized per ctor. `info`-only callers map `e => e.info`. */
1529
1572
  declare function discoverActions(controllerCtor: Function, app: Moost, logger: TConsoleBase): TDbActionEnvelope[];
1530
1573
  //#endregion
1531
- //#region src/actions/id-validation.d.ts
1532
- /** Duck-typed shape; matches `AtscriptDbReadable`'s public surface. */
1533
- interface IdValidationSource {
1534
- readonly identifications: readonly TIdentification[];
1535
- readonly fieldDescriptors: readonly TDbFieldMeta[];
1536
- }
1537
- //#endregion
1538
1574
  //#region src/actions/id-cache.d.ts
1539
1575
  declare const useDbActionId: import("@wooksjs/event-core").WookComposable<{
1540
1576
  load: () => Promise<Record<string, unknown>>;
package/dist/index.mjs CHANGED
@@ -1180,6 +1180,13 @@ const OP_VERB = {
1180
1180
  aggregate: "aggregate over",
1181
1181
  bucket: "bucket"
1182
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
+ }
1183
1190
  function leafHint(leaves) {
1184
1191
  if (leaves.length === 0) return "no leaf fields";
1185
1192
  const shown = leaves.slice(0, 5).join(", ");
@@ -1379,10 +1386,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1379
1386
  * occurrence); it only matters for `op === "filter"` on a listed leaf.
1380
1387
  */
1381
1388
  check(path, op, exists, predicate = "compare") {
1382
- if (!exists(path)) return {
1383
- path,
1384
- message: `Unknown field "${path}"`
1385
- };
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 {
@@ -1423,12 +1427,16 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1423
1427
  }
1424
1428
  }
1425
1429
  switch (kind) {
1426
- case "objectParent":
1430
+ case "objectParent": {
1427
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);
1428
1435
  return {
1429
1436
  path,
1430
- 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)})`
1431
1438
  };
1439
+ }
1432
1440
  case "jsonDescendant": return {
1433
1441
  path,
1434
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.`
@@ -1440,10 +1448,7 @@ var FieldCapabilityIndex = class FieldCapabilityIndex {
1440
1448
  path,
1441
1449
  message: `Cannot ${OP_VERB[op]} encrypted field "${path}"`
1442
1450
  };
1443
- default: return {
1444
- path,
1445
- message: `Unknown field "${path}"`
1446
- };
1451
+ default: return unknownField(path);
1447
1452
  }
1448
1453
  }
1449
1454
  };
@@ -1485,6 +1490,7 @@ const GEO_CONTROLS = [
1485
1490
  ];
1486
1491
  //#endregion
1487
1492
  //#region src/as-db-readable.controller.ts
1493
+ var _AsDbReadableController;
1488
1494
  /** Gate positions checked after the filter entries, in order; `refs[op]` are their paths. */
1489
1495
  const PATH_OPS = [
1490
1496
  "sort",
@@ -1494,7 +1500,7 @@ const PATH_OPS = [
1494
1500
  "aggregate",
1495
1501
  "bucket"
1496
1502
  ];
1497
- let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1503
+ let AsDbReadableController = _AsDbReadableController = class AsDbReadableController extends AsReadableController {
1498
1504
  /** Reference to the underlying readable (table or view). */
1499
1505
  readable;
1500
1506
  /**
@@ -1534,6 +1540,15 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1534
1540
  }
1535
1541
  /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1536
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();
1537
1552
  _preferredIdSet;
1538
1553
  _overlayIsNoOp;
1539
1554
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
@@ -1559,6 +1574,29 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1559
1574
  this._quantityRefByPath = this._collectQuantityRefs();
1560
1575
  const defaultOverlay = AsReadableController.prototype.applyMetaOverlay;
1561
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;
1562
1600
  }
1563
1601
  _collectInvertibleFields() {
1564
1602
  const out = [];
@@ -1593,7 +1631,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1593
1631
  * `$with` relation names and sub-query paths, and the `$search` fallback
1594
1632
  * fields. A path it rejects is answered exactly like a nonexistent one
1595
1633
  * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
1596
- * fields per request (read scopes). The default accepts every real path
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
1597
1639
  * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1598
1640
  * there with `applyMetaOverlay`. Native text search and vector search
1599
1641
  * (`$vector` names an index) run inside the engine over its indexes, out of
@@ -1767,11 +1809,26 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1767
1809
  return widened;
1768
1810
  }
1769
1811
  const widened = {};
1770
- for (const path of this._invertibleFields) if (!excluded.has(path)) widened[path] = 1;
1812
+ for (const path of this._invertExclusion(excluded)) widened[path] = 1;
1771
1813
  for (const field of this._preferredIdSet) widened[field] = 1;
1772
1814
  return widened;
1773
1815
  }
1774
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
+ /**
1775
1832
  * Auto-includes the sibling-ref field whenever its `@db.amount.currency.ref`
1776
1833
  * / `@db.unit.ref` quantity is selected — UI must never get a value without
1777
1834
  * its dimension. No-op when `$select` is undefined (full row covers it).
@@ -1834,10 +1891,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1834
1891
  for (const [k, v] of Object.entries(obj)) if (v === 1 || v === true) included.push(k);
1835
1892
  else if (v === 0 || v === false) excluded.push(k);
1836
1893
  if (included.length > 0 && excluded.length === 0) return included;
1837
- if (excluded.length > 0 && included.length === 0) {
1838
- const excludedSet = new Set(excluded);
1839
- return this._invertibleFields.filter((path) => !excludedSet.has(path));
1840
- }
1894
+ if (excluded.length > 0 && included.length === 0) return this._invertExclusion(new Set(excluded));
1841
1895
  throw new HttpError(500, "[moost-db] mixed inclusion/exclusion projection reached augmenter; widenPreferredIdProjection should have rejected it");
1842
1896
  }
1843
1897
  /** WHY: filter row/rows envelopes by the per-request `applyMetaOverlay` action set; skip `meta()` when overlay is identity. */
@@ -2018,9 +2072,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2018
2072
  });
2019
2073
  return result;
2020
2074
  }
2021
- /** 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
+ */
2022
2081
  extractIdShape(query) {
2023
- for (const id of this.readable.identifications) {
2082
+ for (const id of this.idSource.identifications) {
2024
2083
  const idObj = {};
2025
2084
  let allPresent = true;
2026
2085
  for (const field of id.fields) {
@@ -2257,7 +2316,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2257
2316
  ...parsedControls,
2258
2317
  $select: initialSelect
2259
2318
  };
2260
- const idFilter = this.readable.resolveIdFilter(id);
2319
+ const idFilter = this.readable.resolveIdFilter(id, this._idOpts);
2261
2320
  let row = null;
2262
2321
  if (idFilter) {
2263
2322
  const overlay = await this.transformOne({});
@@ -2380,7 +2439,7 @@ __decorate([
2380
2439
  __decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record, String]),
2381
2440
  __decorateMetadata("design:returntype", Promise)
2382
2441
  ], AsDbReadableController.prototype, "getOneComposite", null);
2383
- AsDbReadableController = __decorate([
2442
+ AsDbReadableController = _AsDbReadableController = __decorate([
2384
2443
  Inherit(),
2385
2444
  __decorateParam(1, Inject(READABLE_DEF)),
2386
2445
  __decorateParam(1, Optional()),
@@ -2412,6 +2471,9 @@ function hasWriteShape(data, many) {
2412
2471
  let AsDbController = _AsDbController = class AsDbController extends AsDbReadableController {
2413
2472
  constructor(app, table) {
2414
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);
2415
2477
  }
2416
2478
  buildCrud() {
2417
2479
  return {
@@ -2479,19 +2541,20 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2479
2541
  withTransaction(fn) {
2480
2542
  return this.table.getAdapter().withTransaction(fn);
2481
2543
  }
2544
+ /** The table write call's trailing options — see {@link _hookArgs}. */
2545
+ _writeArgs;
2546
+ /** `deleteOne`'s trailing options — see {@link _hookArgs}. */
2547
+ _removeArgs;
2482
2548
  /**
2483
- * The table write call's trailing options: `[{ guard }]` only when
2484
- * `guardWrite` is overridden, else nothing (the table is called exactly as
2485
- * 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.
2486
2553
  */
2487
- _writeArgs() {
2488
- if (this.guardWrite === _AsDbController.prototype.guardWrite) return [];
2489
- return [{ guard: (ctx) => this.guardWrite(ctx) }];
2490
- }
2491
- /** `deleteOne`'s trailing options: `[{ guard }]` only when `guardRemove` is overridden. */
2492
- _removeArgs() {
2493
- if (this.guardRemove === _AsDbController.prototype.guardRemove) return [];
2494
- 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] : [];
2495
2558
  }
2496
2559
  /** Resolves a hook result: `undefined` aborts with `abortMessage`, an `Error` is thrown, anything else passes. */
2497
2560
  async _checkHook(pending, abortMessage) {
@@ -2541,7 +2604,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2541
2604
  }
2542
2605
  /** Deletes by id (guard forwarded when overridden) and maps "nothing deleted" to 404. */
2543
2606
  async _deleteOrThrow(id) {
2544
- const result = await this.table.deleteOne(id, ...this._removeArgs());
2607
+ const result = await this.table.deleteOne(id, ...this._removeArgs);
2545
2608
  if (result.deletedCount < 1) throw new HttpError(404);
2546
2609
  return result;
2547
2610
  }
@@ -2552,10 +2615,10 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2552
2615
  assertWriteShape(payload);
2553
2616
  if (Array.isArray(payload)) {
2554
2617
  const rows = await this._writeBody("insertMany", payload, true);
2555
- return this.table.insertMany(rows, ...this._writeArgs());
2618
+ return this.table.insertMany(rows, ...this._writeArgs);
2556
2619
  }
2557
2620
  const row = await this._writeBody("insert", payload, false);
2558
- return this.table.insertOne(row, ...this._writeArgs());
2621
+ return this.table.insertOne(row, ...this._writeArgs);
2559
2622
  }
2560
2623
  /**
2561
2624
  * **PUT /** — fully replaces one or many records matched by primary key.
@@ -2572,11 +2635,11 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2572
2635
  if (Array.isArray(payload)) {
2573
2636
  const rows = await this._writeBody("replaceMany", payload, true);
2574
2637
  this._resolveBulkCas(rows, versionColumn);
2575
- return this.table.bulkReplace(rows, ...this._writeArgs());
2638
+ return this.table.bulkReplace(rows, ...this._writeArgs);
2576
2639
  }
2577
2640
  const row = await this._writeBody("replace", payload, false);
2578
2641
  const hadCas = this._resolveCas(row, versionColumn);
2579
- const result = await this.table.replaceOne(row, ...this._writeArgs());
2642
+ const result = await this.table.replaceOne(row, ...this._writeArgs);
2580
2643
  if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
2581
2644
  return result;
2582
2645
  }
@@ -2593,11 +2656,11 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2593
2656
  if (Array.isArray(payload)) {
2594
2657
  const rows = await this._writeBody("updateMany", payload, true);
2595
2658
  this._resolveBulkCas(rows, versionColumn);
2596
- return this.table.bulkUpdate(rows, ...this._writeArgs());
2659
+ return this.table.bulkUpdate(rows, ...this._writeArgs);
2597
2660
  }
2598
2661
  const row = await this._writeBody("update", payload, false);
2599
2662
  const hadCas = this._resolveCas(row, versionColumn);
2600
- const result = await this.table.updateOne(row, ...this._writeArgs());
2663
+ const result = await this.table.updateOne(row, ...this._writeArgs);
2601
2664
  if (hadCas && result.matchedCount === 0) throw await this._disambiguateMismatch(row, versionColumn);
2602
2665
  return result;
2603
2666
  }
@@ -2608,7 +2671,7 @@ let AsDbController = _AsDbController = class AsDbController extends AsDbReadable
2608
2671
  * but the supplied version is stale (§6.3). Callers throw the result.
2609
2672
  */
2610
2673
  async _disambiguateMismatch(data, versionColumn) {
2611
- const filter = this.table.resolveIdFilter(data);
2674
+ const filter = this.table.resolveIdFilter(data, this._idOpts);
2612
2675
  const row = filter ? await this.table.findOne({
2613
2676
  filter,
2614
2677
  controls: {}
@@ -3157,8 +3220,11 @@ function isPlainObject$1(value) {
3157
3220
  //#endregion
3158
3221
  //#region src/actions/id-cache.ts
3159
3222
  const boundTableKey = key("atscript_db_action_bound_table");
3223
+ function controllerOf(ctx) {
3224
+ return useControllerContext(ctx).getController();
3225
+ }
3160
3226
  function controllerTable(ctx) {
3161
- const ctrl = useControllerContext(ctx).getController();
3227
+ const ctrl = controllerOf(ctx);
3162
3228
  return ctrl?.readable ?? ctrl?.table ?? null;
3163
3229
  }
3164
3230
  function getActionTable(ctx) {
@@ -3179,11 +3245,22 @@ function noTableError(ctx) {
3179
3245
  code: "ACTION_TABLE_NOT_BOUND"
3180
3246
  });
3181
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
+ */
3182
3255
  async function resolveValidatedId(ctx, validate) {
3183
- const table = getActionTable(ctx);
3184
- 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);
3185
3262
  const env = await ctx.get(dbActionBodySlot);
3186
- validate(env.ids, table);
3263
+ validate(env.ids, source);
3187
3264
  return env.ids;
3188
3265
  }
3189
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.133",
3
+ "version": "0.1.134",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@uniqu/url": "^0.1.10",
47
- "@atscript/db-memory": "^0.1.133"
47
+ "@atscript/db-memory": "^0.1.134"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@atscript/core": "^0.1.92",
@@ -64,7 +64,7 @@
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.133"
67
+ "@atscript/db": "^0.1.134"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",