Skip to content
DocsconceptsDocumentation

Generated code

What @lunora/codegen emits from your schema.ts, and how api / Id / Doc keep server and client in sync.

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:

FileWhat it gives you
api.tsthe typed api.* (public) and internal.* (server-only) function registries
server.tsthe project-typed server helpers: query/mutation/action, v, typed ctx
dataModel.tsthe 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, so ctx.db.get(id) resolves to the right document type and you can't pass a users id where a messages id is expected.
  • Doc<"table">: the full stored document shape for a table, including the built-in _id and _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 from lunorash/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:

ToolWhere the entries go
Prettier.prettierignore
oxlintignorePatterns in .oxlintrc.json
Biomenegated patterns in files.includes (or files.ignore in v1)
ESLintan 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 codegen

One 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".