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 blockderiveAllowlists(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.toggleableandprops.defaultHiddenship for every column, andtable.columnsalways 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
localStorageunderadonia:<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 notdefaultHidden.selectVisibleColumns(table, requested)is the same function applied to aResolvedTable.
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 relationThat 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 intoprops.serializeAs, because an ISO string and an epoch integer need differentDateconstruction 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’scan: a user who mayeditthe 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— theCset: descriptors, DSL,valueFor, hints, and theC.computedpayload restriction.packages/core/tests/derivation.spec.ts— §7.5 derivation, explicittable()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 intests/fixtures/serialization/), thecanmerge, and the reserved-key rejection.