launch-quickly

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

SyntaxColumnNotes
name:stringtext not null
name:string?text? means nullable — on any type except bool
body:texttext not nullmulti-line in the generated form
count:intinteger
amountCents:moneyinteger not nullan amount in MINOR units — renders as currency, right-aligned, labelled without the "Cents"
active:boolboolean not null default falsea tri-state boolean is almost always an accident, so bool? is rejected
dueAt:datedatea calendar date, not a timestamp — stored and edited as YYYY-MM-DD, so it never shifts by timezone
status:enum(a,b,c)Postgres enumgenerates a select; required enums default to the first value
ownerId:uuiduuid
to:emailtext not nullvalidates as an email and renders <input type="email">
serviceId:ref(services)uuid not null + composite FKbelongs-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 from src/lib/format.ts.
  • Statuses — a <feature>-badges.tsx module 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 a Record over 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 typelist for the table, form for the form, detail for the record, via PageShell.

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-export
  • src/config/permissions.ts — the four CRUD permissions plus role grants
  • src/config/nav.ts — a sidebar entry, gated on the new .read permission. The insert lands at the lq:generated-nav anchor 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.

On this page