Generators
Why you should almost never write a feature by hand.
lq generate feature invoice \
--fields "number:string, amount:int, status:enum(draft,sent,paid), dueAt:date?"That writes a complete vertical slice — schema, migration including the row-level security policy and grants, queries, mutations, typed actions, validation, form, a paginated data table with URL-state search and filters, a delete confirmation, list and detail routes, and tests. It also registers the feature in the sidebar, so the slice is reachable the moment it exists. Then it typechecks, lints and passes its own tenant-isolation test.
Why not write it by hand
Because those files have to agree with each other, and there are around twenty of them. The schema and the migration have to match. The migration's policy has to match the table's columns. The action's schema has to be the same object the form resolves against. The barrel has to export what other features import.
Getting one of those wrong usually still compiles.
But the deeper reason is the upgrade path:
Generated code has a known shape, and a known shape can be rewritten.
When a new version changes how actions are built, a codemod can find and update every generated action in your project — because it knows what one looks like. It cannot do that for a slice you hand-rolled at 1am. This is the one thing no competitor can offer, and it is a direct consequence of you having used the generator.
Hand-written code is not forbidden. It is simply outside the guarantee, and
lq upgrade will show you a diff instead of making the change.
Field types
| Syntax | Column | Notes |
|---|---|---|
name:string | text not null | |
name:string? | text | ? means nullable — on any type except bool |
body:text | text not null | multi-line in the generated form |
count:int | integer | |
amountCents:money | integer not null | an amount in MINOR units — renders as currency, right-aligned, labelled without the "Cents" |
active:bool | boolean not null default false | a tri-state boolean is almost always an accident, so bool? is rejected |
dueAt:date | date | a calendar date, not a timestamp — stored and edited as YYYY-MM-DD, so it never shifts by timezone |
status:enum(a,b,c) | Postgres enum | generates a select; required enums default to the first value |
ownerId:uuid | uuid | |
to:email | text not null | validates as an email and renders <input type="email"> |
serviceId:ref(services) | uuid not null + composite FK | belongs-to: see below |
money is a separate type from int because the two are rendered
differently and only one of them can be wrong quietly. The invoicing product
built on this template declared amountCents:int and shipped a list column
headed "Amount cents" showing 480,000. With money the column is still an
integer — storing money as a float is how you lose a cent per thousand
transactions — but the list renders $4,800.00, right-aligned with
tabular-nums, the header reads "Amount", and the form input says in so many
words that it wants cents.
What the UI looks like
The generated slice is meant to be shippable, not a placeholder you redraw:
- List — a sortable table (
?sort=&dir=in the URL, validated against a literal union, tie-broken on the primary key so rows cannot swap between pages), a row-actions menu, a result count, and every non-string cell through a formatter fromsrc/lib/format.ts. - Statuses — a
<feature>-badges.tsxmodule mapping every enum value to a tone and a label, imported by the row, the detail page and the filter alike. The tones are guessed from the value names, so check them; the map is aRecordover the union, so adding a value to the enum later is a type error rather than a badge that silently renders grey. - Filters — search, a select per enum (the styled one, not a raw
<select>), active-filter chips and a clear-all. - Detail — a body column for long text beside a metadata rail, instead of
one flat
<dl>giving a memo the same weight as an amount. - Empty states — two of them, with icons: filtered-empty and never-had-any are different problems.
- Skeleton — the same table with the same columns, so nothing moves when the data lands.
- Page width by page type —
listfor the table,formfor the form,detailfor the record, viaPageShell.
References
lq generate feature booking --fields "customerName:string, serviceId:ref(services)"A ref is a belongs-to, and the foreign key it emits is composite:
FOREIGN KEY (organization_id, service_id)
REFERENCES services (organization_id, id)Not REFERENCES services(id). Foreign-key validation does not respect
row-level security, so a single-column key lets Postgres accept a booking in
one organization pointing at a service in another — and the only thing
standing in the way would be an application check on whichever write path
happens to have one. Tenancy here is a property of the database, and a
reference that is merely conventionally tenant-safe would be the hole in it.
The parent gains the matching unique (organization_id, id) automatically,
and the generator emits a list<Parent>Options query — id and label only,
scoped to the caller's organization — which the new/edit pages fetch and pass
to the form's <select>. A client component never reads the database.
What else it touches
Four shared files. The edits are idempotent — each one checks whether its entry already exists before writing, so running the generator twice does not produce two entries. (Today they are careful line-based edits, not AST transforms; the idempotence is the guarantee, the mechanism is honest plumbing.)
src/db/schema.ts— re-exportsrc/config/permissions.ts— the four CRUD permissions plus role grantssrc/config/nav.ts— a sidebar entry, gated on the new.readpermission. The insert lands at thelq:generated-navanchor comment; if you have removed the anchor, the generator skips this edit rather than guessing, and wiring the entry becomes your call..lq/manifest.json— the record that makes upgrades possible
.lq/manifest.json belongs in version control. Nothing else in your
repository records which template version you started from, and lq upgrade
needs it to know which codemods apply.
What exists today
lq generate feature is the only generator, and it is the one that matters —
a vertical slice is where the files-that-must-agree problem actually lives.
Narrower generators for models, actions, pages, emails and jobs are planned. They are not built, and this page will list them when they are: documentation that describes commands you cannot run is worse than documentation that admits a gap.
/new-feature is the slash command wrapper. It runs the generator and then
tells the agent what to fill in — the agent should never be the thing
producing structure.
The software factory
The pipeline from a brief to a merged pull request — the label states, which command runs at each one, and when to stay in the driver's seat instead. Use when planning work or wondering how a ticket should move.
AI
The provider seam, streaming, and a usage budget wired into billing.