Skip to content

Codegen (indexAdoniaResources)

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 reserved id/can plus every index column key, verbatim (dotted keys like 'author.fullName' are quoted, never split). Columns come from an explicit table() when declared, else from §7.5 derivation.
  • FormState — every field projected into the form context (§7.4). A field is | null unless it is required() and not nullable(), because create-mode state is explicit null for every key without a default (protocol §7).
  • RowAbilities and JsonValue are 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, optional schema(builder, ctx) and table(ctx);
  • schema node: key/attribute/props for fields (plus the class statics type and columnType), children for layouts;
  • panel: id, and optionally mountPath, 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close