Skip to content

Index query pipeline

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[].columnallowlists.sortable
  • keys(filters)allowlists.filters
  • columnsallowlists.columns, or null
  • perPage ∈ the effective perPageOptions, 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 → eagerLoad

The 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close