Skip to content

Filters & search

Add allowlisted filters, full-text search, and stable query-string state.

This guide documents the filter DSL, allowlist rules, search drivers, query-string contract, and global search against the frozen protocol §3/§8 filter registry.

The code is packages/core/src/table/filter.ts, table/filters/*, table/search/*, the filters / search / softDeleteScope stages in table/stages/, and the query-string parser in table/pipeline.ts. Everything named below is re-exported from @adonia/core.

Where these stages run relative to one another is the index query pipeline; what the browser does with the resulting URL is the index table. Neither is restated here.

Declaring filters

Filters are declared on the resource’s table() hook, beside its columns. There is no separate filters() hook and no static filterst.filters([…]) is the only entry point.

import { BaseResource, C, F, Filter, TableBuilder } from '@adonia/core'
import type { SchemaComponent } from '@adonia/core'
import Post from '#models/post'

const STATUSES = [
  { value: 'draft', label: 'Draft' },
  { value: 'review', label: 'In review' },
  { value: 'published', label: 'Published' },
]

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(): SchemaComponent[] {
    return [F.text('title').required()]
  }

  override table(t: TableBuilder): TableBuilder {
    return t
      .columns([
        C.text('title').sortable().searchable(),
        C.badge('status').sortable(),
        C.relation('author.fullName').searchable('author.fullName'),
        C.date('publishedAt').sortable(),
      ])
      .filters([
        Filter.select('status').options(STATUSES),
        Filter.ternary('featured'),
        Filter.dateRange('publishedAt'),
        Filter.numberRange('readingMinutes').label('Reading time'),
        Filter.relation('tags').searchable(),
        Filter.ternary('trashed').trashed(),
      ])
      .searchPlaceholder('Search posts…')
  }
}

table() is optional, and declaring it fully replaces §7.5 schema derivation. Filters are the part of the table with no derived fallback: resolveTable reads declaredFilters off the builder and nothing else, so a resource without a table() has no filters at all — columns it will still derive from the schema, filters it will not invent.

Filter is the namespace and the base class

@adonia/core exports one Filter binding. It is the abstract base class with the §8.2 factories attached to the class object (table/filters/index.ts), so Filter.select(…) and class ProximityFilter extends Filter both work and instanceof Filter keeps meaning “is a filter”.

Factory Class Wire type Validated value
Filter.select(key) SelectFilter select-filter one declared option
Filter.multiSelect(key) MultiSelectFilter multi-select-filter non-empty array of declared options
Filter.ternary(key) TernaryFilter ternary-filter yes | no | any
Filter.dateRange(key) DateRangeFilter date-range-filter { from?, to? }, ISO-8601
Filter.numberRange(key) NumberRangeFilter number-range-filter { min?, max? }
Filter.relation(key, relation?) RelationFilter relation-filter non-empty array of related keys
Filter.custom(key, schema, apply) CustomFilter custom-filter, overridable whatever schema accepts

AttributeFilter and OptionFilter are exported too, but they are abstract: they are the bases a plugin extends, not factories. There is no Filter.attribute().

What every filter carries

The base class fixes four things and leaves two abstract:

Member Kind What it does
key readonly the query-string key: filters[<key>]
type getter the wire type, from the subclass’s static type
.label(text) builder sets props.label
.withProps({…}) builder merges arbitrary JSON-safe props (protocol §10: the prop set is open)
toDescriptor() method compiles the wire node
valueSchema() abstract the Vine mini-schema for one filters[<key>] value
apply(query, value, ctx) abstract the query mutation, run only with a validated value

toDescriptor() emits { type, key, props } — filter nodes carry key the way field nodes do — and, when no .label() was set, derives one from the key by splitting camelCase and -/_/. then title-casing:

// Filter.numberRange('readingMinutes')
{
  "type": "number-range-filter",
  "key": "readingMinutes",
  "props": { "label": "Reading Minutes" }
}

compileTableDescriptor maps every declared filter through toDescriptor() and adds nothing: filter nodes have no urls block today, so a filter that needs options on the wire must carry them in props.

Validation happens twice, on purpose

The parser (parseIndexQueryState) is synchronous — route helpers and link builders call it with no event loop to await — while Vine validation is asynchronous. So the two halves are split:

  1. Shape, in the parser: bracket decoding, a prototype-pollution guard, a depth cap, and empty-value pruning. The result is a JsonValue.
  2. Semantics, in the filters stage: the value is run through vine.compile(filter.valueSchema()). A value that fails is skipped exactly like an unknown key — E_VALIDATION_ERROR is swallowed, anything else rethrows, because a non-validation throw is a bug in the filter’s own schema.

Compiled validators are memoized in a WeakMap keyed by the Filter instance, not by its key: resources are constructed per request but filter objects are declared once, so the cache is shared where the instance is and leaks nowhere else. The stage iterates in declaration order, not query-string order, so the emitted SQL is stable for a given filter set.

Column-bound filters

SelectFilter, MultiSelectFilter, TernaryFilter, DateRangeFilter and NumberRangeFilter all extend AttributeFilter, which owns the column half:

  • the filter key doubles as the column — Filter.select('status') narrows status;
  • .column(name) splits the two apart when the wire key and the SQL column must differ;
  • a dotted target compiles to nested whereHas sub-queries, never a join.

That last point is why a filter may traverse a hasMany where a sort may not: a sub-query cannot multiply rows, so neither the page contents nor the paginator’s total can be corrupted by the cardinality of the relation.

// where exists (select … from users where users.email like …)
Filter.select('authorEmail').column('author.email')

The built-ins

Filter.select — one of a declared set

Filter.select('status').options([
  { value: 'draft', label: 'Draft' },
  { value: 'review', label: 'In review' },
  { value: 'published', label: 'Published' },
])

.options() takes FieldOptions (value, label, optional color, icon, disabled) and projects them to props.options. The set is also the validation domain.

  • valueSchema()vine.enum([...declared values as strings]), pre-parsed with String(value) so a JSON body carrying 3 is compared against '3' rather than rejected on type. With no options declared it degrades to vine.string().trim().minLength(1), for a filter whose choices are resolved at runtime.
  • apply()where <column> = <value>.

The comparison is built with the declared value, not the wire string: typedValue() maps the validated member back through the option set, so Filter.select('categoryId').options([{ value: 3, label: 'Engineering' }]) emits where category_id = 3. On Postgres this matters — integer = text is a type error there, not a coercion.

Filter.multiSelect — any of a declared set

Filter.multiSelect('status').options([
  { value: 'draft', label: 'Draft' },
  { value: 'review', label: 'In review' },
])
  • valueSchema()vine.array(member).parse(v => Array.isArray(v) ? v : [v]).minLength(1). The pre-parse accepts both wire shapes: filters[status][]=draft&filters[status][]=review and the scalar filters[status]=draft a shared link may carry.
  • apply()where <column> in (<values>), again with declared types.

minLength(1) is deliberate. An emptied selection means “not filtering”, so it must be dropped; compiling it to where … in () would return no rows.

Filter.ternary — yes / no / any

Filter.ternary('featured')
  • valueSchema()vine.enum(['yes', 'no', 'any']).
  • apply()yeswhere <column> = true, nowhere <column> = false, any → nothing.

The third state is the absence of the key, so a ternary that reaches apply has already chosen a side; any is accepted only so the client can spell “I cleared it” explicitly.

.trashed() turns the control into the soft-delete scope selector — see the trashed scope below.

Filter.dateRange — an inclusive ISO range

Filter.dateRange('publishedAt')

Wire shape: filters[publishedAt][from]=2026-01-01&filters[publishedAt][to]=2026-03-31.

  • valueSchema()vine.object({ from: <iso>.optional(), to: <iso>.optional() }), where <iso> is vine.string().trim().regex(…) accepting 2026-03-01, 2026-03-01T09:30 and 2026-03-01T09:30:00.000Z. Both halves optional, so a one-sided range works; the parser has already turned …[from]= into an absent key rather than ''.
  • apply()where <column> >= from and/or where <column> <= to; both absent applies nothing.

Two behaviours worth knowing. Bounds stay strings all the way into the binding: parsing them to Date would impose the server’s timezone on a value the user picked in theirs, silently shifting “1 March” by a day for anyone west of UTC. And a date-only upper bound is extended to T23:59:59.999, because to=2026-03-01 against a datetime column would otherwise exclude everything after midnight on the day the user asked to include.

Filter.numberRange — an inclusive numeric range

Filter.numberRange('readingMinutes')

Wire shape: filters[readingMinutes][min]=5&filters[readingMinutes][max]=20.

  • valueSchema()vine.object({ min: vine.number().optional(), max: vine.number().optional() }). vine.number() coerces the numeric strings a query string delivers and rejects everything else, so …[min]=lots is dropped rather than compiled to a comparison against NaN — which every dialect answers with “no rows”.
  • apply()where <column> >= min and/or where <column> <= max.

Note the key names: this filter takes min/max, dateRange takes from/to.

Filter.relation('author').searchable().optionLabel('fullName')
Filter.relation('writtenBy', 'author')

Filter.relation(key, relation?) — the second argument names the relation when it differs from the wire key; otherwise the key is the relation. Wire shape: filters[author][]=3&filters[author][]=7, or the scalar filters[author]=3.

  • valueSchema()vine.array(vine.string().trim().minLength(1)), scalar pre-parsed to an array, minLength(1). Keys stay strings: the database compares them against the related primary key, and coercing to a number would break UUID and slug keys.
  • apply()whereHas(<relation>, q => q.whereIn('<related table>.<pk>', ids)). Always a sub-query, never a join, even for belongsTo where where author_id in (…) would be marginally cheaper: one uniform shape is correct for every cardinality, and the author does not have to know the relation’s kind to declare the filter.

The related primary key is table-qualified, because a manyToMany sub-query joins the pivot table and a bare id would be ambiguous there. A relation the model does not have applies nothing — a stale bookmark pointing at a renamed relation renders the table, it does not throw.

.searchable(enabled = true) sets props.searchable and .optionLabel(attr) sets props.optionLabel. Be aware of the current gap: the client’s relation-filter component fetches options from node.urls.options and falls back to inline props.options, but compileTableDescriptor attaches no urls block to filter nodes yet (the compiler must supply it because assembling the URL needs the panel mount). Until it does, give a relation filter its choices with .withProps({ options: [/* … */] }).

Filter.custom — your schema, your predicate

The escape hatch. You supply the mini-schema and the mutation; you get the same §8.3 guarantee as a built-in — the value is validated and dropped on failure before your callback is ever called.

Filter.custom('hasCover', vine.enum(['yes', 'no']), (query, value) => {
  const rows = conditions(query)
  if (value === 'yes') rows.whereNotNull('posts.cover_image')
  else rows.whereNull('posts.cover_image')
})

conditions(query) is the exported view onto the predicate surface of the Lucid builder: where, andWhere, orWhere, whereRaw, orWhereRaw, whereNull, whereNotNull, whereIn, whereHas, orWhereHas. query itself is the full ModelQueryBuilderContract if you need more than that.

apply may be async, and the stage awaits it through pendingMutation rather than a bare await — a callback written as (query, value) => query.where(…) returns the builder, and awaiting a Lucid builder executes it mid-pipeline.

.as(type) overrides the client component the node renders as; the protocol §8 filter namespace is open, and the default custom-filter falls back to the client’s unknown-component behaviour rather than throwing.

The query-string contract

parseIndexQueryState accepts either the decoded bag from ctx.request.qs() or a raw query string, and is the only doorway between the request and the query.

Query key State field Parsing
q search trimmed; null when empty or shorter than search.minQueryLength (default 2)
filters[<key>] filters key must be in the filter allowlist; value sanitized to a JsonValue
filters[<key>][from], [to], [min], [max] filters nested object, one bracket deeper
filters[<key>][] filters repeated entries accumulate into an array
sort sort comma-separated, - prefixes desc, duplicates dropped
columns columns comma-separated; null (= resource default) when nothing survives
trashed trashed with | only; anything else is default
page page /^[1-9][0-9]*$/; 1 when absent or malformed
perPage perPage must be a member of the effective perPageOptions
cursor cursor cursor-mode resources only, opaque here

Bracket decoding is done by the parser itself rather than by a query-string library, and it is deliberately narrow:

  • nesting deeper than 4 brackets is dropped whole;
  • __proto__, constructor and prototype are refused as path segments, at every depth, in both the raw-string and decoded-bag paths;
  • repeated scalar keys are last-wins, repeated key[] keys accumulate;
  • a[][b]=x is not a §10.2 shape and is dropped.

Empty is unset. sanitizeFilterValue drops empty strings, and containers that end up empty drop with them: filters[status]= yields no status entry, and filters[publishedAt][from]=&filters[publishedAt][to]=2026-06-30 yields { publishedAt: { to: '2026-06-30' } }. Only strings, finite numbers, booleans, arrays and plain objects survive; null, functions and class instances are not query input.

Unknown input is dropped, never rejected

This allowlist rule is absolute: an unknown filter key, an unknown sort key, an unknown column key and an out-of-range perPage are silently discarded. Not an error, not a 4xx, not passed through to the query. A stale bookmark or a hand-edited URL must render the default table.

The whole enforcement is four continues and a ternary, all in table/pipeline.ts. The excerpts below are those loops, with the surrounding locals declared:

// filters — the key must be declared, and the value must survive sanitization
const filters: Record<string, JsonValue> = {}
for (const [key, value] of Object.entries(rawFilters)) {
  if (!allowlists.filters.has(key)) continue
  const sanitized = sanitizeFilterValue(value, 1)
  if (sanitized !== undefined) filters[key] = sanitized
}
// sort — the column must be sortable, and each key counts at most once
const instructions: SortInstruction[] = []
const seenSort = new Set<string>()
for (const token of raw.split(',')) {
  const trimmed = token.trim()
  if (trimmed.length === 0) continue
  const descending = trimmed.startsWith('-')
  const column = (descending ? trimmed.slice(1) : trimmed).trim()
  if (column.length === 0 || seenSort.has(column) || !sortable.has(column)) continue
  seenSort.add(column)
  instructions.push({ column, direction: descending ? 'desc' : 'asc' })
}
// columns — the key must exist on the table
const columns: string[] = []
const seenColumn = new Set<string>()
for (const token of raw.split(',')) {
  const key = token.trim()
  if (key.length === 0 || seenColumn.has(key) || !allowed.has(key)) continue
  seenColumn.add(key)
  columns.push(key)
}
// perPage — membership, not a range check
const perPage =
  requestedPerPage !== null && perPageOptions.includes(requestedPerPage)
    ? requestedPerPage
    : fallbackPerPage

…plus the one in the filters stage, which applies the same rule to a value that is structurally fine but semantically wrong:

for (const [key, filter] of declaredFilters) {
  const rawValue = state.filters[key]
  if (rawValue === undefined) continue
  const validator = vine.compile(filter.valueSchema())

  let value: JsonValue
  try {
    value = (await validator.validate(rawValue, { meta: undefined })) as JsonValue
  } catch (error) {
    if (error instanceof vineErrors.E_VALIDATION_ERROR) continue
    throw error
  }

  await pendingMutation(filter.apply(query, value, context))
}

The allowlists themselves come from allowlistsFor(table): columns is every column key, sortable is the keys with a sortColumn, searchable is the keys with at least one search column, filters is table.filters.map(f => f.key), and perPageOptions is the resource’s list. They hold column keys, not SQL columns — ?sort= and ?columns= speak the wire vocabulary, and the stages map a key to SQL afterwards. A column hidden from the request by canSee never enters them, so a user who cannot see a column cannot sort by it either.

Nothing in this file throws. The returned state satisfies, by construction: sort[].column ⊆ sortable, keys(filters) ⊆ filters, columns ⊆ columns or null, perPage ∈ perPageOptions, page ≥ 1, trashed ∈ {default, with, only}.

import { ADONIA_CONFIG_DEFAULTS, parseIndexQueryState } from '@adonia/core'
import type { QueryAllowlists } from '@adonia/core'

const allowlists: QueryAllowlists = {
  sortable: new Set(['title', 'publishedAt']),
  searchable: new Set(['title']),
  filters: new Set(['status', 'publishedAt']),
  columns: new Set(['title', 'status', 'publishedAt']),
  perPageOptions: [10, 25, 50],
}

const state = parseIndexQueryState(
  '?q=ada&sort=-publishedAt,internalScore' +
    '&filters[status]=published&filters[internalScore]=9' +
    '&perPage=100000&trashed=maybe&columns=title,ssn',
  { allowlists, defaults: ADONIA_CONFIG_DEFAULTS.defaults }
)

// state.search  === 'ada'
// state.sort    -> [{ column: 'publishedAt', direction: 'desc' }]  internalScore gone
// state.filters -> { status: 'published' }                         internalScore gone
// state.columns -> ['title']                                       ssn gone
// state.perPage === 25                                             100000 -> defaults.perPage
// state.trashed === 'default'                                      'maybe' unrecognized

The client mirrors all of this in parseTableQuery; see the index table.

Declaring what is searchable

Search has its own allowlist, declared per column:

import { C } from '@adonia/core'

C.text('title').searchable() //                      searches the `title` column
C.relation('author.fullName').searchable('author.fullName') // searches through the relation
C.text('name').searchable('first_name', 'last_name') //        one key, two SQL columns

.searchable(...columns) defaults to [this.key] when called bare, sets props.searchable = true for the client, and puts the column key into allowlists.searchable. A column with no search columns is not searchable, and ?q= can never reach it.

?q= itself is gated in the parser: the value is trimmed, and anything shorter than search.minQueryLength (config default 2) becomes null, which makes the search stage a no-op.

From allowlist key to SQL target

resolveSearchTargets(model, searchable, columns) — exported, because the §10.4 global-search endpoint needs the same expansion without running the index pipeline — turns each allowlist key into zero or more SearchTargets:

import type { SearchTarget } from '@adonia/core'

const targets: readonly SearchTarget[] = [
  { relation: null, column: 'posts.title' },
  { relation: 'author', column: 'users.full_name' },
]

A key expands to its column’s declared search columns, or to itself when no Column matches it. Each path is walked against the model’s relation metadata: a local attribute becomes <table>.<column>, a dotted path qualifies against the related table and records the relation prefix. Any hop that does not resolve — a renamed relation, a column dropped in a migration — is skipped, per §8.3. Duplicates collapse. The result is memoized per allowlist identity, so resolution happens once, not per request.

Doing this in the stage is what keeps drivers portable: a driver receives SearchTarget[] and never imports Lucid.

The driver contract

import type { SearchDriver } from '@adonia/core'

export type Contract = Pick<SearchDriver, 'name' | 'search'>
// name: string
// search(resource, term, context): SearchDriverResult | Promise<SearchDriverResult>

context is SearchDriverContext — the whole PipelineContext (ctx, resource, state, allowlists, hints, tenant, dialect) plus the resolved targets. term is already trimmed and already past minQueryLength.

SearchDriverResult is a three-way answer:

Return Meaning What the stage does
readonly (string | number)[] “these primary keys match” whereIn('<table>.<pk>', ids)
(query) => void | Promise<void> “apply this to the query” runs it through pendingMutation
null “nothing to narrow on” leaves the query untouched

The distinction between [] and null is load-bearing. [] is a result — “I searched and found nothing” — and yields an empty page. null is an absence and yields every row.

The database driver

Registered under the name database by importing table/search/index.ts; it is the default of config.search.driver. Its shape is fixed by §8.4:

  1. Split term on whitespace. No searchable targets, or no non-empty terms → return null.
  2. Escape %, _ and ! in each term and wrap it in %…%.
  3. Return a mutation that opens one outer where(…) group, containing one andWhere(…) group per term, containing one or alternative per target.

So it is AND across terms, OR across columns: ?q=ada lovelace asks for rows where some searchable column contains “ada” and some searchable column contains “lovelace” — not necessarily the same one, which is what makes a name split across first_name/last_name findable from a single box.

-- ?q=ada lovelace, searchable: title + author.fullName, on Postgres
where (
      (posts.title ilike '%ada%' escape '!'
        or exists (select * from users whereand users.full_name ilike '%ada%' escape '!'))
  and (posts.title ilike '%lovelace%' escape '!'
        or exists (select * from users whereand users.full_name ilike '%lovelace%' escape '!'))
)

Three details are not cosmetic:

  • The outer group. Without it, the leading or of the first alternative would associate with whatever the filters stage appends next, and a search would widen the result set past an active filter instead of narrowing it. That is a data-exposure bug (§19), not a formatting preference.
  • The dialect branch. context.dialect === 'postgres' emits ?? ilike ? escape '!'; every other dialect emits lower(??) like lower(?) escape '!', the portable spelling. MySQL’s default collation is already case-insensitive, so lower() is redundant there but never wrong.
  • The escape character is !, not \. MySQL treats a backslash as a string escape, so the SQL literal '\' is a syntax error there; '!' is inert everywhere. A user typing 50% gets the two characters, not a wildcard.

A target with a relation prefix is emitted as orWhereHas, nesting one whereHas per hop for a multi-segment path — never a join, so a hasMany search cannot multiply rows.

Registering your own driver

defineSearchDriver is an identity function whose only job is to typecheck the literal at its declaration site. registerSearchDriver puts it in the process-wide registry, replacing any driver of the same name — so overriding the built-in database driver needs no new name and no resource edits.

import { defineSearchDriver, registerSearchDriver } from '@adonia/core'
import type { ResourceClass } from '@adonia/core'

interface EngineResponse {
  hits: { id: number }[]
}

/** An external engine that answers with primary keys. */
export const meilisearch = registerSearchDriver(
  defineSearchDriver({
    name: 'meilisearch',

    async search(resource, term, context) {
      const { slug } = resource.constructor as ResourceClass
      const response = await fetch(`http://127.0.0.1:7700/indexes/${slug}/search`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          q: term,
          limit: context.state.perPage * 10,
          filter: context.tenant === undefined ? [] : [`tenant = ${String(context.tenant)}`],
        }),
      })

      if (!response.ok) return null
      const body = (await response.json()) as EngineResponse
      return body.hits.map((hit) => hit.id)
    },
  })
)

Register at boot — a provider’s boot(), or start/adonia.ts — then point the config at it:

import { defineConfig } from '@adonia/core'

export default defineConfig({
  search: { driver: 'meilisearch', minQueryLength: 2 },
})

Resolution is the one place in this document that is not a silent drop. resolveSearchDriver(name) throws InvalidConfigException (E_ADONIA_INVALID_CONFIG) listing the registered names when nothing matches. A driver name is developer configuration, not user input, and a typo that quietly disabled search on every resource would be far worse than a failed boot. The pipeline resolves the driver when it is built, so the throw lands at boot. hasSearchDriver(name) and searchDriverNames() are available for a doctor-style check (diagnostics).

Soft deletes: the trashed scope

The soft-delete scope is decided once, in the softDeleteScope stage, which runs first — before search, filters and sort — so every later stage narrows the same row set.

Scope Predicate Selected by
default where <table>.<col> is null nothing (the default)
with (none) ?trashed=with, or filters[<key>]=with
only where <table>.<col> is not null ?trashed=only, or filters[<key>]=only

resolveTrashedScope(state, filters) picks it:

  1. Scan the declared filters for a TernaryFilter with .trashed() set. If its sanitized value maps through the scope table, that wins — it is the control the resource actually rendered, and its key already passed the allowlist.
  2. Otherwise fall back to state.trashed, so a resource can honour the §10.2 ?trashed= key without declaring a filter for it.

A .trashed() ternary widens its own vocabulary and accepts the plain ternary words as aliases, so one UI control can emit either:

Value Scope
absent, default, no default
with, any with
only, yes only

It also sets props.variant = 'trashed' and the two options with / only, matching the frozen protocol fixture.

A trashed ternary applies no predicate of its own — its apply() returns immediately. softDeleteScope has already consumed the value, and emitting the predicate twice would be redundant at best and, after ?trashed=only, contradictory.

The scope column is table-qualified because the sort stage may LEFT JOIN a related table with its own deleted_at; unqualified, that is an error on MySQL and, worse, the silently wrong column elsewhere. Detection (detectSoftDeletes) looks for a deletedAt/deleted_at model attribute and is overridable per resource with static softDeletes. A resource without soft deletes gets a shared no-op stage and pays nothing.

The panel’s ⌘K palette reads one endpoint, registered as adonia.<panelId>.search inside the adonia.panel-access middleware group and routed before /:resource, so a panel may still own a resource slugged search.

The §10.4 envelope is final:

import type { GlobalSearchResponse } from '@adonia/core'

const body: GlobalSearchResponse = {
  groups: [
    {
      resource: 'posts',
      label: 'Posts',
      results: [
        {
          id: 12,
          title: 'Designing the pipeline',
          subtitle: 'Published 12 Jun 2026',
          url: '/admin/posts/12',
          thumbnail: '/uploads/covers/12.jpg',
        },
      ],
    },
  ],
}

subtitle and thumbnail are optional; groups is one entry per searched resource.

The controller does three things: panelOf(ctx) — which throws UnknownResourceException if no panel resolved for the request — then noStore(ctx), which sets Cache-Control: no-store, then a JSON response. Search results are per-user and authorization-shaped, so they must never sit in a shared or browser cache.

The handler walks registered resources in panel order and skips a resource when globallySearchable === false, viewList is denied, or its resolved searchable allowlist is empty. An explicit static globallySearchable = [...] replaces the derived table allowlist; otherwise the index plan’s searchable columns are reused. Each query runs through the ordinary index scope and pipeline, is capped by search.globalSearchLimitPerResource (default 5), and then drops records denied by view. Empty result groups are omitted. Hit titles use recordTitle with the primary key as fallback, and hit URLs are built server-side for the active panel. Queries shorter than search.minQueryLength return { groups: [] } without touching resource models.

Per-resource search on the index (?q=) is unaffected by any of this; it runs through the pipeline’s search stage described above.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close