Skip to content
DocsconceptsDocumentation

Modules

Group lunora/ folders into modules and get a module catalog and an architecture diagram from codegen.

Last updated:

A module is a folder under lunora/ that holds a module.ts whose default export is defineModule(...). Every file beneath that folder belongs to the module, and the folder path is its name.

// lunora/billing/module.ts
import { defineModule } from "@lunora/server";

export default defineModule({
    description: "Invoices, payments and dunning",
    tables: ["invoices", "payments"], // optional: tables this module owns
});

Modules are metadata. They change no api.* path and nothing at runtime, and the whole app still deploys as one Worker. lunora/billing/invoices.ts stays api.billing_invoices.* either way. Codegen reads description and tables without running the file, so write them inline.

Modules do not nest: a module.ts inside another module's folder is a codegen error, because a file would then belong to two modules. Files outside every module folder are shown as "outside any module".

What you get

Once the app declares a module, codegen writes _generated/architecture.json (and an inlined architecture.ts the generated app passes to the worker). The manifest lists every function, HTTP route, table, queue, topic, workflow and cron, the module each belongs to, and the edges between them:

EdgeFrom → toRead from
callfunction → functionctx.runQuery/runMutation/runAction(api.x.y, …), a queue's message.run(…)
schedulefunction → functionctx.scheduler.runAfter/runAt(…, api.x.y)
readfunction → tablectx.db.query("table")
writefunction → tablectx.db.insert("table", …), ctx.db.patch/replace/delete(id, …) (table read off the id's Id<"table"> type), batch writes, ctx.db.<table>.* facade writes
enqueuefunction → queuectx.queues.<name>.send/sendBatch
publishfunction → topicctx.topics.<name>.publish/publishBatch
subscribetopic → subscriptiondefineSubscription(topic, …)
startfunction → workflowctx.workflows.get("name")
triggercron → function/workflowcronJobs()

The Studio's Functions → Architecture page renders it: a catalog of modules with their descriptions, owned tables and function counts, and a diagram with one lane per module that you can filter by module and edge kind. Click a node to open it: a table opens its rows in the data browser, a function the Functions page, a queue or topic the Queues page. The canvas exports to PNG, SVG or JSON. The worker serves the manifest at the admin-gated GET /_lunora/admin/architecture.

The graph is static, read from source by codegen, so it is complete before the first deploy and diffable in review. It does not guess: an edge whose target is held in a variable, or a call inside a non-exported helper, is listed under "call sites could not be drawn" instead, as is a write through an untyped id (a plain string rather than Id<"table">).

Table ownership

tables declares which tables a module owns. A function in another module, or outside every module, that inserts into an owned table is reported by the cross_module_table_write advisor lint. Route the write through a function in the owning module instead:

// lunora/accounts/signup.ts — not the billing module
await ctx.runMutation(internal.billing_invoices.open, { userId });

A table can have one owner, and it must exist in lunora/schema.ts; codegen rejects either mistake. A module that declares no tables opts out of the lint.

The OpenAPI and OpenRPC specs tag each operation with its module (falling back to its file namespace), so generated API docs group by module too.

Installed components

Every installed component (a schema extension merged with .extend(...)) counts as a module named after its key, with no module.ts needed. It owns its prefixed tables (voting_votes). A component installed as a registry item also owns the lunora/<key>/ folder its code is copied into; one installed from npm owns no folder, since its code lives in node_modules. So:

  • the Architecture view gives it its own lane, marked as a component;
  • cross_module_table_write warns when your code inserts straight into a component's table instead of calling the function the component exports.

The lint applies whether or not you declare any modules yourself; the Architecture view appears once you declare one. Code inside a copied-in component's folder may write its tables freely. A module you declare inside a component's folder is a codegen error, as with any nested module. To take over a component's tables, declare a module with the same name, or list the table in your own module's tables; a declared claim wins. The component's code in node_modules is not scanned, so its internal calls and writes are not drawn.