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 filters — t.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:
- Shape, in the parser: bracket decoding, a prototype-pollution guard, a
depth cap, and empty-value pruning. The result is a
JsonValue. - Semantics, in the
filtersstage: the value is run throughvine.compile(filter.valueSchema()). A value that fails is skipped exactly like an unknown key —E_VALIDATION_ERRORis 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')narrowsstatus; .column(name)splits the two apart when the wire key and the SQL column must differ;- a dotted target compiles to nested
whereHassub-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 withString(value)so a JSON body carrying3is compared against'3'rather than rejected on type. With no options declared it degrades tovine.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][]=reviewand the scalarfilters[status]=drafta 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()—yes→where <column> = true,no→where <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>isvine.string().trim().regex(…)accepting2026-03-01,2026-03-01T09:30and2026-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> >= fromand/orwhere <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]=lotsis dropped rather than compiled to a comparison againstNaN— which every dialect answers with “no rows”.apply()—where <column> >= minand/orwhere <column> <= max.
Note the key names: this filter takes min/max, dateRange takes from/to.
Filter.relation — related records
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 forbelongsTowherewhere 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__,constructorandprototypeare 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]=xis 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' unrecognizedThe client mirrors all of this in parseTableQuery; see
the index table.
Search
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:
- Split
termon whitespace. No searchable targets, or no non-empty terms → returnnull. - Escape
%,_and!in each term and wrap it in%…%. - Return a mutation that opens one outer
where(…)group, containing oneandWhere(…)group per term, containing oneoralternative 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 where … and users.full_name ilike '%ada%' escape '!'))
and (posts.title ilike '%lovelace%' escape '!'
or exists (select * from users where … and users.full_name ilike '%lovelace%' escape '!'))
)Three details are not cosmetic:
- The outer group. Without it, the leading
orof the first alternative would associate with whatever thefiltersstage 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 emitslower(??) like lower(?) escape '!', the portable spelling. MySQL’s default collation is already case-insensitive, solower()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 typing50%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:
- Scan the declared filters for a
TernaryFilterwith.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. - 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.
Global search (GET /search)
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.