Implements TECH_SPEC §14 following the codegen contract (worker-isolated evaluation).
Register the hook in adonisrc.ts, after AdonisJS’ own indexEntities():
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { indexAdoniaResources } from '@adonia/devtools/hooks'
export default defineConfig({
hooks: {
init: [indexEntities({ transformers: { enabled: true } }), indexAdoniaResources()],
},
})It runs when the assembler boots the dev server, test runner, or bundler, and
regenerates under node ace serve --hmr when a file under app/adonia/resources/
or app/adonia/panels/ changes.
Artifacts
All three land under .adonisjs/adonia/ and are written as one set.
resources.ts
The typed registry the panel consumes: slug → () => import(module), sorted by
slug and checked with satisfies AdoniaResourceRegistry (an alias of
ResourceRegistryInput from @adonia/core).
Its first line is /// <reference path="./descriptor.d.ts" />. That is
load-bearing: AdonisJS’ shared tsconfig includes **/* plus
./.adonisjs/server/**/*, and neither glob reaches .adonisjs/adonia, so the
ambient types below would never enter the program. The registry is in the
program (your panel imports it), and TypeScript follows the reference from
there — no tsconfig edit required in the host app.
descriptor.d.ts
One ambient namespace per resource, derived by evaluating each schema in the worker (the codegen contract — evaluation, not static parsing, is what sees computed and conditional fields):
declare namespace AdoniaGen {
namespace Post {
interface Row {
can: RowAbilities
id: string | number
status: 'draft' | 'review' | 'published'
title: string
}
interface FormState {
publishedAt: string | null
status: 'draft' | 'review' | 'published'
title: string
}
}
}Row— protocol §6’s reservedid/canplus every index column key, verbatim (dotted keys like'author.fullName'are quoted, never split). Columns come from an explicittable()when declared, else from §7.5 derivation.FormState— every field projected into theformcontext (§7.4). A field is| nullunless it isrequired()and notnullable(), because create-mode state is explicitnullfor every key without a default (protocol §7).RowAbilitiesandJsonValueare emitted alongside the namespaces, so the file typechecks standalone.
manifest.json
Consumed by adonia:doctor and the docs generator:
{
"protocolVersion": 1,
"generatedAt": null,
"resources": [
{
"slug": "posts",
"class": "PostResource",
"module": "#adonia/resources/post_resource",
"panels": ["admin"],
"columns": ["status", "title"],
"fields": ["publishedAt", "status", "title"],
"actions": [],
"widgets": []
}
],
"panels": [
{ "id": "admin", "path": "/admin", "domain": null, "guard": "web", "resources": ["posts"] }
]
}actions and widgets are always [] until M2 (§22); the keys exist so
consumers read one stable shape across milestones. generatedAt is always
null — a timestamp would make the file churn on every regen.
node ace add @adonia/core seeds all three artifacts (§3 step 3b), including
an empty manifest on an app with no resources yet. That is deliberate: “the
file is missing” and “the file says there is nothing” are different diagnoses,
and only the first is a broken install. The hook owns regeneration from the
first assembler run on and never has its output overwritten by the installer.
Determinism
§14 requires committable, CI-diffable output, so generation is byte-stable: resources sorted by slug, interface members and name lists sorted alphabetically by UTF-16 code unit (never locale), LF line endings, exactly one trailing newline, no timestamps, and app-root-relative POSIX paths in comments. Two checkouts at different paths produce identical bytes.
Adding a field type
descriptor.d.ts derivation is a table in
packages/devtools/src/codegen/type_map.ts, keyed by the field class’s
static type (the protocol §8 registry key — a field’s TValue is a
compile-time-only parameter the worker cannot observe). A new field type plugs
in by adding one row:
export const FIELD_TS_TYPES = {
// …
'vendor/map-point': () => '{ lat: number; lng: number }',
}Rows are functions of the extracted field shape, so props-sensitive types work
— that is how select renders a literal union from its declared options().
Unknown keys (plugin fields, types the table has not caught up with) fall back
to unknown: the widest safe type, never any.
Failure mode
Any of the following fails the hook with E_ADONIA_CODEGEN, naming the
offending file path:
- a resource or panel module that throws during evaluation;
- a resource class without a non-empty
static slug; - a resource class without a
static model(it could not be queried, and indexing it would only defer the failure to the first request); - two resources claiming the same slug;
- an evaluation worker that crashes and fails to respawn (the codegen contract).
No partial registry is ever written. During HMR regens the same failure is logged through the assembler’s CLI UI instead and the previous artifacts stay in place, so a transient syntax error mid-edit never kills the dev server.
How evaluation reads your code
The worker imports resource and panel modules with a cache-busting ?v= query
and reads them structurally — it never imports @adonia/core (core depends
on devtools, and a pnpm peer-variant split would break instanceof against
your app’s instance anyway). The contract it relies on is documented on the
shapes in packages/devtools/src/codegen/protocol.ts:
- resource class:
static slug,static model, optionalschema(builder, ctx)andtable(ctx); - schema node:
key/attribute/propsfor fields (plus the class staticstypeandcolumnType),childrenfor layouts; - panel:
id, and optionallymountPath,domainPattern,guardName,resourceRegistry(only its keys are read — the lazy imports are never invoked).
schema() receives a stub layout builder that accepts any factory name, and a
stub HttpContext (§14). Codegen does not apply canSee projection: a
.d.ts describes every request, so the emitted types are the union over
requests rather than one request’s projection.
Resource modules are not required to be side-effect free (the codegen contract), but their side effects run inside the worker on every regen — avoid opening connections or timers at import time.