A resource is the one place a model becomes a panel: it binds the Lucid model, claims a URL slug, and declares the schema and table behavior.
Resources are instantiated per request. Construction must be cheap and state-free — an instance holds no request data, and the compiled artifacts (descriptors, validators) are cached across requests rather than the instance itself.
The shape of a resource
import { BaseResource, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent } from '@adonia/core'
import Post from '#models/post'
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
static override labels = { singular: 'Post', plural: 'Posts' }
schema(s: SchemaBuilder): SchemaComponent[] {
return [s.section('Content').components([F.text('title').required().maxLength(200)])]
}
}Three required pieces: static model, static slug, and schema(). Everything
else on the class is optional.
The type parameter — BaseResource<typeof Post> — is anchored on the instance
side through a phantom modelType property that is never assigned at runtime.
Statics cannot reference a class type parameter, which is why static model is
typed as the base LucidModel on BaseResource and why static recordTitle’s
callback is declared over never. Declaring the parameter still pays: your
static query() gets a ModelQueryBuilderContract<typeof Post> instead of an
untyped builder.
The statics
Every static BaseResource declares, its type, its default, and what reads it.
Anything not in this table does not exist on the class today.
| Static | Type | Default | Effect |
|---|---|---|---|
model |
LucidModel |
— (required) | The Lucid model the resource manages. Every query, fill and delete goes through it. |
slug |
string |
— (required) | URL segment and registry key, unique per panel. Convention: pluralized snake of the model name (posts, blog_categories). |
labels |
{ singular: string; plural: string } |
derived from slug |
Singular/plural display labels in the descriptor (protocol §3), the navigation item and the flash copy after a delete. |
navigationIcon |
string |
undefined → icon: null |
Lucide icon name beside the navigation item. |
navigationGroup |
string |
undefined |
Sidebar group. Resources sharing a group collapse into one group node; ungrouped ones stay top-level. |
navigationSort |
number |
0 |
Ascending weight; ties keep registry order. A group weighs as much as its lightest child, so items and groups sort against each other in one pass. |
hidden |
boolean |
undefined |
Omits the resource from the navigation tree. It stays registered, routable and resolvable — this is for resources reached only from a parent’s detail page. |
softDeletes |
boolean |
undefined (convention) |
Forces soft-delete detection on or off. Undeclared, a deletedAt/deleted_at attribute enables the trashed scopes. Set false when deletedAt is a plain audit timestamp; set true when the tombstone column has a custom name. |
pagination |
'offset' | 'cursor' |
panel/config defaults.pagination |
Pagination mode for this resource’s index. Cursor mode trades total/lastPage and the COUNT(*) behind them for stable pages and a single sort key. |
perPage |
number |
panel/config defaults.perPage |
Initial rows per page. Also unioned into the effective perPageOptions, so a client echoing the value back as ?perPage= is not dropped by the §8.3 allowlist. |
recordTitle |
string | ((record: never) => string) |
primary key | How one record is named in prose. See below. |
globallySearchable |
string[] | false |
derived searchable table columns | Columns queried by grouped global search; false opts the resource out. |
lockVersion |
string |
disabled | Numeric model attribute used for opt-in optimistic locking; edit state carries its value out-of-band as _adoniaLock. |
query |
(query, ctx, tenant?) => unknown |
none | Base scope. See below. |
onInvalidCursor |
'error' | 'reset' |
'error' |
What a malformed, tampered or sort-mismatched ?cursor= does. 'error' is the normative 422 E_ADONIA_INVALID_CURSOR; 'reset' serves the first page instead. It grants nothing extra — filters, scopes and authorization all run before paginate and come from the query string, never from the token. |
policy |
PolicyReference |
undefined |
Bouncer policy consulted for every ability can does not override — step 2 of the §12 chain. @adonisjs/bouncer is an optional peer; without it the policy is never consulted at all. |
can |
Partial<Record<Ability, AbilityCheck>> |
{} |
Inline ability overrides — step 1 of the chain, and the only step that always wins. An entry returning false is a deny that no fallback rescues. |
pagination and perPage are folded into the resolved defaults by
resolvePaginationDefaults(resourceClass, defaults): most-specific wins, and
the object is returned unchanged (same reference) when a resource declares
neither. A table() that calls perPageOptions() is the more specific
declaration of the selectable list and still wins there; the static then
governs only the initial page size.
labels derivation is mechanical: deriveLabels('blog-posts') splits on -/_,
title-cases each word, and strips one trailing s from the last word of the
singular — Blog Post / Blog Posts. The navigation builder and the descriptor
header call the same function, so protocol §1 and §3 cannot disagree.
schema(s)
abstract schema(s: SchemaBuilder): SchemaComponent[]The one abstract member. It returns a flat list of root components; nesting
happens through each layout’s .components([...]).
s is the layout and display factory — it is stateless, so the shared
instance is safe under concurrent compilation, and every call returns a fresh
node:
| Category | Factories |
|---|---|
| Layout | section(label?), grid(), tabs(), tab(label?), wizard(), step(label?), aside(), fieldset(label?), card(label?), divider(label?), spacer() |
| Display (detail only) | textEntry(attr), badgeEntry(attr), imageEntry(attr), codeEntry(attr), keyValueEntry(attr), relationEntry(attr), html(attr) |
State-bearing fields do not come from s. They come from the separate F
namespace, imported from @adonia/core — see fields and
schema components for the full DSL.
The returned tree is the single source for every projection.
The descriptor compiler maps create and edit to the single projection context
form, maps detail to detail, and consults index only when deriving a table.
visibleOn(...) is an allowlist: once set, the field appears only in the named
contexts. hiddenOn(...) is a denylist, and wins when the same context is present
in both lists. Repeating either method replaces that method’s previous list; it does
not append. canSee is the separate request-dependent gate. A field excluded from
the form projection is absent from both create/edit descriptors, validators and fill
sets, not merely unrendered; readonlyOn('create' | 'edit') is the API for
distinguishing those two form modes.
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
schema(s: SchemaBuilder): SchemaComponent[] {
return [
s.grid().columns(3).components([
s.section('Content').columnSpan(2).components([
F.text('title').required().maxLength(200),
F.textarea('body').rows(12).required(),
]),
s.aside().components([
F.select('status').options({ draft: 'Draft', review: 'In review', published: 'Published' }),
F.datetime('publishedAt').nullable(),
s.textEntry('createdAt').label('Created'),
]),
]),
]
}
}A single grid() returned as the sole root is used as the tree root verbatim.
The textEntry above appears on the detail page and nowhere else — display
entries are read-only and detail-only by construction.
table()
table?(t: TableBuilder): TableBuilderOptional. Leave it off and the index columns are derived from the schema; declare it and derivation does not run at all.
Derivation (§7.5)
resolveTable(resource, defaults, ctx?) is the entry point;
deriveTableDescriptor(resource, defaults) is the same resolution compiled to
the wire §3 table block. With no table(), columns are derived like this:
- Walk the schema tree in document order, collecting every
Field. When a request context is passed,canSee(and with itrequiresAbility()) is applied here — a field the request may not see contributes no column, no row value, and no sortable/searchable/?columns=key. Omission has to reach the allowlists, or a column the user cannot see could still be sorted by, which leaks its ordering. - Keep only tabulatable fields: those whose instance
columnTypeis defined. Untabulatable types (repeater, rich text, hidden, …) leave itundefinedand are skipped. - If any tabulatable field’s
visibleOn(...)allowlist includesindex, those explicitly marked fields are the candidates — all of them, no cap. Otherwise the candidates are the firstINDEX_COLUMN_CAP(5) tabulatable fields in document order. - Apply
appearsIn('index')to the candidates.hiddenOn('index')always removes a candidate, including one also marked byvisibleOn('index'). - Instantiate each surviving field’s column from its
columnType:text,badge,boolean,date,number,image,relation. The field’s declaredlabelcarries over. An unrecognisedcolumnTypefalls back totextrather than failing at boot — a plugin field naming an unregistered cell is the client’s<UnknownComponent>problem.
A derived table has columns and nothing else: no filters, no defaultSort,
no searchPlaceholder (so the search input is hidden), perPageOptions from the
resolved defaults, and multiSort off.
Both paths — declared and derived — reject a column keyed id or can, the two
reserved row keys every serialized row carries. It throws
E_ADONIA_INVALID_CONFIG at derivation time (boot for a declared table, first
request for a derived one) rather than letting a silent winner emerge at
serialization.
Overriding
A declared table() fully replaces derivation. There is no merge and no
“derived plus these”: a partial override would make the visible column set
change whenever an unrelated schema field was added, which is exactly the
surprise §7.5 rules out. Everything the builder can carry comes from it —
columns, filters, defaultSort, searchPlaceholder, perPageOptions,
multiSort — and the request context is not applied to declared columns,
because a resource that owns its column set gates a column through the column
DSL, not through the schema field it happens to share a key with.
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
schema(): SchemaComponent[] {
return [F.text('title').required()]
}
override table(t: TableBuilder): TableBuilder {
return t
.columns([
C.text('title').sortable().searchable().link('detail'),
C.badge('status').colors({ draft: 'gray', review: 'amber', published: 'green' }),
C.date('publishedAt').since().sortable(),
C.count('tags').label('Tags').toggleable(true),
])
.defaultSort('publishedAt', 'desc')
.searchPlaceholder('Search posts…')
.perPageOptions([10, 25, 100])
}
}The builder arrives fresh on every invocation, so mutating it is safe. The C
namespace and every column modifier are documented in
table columns; how the declared columns turn into SQL is
the query pipeline.
query() and scoping
static query?(
query: ModelQueryBuilderContract<LucidModel>,
ctx: HttpContextLike,
tenant?: unknown
): unknownThe base scope, mutated in place and returned. Returning a different builder is not supported — callers keep the one they passed. The panel, tenant, parent and lens scopes compose on top of this at their own layers, so a resource never re-implements them here.
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
static override query(query: ModelQueryBuilderContract<typeof Post>, ctx: HttpContextLike) {
const user = ctx.auth.user as User | null
if (user === null || user.role === 'admin') return query
return query.where('authorId', user.id)
}
schema(): SchemaComponent[] {
return [F.text('title').required()]
}
}What honours it today. The only caller in the shipped code is the relation
field’s target scoping: both the option list a belongs-to/belongs-to-many
field offers and the §11.1 existence check behind it go through the same scope,
which is what makes them agree — a key outside the scope is neither offerable
nor submittable.
The index pipeline’s base stage — first of the six in
base → softDeleteScope → search → filters → sort → eagerLoad — is still
registered as a no-op by buildIndexPipeline, and the controller’s
record loader (findForWrite) calls Model.query() directly. So a declared
query() does not yet scope the index list or the detail/edit/delete
lookup. §6.1 specifies that it should; the code does not do it yet. Do not rely
on query() for security today — put that in
can / policy, which are enforced at all three points.
onInvalidCursor is the other scoping-adjacent static and is honoured: the
index action passes resourceClass.onInvalidCursor straight into paginateIndex.
recordTitle and labels
static recordTitle?: string | ((record: never) => string)How one record is titled when it has to be named rather than tabulated. The
callback is declared over never because a static cannot name its own class’
type parameter; the row it receives is an instance of model.
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
static override recordTitle = (post: Post) => `${post.title} (${post.status})`
schema(): SchemaComponent[] {
return [F.text('title').required()]
}
}Relation option labels, global-search hits, relation managers, and page headings consume
recordTitle in this order: a field’s own optionLabel(attribute), then the resource’s
recordTitle attribute/function, then the primary key rendered as text.
labels is the plural/singular pair, not the record title: it names the
resource in the descriptor header, the navigation item, and the flash message
after a delete (labels.singular, falling back to the slug). Navigation
placement is navigationIcon / navigationGroup / navigationSort / hidden,
all read by the default navigation derivation; a panel calling
panel.navigation(...) starts from an empty tree instead and places items by
hand. See panels.
Lifecycle hooks
Two, both around delete:
beforeDelete?(ctx: HttpContext, record: LucidRow): Promise<void>
afterDelete?(ctx: HttpContext, record: LucidRow): Promise<void>There is no beforeSave/afterSave on BaseResource today, and nothing
calls one. Field.virtual()’s own documentation still promises them —
“persistence is the resource’s job in beforeSave/afterSave” — so a
virtual() field currently has no resource-level hook to persist from. Field
types that own their persistence do it in their dehydrate.
Both hooks run inside the delete transaction, after the §12 mutation re-check
and around the write. Both delete shapes pass through them — the soft-delete
tombstone write and the hard row removal, ?force=1 included — so a hook that
detaches related rows, releases a slug or writes an audit entry runs exactly
once per delete without having to ask which one happened. In afterDelete the
record instance is still readable: a hard delete removes the row, not the
in-memory attributes.
Throwing aborts the delete: the transaction rolls back and the request
fails. That is the deliberate record:* exception to §16.4’s “a throwing
runtime hook is logged and must not break the request” — a hook that cannot
complete its half of the delete must not leave the other half committed.
import type { HttpContext } from '@adonisjs/core/http'
import type { LucidRow } from '@adonisjs/lucid/types/model'
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
schema(): SchemaComponent[] {
return [F.text('title').required()]
}
override async beforeDelete(_ctx: HttpContext, record: LucidRow): Promise<void> {
await (record as Post).related('tags').detach()
}
}Actions, lenses, widgets, and relation managers
Four instance declarations extend a resource without changing its schema/table contracts:
actions(): AnyAction[]returnsActioninstances. Each action declares a stable slug, label, contexts (row,bulk,page,form, orrelation), optional confirmation and modal schema, andisQueued.canSee,canRun, andrunAction:<slug>are checked before descriptor emission and again before execution. Inline and queued workers share the same chunked, transaction-scoped executor and mandatory scrubbed audit events.lenses(): Lens[]returns alternate authorized index scopes. A lens has a stableslug, label, query mutation, and optional replacement columns. Access requiresaccessLens:<slug>; bulk “all matching” actions replay the exact validated lens query and recheck the lens inside each transaction.widgets(): AnyWidget[]returns value, trend, partition, table, or custom widgets for the resource surface. Initial data is grouped in one deferred Inertia request. Ranges refetch throughwidgets.data;cacheFor(minutes)uses@adonisjs/cachewhile HTTP responses remainno-store.relations(): RelationManagerDef[]defines parent-scoped child managers. Place one withs.relationManager('<relation>')in the detail/edit schema. Create, attach, edit, search, and pagination use server-built child descriptors and URLs; capability flags are enforced by both the descriptor and nested write routes.
Set static lockVersion = 'version' to opt into optimistic locking. The update route
atomically compares and increments that numeric attribute. A stale write returns 409; the
packaged form keeps locally dirty declared fields, reloads the fresh baseline and lock, and
requires an explicit retry.
F.hasMany(relation, schema) and
F.morphTo(attribute, { types, typeColumn?, idColumn? }) complete the built-in relation
field set. Has-many writes load owned children inside the parent transaction, reject foreign
IDs, and apply the authoritative create/update/delete diff. Morph-to stores an allowlisted
stable resource slug plus row id and uses searchable, pageable options that hydrate the
selected id on edit.
Each has-many item has a reserved boundary key, id, in addition to its
declared child-field keys. Existing hydrated rows carry their string or number
id; new rows omit it. Preserve existing ids unchanged: they are validated and
checked against the active parent’s relation before update. They are deliberately
not represented by a child field in the descriptor.
Registration
A resource lives at app/adonia/resources/<model>_resource.ts and is exported
as the module’s default.
You do not list it anywhere by hand. The indexAdoniaResources() assembler hook
writes .adonisjs/adonia/resources.ts — a slug → () => import(module) map
checked with satisfies AdoniaResourceRegistry (an alias of
ResourceRegistryInput) — and regenerates it under node ace serve --hmr
whenever a file under app/adonia/resources/ changes. It is that shape:
import type { ResourceRegistryInput } from '@adonia/core'
const registry = {
posts: () => import('#adonia/resources/post_resource'),
} satisfies ResourceRegistryInput
export default registryThe panel imports the generated file and passes it to resources() —
Panel.make('admin').resources(registry); see panels. Two
resources claiming the same slug is a generation failure, and no partial
registry is ever written. See codegen.
Scaffold one with the generator, which reads the model’s columns and writes a schema mirroring the table:
node ace adonia:resource Post
node ace adonia:resource Post --fields=title,slug,body --no-table
node ace adonia:resource Post --panel=adminFull flag and type-mapping tables are in generators.
Full example
Everything above, on one class, against the Post model.
import { BaseResource, C, F } from '@adonia/core'
import type { HttpContextLike, SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { LucidRow } from '@adonisjs/lucid/types/model'
import Post from '#models/post'
import type User from '#models/user'
const STATUSES = { draft: 'Draft', review: 'In review', published: 'Published' } as const
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
static override labels = { singular: 'Post', plural: 'Posts' }
static override navigationIcon = 'newspaper'
static override navigationGroup = 'Content'
static override navigationSort = 10
static override recordTitle = 'title'
static override perPage = 25
static override pagination = 'offset' as const
/** Authors may only delete their own drafts; everything else falls to the policy. */
static override can = {
delete: (ctx: HttpContextLike, record?: unknown) => {
const user = ctx.auth.user as User | null
if (user === null || !(record instanceof Post)) return false
return user.role === 'admin' || record.authorId === user.id
},
}
schema(s: SchemaBuilder): SchemaComponent[] {
return [
s.grid().columns(3).components([
s.section('Content').columnSpan(2).components([
F.text('title').label('Title').required().maxLength(200).live(),
F.text('slug').label('Slug').required().maxLength(220).helper('Lower-case, dash separated.'),
F.textarea('body').label('Body').rows(16).required(),
]),
s.aside().components([
F.select('status').label('Status').options(STATUSES).required().default('draft'),
F.toggle('featured').label('Featured').default(false),
F.number('readingMinutes').label('Reading time').min(1).max(120).nullable(),
F.datetime('publishedAt').label('Published at').nullable(),
s.textEntry('createdAt').label('Created'),
]),
]),
]
}
override table(t: TableBuilder): TableBuilder {
return t
.columns([
C.text('title').sortable().searchable().link('edit').limit(60),
C.badge('status').colors({ draft: 'gray', review: 'amber', published: 'green' }),
C.boolean('featured').label('★'),
C.count('tags').label('Tags').toggleable(true),
C.date('publishedAt').label('Published').since().sortable(),
])
.defaultSort('publishedAt', 'desc')
.searchPlaceholder('Search posts…')
.perPageOptions([25, 50, 100])
}
override async beforeDelete(_ctx: HttpContext, record: LucidRow): Promise<void> {
await (record as Post).related('tags').detach()
}
}The schema declares no visibleOn('index'), so if table() were removed the
index would derive its columns from the first five tabulatable fields —
title, slug, body, status, featured — which is a serviceable default
and a poor list. That gap is the reason to declare a table.