---
title: "Table columns and the index table"
description: "Define index columns, derived projections, sorting, and row serialization."
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.

# Table columns and the index table

The column namespace, schema derivation, and zero-per-row-query budget are implemented
against the frozen
[protocol §3](/protocol/v1) `TableDescriptor`.

Columns live in `packages/core/src/table/columns/`, the builder in
`table/table_builder.ts`, and resolution in `table/derivation.ts`.
Everything below is re-exported from `@adonia/core`.

## Declaring a table

```ts
import Post from '#models/post'
import { BaseResource, C, F, Filter } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(s: SchemaBuilder): SchemaComponent[] {
return [s.section('Content').components([F.text('title')])]
  }

  override table(t: TableBuilder): TableBuilder {
return t
  .columns([
    C.text('title').sortable().searchable().link('detail').limit(60),
    C.badge('status').colors({ draft: 'gray', published: 'green' }),
    C.relation('author.fullName').sortable('author.last_name'),
    C.count('comments').sortable(),
    C.date('publishedAt').since().toggleable(true),
  ])
  .filters([
    Filter.select('status').options([
      { value: 'draft', label: 'Draft' },
      { value: 'published', label: 'Published' },
    ]),
  ])
  .defaultSort('publishedAt', 'desc')
  .searchPlaceholder('Search posts…')
  .perPageOptions([10, 25, 100])
  .multiSort()
  }
}
```

`table()` is optional. Declaring it **fully replaces** §7.5 derivation —
there is no merge, so the visible column set never shifts because an
unrelated schema field was added.

## The `C` namespace (§8.1)

| Factory | Cell type | Modifiers | Eager-load hint |
| --- | --- | --- | --- |
| `C.text(attr)` | `text` | `.limit(n)` `.copyable()` | — |
| `C.badge(attr)` | `badge` | `.colors(map)` `.icons(map)` | — |
| `C.boolean(attr)` | `boolean` | `.trueIcon()` `.falseIcon()` | — |
| `C.date(attr)` | `date` | `.format(fmt)` `.since()` `.serializeAs(enc)` | — |
| `C.number(attr)` | `number` | `.decimal(n)` `.money(code)` | — |
| `C.image(attr)` | `image` | `.rounded()` `.stacked(rel)` | `preload(rel)` when stacked |
| `C.relation('a.b')` | `relation` | `.counts(bool)` | `preload('a')` |
| `C.count(rel)` | `count` | — | `withCount(rel)` |
| `C.sum(rel, col)` | `number` | `.decimal(n)` `.money(code)` | `withAggregate` |
| `C.exists(rel)` | `boolean` | — | `withCount(rel)` |
| `C.computed(key, fn)` | `text` / `badge` | `.badge()` | — |
| `C.custom(key, type, fn)` | `type` | — | — |

Every column also carries the shared DSL: `.label()`, `.sortable(col?)`,
`.searchable(...cols)`, `.link('detail' \| 'edit' \| fn)`,
`.toggleable(defaultHidden?)`, `.withProps({…})`.

### Aggregate keys vs. SQL aliases

The wire key and the SQL alias differ, and stages must not conflate them:

| Column | Row key | `$extras` alias | `sortColumn` default |
| --- | --- | --- | --- |
| `C.count('comments')` | `comments` | `comments_count` | `comments_count` |
| `C.sum('items','total')` | `items_sum_total` | `items_sum_total` | `items_sum_total` |
| `C.exists('revision')` | `revision_exists` | `revision_count` | `revision_count` |

`comments` as the row key is fixed by the protocol fixture
`fixtures/protocol/post.index.json`. Lucid exposes no `withExists`, so
`C.exists` rides the same count sub-query and compares it against zero —
one correlated sub-select, no extra statement.

### `C.computed` is restricted on purpose

Computed cells use the restricted contract: a
computed cell may return only `string`, `number`, `null`, or — after
`.badge()` — `{ label, color?, icon? }`. Anything else throws
`E_ADONIA_INVALID_CONFIG` naming the column and pointing at `C.custom`,
which is the escape hatch for arbitrary JSON payloads consumed by a
registered cell component.

The callback runs **once per row and must not query**. Everything it needs
has to come from data the eager-load stage already fetched.

## Derivation (§7.5)

Without `table()`, columns come from the schema: each field's
per-instance `columnType` (`text-input → text`, `toggle → boolean`,
coloured `select → badge`, dates → `date`, `belongs-to → relation`) picks
the factory from `DERIVABLE_COLUMN_TYPES`. A `columnType` outside that map
degrades to `text` rather than failing boot — a plugin field naming an
unregistered cell is the client's `<UnknownComponent>` problem (protocol
§8). Fields marked `visibleOn('index')` win when any are marked; otherwise
the first `INDEX_COLUMN_CAP` (5) tabulatable fields are used, and either
way `hiddenOn('index')` and a failing `canSee` still remove a field. A
field whose `columnType` is `undefined` is not tabulatable and is skipped.

Derivation produces `Column` **instances**, not descriptors, so a derived
table contributes eager-load hints exactly like a declared one.

## Gating a column or a filter (§12)

`Column` and `Filter` carry the same two visibility methods a schema node
does, which is how a resource that declares its own `table()` hides a column
from a request that lacks an ability:

<!-- @sample-preamble
import { C, Filter } from '@adonia/core'
import type { TableBuilder } from '@adonia/core'
declare const t: TableBuilder
-->

```ts
t.columns([
  C.text('title').sortable(),
  C.number('salary').money('USD').sortable().searchable().requiresAbility('edit'),
]).filters([Filter.ternary('trashed').requiresAbility('restore')])
```

<!-- @sample-preamble-reset -->

Gates are **additive** — `canSee(fn)` and `requiresAbility(a)` each add one and
all must pass. Omission is total: `resolveTable` filters the list once and
every consumer reads that list, so a gated-away column disappears from the §3
wire table, from the `sortable`/`searchable`/`columns` allowlists, from the
default sort, from the eager-load hints and from every row — it cannot be
ordered by through `?sort=` after it stopped being visible (§8.3/§19). A
gated-away filter likewise leaves the `filters[<key>]` allowlist, so a
hand-written value is silently dropped.

A raw `canSee` reads things the index-plan cache key does not carry, so it
also marks the resolved table `requestScoped` and keeps it out of the shared
memo. `requiresAbility` alone stays cacheable — the ability hash *is* in the
key. A table resolved without a context (offline tooling, codegen) keeps
everything.

## Resolution API

<!-- @sample-preamble
import { allowlistsFor, compileTableDescriptor, resolveTable, selectVisibleColumns } from '@adonia/core'
import type { AdoniaDefaults, BaseResource, IndexQueryState } from '@adonia/core'
declare const resource: BaseResource
declare const defaults: AdoniaDefaults
declare const state: IndexQueryState
-->

```ts
const table = resolveTable(resource, defaults)          // ResolvedTable
const allowlists = allowlistsFor(table)                 // QueryAllowlists (§8.3)
const visible = selectVisibleColumns(table, state.columns)
const descriptor = compileTableDescriptor(table)        // protocol §3 block
```

`deriveAllowlists(resource, defaults)` and
`deriveTableDescriptor(resource, defaults)` are the one-shot forms for
callers with no `ResolvedTable` in hand. `DescriptorCompiler.compileIndex`
accepts an already-resolved table as its third argument so a request pays
for one schema walk, not two.

`allowlists.sortable` / `.searchable` hold **column keys**, because that is
what `?sort=` and `?columns=` speak. The sort and search stages map a key
through `columnsByKey.get(key)` to reach `sortColumn` / `searchColumns`.

## Toggleable columns

Every column is toggleable; `toggleable(true)` starts it hidden.

- **Descriptor** — `props.toggleable` and `props.defaultHidden` ship for
  *every* column, and `table.columns` always lists the *full* set so the
  client's toggle menu can render the hidden ones.
- **Client contract** — the panel persists the user's selection in
  `localStorage` under `adonia:<panel>:<resource>:columns` (a JSON array of
  column keys) and replays it as `?columns=a,b,c`.
- **Server** — `visibleColumns(columns, requested)` resolves what a request
  actually renders: the requested keys when `?columns=` is present (unknown
  keys already dropped by the parser, §8.3), otherwise every column that is
  not `defaultHidden`. `selectVisibleColumns(table, requested)` is the same
  function applied to a `ResolvedTable`.

Filtering server-side is what makes the toggle real: a hidden `C.count`
column contributes no `withCount`, so hiding it removes its sub-query
rather than merely hiding the cell. **The `eagerLoad` stage and the row
serializer call the same function**, deliberately — a stage that
re-implemented the predicate silently unioned every `defaultHidden` column's
hints on the far more common request that carries no `?columns=` at all,
buying a preload statement or a correlated sub-select for a value no row
would ever carry.

## The §18 budget

An index page costs a fixed number of statements regardless of row count:

```text
1 (pagination count) + 1 (select, with every withCount/withAggregate
folded in as a correlated sub-select) + 1 per preloaded relation
```

That holds only because every column reads exclusively from materialized
data (`$attributes`, `$preloaded`, `$extras`) and declares what it needs in
`contributeHints`. `packages/core/tests/query_count.spec.ts` asserts the
exact count, asserts it is identical at 5 and 50 rows, and includes a
negative control proving the harness catches an un-hinted relation column
as a 50-query regression.

## Row serialization (protocol §6)

Two variants, one coercion — `packages/core/src/table/serializer.ts`:

| | Keys on the wire | Built by |
| --- | --- | --- |
| **Index row** | the declared column keys **verbatim** (dotted keys are never split), plus `id` and `can` | `serializeRow` / `serializeRecords` |
| **Detail / edit record** | the record's full serialized attribute set, plus `id` and `can` | `serializeRecord` |

An index row is the projection of the *declared columns* and nothing else.
That is a §19 property, not a payload optimization: an attribute nobody put
in a column — `password`, `rememberMeToken`, a `secret` JSON blob — cannot
reach the client by accident.

### Value coercion

Both variants and every `Column.valueFor` go through one function,
`toJsonValue(value, options?)`, so a `bigint` id or a Luxon `DateTime`
looks identical on an index row and on the detail record it links to.

| Input | Wire |
| --- | --- |
| `null` / `undefined` | `null` |
| `string` / `boolean` | unchanged |
| `number` | unchanged; `NaN`/`Infinity` → `null` |
| `bigint` | decimal **string** — never a lossy `Number` |
| TS string/numeric enum | its value (a `string`/`number` at runtime) |
| `Date` | per `dateFormat`, default ISO-8601; invalid → `null` |
| Luxon `DateTime` | per `dateFormat`, default ISO-8601; invalid → `null` |
| `Buffer` / typed array / `ArrayBuffer` | base64 string |
| array | element-wise |
| plain object | key-wise (`undefined` members become `null`) |
| anything with `toJSON()` | the coerced result of that call |
| **any other class instance** | `null` + a dev warning naming the class |
| cycle | `null` + a dev warning |

The last two rows are the point. There is no `String(value)` fallback: a
Lucid model, a `Map`, a knex `Raw` used to stringify to `"[object Object]"`
and ship silently. Now they are dropped, and dev logs say which key and
which class — give the class a `toJSON()`, or map it in a `C.computed()`.

Base64 rather than omission for binary, because a client cannot tell an
omitted key from a column the server dropped; a row key must always exist.
Large blobs still belong behind a `file`/`image` field's Drive key.

### `serializeAs` vs `format`

Easy to confuse, and must not be:

- `C.date(attr).serializeAs('iso' | 'iso-date' | 'millis' | 'seconds')`
  picks the **wire encoding** the client parses. It is mirrored into
  `props.serializeAs`, because an ISO string and an epoch integer need
  different `Date` construction client-side.
- `.format(fmt)` and `.since()` are **presentational** — the client owns
  the user's locale and timezone, so a Luxon token string is never
  evaluated server-side.

### `can`, and the keys you may not use

`id` and `can` are reserved row keys (protocol §6). A column claiming
either is rejected by `resolveTable` with `E_ADONIA_INVALID_CONFIG` naming
the resource and the key — at boot for a declared table, on first request
for a derived one. The alternative is a silent winner: a `can` column would
either overwrite the authorization projection or be overwritten by it. On a
*detail record* the same collision can come from the database schema rather
than the panel, so an attribute literally named `can` is dropped with a dev
warning instead.

The last argument of `serializeRow` / `serializeRecords` / `serializeRecord`
supplies the projection:

<!-- @sample-preamble-reset -->
<!-- @sample-preamble
import { serializeRecords } from '@adonia/core'
import type {
  Column,
  HttpContextLike,
  PaginatorLike,
  RecordAbilityLookup,
  ResourceAbility,
} from '@adonia/core'
declare const paginator: PaginatorLike
declare const columns: readonly Column[]
declare const ctx: HttpContextLike
declare const lookup: RecordAbilityLookup
declare const resource: Record<ResourceAbility, boolean>
-->

```ts
serializeRecords(paginator, columns, ctx, lookup)          // per-record map wins
serializeRecords(paginator, columns, ctx, { resource, record: lookup })
```

- **Nothing passed** → every ability granted. No authorization layer is
  installed, which §12 treats as *absent*, not *denied*.
- **A bare lookup** (`resolveRowAbilities`, §12) → authoritative; anything
  it does not project denies, per the §12 production fallback.
- **`{ resource, record }`** → the resource-level map with the
  record-dependent verdicts merged over it. That merge direction is the
  whole point of §6's `can`: a user who may `edit` the resource may still
  not edit *this* row.

Resolution is async and happens **once per page**, before serialization;
the serializers are synchronous so no row can await, because a per-row
await is a per-row query (§18).

## Tests

- `packages/core/tests/columns.spec.ts` — the `C` set: descriptors, DSL,
  `valueFor`, hints, and the `C.computed` payload restriction.
- `packages/core/tests/derivation.spec.ts` — §7.5 derivation, explicit
  `table()` override, allowlists, toggle selection.
- `packages/core/tests/query_count.spec.ts` — §18 conformance.
- `packages/core/tests/serialization.spec.ts` — row/record variants, the
  coercion table above (golden files in
  `tests/fixtures/serialization/`), the `can` merge, and the reserved-key
  rejection.

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