---
title: "The 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.

# The index query pipeline

Reference for the pipeline stages, filter DSL, and search drivers.

## Stage order

```text
resolve → authorize(viewList) → base → softDeleteScope → search
  → filters → sort → eagerLoad → paginate → serialize
```

`resolve`/`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).

<!-- @sample-preamble
import {
  allowlistsFor,
  buildIndexPipeline,
  createEagerHints,
  dialectOf,
  paginateIndex,
  parseIndexQueryState,
  resolveTable,
  selectVisibleColumns,
  serializeIndexPage,
} from '@adonia/core'
import type {
  AdoniaDefaults,
  AdoniaSearchConfig,
  BaseResource,
  HttpContextLike,
  RecordAbilityLookup,
  ResourceClass,
} from '@adonia/core'
import type { LucidModel } from '@adonisjs/lucid/types/model'
declare const ctx: HttpContextLike & { request: { qs(): Record<string, unknown> } }
declare const resource: BaseResource
declare const PostResource: ResourceClass
declare const defaults: AdoniaDefaults
declare const search: AdoniaSearchConfig
declare const Model: LucidModel
declare const can: RecordAbilityLookup
-->

```ts
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`/`hasOne` only — anything
  with higher cardinality would multiply rows) under a deterministic alias,
  `author.company` → `adonia_author__company`, recorded in
  `EagerHints.joins` so 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

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

<!-- @sample-preamble-reset -->
<!-- @sample-preamble
import { defineSearchDriver, registerSearchDriver } from '@adonia/core'
import type { BaseResource } from '@adonia/core'
declare function index(resource: BaseResource): {
  search(term: string): Promise<{ id: number }[]>
}
-->

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

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