Skip to content

Resources

Bind Lucid models to schemas, tables, policies, and panel routes.

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 undefinedicon: 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): TableBuilder

Optional. 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:

  1. Walk the schema tree in document order, collecting every Field. When a request context is passed, canSee (and with it requiresAbility()) 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.
  2. Keep only tabulatable fields: those whose instance columnType is defined. Untabulatable types (repeater, rich text, hidden, …) leave it undefined and are skipped.
  3. If any tabulatable field’s visibleOn(...) allowlist includes index, those explicitly marked fields are the candidates — all of them, no cap. Otherwise the candidates are the first INDEX_COLUMN_CAP (5) tabulatable fields in document order.
  4. Apply appearsIn('index') to the candidates. hiddenOn('index') always removes a candidate, including one also marked by visibleOn('index').
  5. Instantiate each surviving field’s column from its columnType: text, badge, boolean, date, number, image, relation. The field’s declared label carries over. An unrecognised columnType falls back to text rather 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
): unknown

The 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[] returns Action instances. Each action declares a stable slug, label, contexts (row, bulk, page, form, or relation), optional confirmation and modal schema, and isQueued. canSee, canRun, and runAction:<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 stable slug, label, query mutation, and optional replacement columns. Access requires accessLens:<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 through widgets.data; cacheFor(minutes) uses @adonisjs/cache while HTTP responses remain no-store.
  • relations(): RelationManagerDef[] defines parent-scoped child managers. Place one with s.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 registry

The 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=admin

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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close