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 additivecanSee(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.

  • Descriptorprops.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.
  • ServervisibleColumns(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/Infinitynull
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