@atscript/moost-db 0.1.148 → 0.1.149

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
@@ -71,6 +71,13 @@ function insightError(insights, message) {
71
71
  const entry = insightPaths.get(insights);
72
72
  return entry?.message === message ? badRequest(entry.path, message) : new _moostjs_event_http.HttpError(400, message);
73
73
  }
74
+ /**
75
+ * The 400 envelope of a `ValidatorError`; `undefined` for any other error.
76
+ * @internal Not part of the public API (not re-exported from the barrel).
77
+ */
78
+ function validatorErrorToHttp(error) {
79
+ return error instanceof _atscript_typescript_utils.ValidatorError ? errorEnvelope(400, error.message, error.errors) : void 0;
80
+ }
74
81
  //#endregion
75
82
  //#region src/validation-interceptor.ts
76
83
  const dbErrorCodeToStatus = {
@@ -81,7 +88,8 @@ const dbErrorCodeToStatus = {
81
88
  BUCKET_TZ_UNAVAILABLE: 501
82
89
  };
83
90
  function transformValidationError(error, reply) {
84
- if (error instanceof _atscript_typescript_utils.ValidatorError) reply(errorEnvelope(400, error.message, error.errors));
91
+ const validation = validatorErrorToHttp(error);
92
+ if (validation) reply(validation);
85
93
  else if (error instanceof _atscript_db.DbError) reply(errorEnvelope(dbErrorCodeToStatus[error.code] ?? 400, error.message, error.errors));
86
94
  }
87
95
  const validationErrorTransform = () => (0, moost.defineInterceptor)({ error: transformValidationError }, moost.TInterceptorPriority.BEFORE_ALL);
@@ -4192,6 +4200,17 @@ const PATH_OPS = [
4192
4200
  "aggregate",
4193
4201
  "bucket"
4194
4202
  ];
4203
+ /** The controller of the event that owns the route params (nearest ancestor that set them). */
4204
+ function routedController(ctx) {
4205
+ for (let c = ctx; c; c = c.parent) {
4206
+ try {
4207
+ c.getOwn(_wooksjs_event_core.routeParamsKey);
4208
+ } catch {
4209
+ continue;
4210
+ }
4211
+ return controllerOf(c);
4212
+ }
4213
+ }
4195
4214
  /** The 400 of a filter / sort on a `@db.writeOnly` field. */
4196
4215
  function writeOnlyError(path, op) {
4197
4216
  const verdict = writeOnlyVerdict(path, op);
@@ -4467,7 +4486,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4467
4486
  * (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
4468
4487
  * answers exactly like a nonexistent index (400); a hidden DEFAULT text
4469
4488
  * index falls back to the `@db.column.searchable` substring search over
4470
- * visible fields (or ignores the term when there are none). A
4489
+ * visible fields (or ignores the term when there are none — on list
4490
+ * endpoints; query targets, delegated targets and {@link resolveQuery}
4491
+ * answer 400 `TARGET_INVALID` instead). A
4471
4492
  * `@db.column.derived` field is visible only while its source path is,
4472
4493
  * and one whose source is hidden is sealed out of every read projection
4473
4494
  * for the request, like a `@db.writeOnly` field. The same holds for a
@@ -5070,6 +5091,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5070
5091
  * {@link checkCapabilities}, {@link hasField}), where the client
5071
5092
  * predicates' {@link transformRelationFilter} also runs: a query target
5072
5093
  * never filters on, nor counts by, a field the caller can't read.
5094
+ * {@link resolveQuery} does not call it (there is no action of this
5095
+ * controller to scope).
5073
5096
  *
5074
5097
  * @since 0.1.147
5075
5098
  */
@@ -5406,18 +5429,76 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5406
5429
  async [RESOLVE_TARGET](req) {
5407
5430
  const { action } = req;
5408
5431
  const body = parseQueryTargetBody(action, req.query);
5432
+ const { rows, filter, findMany, exclude, visibleOf, cap } = await this._resolveMatching({
5433
+ label: action,
5434
+ body,
5435
+ cap: req.cap,
5436
+ maxExclude: req.maxExclude,
5437
+ overlay: req.overlay,
5438
+ scopeByAction: true,
5439
+ select: [...new Set(req.select)],
5440
+ sortBy: req.select,
5441
+ excludeShapes: req.excludeShapes,
5442
+ visibleOf: req.visibleOf
5443
+ });
5444
+ const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
5445
+ ...q,
5446
+ controls: {
5447
+ ...q.controls,
5448
+ $limit: Math.max(ids.length, cap + 1)
5449
+ }
5450
+ }) }, ids, scope, select);
5451
+ const snapshotFields = new Set(req.select);
5452
+ let first = true;
5453
+ return {
5454
+ matched: rows.length,
5455
+ rows,
5456
+ dryRun: body.dryRun === true,
5457
+ exclude,
5458
+ visibleOf,
5459
+ load: (ids, select) => {
5460
+ if (!first) return byIds(findMany, ids, filter, select);
5461
+ first = false;
5462
+ const fields = [...select];
5463
+ if (!fields.every((f) => snapshotFields.has(f))) {
5464
+ const plain = (q) => this.readable.findMany(q);
5465
+ return byIds(plain, ids, void 0, fields);
5466
+ }
5467
+ return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$2(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
5468
+ }
5469
+ };
5470
+ }
5471
+ /**
5472
+ * The shared resolver behind query targets and {@link resolveQuery}: the
5473
+ * query body validated, checked and run as a READ of this controller, then
5474
+ * ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
5475
+ * scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
5476
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
5477
+ * `TARGET_CHANGED`.
5478
+ *
5479
+ * Everything that depends on the read's visibility — the `$search`
5480
+ * fallback, the native-search memo, the un-appliable-term refusal and the
5481
+ * delegated identity — is computed INSIDE the read child, where the
5482
+ * permission layer's per-request state is the read's.
5483
+ */
5484
+ async _resolveMatching(spec) {
5485
+ const { label, body } = spec;
5486
+ const cap = Math.min(spec.cap, body.maxRows ?? Infinity);
5409
5487
  const parsed = this.parseUrlOr400(body.q.startsWith("?") ? body.q.slice(1) : body.q);
5410
5488
  const controls = {};
5411
5489
  for (const [k, v] of Object.entries(parsed.controls ?? {})) {
5412
5490
  if (v === void 0) continue;
5413
- if (k !== "$search" && k !== "$index") throw targetInvalid(action, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
5414
- if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(action, "$search must be a search term");
5491
+ if (k !== "$search" && k !== "$index") throw targetInvalid(label, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
5492
+ if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(label, "$search must be a search term");
5415
5493
  controls[k] = k === "$search" ? `${v}` : v;
5416
5494
  }
5417
- if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(action, "$index must be an index name");
5495
+ if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(label, "$index must be an index name");
5418
5496
  const exclude = body.exclude ?? [];
5419
- const shapes = req.excludeShapes ?? [];
5497
+ const shapes = spec.excludeShapes ?? [];
5498
+ const sealedSet = () => new Set([...this.fieldVisibility.sealedFor(this.readable), ...this._leavesOf(this.readable).filter((leaf) => !this.fieldVisibility.isVisible(leaf))]);
5420
5499
  const check = () => {
5500
+ let sealed;
5501
+ const readableLeaf = (leaf) => !(sealed ??= sealedSet()).has(leaf);
5421
5502
  const controlsError = this.validateControls(controls, "query");
5422
5503
  if (controlsError) throw new _moostjs_event_http.HttpError(400, controlsError);
5423
5504
  const gateError = this.checkCapabilities({
@@ -5425,71 +5506,160 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5425
5506
  controls
5426
5507
  });
5427
5508
  if (gateError) throw gateError;
5428
- if (exclude.length > 0) validateMultiId(exclude, shapes.length === 0 ? this.idSource : {
5429
- identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
5430
- fields,
5431
- source: "target"
5432
- }))],
5433
- fieldDescriptors: this.readable.fieldDescriptors
5434
- }, req.maxExclude);
5509
+ if (exclude.length > 0) {
5510
+ const source = shapes.length === 0 ? this.idSource : {
5511
+ identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
5512
+ fields,
5513
+ source: "target"
5514
+ }))],
5515
+ fieldDescriptors: this.readable.fieldDescriptors
5516
+ };
5517
+ try {
5518
+ validateMultiId(exclude, source, spec.maxExclude);
5519
+ } catch (error) {
5520
+ throw validatorErrorToHttp(error) ?? error;
5521
+ }
5522
+ }
5523
+ const nav = FieldCapabilityIndex.navPathsOf(this.readable);
5524
+ for (const path of spec.selectGate ?? []) {
5525
+ if (this.capabilities.decorationCap(path) || (0, _atscript_db.selfOrAncestor)(path, nav) !== void 0) throw badRequest(path, `Unknown field "${path}"`);
5526
+ const verdict = this.capabilities.check(path, "select", this.fieldVisibility.isVisible);
5527
+ if (verdict) throw badRequest(verdict.path, verdict.message);
5528
+ if (this._writeOnlySet.has(path)) throw badRequest(path, `Field "${path}" is @db.writeOnly`);
5529
+ const prefix = `${path}.`;
5530
+ const leaves = this._leavesOf(this.readable).filter((leaf) => leaf.startsWith(prefix));
5531
+ if (leaves.length > 0 && !leaves.some((leaf) => readableLeaf(leaf))) throw badRequest(path, `Unknown field "${path}"`);
5532
+ }
5533
+ for (const path of spec.sortGate ?? []) {
5534
+ const verdict = this.capabilities.check(path, "sort", this.fieldVisibility.isVisible);
5535
+ if (verdict) throw targetInvalid(label, verdict.message);
5536
+ }
5435
5537
  };
5436
- const ownScope = req.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope;
5437
- const [[base, scope], overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
5538
+ const ownScope = spec.scopeByAction === true && (spec.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope);
5539
+ const [read, overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
5438
5540
  check();
5439
- const [clientFilter, readScope] = await Promise.all([this._relationOverlay(parsed), ownScope ? this.queryTargetScope(action) : void 0]);
5440
- return [req.overlay === "read" ? await this.transformFilter(clientFilter ?? {}) : clientFilter, readScope];
5441
- }), req.overlay === "action" ? this.rowOverlay() : void 0]);
5442
- const filter = (0, _atscript_db.andFilters)(this.applySearchFallback(base, controls), overlay, scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
5443
- const strategy = await this._resolveReadStrategy(controls);
5541
+ const [clientFilter, readScope, strategy] = await Promise.all([
5542
+ this._relationOverlay(parsed),
5543
+ ownScope ? this.queryTargetScope(label) : void 0,
5544
+ this._resolveReadStrategy(controls)
5545
+ ]);
5546
+ const base = spec.overlay === "action" ? clientFilter : await this.transformFilter(clientFilter ?? {});
5547
+ const searched = this.applySearchFallback(base, controls);
5548
+ if (controls.$search && strategy.kind !== "search" && searched === base) throw targetInvalid(label, "$search is not available here");
5549
+ const visibleOf = spec.visibleOf?.filter((f) => this.fieldVisibility.isVisible(f));
5550
+ let select = spec.select;
5551
+ if (spec.selectGate) {
5552
+ const sealed = sealedSet();
5553
+ for (const id of spec.identity ?? []) sealed.delete(id);
5554
+ select = this._sealSelect([...spec.select], sealed, this.readable);
5555
+ }
5556
+ return {
5557
+ searched,
5558
+ readScope,
5559
+ strategy,
5560
+ visibleOf,
5561
+ select
5562
+ };
5563
+ }, spec.routeParams), spec.overlay === "action" ? this.rowOverlay() : void 0]);
5564
+ const { strategy } = read;
5565
+ const filter = (0, _atscript_db.andFilters)(read.searched, overlay, read.readScope, spec.scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
5444
5566
  const findMany = (q) => strategy.kind === "search" ? this.readable.search(strategy.term, q, strategy.index) : this.readable.findMany(q);
5445
- const cap = Math.min(req.cap, body.maxRows ?? Infinity);
5446
- const sort = {};
5447
- for (const f of req.select) sort[f] = 1;
5448
5567
  const rows = await findMany({
5449
5568
  filter,
5450
5569
  controls: {
5451
- $select: [...new Set(req.select)],
5452
- $sort: sort,
5570
+ $select: [...read.select],
5571
+ $sort: Object.fromEntries(spec.sortBy.map((f) => [f, 1])),
5453
5572
  $limit: cap + 1
5454
5573
  }
5455
5574
  });
5456
- if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", action, `The query matches more than ${cap} rows`, { cap });
5457
- if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", action, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
5458
- const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
5459
- ...q,
5460
- controls: {
5461
- ...q.controls,
5462
- $limit: Math.max(ids.length, cap + 1)
5463
- }
5464
- }) }, ids, scope, select);
5465
- const snapshotFields = new Set(req.select);
5466
- let first = true;
5575
+ if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", label, `The query matches more than ${cap} rows`, { cap });
5576
+ if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", label, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
5467
5577
  return {
5468
- matched: rows.length,
5469
5578
  rows,
5470
- dryRun: body.dryRun === true,
5579
+ filter,
5580
+ findMany,
5471
5581
  exclude,
5472
- load: (ids, select) => {
5473
- if (!first) return byIds(findMany, ids, filter, select);
5474
- first = false;
5475
- const fields = [...select];
5476
- if (!fields.every((f) => snapshotFields.has(f))) {
5477
- const plain = (q) => this.readable.findMany(q);
5478
- return byIds(plain, ids, void 0, fields);
5479
- }
5480
- return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$2(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
5481
- }
5582
+ visibleOf: read.visibleOf,
5583
+ cap,
5584
+ select: read.select
5482
5585
  };
5483
5586
  }
5484
5587
  /**
5588
+ * Rows of THIS controller matching `q`, resolved as a READ of it for the
5589
+ * current event's caller (since 0.1.149) — from your own command, e.g. to
5590
+ * act on "every issue matching this search". `q` is a `GET /query` string
5591
+ * (`$search` / `$index` and a filter only) or a query-target envelope
5592
+ * `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
5593
+ *
5594
+ * The read runs under this controller's full read policy
5595
+ * ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
5596
+ * {@link validateControls}, the capability / index gate and the
5597
+ * {@link transformFilter} overlay) with the current event's identity.
5598
+ * Route interceptors and guards of the `query` route do not run; read
5599
+ * authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
5600
+ * is not called. Hooks see no route params of the caller (only a call from
5601
+ * the routed event's own controller instance keeps its params); pass route-derived
5602
+ * restrictions as `opts.scope`. Joins the caller's open transaction.
5603
+ *
5604
+ * Rows are ordered by identity (`preferredId`, else the primary key) and
5605
+ * carry the identity fields plus `opts.select` (gated like `/query`
5606
+ * `$select`; `transformProjection` is not applied — hide fields with
5607
+ * {@link hasField}; decoration keys and navigation paths are refused). An
5608
+ * identity-less readable is ordered by `select` (each path sortable, else
5609
+ * `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
5610
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
5611
+ * `TARGET_CHANGED`; a `$search` that can't be applied → 400
5612
+ * `TARGET_INVALID`. Must be awaited inside a running event handler.
5613
+ */
5614
+ async resolveQuery(q, opts = {}) {
5615
+ let caller;
5616
+ try {
5617
+ caller = (0, _wooksjs_event_core.current)();
5618
+ } catch {
5619
+ throw new Error("[moost-db] resolveQuery must be awaited inside an event handler");
5620
+ }
5621
+ const cap = opts.cap ?? 1e3;
5622
+ if (!Number.isInteger(cap) || cap < 1) throw new Error("[moost-db] resolveQuery: `cap` must be a positive integer");
5623
+ const select = opts.select ?? [];
5624
+ if (!Array.isArray(select) || select.some((p) => typeof p !== "string")) throw new Error("[moost-db] resolveQuery: `select` must be an array of field paths");
5625
+ const ids = this.readable.preferredId?.length ? this.readable.preferredId : this.readable.primaryKeys;
5626
+ const order = ids.length > 0 ? ids : select;
5627
+ if (order.length === 0) throw new Error("[moost-db] resolveQuery: this readable has no identity — pass `select` (rows are ordered by it)");
5628
+ const label = readCurrentActionMeta(caller)?.name ?? "";
5629
+ const body = parseQueryTargetBody(label, typeof q === "string" ? { q } : q);
5630
+ if (body.dryRun !== void 0) throw targetInvalid(label, "resolveQuery takes no `dryRun` — read `query.dryRun` yourself");
5631
+ let sameRoute = false;
5632
+ try {
5633
+ sameRoute = controllerOf(caller) === this && routedController(caller) === this;
5634
+ } catch {}
5635
+ const fields = [...new Set([...ids, ...select])];
5636
+ const { rows, select: sealedSelect } = await this._resolveMatching({
5637
+ label,
5638
+ body,
5639
+ cap,
5640
+ maxExclude: DEFAULT_MAX_ACTION_IDS,
5641
+ overlay: "read",
5642
+ scope: opts.scope,
5643
+ select: fields,
5644
+ sortBy: order,
5645
+ selectGate: select,
5646
+ identity: ids,
5647
+ sortGate: ids.length > 0 ? void 0 : order,
5648
+ routeParams: sameRoute ? void 0 : {}
5649
+ });
5650
+ return rows.map((row) => projectRow$2(row, sealedSelect));
5651
+ }
5652
+ /**
5485
5653
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
5486
5654
  * the current event whose controller context is this controller's `query`
5487
5655
  * handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
5488
5656
  * request-scoped state a permission layer builds there (read grant, field
5489
- * visibility) is the read's and stays in the child.
5657
+ * visibility) is the read's and stays in the child. `routeParams` (since
5658
+ * 0.1.149) replaces the route params the child's hooks read.
5490
5659
  */
5491
- _asRead(controls, filter, fn) {
5660
+ _asRead(controls, filter, fn, routeParams) {
5492
5661
  return runAsController(this, "query", async () => {
5662
+ if (routeParams) (0, _wooksjs_event_core.current)().set(_wooksjs_event_core.routeParamsKey, routeParams);
5493
5663
  if (typeof this.prepareRequest === "function") await this.prepareRequest(readRequestContext("query", controls, filter));
5494
5664
  return fn();
5495
5665
  });
@@ -5573,7 +5743,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5573
5743
  * with the existing filter. Applies only when native search does not serve
5574
5744
  * the request (no native search, or — since 0.1.143 — its default index
5575
5745
  * reads a field {@link hasField} hides) and the request isn't a vector
5576
- * search (`$vector` consumes the term).
5746
+ * search (`$vector` consumes the term). Lenient on list endpoints: a term
5747
+ * nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
5748
+ * refuse it — a subclass override that applies the term must return a new
5749
+ * filter object.
5577
5750
  */
5578
5751
  applySearchFallback(filter, controls) {
5579
5752
  const term = controls.$search;
@@ -6363,7 +6536,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6363
6536
  maxExclude: limits.maxIds,
6364
6537
  overlay: "read",
6365
6538
  select: [...new Set([...identity, ...delegation.paths])],
6366
- excludeShapes: [delegation.paths]
6539
+ excludeShapes: [delegation.paths],
6540
+ visibleOf: identity
6367
6541
  });
6368
6542
  if (resolved.dryRun) return { matched: resolved.matched };
6369
6543
  const summary = {
@@ -6373,7 +6547,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6373
6547
  failed: []
6374
6548
  };
6375
6549
  const { ids, index } = mapToSourceIds(resolved.rows, delegation.idMap);
6376
- const visibleIdentity = identity.filter((f) => this.fieldVisibility.isVisible(f));
6550
+ const visibleIdentity = resolved.visibleOf ?? [];
6377
6551
  for (let i = 0; i < index.length; i++) {
6378
6552
  if (index[i] >= 0) continue;
6379
6553
  const row = resolved.rows[i];
package/dist/index.d.cts CHANGED
@@ -385,6 +385,12 @@ type TDbQueryTargetOpts = boolean | {
385
385
  * registered symbol, like `ACTION_OVERLAY`).
386
386
  */
387
387
  declare const RESOLVE_TARGET: unique symbol;
388
+ /**
389
+ * `q` of {@link AsDbReadableController.resolveQuery}: a `GET /query` string, or a
390
+ * query-target envelope without `dryRun`.
391
+ * @since 0.1.149
392
+ */
393
+ type TDbResolveQueryInput = string | Omit<TDbActionQueryTarget, "dryRun">;
388
394
  /** What {@link RESOLVE_TARGET} resolves. */
389
395
  interface TTargetRequest {
390
396
  action: string;
@@ -400,6 +406,8 @@ interface TTargetRequest {
400
406
  * rows); `"read"` — `transformFilter` (a view resolving rows it delegates).
401
407
  */
402
408
  overlay: "action" | "read";
409
+ /** Identity fields to report the READ-visible subset of ({@link TResolvedTarget.visibleOf}). */
410
+ visibleOf?: readonly string[];
403
411
  /** Fields of the phase-1 read (sorted by the first identification's fields). */
404
412
  select: readonly string[];
405
413
  /** Key sets `exclude` entries may use besides the controller's identifications. */
@@ -414,6 +422,8 @@ interface TResolvedTarget {
414
422
  dryRun: boolean;
415
423
  /** The validated `exclude` entries (already applied to {@link rows}). */
416
424
  exclude: Record<string, unknown>[];
425
+ /** {@link TTargetRequest.visibleOf} filtered by the read's field visibility. */
426
+ visibleOf?: readonly string[];
417
427
  /**
418
428
  * The rows `ids` address that STILL match the target (filter, search,
419
429
  * overlay, `queryTargetScope`, `exclude`), aligned with `ids`; `select`
@@ -940,6 +950,15 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
940
950
  }
941
951
  //#endregion
942
952
  //#region src/as-db-readable.controller.d.ts
953
+ /** Options of {@link AsDbReadableController.resolveQuery}. @since 0.1.149 */
954
+ interface TDbResolveQueryOpts<K extends string = string> {
955
+ /** Fields to return besides the identity. Gated like `/query` `$select` under `hasField`. */
956
+ select?: readonly K[];
957
+ /** Most rows (default 1000); `q.maxRows` can only lower it. */
958
+ cap?: number;
959
+ /** Server-side restriction, ANDed in. Trusted: not gated, not shown to `prepareRequest`. */
960
+ scope?: FilterExpr;
961
+ }
943
962
  /** Read endpoint a {@link AsDbReadableController.decorateRows} call serves. */
944
963
  type TDbDecorateEndpoint = "query" | "pages" | "geo" | "one";
945
964
  /**
@@ -1168,7 +1187,9 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1168
1187
  * (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
1169
1188
  * answers exactly like a nonexistent index (400); a hidden DEFAULT text
1170
1189
  * index falls back to the `@db.column.searchable` substring search over
1171
- * visible fields (or ignores the term when there are none). A
1190
+ * visible fields (or ignores the term when there are none — on list
1191
+ * endpoints; query targets, delegated targets and {@link resolveQuery}
1192
+ * answer 400 `TARGET_INVALID` instead). A
1172
1193
  * `@db.column.derived` field is visible only while its source path is,
1173
1194
  * and one whose source is hidden is sealed out of every read projection
1174
1195
  * for the request, like a `@db.writeOnly` field. The same holds for a
@@ -1451,6 +1472,8 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1451
1472
  * {@link checkCapabilities}, {@link hasField}), where the client
1452
1473
  * predicates' {@link transformRelationFilter} also runs: a query target
1453
1474
  * never filters on, nor counts by, a field the caller can't read.
1475
+ * {@link resolveQuery} does not call it (there is no action of this
1476
+ * controller to scope).
1454
1477
  *
1455
1478
  * @since 0.1.147
1456
1479
  */
@@ -1567,12 +1590,55 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1567
1590
  * the first batch, which directly follows the snapshot.
1568
1591
  */
1569
1592
  [RESOLVE_TARGET](req: TTargetRequest): Promise<TResolvedTarget>;
1593
+ /**
1594
+ * The shared resolver behind query targets and {@link resolveQuery}: the
1595
+ * query body validated, checked and run as a READ of this controller, then
1596
+ * ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
1597
+ * scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
1598
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
1599
+ * `TARGET_CHANGED`.
1600
+ *
1601
+ * Everything that depends on the read's visibility — the `$search`
1602
+ * fallback, the native-search memo, the un-appliable-term refusal and the
1603
+ * delegated identity — is computed INSIDE the read child, where the
1604
+ * permission layer's per-request state is the read's.
1605
+ */
1606
+ private _resolveMatching;
1607
+ /**
1608
+ * Rows of THIS controller matching `q`, resolved as a READ of it for the
1609
+ * current event's caller (since 0.1.149) — from your own command, e.g. to
1610
+ * act on "every issue matching this search". `q` is a `GET /query` string
1611
+ * (`$search` / `$index` and a filter only) or a query-target envelope
1612
+ * `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
1613
+ *
1614
+ * The read runs under this controller's full read policy
1615
+ * ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
1616
+ * {@link validateControls}, the capability / index gate and the
1617
+ * {@link transformFilter} overlay) with the current event's identity.
1618
+ * Route interceptors and guards of the `query` route do not run; read
1619
+ * authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
1620
+ * is not called. Hooks see no route params of the caller (only a call from
1621
+ * the routed event's own controller instance keeps its params); pass route-derived
1622
+ * restrictions as `opts.scope`. Joins the caller's open transaction.
1623
+ *
1624
+ * Rows are ordered by identity (`preferredId`, else the primary key) and
1625
+ * carry the identity fields plus `opts.select` (gated like `/query`
1626
+ * `$select`; `transformProjection` is not applied — hide fields with
1627
+ * {@link hasField}; decoration keys and navigation paths are refused). An
1628
+ * identity-less readable is ordered by `select` (each path sortable, else
1629
+ * `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
1630
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
1631
+ * `TARGET_CHANGED`; a `$search` that can't be applied → 400
1632
+ * `TARGET_INVALID`. Must be awaited inside a running event handler.
1633
+ */
1634
+ resolveQuery<K extends string = never>(q: TDbResolveQueryInput, opts?: TDbResolveQueryOpts<K>): Promise<Array<Pick<DataType, K & keyof DataType> & Record<string, unknown>>>;
1570
1635
  /**
1571
1636
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
1572
1637
  * the current event whose controller context is this controller's `query`
1573
1638
  * handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
1574
1639
  * request-scoped state a permission layer builds there (read grant, field
1575
- * visibility) is the read's and stays in the child.
1640
+ * visibility) is the read's and stays in the child. `routeParams` (since
1641
+ * 0.1.149) replaces the route params the child's hooks read.
1576
1642
  */
1577
1643
  private _asRead;
1578
1644
  /**
@@ -1598,7 +1664,10 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1598
1664
  * with the existing filter. Applies only when native search does not serve
1599
1665
  * the request (no native search, or — since 0.1.143 — its default index
1600
1666
  * reads a field {@link hasField} hides) and the request isn't a vector
1601
- * search (`$vector` consumes the term).
1667
+ * search (`$vector` consumes the term). Lenient on list endpoints: a term
1668
+ * nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
1669
+ * refuse it — a subclass override that applies the term must return a new
1670
+ * filter object.
1602
1671
  */
1603
1672
  protected applySearchFallback(filter: FilterExpr | undefined, controls: Record<string, unknown>): FilterExpr | undefined;
1604
1673
  /**
@@ -3419,4 +3488,4 @@ declare const REL_FILTER_CLIENT_MAX_DEPTH = 3;
3419
3488
  */
3420
3489
  declare const REL_FILTER_CLIENT_MAX_NODES = 8;
3421
3490
  //#endregion
3422
- export { ActionDisabledError, type ActionDisabledErrorBody, ActionTargetError, type ActionTargetErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, type TActionTargetErrorCode, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionDisabledVerdict, type TDbActionEnvelope, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionQueryTarget, type TDbActionScopeContext, type TDbActionScopePurpose, type TDbActionTarget, type TDbActionTargetSummary, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbActionsFromMeta, type TDbActionsFromOpts, type TDbAvailableActions, type TDbClassActionMeta, TDbControlsType, TDbDecorateContext, TDbDecorateEndpoint, type TDbDecorationsOpts, TDbFieldVisibility, TDbIndexFieldPaths, TDbParsedRequest, type TDbQueryTargetOpts, type TDbRemoveGuardContext, TDbRequestContext, TDbRequestEndpoint, type TDbRowIdInput, type TDbRowIdPurpose, type TDbRowIdsContext, type TDbWriteAction, type TDbWriteCheckContext, type TDbWriteGuardContext, type TFieldCapability, type TGateOp, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ValueHelpQuery, ValueHelpSelect, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
3491
+ export { ActionDisabledError, type ActionDisabledErrorBody, ActionTargetError, type ActionTargetErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, type TActionTargetErrorCode, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionDisabledVerdict, type TDbActionEnvelope, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionQueryTarget, type TDbActionScopeContext, type TDbActionScopePurpose, type TDbActionTarget, type TDbActionTargetSummary, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbActionsFromMeta, type TDbActionsFromOpts, type TDbAvailableActions, type TDbClassActionMeta, TDbControlsType, TDbDecorateContext, TDbDecorateEndpoint, type TDbDecorationsOpts, TDbFieldVisibility, TDbIndexFieldPaths, TDbParsedRequest, type TDbQueryTargetOpts, type TDbRemoveGuardContext, TDbRequestContext, TDbRequestEndpoint, type TDbResolveQueryInput, TDbResolveQueryOpts, type TDbRowIdInput, type TDbRowIdPurpose, type TDbRowIdsContext, type TDbWriteAction, type TDbWriteCheckContext, type TDbWriteGuardContext, type TFieldCapability, type TGateOp, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ValueHelpQuery, ValueHelpSelect, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
package/dist/index.d.mts CHANGED
@@ -385,6 +385,12 @@ type TDbQueryTargetOpts = boolean | {
385
385
  * registered symbol, like `ACTION_OVERLAY`).
386
386
  */
387
387
  declare const RESOLVE_TARGET: unique symbol;
388
+ /**
389
+ * `q` of {@link AsDbReadableController.resolveQuery}: a `GET /query` string, or a
390
+ * query-target envelope without `dryRun`.
391
+ * @since 0.1.149
392
+ */
393
+ type TDbResolveQueryInput = string | Omit<TDbActionQueryTarget, "dryRun">;
388
394
  /** What {@link RESOLVE_TARGET} resolves. */
389
395
  interface TTargetRequest {
390
396
  action: string;
@@ -400,6 +406,8 @@ interface TTargetRequest {
400
406
  * rows); `"read"` — `transformFilter` (a view resolving rows it delegates).
401
407
  */
402
408
  overlay: "action" | "read";
409
+ /** Identity fields to report the READ-visible subset of ({@link TResolvedTarget.visibleOf}). */
410
+ visibleOf?: readonly string[];
403
411
  /** Fields of the phase-1 read (sorted by the first identification's fields). */
404
412
  select: readonly string[];
405
413
  /** Key sets `exclude` entries may use besides the controller's identifications. */
@@ -414,6 +422,8 @@ interface TResolvedTarget {
414
422
  dryRun: boolean;
415
423
  /** The validated `exclude` entries (already applied to {@link rows}). */
416
424
  exclude: Record<string, unknown>[];
425
+ /** {@link TTargetRequest.visibleOf} filtered by the read's field visibility. */
426
+ visibleOf?: readonly string[];
417
427
  /**
418
428
  * The rows `ids` address that STILL match the target (filter, search,
419
429
  * overlay, `queryTargetScope`, `exclude`), aligned with `ids`; `select`
@@ -940,6 +950,15 @@ declare class FieldCapabilityIndex implements TQueryPathSource {
940
950
  }
941
951
  //#endregion
942
952
  //#region src/as-db-readable.controller.d.ts
953
+ /** Options of {@link AsDbReadableController.resolveQuery}. @since 0.1.149 */
954
+ interface TDbResolveQueryOpts<K extends string = string> {
955
+ /** Fields to return besides the identity. Gated like `/query` `$select` under `hasField`. */
956
+ select?: readonly K[];
957
+ /** Most rows (default 1000); `q.maxRows` can only lower it. */
958
+ cap?: number;
959
+ /** Server-side restriction, ANDed in. Trusted: not gated, not shown to `prepareRequest`. */
960
+ scope?: FilterExpr;
961
+ }
943
962
  /** Read endpoint a {@link AsDbReadableController.decorateRows} call serves. */
944
963
  type TDbDecorateEndpoint = "query" | "pages" | "geo" | "one";
945
964
  /**
@@ -1168,7 +1187,9 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1168
1187
  * (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
1169
1188
  * answers exactly like a nonexistent index (400); a hidden DEFAULT text
1170
1189
  * index falls back to the `@db.column.searchable` substring search over
1171
- * visible fields (or ignores the term when there are none). A
1190
+ * visible fields (or ignores the term when there are none — on list
1191
+ * endpoints; query targets, delegated targets and {@link resolveQuery}
1192
+ * answer 400 `TARGET_INVALID` instead). A
1172
1193
  * `@db.column.derived` field is visible only while its source path is,
1173
1194
  * and one whose source is hidden is sealed out of every read projection
1174
1195
  * for the request, like a `@db.writeOnly` field. The same holds for a
@@ -1451,6 +1472,8 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1451
1472
  * {@link checkCapabilities}, {@link hasField}), where the client
1452
1473
  * predicates' {@link transformRelationFilter} also runs: a query target
1453
1474
  * never filters on, nor counts by, a field the caller can't read.
1475
+ * {@link resolveQuery} does not call it (there is no action of this
1476
+ * controller to scope).
1454
1477
  *
1455
1478
  * @since 0.1.147
1456
1479
  */
@@ -1567,12 +1590,55 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1567
1590
  * the first batch, which directly follows the snapshot.
1568
1591
  */
1569
1592
  [RESOLVE_TARGET](req: TTargetRequest): Promise<TResolvedTarget>;
1593
+ /**
1594
+ * The shared resolver behind query targets and {@link resolveQuery}: the
1595
+ * query body validated, checked and run as a READ of this controller, then
1596
+ * ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
1597
+ * scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
1598
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
1599
+ * `TARGET_CHANGED`.
1600
+ *
1601
+ * Everything that depends on the read's visibility — the `$search`
1602
+ * fallback, the native-search memo, the un-appliable-term refusal and the
1603
+ * delegated identity — is computed INSIDE the read child, where the
1604
+ * permission layer's per-request state is the read's.
1605
+ */
1606
+ private _resolveMatching;
1607
+ /**
1608
+ * Rows of THIS controller matching `q`, resolved as a READ of it for the
1609
+ * current event's caller (since 0.1.149) — from your own command, e.g. to
1610
+ * act on "every issue matching this search". `q` is a `GET /query` string
1611
+ * (`$search` / `$index` and a filter only) or a query-target envelope
1612
+ * `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
1613
+ *
1614
+ * The read runs under this controller's full read policy
1615
+ * ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
1616
+ * {@link validateControls}, the capability / index gate and the
1617
+ * {@link transformFilter} overlay) with the current event's identity.
1618
+ * Route interceptors and guards of the `query` route do not run; read
1619
+ * authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
1620
+ * is not called. Hooks see no route params of the caller (only a call from
1621
+ * the routed event's own controller instance keeps its params); pass route-derived
1622
+ * restrictions as `opts.scope`. Joins the caller's open transaction.
1623
+ *
1624
+ * Rows are ordered by identity (`preferredId`, else the primary key) and
1625
+ * carry the identity fields plus `opts.select` (gated like `/query`
1626
+ * `$select`; `transformProjection` is not applied — hide fields with
1627
+ * {@link hasField}; decoration keys and navigation paths are refused). An
1628
+ * identity-less readable is ordered by `select` (each path sortable, else
1629
+ * `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
1630
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
1631
+ * `TARGET_CHANGED`; a `$search` that can't be applied → 400
1632
+ * `TARGET_INVALID`. Must be awaited inside a running event handler.
1633
+ */
1634
+ resolveQuery<K extends string = never>(q: TDbResolveQueryInput, opts?: TDbResolveQueryOpts<K>): Promise<Array<Pick<DataType, K & keyof DataType> & Record<string, unknown>>>;
1570
1635
  /**
1571
1636
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
1572
1637
  * the current event whose controller context is this controller's `query`
1573
1638
  * handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
1574
1639
  * request-scoped state a permission layer builds there (read grant, field
1575
- * visibility) is the read's and stays in the child.
1640
+ * visibility) is the read's and stays in the child. `routeParams` (since
1641
+ * 0.1.149) replaces the route params the child's hooks read.
1576
1642
  */
1577
1643
  private _asRead;
1578
1644
  /**
@@ -1598,7 +1664,10 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
1598
1664
  * with the existing filter. Applies only when native search does not serve
1599
1665
  * the request (no native search, or — since 0.1.143 — its default index
1600
1666
  * reads a field {@link hasField} hides) and the request isn't a vector
1601
- * search (`$vector` consumes the term).
1667
+ * search (`$vector` consumes the term). Lenient on list endpoints: a term
1668
+ * nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
1669
+ * refuse it — a subclass override that applies the term must return a new
1670
+ * filter object.
1602
1671
  */
1603
1672
  protected applySearchFallback(filter: FilterExpr | undefined, controls: Record<string, unknown>): FilterExpr | undefined;
1604
1673
  /**
@@ -3419,4 +3488,4 @@ declare const REL_FILTER_CLIENT_MAX_DEPTH = 3;
3419
3488
  */
3420
3489
  declare const REL_FILTER_CLIENT_MAX_NODES = 8;
3421
3490
  //#endregion
3422
- export { ActionDisabledError, type ActionDisabledErrorBody, ActionTargetError, type ActionTargetErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, type TActionTargetErrorCode, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionDisabledVerdict, type TDbActionEnvelope, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionQueryTarget, type TDbActionScopeContext, type TDbActionScopePurpose, type TDbActionTarget, type TDbActionTargetSummary, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbActionsFromMeta, type TDbActionsFromOpts, type TDbAvailableActions, type TDbClassActionMeta, TDbControlsType, TDbDecorateContext, TDbDecorateEndpoint, type TDbDecorationsOpts, TDbFieldVisibility, TDbIndexFieldPaths, TDbParsedRequest, type TDbQueryTargetOpts, type TDbRemoveGuardContext, TDbRequestContext, TDbRequestEndpoint, type TDbRowIdInput, type TDbRowIdPurpose, type TDbRowIdsContext, type TDbWriteAction, type TDbWriteCheckContext, type TDbWriteGuardContext, type TFieldCapability, type TGateOp, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ValueHelpQuery, ValueHelpSelect, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
3491
+ export { ActionDisabledError, type ActionDisabledErrorBody, ActionTargetError, type ActionTargetErrorBody, AsDbController, AsDbReadableController, AsJsonValueHelpController, AsReadableController, AsValueHelpController, type AtscriptDbMate, type AtscriptDbMeta, type AtscriptDbParamsMeta, DB_CRUD_HANDLERS, DEFAULT_DB_SPACE, DbAction, DbActionDefault, type DbActionEnvelope, DbActionID, DbActionIDs, type DbActionOpts, DbActionRow, DbActionRows, DbActionTarget, DbActions, DbActionsFrom, DbDecorations, DbRowActions, DbRowsActions, DbTableActions, FieldCapabilityIndex, type IdValidationSource, InputForm, ONE_CONTROLS, PAGES_CONTROLS, QUERY_CONTROLS, READABLE_DEF, REL_FILTER_CLIENT_MAX_DEPTH, REL_FILTER_CLIENT_MAX_NODES, ReadableController, TABLE_DEF, type TActionTargetErrorCode, TAssertExposedOptions, type TCapabilityReadable, type TCapabilityVerdict, TControllerBindingOptions, type TCrudOp, type TCrudPermissions, type TDbActionDisabledVerdict, type TDbActionEnvelope, type TDbActionInfo, type TDbActionInputFormMeta, type TDbActionIntent, type TDbActionLevel, type TDbActionMeta, type TDbActionParamKind, type TDbActionProcessor, type TDbActionQueryTarget, type TDbActionScopeContext, type TDbActionScopePurpose, type TDbActionTarget, type TDbActionTargetSummary, type TDbActionsEntry, type TDbActionsEntryUnpinned, type TDbActionsFromMeta, type TDbActionsFromOpts, type TDbAvailableActions, type TDbClassActionMeta, TDbControlsType, TDbDecorateContext, TDbDecorateEndpoint, type TDbDecorationsOpts, TDbFieldVisibility, TDbIndexFieldPaths, TDbParsedRequest, type TDbQueryTargetOpts, type TDbRemoveGuardContext, TDbRequestContext, TDbRequestEndpoint, type TDbResolveQueryInput, TDbResolveQueryOpts, type TDbRowIdInput, type TDbRowIdPurpose, type TDbRowIdsContext, type TDbWriteAction, type TDbWriteCheckContext, type TDbWriteGuardContext, type TFieldCapability, type TGateOp, type THttpErrorEntry, type TQueryPathOp, type TQueryPathRefs, TReadableBinding, type TReadableBindingMeta, type TTerminalRef, TableController, UseValidationErrorTransform, VALUE_HELP_CRUD_HANDLERS, ValueHelpQuery, ValueHelpSelect, ViewController, applyTerminalRefs, assertExposed, badRequest, clearDbSpaces, closeDbSpaces, collectQueryPaths, dbActionBodySlot, dbActionInputSlot, discoverActions, discoverRowLevelActions, errorEnvelope, findReadableBinding, getAtscriptDbMate, getControllerFormType, getDbEndpoint, hasActionDelegations, perRow, provideDbSpace, resolveBoundReadable, resolveDbSpace, resolveProp, resolveTerminalRef, unknownRelationError, useDbActionId, useDbActionIds, useDbActionInput, useDbActionRow, useDbActionRows, useDbActionTarget, validationErrorTransform };
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { Body, Delete, Get, HttpError, MoostHttp, Patch, Post, Put, Query, Url }
4
4
  import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Pipe, Provide, Resolve, TInterceptorPriority, TPipePriority, defineBeforeInterceptor, defineInterceptor, definePipeFn, getMoostMate, useControllerContext, withControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
6
  import { ADAPTER_FILTER_REASON, ALL_AGGREGATE_FNS, DbError, ENCRYPTED_REASON, acceptedOperatorsHint, aliasTargetOf, andFilters, bucketSourceVerdict, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, containsRelationFilter, findAncestorInSet, geoIndexNotFoundMessage, groupSourceVerdict, hasRelationOp, isEmptyObject, isJsonValueField, isPlainObject, isRelationOp, narrowerFilterOps, normalizeComputedSelect, numericOperandProblem, reconcileCas, searchIndexNotFoundMessage, selfOrAncestor, unsupportedOperatorMessage, vectorIndexNotFoundMessage } from "@atscript/db";
7
- import { cached, current, defineWook, key, useRouteParams } from "@wooksjs/event-core";
7
+ import { cached, current, defineWook, key, routeParamsKey, useRouteParams } from "@wooksjs/event-core";
8
8
  import { useBody } from "@wooksjs/http-body";
9
9
  import { BUCKET_UNITS } from "@uniqu/core";
10
10
  import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
@@ -70,6 +70,13 @@ function insightError(insights, message) {
70
70
  const entry = insightPaths.get(insights);
71
71
  return entry?.message === message ? badRequest(entry.path, message) : new HttpError(400, message);
72
72
  }
73
+ /**
74
+ * The 400 envelope of a `ValidatorError`; `undefined` for any other error.
75
+ * @internal Not part of the public API (not re-exported from the barrel).
76
+ */
77
+ function validatorErrorToHttp(error) {
78
+ return error instanceof ValidatorError ? errorEnvelope(400, error.message, error.errors) : void 0;
79
+ }
73
80
  //#endregion
74
81
  //#region src/validation-interceptor.ts
75
82
  const dbErrorCodeToStatus = {
@@ -80,7 +87,8 @@ const dbErrorCodeToStatus = {
80
87
  BUCKET_TZ_UNAVAILABLE: 501
81
88
  };
82
89
  function transformValidationError(error, reply) {
83
- if (error instanceof ValidatorError) reply(errorEnvelope(400, error.message, error.errors));
90
+ const validation = validatorErrorToHttp(error);
91
+ if (validation) reply(validation);
84
92
  else if (error instanceof DbError) reply(errorEnvelope(dbErrorCodeToStatus[error.code] ?? 400, error.message, error.errors));
85
93
  }
86
94
  const validationErrorTransform = () => defineInterceptor({ error: transformValidationError }, TInterceptorPriority.BEFORE_ALL);
@@ -4189,6 +4197,17 @@ const PATH_OPS = [
4189
4197
  "aggregate",
4190
4198
  "bucket"
4191
4199
  ];
4200
+ /** The controller of the event that owns the route params (nearest ancestor that set them). */
4201
+ function routedController(ctx) {
4202
+ for (let c = ctx; c; c = c.parent) {
4203
+ try {
4204
+ c.getOwn(routeParamsKey);
4205
+ } catch {
4206
+ continue;
4207
+ }
4208
+ return controllerOf(c);
4209
+ }
4210
+ }
4192
4211
  /** The 400 of a filter / sort on a `@db.writeOnly` field. */
4193
4212
  function writeOnlyError(path, op) {
4194
4213
  const verdict = writeOnlyVerdict(path, op);
@@ -4464,7 +4483,9 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
4464
4483
  * (`$vector`) or a geo index (`/geo`, `$index`) reading a hidden path
4465
4484
  * answers exactly like a nonexistent index (400); a hidden DEFAULT text
4466
4485
  * index falls back to the `@db.column.searchable` substring search over
4467
- * visible fields (or ignores the term when there are none). A
4486
+ * visible fields (or ignores the term when there are none — on list
4487
+ * endpoints; query targets, delegated targets and {@link resolveQuery}
4488
+ * answer 400 `TARGET_INVALID` instead). A
4468
4489
  * `@db.column.derived` field is visible only while its source path is,
4469
4490
  * and one whose source is hidden is sealed out of every read projection
4470
4491
  * for the request, like a `@db.writeOnly` field. The same holds for a
@@ -5067,6 +5088,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5067
5088
  * {@link checkCapabilities}, {@link hasField}), where the client
5068
5089
  * predicates' {@link transformRelationFilter} also runs: a query target
5069
5090
  * never filters on, nor counts by, a field the caller can't read.
5091
+ * {@link resolveQuery} does not call it (there is no action of this
5092
+ * controller to scope).
5070
5093
  *
5071
5094
  * @since 0.1.147
5072
5095
  */
@@ -5403,18 +5426,76 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5403
5426
  async [RESOLVE_TARGET](req) {
5404
5427
  const { action } = req;
5405
5428
  const body = parseQueryTargetBody(action, req.query);
5429
+ const { rows, filter, findMany, exclude, visibleOf, cap } = await this._resolveMatching({
5430
+ label: action,
5431
+ body,
5432
+ cap: req.cap,
5433
+ maxExclude: req.maxExclude,
5434
+ overlay: req.overlay,
5435
+ scopeByAction: true,
5436
+ select: [...new Set(req.select)],
5437
+ sortBy: req.select,
5438
+ excludeShapes: req.excludeShapes,
5439
+ visibleOf: req.visibleOf
5440
+ });
5441
+ const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
5442
+ ...q,
5443
+ controls: {
5444
+ ...q.controls,
5445
+ $limit: Math.max(ids.length, cap + 1)
5446
+ }
5447
+ }) }, ids, scope, select);
5448
+ const snapshotFields = new Set(req.select);
5449
+ let first = true;
5450
+ return {
5451
+ matched: rows.length,
5452
+ rows,
5453
+ dryRun: body.dryRun === true,
5454
+ exclude,
5455
+ visibleOf,
5456
+ load: (ids, select) => {
5457
+ if (!first) return byIds(findMany, ids, filter, select);
5458
+ first = false;
5459
+ const fields = [...select];
5460
+ if (!fields.every((f) => snapshotFields.has(f))) {
5461
+ const plain = (q) => this.readable.findMany(q);
5462
+ return byIds(plain, ids, void 0, fields);
5463
+ }
5464
+ return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
5465
+ }
5466
+ };
5467
+ }
5468
+ /**
5469
+ * The shared resolver behind query targets and {@link resolveQuery}: the
5470
+ * query body validated, checked and run as a READ of this controller, then
5471
+ * ONE read of `select` ordered by `sort` — `filter (+ $search) ∧ overlay ∧
5472
+ * scope ∧ ¬exclude`, at most `cap + 1` rows. More than `cap` → 400
5473
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
5474
+ * `TARGET_CHANGED`.
5475
+ *
5476
+ * Everything that depends on the read's visibility — the `$search`
5477
+ * fallback, the native-search memo, the un-appliable-term refusal and the
5478
+ * delegated identity — is computed INSIDE the read child, where the
5479
+ * permission layer's per-request state is the read's.
5480
+ */
5481
+ async _resolveMatching(spec) {
5482
+ const { label, body } = spec;
5483
+ const cap = Math.min(spec.cap, body.maxRows ?? Infinity);
5406
5484
  const parsed = this.parseUrlOr400(body.q.startsWith("?") ? body.q.slice(1) : body.q);
5407
5485
  const controls = {};
5408
5486
  for (const [k, v] of Object.entries(parsed.controls ?? {})) {
5409
5487
  if (v === void 0) continue;
5410
- if (k !== "$search" && k !== "$index") throw targetInvalid(action, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
5411
- if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(action, "$search must be a search term");
5488
+ if (k !== "$search" && k !== "$index") throw targetInvalid(label, `A query target takes a filter, $search and $index only — "${k}" is not accepted`);
5489
+ if (k === "$search" && typeof v !== "string" && typeof v !== "number") throw targetInvalid(label, "$search must be a search term");
5412
5490
  controls[k] = k === "$search" ? `${v}` : v;
5413
5491
  }
5414
- if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(action, "$index must be an index name");
5492
+ if (controls.$index !== void 0 && typeof controls.$index !== "string") throw targetInvalid(label, "$index must be an index name");
5415
5493
  const exclude = body.exclude ?? [];
5416
- const shapes = req.excludeShapes ?? [];
5494
+ const shapes = spec.excludeShapes ?? [];
5495
+ const sealedSet = () => new Set([...this.fieldVisibility.sealedFor(this.readable), ...this._leavesOf(this.readable).filter((leaf) => !this.fieldVisibility.isVisible(leaf))]);
5417
5496
  const check = () => {
5497
+ let sealed;
5498
+ const readableLeaf = (leaf) => !(sealed ??= sealedSet()).has(leaf);
5418
5499
  const controlsError = this.validateControls(controls, "query");
5419
5500
  if (controlsError) throw new HttpError(400, controlsError);
5420
5501
  const gateError = this.checkCapabilities({
@@ -5422,71 +5503,160 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5422
5503
  controls
5423
5504
  });
5424
5505
  if (gateError) throw gateError;
5425
- if (exclude.length > 0) validateMultiId(exclude, shapes.length === 0 ? this.idSource : {
5426
- identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
5427
- fields,
5428
- source: "target"
5429
- }))],
5430
- fieldDescriptors: this.readable.fieldDescriptors
5431
- }, req.maxExclude);
5506
+ if (exclude.length > 0) {
5507
+ const source = shapes.length === 0 ? this.idSource : {
5508
+ identifications: [...this.idSource.identifications, ...shapes.map((fields) => ({
5509
+ fields,
5510
+ source: "target"
5511
+ }))],
5512
+ fieldDescriptors: this.readable.fieldDescriptors
5513
+ };
5514
+ try {
5515
+ validateMultiId(exclude, source, spec.maxExclude);
5516
+ } catch (error) {
5517
+ throw validatorErrorToHttp(error) ?? error;
5518
+ }
5519
+ }
5520
+ const nav = FieldCapabilityIndex.navPathsOf(this.readable);
5521
+ for (const path of spec.selectGate ?? []) {
5522
+ if (this.capabilities.decorationCap(path) || selfOrAncestor(path, nav) !== void 0) throw badRequest(path, `Unknown field "${path}"`);
5523
+ const verdict = this.capabilities.check(path, "select", this.fieldVisibility.isVisible);
5524
+ if (verdict) throw badRequest(verdict.path, verdict.message);
5525
+ if (this._writeOnlySet.has(path)) throw badRequest(path, `Field "${path}" is @db.writeOnly`);
5526
+ const prefix = `${path}.`;
5527
+ const leaves = this._leavesOf(this.readable).filter((leaf) => leaf.startsWith(prefix));
5528
+ if (leaves.length > 0 && !leaves.some((leaf) => readableLeaf(leaf))) throw badRequest(path, `Unknown field "${path}"`);
5529
+ }
5530
+ for (const path of spec.sortGate ?? []) {
5531
+ const verdict = this.capabilities.check(path, "sort", this.fieldVisibility.isVisible);
5532
+ if (verdict) throw targetInvalid(label, verdict.message);
5533
+ }
5432
5534
  };
5433
- const ownScope = req.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope;
5434
- const [[base, scope], overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
5535
+ const ownScope = spec.scopeByAction === true && (spec.overlay === "action" || this.queryTargetScope !== _AsDbReadableController.prototype.queryTargetScope);
5536
+ const [read, overlay] = await Promise.all([this._asRead(controls, parsed.filter, async () => {
5435
5537
  check();
5436
- const [clientFilter, readScope] = await Promise.all([this._relationOverlay(parsed), ownScope ? this.queryTargetScope(action) : void 0]);
5437
- return [req.overlay === "read" ? await this.transformFilter(clientFilter ?? {}) : clientFilter, readScope];
5438
- }), req.overlay === "action" ? this.rowOverlay() : void 0]);
5439
- const filter = andFilters(this.applySearchFallback(base, controls), overlay, scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
5440
- const strategy = await this._resolveReadStrategy(controls);
5538
+ const [clientFilter, readScope, strategy] = await Promise.all([
5539
+ this._relationOverlay(parsed),
5540
+ ownScope ? this.queryTargetScope(label) : void 0,
5541
+ this._resolveReadStrategy(controls)
5542
+ ]);
5543
+ const base = spec.overlay === "action" ? clientFilter : await this.transformFilter(clientFilter ?? {});
5544
+ const searched = this.applySearchFallback(base, controls);
5545
+ if (controls.$search && strategy.kind !== "search" && searched === base) throw targetInvalid(label, "$search is not available here");
5546
+ const visibleOf = spec.visibleOf?.filter((f) => this.fieldVisibility.isVisible(f));
5547
+ let select = spec.select;
5548
+ if (spec.selectGate) {
5549
+ const sealed = sealedSet();
5550
+ for (const id of spec.identity ?? []) sealed.delete(id);
5551
+ select = this._sealSelect([...spec.select], sealed, this.readable);
5552
+ }
5553
+ return {
5554
+ searched,
5555
+ readScope,
5556
+ strategy,
5557
+ visibleOf,
5558
+ select
5559
+ };
5560
+ }, spec.routeParams), spec.overlay === "action" ? this.rowOverlay() : void 0]);
5561
+ const { strategy } = read;
5562
+ const filter = andFilters(read.searched, overlay, read.readScope, spec.scope, exclude.length > 0 ? { $not: { $or: exclude } } : void 0);
5441
5563
  const findMany = (q) => strategy.kind === "search" ? this.readable.search(strategy.term, q, strategy.index) : this.readable.findMany(q);
5442
- const cap = Math.min(req.cap, body.maxRows ?? Infinity);
5443
- const sort = {};
5444
- for (const f of req.select) sort[f] = 1;
5445
5564
  const rows = await findMany({
5446
5565
  filter,
5447
5566
  controls: {
5448
- $select: [...new Set(req.select)],
5449
- $sort: sort,
5567
+ $select: [...read.select],
5568
+ $sort: Object.fromEntries(spec.sortBy.map((f) => [f, 1])),
5450
5569
  $limit: cap + 1
5451
5570
  }
5452
5571
  });
5453
- if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", action, `The query matches more than ${cap} rows`, { cap });
5454
- if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", action, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
5455
- const byIds = (read, ids, scope, select) => findRowsByIds({ findMany: (q) => read({
5456
- ...q,
5457
- controls: {
5458
- ...q.controls,
5459
- $limit: Math.max(ids.length, cap + 1)
5460
- }
5461
- }) }, ids, scope, select);
5462
- const snapshotFields = new Set(req.select);
5463
- let first = true;
5572
+ if (rows.length > cap) throw new ActionTargetError("TARGET_TOO_LARGE", label, `The query matches more than ${cap} rows`, { cap });
5573
+ if (body.expectCount !== void 0 && body.expectCount !== rows.length) throw new ActionTargetError("TARGET_CHANGED", label, `The query now matches ${rows.length} rows (expected ${body.expectCount})`, { matched: rows.length });
5464
5574
  return {
5465
- matched: rows.length,
5466
5575
  rows,
5467
- dryRun: body.dryRun === true,
5576
+ filter,
5577
+ findMany,
5468
5578
  exclude,
5469
- load: (ids, select) => {
5470
- if (!first) return byIds(findMany, ids, filter, select);
5471
- first = false;
5472
- const fields = [...select];
5473
- if (!fields.every((f) => snapshotFields.has(f))) {
5474
- const plain = (q) => this.readable.findMany(q);
5475
- return byIds(plain, ids, void 0, fields);
5476
- }
5477
- return Promise.resolve(alignRowsToIds(rows, ids).map((row, i) => row ? projectRow$1(row, new Set([...fields, ...Object.keys(ids[i])])) : void 0));
5478
- }
5579
+ visibleOf: read.visibleOf,
5580
+ cap,
5581
+ select: read.select
5479
5582
  };
5480
5583
  }
5481
5584
  /**
5585
+ * Rows of THIS controller matching `q`, resolved as a READ of it for the
5586
+ * current event's caller (since 0.1.149) — from your own command, e.g. to
5587
+ * act on "every issue matching this search". `q` is a `GET /query` string
5588
+ * (`$search` / `$index` and a filter only) or a query-target envelope
5589
+ * `{ q, exclude?, expectCount?, maxRows? }` (no `dryRun`).
5590
+ *
5591
+ * The read runs under this controller's full read policy
5592
+ * ({@link prepareRequest} with `endpoint: "query"`, {@link hasField},
5593
+ * {@link validateControls}, the capability / index gate and the
5594
+ * {@link transformFilter} overlay) with the current event's identity.
5595
+ * Route interceptors and guards of the `query` route do not run; read
5596
+ * authorization belongs in {@link prepareRequest}. {@link queryTargetScope}
5597
+ * is not called. Hooks see no route params of the caller (only a call from
5598
+ * the routed event's own controller instance keeps its params); pass route-derived
5599
+ * restrictions as `opts.scope`. Joins the caller's open transaction.
5600
+ *
5601
+ * Rows are ordered by identity (`preferredId`, else the primary key) and
5602
+ * carry the identity fields plus `opts.select` (gated like `/query`
5603
+ * `$select`; `transformProjection` is not applied — hide fields with
5604
+ * {@link hasField}; decoration keys and navigation paths are refused). An
5605
+ * identity-less readable is ordered by `select` (each path sortable, else
5606
+ * `TARGET_INVALID`). More than `opts.cap` (default 1000) rows → 400
5607
+ * `TARGET_TOO_LARGE`; a count other than `expectCount` → 409
5608
+ * `TARGET_CHANGED`; a `$search` that can't be applied → 400
5609
+ * `TARGET_INVALID`. Must be awaited inside a running event handler.
5610
+ */
5611
+ async resolveQuery(q, opts = {}) {
5612
+ let caller;
5613
+ try {
5614
+ caller = current();
5615
+ } catch {
5616
+ throw new Error("[moost-db] resolveQuery must be awaited inside an event handler");
5617
+ }
5618
+ const cap = opts.cap ?? 1e3;
5619
+ if (!Number.isInteger(cap) || cap < 1) throw new Error("[moost-db] resolveQuery: `cap` must be a positive integer");
5620
+ const select = opts.select ?? [];
5621
+ if (!Array.isArray(select) || select.some((p) => typeof p !== "string")) throw new Error("[moost-db] resolveQuery: `select` must be an array of field paths");
5622
+ const ids = this.readable.preferredId?.length ? this.readable.preferredId : this.readable.primaryKeys;
5623
+ const order = ids.length > 0 ? ids : select;
5624
+ if (order.length === 0) throw new Error("[moost-db] resolveQuery: this readable has no identity — pass `select` (rows are ordered by it)");
5625
+ const label = readCurrentActionMeta(caller)?.name ?? "";
5626
+ const body = parseQueryTargetBody(label, typeof q === "string" ? { q } : q);
5627
+ if (body.dryRun !== void 0) throw targetInvalid(label, "resolveQuery takes no `dryRun` — read `query.dryRun` yourself");
5628
+ let sameRoute = false;
5629
+ try {
5630
+ sameRoute = controllerOf(caller) === this && routedController(caller) === this;
5631
+ } catch {}
5632
+ const fields = [...new Set([...ids, ...select])];
5633
+ const { rows, select: sealedSelect } = await this._resolveMatching({
5634
+ label,
5635
+ body,
5636
+ cap,
5637
+ maxExclude: DEFAULT_MAX_ACTION_IDS,
5638
+ overlay: "read",
5639
+ scope: opts.scope,
5640
+ select: fields,
5641
+ sortBy: order,
5642
+ selectGate: select,
5643
+ identity: ids,
5644
+ sortGate: ids.length > 0 ? void 0 : order,
5645
+ routeParams: sameRoute ? void 0 : {}
5646
+ });
5647
+ return rows.map((row) => projectRow$1(row, sealedSelect));
5648
+ }
5649
+ /**
5482
5650
  * Runs `fn` as a READ of this controller (since 0.1.147): in a child of
5483
5651
  * the current event whose controller context is this controller's `query`
5484
5652
  * handler, after `prepareRequest({ endpoint: "query", controls, filter })` — the
5485
5653
  * request-scoped state a permission layer builds there (read grant, field
5486
- * visibility) is the read's and stays in the child.
5654
+ * visibility) is the read's and stays in the child. `routeParams` (since
5655
+ * 0.1.149) replaces the route params the child's hooks read.
5487
5656
  */
5488
- _asRead(controls, filter, fn) {
5657
+ _asRead(controls, filter, fn, routeParams) {
5489
5658
  return runAsController(this, "query", async () => {
5659
+ if (routeParams) current().set(routeParamsKey, routeParams);
5490
5660
  if (typeof this.prepareRequest === "function") await this.prepareRequest(readRequestContext("query", controls, filter));
5491
5661
  return fn();
5492
5662
  });
@@ -5570,7 +5740,10 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
5570
5740
  * with the existing filter. Applies only when native search does not serve
5571
5741
  * the request (no native search, or — since 0.1.143 — its default index
5572
5742
  * reads a field {@link hasField} hides) and the request isn't a vector
5573
- * search (`$vector` consumes the term).
5743
+ * search (`$vector` consumes the term). Lenient on list endpoints: a term
5744
+ * nothing can apply is ignored. Resolvers (query targets, `resolveQuery`)
5745
+ * refuse it — a subclass override that applies the term must return a new
5746
+ * filter object.
5574
5747
  */
5575
5748
  applySearchFallback(filter, controls) {
5576
5749
  const term = controls.$search;
@@ -6360,7 +6533,8 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6360
6533
  maxExclude: limits.maxIds,
6361
6534
  overlay: "read",
6362
6535
  select: [...new Set([...identity, ...delegation.paths])],
6363
- excludeShapes: [delegation.paths]
6536
+ excludeShapes: [delegation.paths],
6537
+ visibleOf: identity
6364
6538
  });
6365
6539
  if (resolved.dryRun) return { matched: resolved.matched };
6366
6540
  const summary = {
@@ -6370,7 +6544,7 @@ let AsDbReadableController = _AsDbReadableController = class AsDbReadableControl
6370
6544
  failed: []
6371
6545
  };
6372
6546
  const { ids, index } = mapToSourceIds(resolved.rows, delegation.idMap);
6373
- const visibleIdentity = identity.filter((f) => this.fieldVisibility.isVisible(f));
6547
+ const visibleIdentity = resolved.visibleOf ?? [];
6374
6548
  for (let i = 0; i < index.length; i++) {
6375
6549
  if (index[i] >= 0) continue;
6376
6550
  const row = resolved.rows[i];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.148",
3
+ "version": "0.1.149",
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.13",
47
- "@atscript/db-memory": "^0.1.148"
47
+ "@atscript/db-memory": "^0.1.149"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@atscript/core": "^0.1.100",
@@ -57,7 +57,7 @@
57
57
  "better-sqlite3": "^12.6.2",
58
58
  "moost": "^0.6.45",
59
59
  "unplugin-atscript": "^0.1.100",
60
- "@atscript/db-sqlite": "^0.1.148"
60
+ "@atscript/db-sqlite": "^0.1.149"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@atscript/typescript": "^0.1.100",
@@ -66,7 +66,7 @@
66
66
  "@wooksjs/event-core": "^0.7.26",
67
67
  "@wooksjs/http-body": "^0.7.26",
68
68
  "moost": "^0.6.45",
69
- "@atscript/db": "^0.1.148"
69
+ "@atscript/db": "^0.1.149"
70
70
  },
71
71
  "scripts": {
72
72
  "postinstall": "asc -f dts",