Skip to content

Table columns and the index table

Define index columns, derived projections, sorting, and row serialization.

The column namespace, schema derivation, and zero-per-row-query budget are implemented against the frozen protocol §3 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

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:

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

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

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:

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:

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

Type to search…

↑↓ navigate↵ selectEsc close