Reference for the pipeline stages, filter DSL, and search drivers.
Stage order
resolve → authorize(viewList) → base → softDeleteScope → search
→ filters → sort → eagerLoad → paginate → serializeresolve/authorize run before a query builder exists and
paginate/serialize are terminal, so AdoniaPipeline owns the six
query-mutating stages in the middle. base is the caller’s: the controller
starts from Model.query() and applies the panel/tenant/lens scopes before
run.
buildIndexPipeline({ model, table, search, resource }) returns a pipeline
with all five built-in stages wired. Plugins reshape it with
before/after/replace (§16.2).
const table = resolveTable(resource, defaults)
const allowlists = allowlistsFor(table)
const state = parseIndexQueryState(ctx.request.qs(), { allowlists, defaults, search })
const query = Model.query()
const hints = createEagerHints()
await buildIndexPipeline({ model: Model, table, search, resource }).run(query, {
ctx, resource, state, allowlists, hints, dialect: dialectOf(query),
})
const page = await paginateIndex(query, {
pagination: table.pagination,
model: Model, columns: table.columnsByKey, sortable: allowlists.sortable,
defaultSort: table.defaultSort, state, hints,
onInvalidCursor: PostResource.onInvalidCursor,
})
const columns = selectVisibleColumns(table, state.columns)
const records = serializeIndexPage(page, columns, ctx, can)run resolves to void, not to the builder — a Lucid builder is thenable,
so a promise can never resolve to one; it would adopt it and execute the
half-built query. For the same reason every stage, filter and driver
mutation is awaited through pendingMutation(...), which awaits real
promises only. Write (query) => { query.where(…) }, not
(query) => query.where(…), and it does not matter either way.
softDeleteScope
Detects soft deletes with detectSoftDeletes(model, resource): an explicit
static softDeletes on the resource wins, otherwise a deletedAt /
deleted_at column on the model enables the scope.
?trashed= |
predicate |
|---|---|
| absent | where <col> is null |
with |
(none) |
only |
where <col> is not null |
A declared Filter.ternary('trashed').trashed() takes precedence over
?trashed=, and applies no predicate of its own — the scope is this stage’s
job, and applying it twice would contradict itself.
search
Resolves the searchable allowlist into table-qualified SQL columns, then
hands them to the configured driver. Dotted paths compile to whereHas.
filters
Validates each filters[<key>] value against that filter’s compiled Vine
mini-schema and drops it silently on failure (§8.3), then calls apply.
This is the only place validation happens: parseIndexQueryState is
synchronous and can only guarantee the JSON shape.
sort
Maps a sort key through its column’s sortColumn, then:
- a dotted target LEFT JOINs each hop (
belongsTo/hasOneonly — anything with higher cardinality would multiply rows) under a deterministic alias,author.company→adonia_author__company, recorded inEagerHints.joinsso nothing joins it twice; - an aggregate column orders by its sub-select alias, unqualified;
- the primary key is always appended, so the order is total and pages never overlap.
eagerLoad
Replays the unioned EagerHints — preload (dotted paths folded into one
nested preload), withCount, withAggregate — for the columns the request
will actually render. Hiding a column with ?columns= removes its query.
paginate (terminal)
paginateIndex dispatches on the resource’s effective mode — config →
panel → static pagination, folded once by resolvePaginationDefaults
alongside static perPage (§6.1, most-specific wins; a declared perPage is
also unioned into perPageOptions so the client’s echo is not dropped).
Offset is LIMIT/OFFSET + COUNT(*) and emits the frozen §6 meta.
offsetMeta owns the boundaries: lastPage is 1 on an empty set (there is
always a page one), from/to are 0/0 when the page is empty and report
the real row count on a partial last page, and a page past the end is an
empty page, never an error — currentPage echoes the request rather than
clamping, so the client can render “no results” and offer the way back.
Cursor (the cursor contract) is keyset. It applies at most one sort key plus the primary-key tiebreaker — a multi-sort query string keeps its first key and silently drops the rest, staying in cursor mode — and emits
ORDER BY (sort_col IS NULL) ASC, sort_col <DIR>, pk <DIR>which pins NULLS LAST identically on PostgreSQL, MySQL and SQLite. The seek is
the expanded three-branch OR with an explicit NULL branch, never a row-value
comparison ((a,b) > (?,?) is UNKNOWN against NULL — silently wrong). Each
page fetches perPage + 1 rows; the extra row is hasMore, which is how the
mode avoids a COUNT(*). Because it owns the whole ORDER BY — and inverts it
wholesale to walk a prevCursor — the sort stage stands down in cursor mode.
Meta is { perPage, nextCursor, prevCursor, hasMore }; no total, no
lastPage. Tokens are opaque base64url JSON ({v, s, pk, b?}), unsigned:
they carry no filter or scope state, and every field is re-validated against
the sortable allowlist and the request’s resolved sort before use, so a forged
token can only describe a query the same user could already ask for. A token
that fails any of those checks is a 422 E_ADONIA_INVALID_CURSOR; resources
that would rather degrade a stale deep link set
static onInvalidCursor = 'reset' and get page one instead.
Filters (§8.2)
Filter is both the DSL namespace and the abstract base class plugins
extend; they are the same object.
| Factory | Wire type | Value |
|---|---|---|
Filter.select(key) |
select-filter |
one declared option |
Filter.multiSelect(key) |
multi-select-filter |
array of declared options |
Filter.ternary(key) |
ternary-filter |
yes | no | any |
Filter.ternary(key).trashed() |
ternary-filter |
with | only (else default) |
Filter.dateRange(key) |
date-range-filter |
{ from?, to? } ISO-8601 |
Filter.numberRange(key) |
number-range-filter |
{ min?, max? } |
Filter.relation(key, relation?) |
relation-filter |
array of related keys |
Filter.custom(key, schema, apply) |
custom-filter |
whatever schema accepts |
.column(name) retargets a filter at a different column; a dotted name
compiles to whereHas. A date-only to bound is extended to the end of that
day, so to=2024-03-01 includes the whole first of March.
Search drivers (§8.4)
The built-in database driver builds grouped LIKE/ILIKE '%q%' across the
searchable allowlist: AND across whitespace-separated terms, OR across
columns, all inside ONE grouped where so a search can never widen past an
active filter. Postgres gets ILIKE; every other dialect gets
LOWER(col) LIKE LOWER(?). %, _ and ! in the term are escaped with
! (not \, which MySQL reads as a string escape).
Register an alternative:
registerSearchDriver(
defineSearchDriver({
name: 'meilisearch',
async search(resource, term) {
const hits = await index(resource).search(term)
return hits.map((hit) => hit.id) // merged as `whereIn(<pk>, ids)`
},
})
)A driver returns matching ids, a (query) => void mutation, or null for
“nothing to narrow on” (distinct from [], which is an empty result and
yields an empty page). config.search.driver selects it; an unregistered
name is a boot error, not a silent drop — a typo must not disable search
across every resource.