This page documents the index pipeline’s stage order, query-string contract, plugin insertion points, and cursor-pagination behavior.
Parsing lives in packages/core/src/table/pipeline.ts, the runner in
packages/core/src/table/pipeline_runner.ts, and the shared types in
packages/core/src/table/state.ts. Everything below is re-exported from
@adonia/core.
1. Parse the request (§10.2)
const state = parseIndexQueryState(ctx.request.qs(), {
allowlists, // QueryAllowlists derived from the resource's columns/filters
defaults, // AdoniaDefaults, panel/resource overrides already merged
pagination, // optional; defaults to defaults.pagination
search, // optional; defaults to the framework search config
})parseIndexQueryState accepts either the decoded bag from ctx.request.qs()
or a raw query string ('?q=…&sort=-publishedAt'), and returns an
IndexQueryState:
| Param | State field | Notes |
|---|---|---|
q |
search |
Trimmed; null below search.minQueryLength |
page |
page |
1-based; 1 when absent or malformed |
perPage |
perPage |
Always a member of the effective perPageOptions |
sort |
sort |
Comma-separated, - prefixes desc, duplicates dropped |
filters[key] |
filters |
Nested (filters[publishedAt][from]) and repeated (filters[tags][]) forms supported |
columns |
columns |
Comma-separated; null means “resource default” |
trashed |
trashed |
default | with | only (§8.2) |
cursor |
cursor |
Cursor mode only; opaque here, decoded by the paginate stage |
Everything unknown is dropped, never rejected
Per §8.3 this function never throws. Unknown sort keys, unknown filter
keys, unknown column keys, a perPage outside perPageOptions, a malformed
page, an unrecognized trashed value and non-string inputs all decay to the
resource default. A stale bookmark renders the default table; it does not
render an error page. The returned state therefore guarantees, by
construction:
sort[].column⊆allowlists.sortablekeys(filters)⊆allowlists.filterscolumns⊆allowlists.columns, ornullperPage∈ the effectiveperPageOptions,page≥ 1
Stages consume the state without re-validating.
Empty values are unset values: filters[status]= produces no status filter,
and filters[publishedAt][from]=&filters[publishedAt][to]=2026-06-30 produces
{ publishedAt: { to: '2026-06-30' } }. Per-filter semantic validation is
each Filter’s own Vine mini-schema (§8.2), applied by the filters stage.
Cursor mode (the cursor contract)
On a pagination: 'cursor' resource only the first surviving sort key
applies — the rest are silently dropped, the request stays in cursor mode — and
cursor wins over page, which resets to 1. A cursor sent to an
offset-paginated resource is meaningless input and is dropped. The token is
carried through opaquely; decoding it (and the 422 E_ADONIA_INVALID_CURSOR
on a bad one) belongs to the paginate stage.
2. Run the pipeline (§8.3)
const pipeline = new AdoniaPipeline()
await pipeline.run(model.query(), {
ctx, resource, state, allowlists,
hints: createEagerHints(),
dialect: 'postgres',
})run executes the six addressable stages in the normative order and awaits
async stages, so each stage observes the completed effects of its
predecessors:
base → softDeleteScope → search → filters → sort → eagerLoadThe base stage is registered but stays a no-op: §8.3 defines it as
Resource.query plus the panel, tenant, parent and lens scopes, and those
apply to every row-touching route, not only to the index. Scoping them
inside the index pipeline would narrow the list and leave /:resource/:id
wide open, so they live in resource/scope.ts (applyResourceScope) and the
controller applies them to the builder it hands to run — and to every
single-record lookup behind show, edit, update, destroy and restore. See
authorization.
resolve/authorize run in the controller before a query builder exists, and
paginate/serialize are terminal, so they are not runner stages. Stages
mutate the builder in place and run resolves to void — the caller keeps
the builder it passed in, and a promise resolving to a Lucid builder would
adopt the thenable and execute the half-built query.
Every stage ships as a no-op by default, so a bare AdoniaPipeline is a valid
(if inert) pipeline and each real implementation lands as one replace().
An AdoniaPipeline is built once per resource and holds no per-request state:
everything a stage needs arrives on the PipelineContext. The index route
keeps that promise through resolveIndexPlan(), which memoizes the resolved
table, its allowlists and the wired pipeline as one unit, keyed exactly like a
§9.4 descriptor — (resource generation, slug, panel, protocol version, ability hash) — and invalidated with the descriptor cache. Sharing the
instances, not merely equal copies, is what makes the stage-level memos real
caches: the filters stage memoizes compiled Vine value schemas per Filter
instance, and the search stage memoizes resolved SQL targets per allowlist
instance. Rebuilding the plan per request left both permanently cold and
re-ran vine.compile for every active filter on every page load.
createEagerHints() returns the per-request accumulator that columns and
fields union their preload / withCount / withAggregate / join
requirements into, so index serialization stays O(rows) with zero per-row
queries (§18).
3. Insertion points (§16.2)
pipeline.before('sort', (query, ctx) => { /* runs just before the sort body */ })
pipeline.after('sort', (query, ctx) => { /* runs just after it */ })
pipeline.replace('sort', (query, ctx) => { /* becomes the sort body */ })All three are chainable and take a PipelineStage
((query, context) => void | Promise<void>). Hooks registered on the same
position run in registration order. replace drops the previous body but
keeps before/after hooks attached around it, so a plugin can override
what a stage does without disturbing what others attached around it.
Registering against a name outside the six stages throws
InvalidConfigException (E_ADONIA_INVALID_CONFIG) listing the valid set.
4. Tracing hook (§20)
PipelineContext.onStage is an optional around-hook invoked once per stage
function — bodies and hooks alike:
type StageObserver = (stage: StageName, run: () => void | Promise<void>) => void | Promise<void>The observer MUST call run() exactly once and return (or await) its result.
This is the seam the OTel integration uses to open adonia.pipeline.<stage>
spans, which keeps table/pipeline_runner.ts free of any telemetry import.
Stage failures propagate — a broken stage surfaces rather than silently
yielding an under-scoped query.