---
title: "Index query pipeline"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 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)

<!-- @sample-preamble
import { parseIndexQueryState } from '@adonia/core'
import type {
  AdoniaDefaults,
  AdoniaSearchConfig,
  HttpContextLike,
  PaginationMode,
  QueryAllowlists,
} from '@adonia/core'
declare const ctx: HttpContextLike & { request: { qs(): Record<string, unknown> } }
declare const allowlists: QueryAllowlists
declare const defaults: AdoniaDefaults
declare const pagination: PaginationMode
declare const search: AdoniaSearchConfig
-->

```ts
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.sortable`
- `keys(filters)` ⊆ `allowlists.filters`
- `columns` ⊆ `allowlists.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)

<!-- @sample-preamble
import { AdoniaPipeline, createEagerHints } from '@adonia/core'
import type { BaseResource, IndexQueryState, StageName } from '@adonia/core'
import type { LucidModel } from '@adonisjs/lucid/types/model'
declare const model: LucidModel
declare const resource: BaseResource
declare const state: IndexQueryState
-->

```ts
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:

```text
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](/authorization#row-level-scoping-static-query).

`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)

<!-- @sample-preamble
declare const pipeline: AdoniaPipeline
-->

```ts
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:

```ts
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.

Source: https://adonia.pages.dev/query-pipeline/index.mdx
