Last updated:
@lunora/codegen reads your schema.ts (and the functions it discovers) and
emits a _generated/ directory next to it. You never edit these files; they
are derived artifacts, regenerated on every save. What they buy you is end-to-end
type safety: a query's argument and return types are inferred on the server and
handed to the client through api, so a rename or a wrong arg is a compile
error, not a runtime surprise.
Three files are emitted from schema.ts:
| File | What it gives you |
|---|---|
api.ts | the typed api.* (public) and internal.* (server-only) function registries |
server.ts | the project-typed server helpers: query/mutation/action, v, typed ctx |
dataModel.ts | the data-model types: Id<"table">, Doc<"table">, and insert/document shapes |
api.ts: function references
api is a typed registry of your public functions; internal is the same
registry for functions you mark internal. Each entry is a FunctionReference
carrying the function's kind, argument type, and return type. That's what lets a
client call hooks with full inference:
import { useQuery } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
// args are checked against the query's input validator; the result is typed.
const messages = useQuery(api.messages.list, { channelId });api and internal are the same proxy at runtime; visibility is enforced
server-side at dispatch, not in the reference. Splitting the types is what
keeps internal functions off the client-facing api surface, so you can't
accidentally call one from the browser. Internal functions are reachable only
server-side via ctx.runQuery / ctx.runMutation / ctx.runAction, passing an
internal.* reference.
server.ts: typed helpers
server.ts re-exports the procedure builders and v validators bound to your
schema, so ctx.db knows your tables. Author functions by importing from the
generated server (not the base package); then ctx.db.query("messages"),
ctx.db.insert("messages", …), and ctx.db.get(id) are all typed against your
real schema with no casts.
dataModel.ts: Id and Doc
Two types you reach for constantly:
Id<"table">: a branded string id. It carries its table name in the type, soctx.db.get(id)resolves to the right document type and you can't pass ausersid where amessagesid is expected.Doc<"table">: the full stored document shape for a table, including the built-in_idand_creationTime.
import type { Doc, Id } from "@/lunora/_generated/dataModel";
function format(message: Doc<"messages">): string {
const author: Id<"users"> = message.userId;
return `${author}: ${message.text}`;
}dataModel.ts imports nothing. It carries only the shapes derived from your
schema, so a sibling package (a web app, another Worker) can compile it (and
api.ts through it) with @lunora/client alone. The query-DSL bindings that
need @lunora/server live in server.ts instead, which only your function code
imports. See Monorepos and IaC
for the exports mapping that goes with it.
Importing the generated code
The CLI scaffolds your functions under lunora/, with _generated/ alongside.
Import api/internal from _generated/api, data-model types from
_generated/dataModel, and the typed builders from _generated/server. Most
projects alias the directory (e.g. @/lunora/_generated/*).
lunora/* vs @lunora/* imports
Codegen detects whether your project depends on the unscoped lunora
umbrella package or on the granular @lunora/* packages, by inspecting the
project's declared dependencies, and emits matching imports in _generated/*:
- depend on
lunorash→ the generated files import fromlunorash/server,lunorash/server/types,lunorash/client, etc. - depend on
@lunora/server,@lunora/client, … → they import from@lunora/server,@lunora/server/types,@lunora/client, etc.
This is opt-in and fully backward-compatible: switching to the umbrella changes only the import specifiers in the generated code, never your function code.
Linting and formatting
_generated/* lands in your repo and is compiled under your own settings, so
your linter and formatter will happily report thousands of problems in files
nobody wrote. lunora init asks which tools you use and configures them to skip
Lunora's generated and derived paths; lunora add re-derives the same answer
from your package.json on every feature install, with no prompt.
The paths excluded are lunora/_generated/, lunora/.lunora-schema.json,
lunora.advisor.map.json, .lunora/, and .wrangler/. Two of those are
committed on purpose, since the schema-drift baseline is the whole point of the
deploy gate, which is why .gitignore alone does
not cover it.
Each tool gets its own mechanism, because there is no shared ignore format:
| Tool | Where the entries go |
|---|---|
| Prettier | .prettierignore |
| oxlint | ignorePatterns in .oxlintrc.json |
| Biome | negated patterns in files.includes (or files.ignore in v1) |
| ESLint | an ignores entry in eslint.config.* |
Two cases are reported for you to apply by hand rather than written: an existing ESLint flat config (it is arbitrary JavaScript, so it is not rewritten
on your behalf) and a config inherited from a workspace root (writing a nested one would shadow it: ESLint 9 stops at the first flat config it finds
walking up, so a package-level file holding only ignores would silently switch off every rule the root enforced). Both print the exact entry to add.
No .eslintignore is ever written: flat config removed support for it, and
ESLint warns about the file while ignoring its contents, so creating one would
leave behind something that looks like it works.
Regenerating
You rarely run codegen by hand. The Vite plugin (@lunora/vite) regenerates
_generated/* on every change to schema.ts or your functions during
lunora dev, and codegen also runs on deploy. The
Advisors static lints run as part of the same pass, so
schema problems show up the moment you save.
For CI, scripts, or a manual refresh, run it directly:
lunora codegenOne run is enough. Codegen emits the declaration surface (dataModel.ts,
server.ts) before it infers any handler's return type, so a single pass
converges even from a cold _generated/ or after adding a table. You do not need
to run it twice, and you do not need a loop that re-runs it until the output
stops changing. A failed run writes nothing, so the previous output stays intact.
Running your own step after codegen
lunora prepare and lunora deploy generate in-process: they do not shell
out to your project's codegen script. So wrapping the CLI in your own script
does not make a post-step part of a deploy:
// Runs on `pnpm codegen`. Does NOT run on `lunora prepare` / `lunora deploy`.
"codegen": "lunora codegen && pnpm run patch-generated"Declare it as postcodegen instead. prepare and deploy invoke it directly,
on every package manager, and npm, pnpm and bun also run postX after run X
automatically, so it fires for your own codegen script there too:
{
"scripts": {
"codegen": "lunora codegen",
"postcodegen": "pnpm run patch-generated",
},
}A non-zero exit fails the run, so a deploy cannot ship output your post-step rejected.
Yarn Berry (2+) does not run automatic postX hooks. lunora prepare and lunora deploy still invoke postcodegen themselves (that part is the
CLI's, not the package manager's), but yarn codegen will not. Chain it explicitly there: "codegen": "lunora codegen && yarn postcodegen".