.offrails/generators/scaffold.md
Generator: resource (scaffold) / model
TL;DR
cargo offrails g resource posts title:string slug:string:unique body:text published_at:datetime:optionalwrites steps 2–10 below (migration, model, factory, model and request tests, controller, routes, htmx pages, nav, tenancy suites) and prints the rest.--dry-runshows the plan;cargo offrails destroy resource postsundoes it.cargo offrails g model Post title:string …= the migration, model, factory and model tests only (join tables, internal records).- Yours: the spec with AC ids first (step 1), then the
// APP:lines (validation limits, the label, what each page shows), the spec's tests, seeds (step 11), the browser smoke (step 12).Read the rest for what each step produces (and how to do it by hand in the modes the generator doesn't write).
cargo offrails g resource books title:string author:references isbn:string:unique published_on:date:optional
cargo offrails g model Tag name:string:uniqueThe field types, their columns, form controls, validations and factory values are in the generators README. Records belong to the signed-in user when the app has accounts (--public otherwise; the generator refuses to guess).
Field syntax (by hand, and in the other modes)
name:type plus optional modifiers: ? (or :optional) makes it nullable (default is required), ! (or :unique) makes it unique. Example: email:string!, bio:text?. The generator also takes :index, decimal{p.s}, uuid, references{model} (README); rich_text, enum{…} and attachment below are by hand.
| Type | Migration helper | Rust type | Form control / TS type |
|---|---|---|---|
string | string | String | text input / string |
text | text | String | textarea / string |
rich_text | text | String (Markdown) | textarea + rendered via views::helpers::markdown |
integer | integer | i32 | number input / number |
bigint | big_integer | i64 | number input / number |
money | big_integer (<name>_cents) | i64 | decimal input parsed to cents / number |
boolean | boolean | bool | checkbox (absent = false) / boolean |
date | date | chrono::NaiveDate | date input / string |
datetime | timestamp_with_time_zone | DateTimeUtc | datetime-local input interpreted in the user's zone / string |
enum{a,b,c} | string + CHECK | DeriveActiveEnum | select / string union |
references | big_integer <name>_id + FK + index | i64 + Relation | select, or set from the current user if the name is owner/user/author |
attachment | big_integer_null <name>_blob_id FK → blobs (SetNull) + index | Option<i64> | file input in a multipart/form-data form; the handlers take Multipart + storage::read_multipart, and the two form routes get DefaultBodyLimit::max(..) (storage; on first use add the storage kernel, create_blobs, models/blob.rs, PurgeBlobJob and GET /blobs/{key}) |
json | json | serde_json::Value | not form-editable by default |
Field names that imply ownership (owner, user, author + references) are never in Params. They're set from RequireUser, and the policy restricts edits to the owner unless the spec says otherwise.
Scaffold steps
generate resource does 2, 3 (per-user scope; the has_many side when the parent was generated), 5, 6 (the standard set, not your ACs), 7, 8, 9 (htmx), 10 and the tenancy suites, then runs cargo fmt and rewrites docs/routes.md. In inertia/spa/api mode run generate model (and generate api), then do 6–10 by hand from this list.
- Spec: make sure
product/features/<plural>.mdexists. If not, write it from the request, with acceptance criteria for list/create/show/edit/delete, validation rules, and who can do what (interview feature checklist). - Migration:
cargo offrails g migration create_<plural> field:type…(neversea-orm-cli migrate generate: it rewritesmigration/src/lib.rswithout the anchors) → table per migrations (id, fields, timestamps, FKs with indexes, unique indexes for!,CHECKs for enums). Runcargo run -- db migrate, then prove rollback. - Model:
src/models/<singular>/(mod.rsthe entity,params.rs,queries.rs; one<singular>.rsis fine by hand) per models: entity, relations (both sides: addhas_many+Relatedto the parent model too),Params,New,validate()(required fields →can't be blank; strings → max length 255 unless text; integers → range if the spec says; enums →is not included in the list),find, the list query (paginated,order_by_desc(created_at)thenid),create,update,destroy. Plus the alias inmodels/mod.rs.TENANCYisn'tnone:findanddestroyarepub(in crate::models). The public surface isfind_for(db, &user, id),all_for(db, &user, page)anddestroy_for(db, &user, record)(scoped lookups). Add areload_<singular>helper totests/app/factories.rs(tests read through the entity), add the model toTenantintests/app/tenancy_test.rs, teachfillits routes, and list its shared routes inEXPECTEDwith their AC ids. Check:cargo nextest run tenancy_test guard_test.
- Policy:
src/policies/<plural>.rs:can_view,can_edit,can_destroyfrom the spec (default: anyone logged in views, owner edits/destroys), plus the inline truth-table test (test-standards §2). - Factory:
<singular>(&db, …)intests/app/factories/<singular>.rs(wired intofactories.rsbymod <singular>; pub use <singular>::*;), going throughParams::validate+ the model'screate(so factories can't build invalid rows). - Tests first (
tests/app/<plural>/:mod.rsplusread_test.rs,write_test.rs,access_test.rs, the shapeg resourcewrites): one test per acceptance criterion, plus the standard set: index lists records; show 404s for missing ids; create with valid params redirects and persists; create with invalid params re-renders 422 with messages (htmx), or redirects back with errors (inertia), or returns 422 JSON (spa); update; destroy; non-owner gets 403 (or 404 when the record is private and scoped:find_foranswers that); logged-out gets redirected to login (if auth exists);assert_query_count_stableon index (and on show if it lists children); escaping of a<script>title. Each test cites itsAC-<plural>-<n>. Full list: test-standards. Ininertiamode assert on props withapp.inertia_get/inertia_send(frontend-inertia), and add a Vitest test for the<Model>Form. - Controller:
src/controllers/<plural>/with all 7 actions (index.rs,show.rs,new.rsnew + create,edit.rsedit + update,destroy.rs, re-exported bymod.rs), its routes insrc/routes/<plural>.rs(routing) (spa:controllers/api/<plural>.rswith index/show/create/update/destroy) per controllers.inertia: handlers takeInertiaandJson(params), and validation is justparams.validate().map_err(AppError::Invalid)?, becauseshape_errorsredirects back (frontend-inertia). - Routes: resource routes for the mode (routing),
paths::helpers,ROUTESrows. - Views by mode:
htmx:views/<plural>.rsstructs +templates/<plural>/{index,show,new,edit,_form}.html, built from the component kit ({% import "components/ui.html" as ui %}:page_header,table+empty_state+pagination,card,confirm_button,field/select/checkbox+form_actions; views); any class of your own from the two-scheme palette (views;theme_test.rsmust stay green). Askama passes field args by reference: writepaths::book(*book.id)(views gotchas).inertia:views/<plural>.rs:<Model>Jsonand<Plural>{Index,Show,New,Edit}Props(Serialize, TS,#[ts(export)]). New/Edit carry<Model>Params, and Edit also carriesid.<Model>ParamsderivesTStoo.- Pages:
web/src/pages/<plural>/{Index,Show,New,Edit}.tsx. - The shared form is
web/src/components/<plural>/<Model>Form.tsxwith<Model>Form.test.tsxnext to it. It goes not underpages/: the page glob would register it as a page, and tests can't live there. - Add the helpers to
web/src/paths.ts. - Run
cargo test --lib export_bindingsbeforepnpm --dir web typecheck(frontend-inertia). UI_LIBRARY: svelte: the same files as.svelte(pages read$props()). The form component owns itsuseForm(data)and takesmethod/url/initial values as props (<BookForm method="patch" url={paths.book(id)} {book} …/>). Its test is<Model>Form.svelte.test.ts(frontend-svelte).
spa:views/<plural>.rsJSON (Serialize, TS) +web/src/api/<plural>.tshooks +web/src/routes/<plural>/{Index,Show,New,Edit,Form}.tsx+ router entriesUI_LIBRARY: svelte(SvelteKit SPA):web/src/lib/api/<plural>.tsquery factories +web/src/routes/<plural_kebab>/{+page.svelte,new/+page.svelte,[id]/+page.svelte,[id]/edit/+page.svelte}+ a form component inweb/src/lib/components/. No+page.server.tsor+server.ts(frontend-svelte).
api: no pages.views/<plural>.rsholds<Model>Json(Serialize, ToSchema),<Model>Body({"data": …}) and<Plural>Page(data+PageInfo) (api). 9b. Public API surfaces (whenAPIisn'tnone; inapimode this replaces steps 7–9's HTML parts):rest/both:controllers/api/v1/<plural>.rswith index/show/create/update/destroy, each with#[utoipa::path(...)](tag,params(Page),request_body = <Model>Params,security(("bearer" = [])), every response with its body). Bodies viaApiJson<<Model>Params>; the caller viaApiUser, withme.require(Scope::Read)or the policy's scope checks; another user's record is 404.<Model>ParamsderivesToSchema. Add the paths toApiDocincontrollers/api/mod.rs, the routes under/api/v1, andROUTESrows (Access::User). ThenUPDATE_SCHEMAS=1 cargo nextest run openapiand commitdocs/openapi.json.graphql/both: insrc/graphql/types.rsa<Model>Object(#[graphql(name = "<Model>")],From<Model>), a<Model>Input(From<…> for <Model>Params),<Model>Payload { <singular>, errors }andDelete<Model>Payload;query.rsgets<plural>(page, per)(with the per-size complexity) and<singular>(id);mutation.rsgetscreate<Model>,update<Model>,delete<Model>. Every association field resolves through aLoaderinloaders.rs. ThenUPDATE_SCHEMAS=1 cargo nextest run schema_snapshotand commitdocs/schema.graphql(graphql).- Tests: the REST request matrix (
tests/app/<plural>_test.rswithpost_json/patch_json/delete), insta contract snapshots of<Plural>Pageand<Model>Body(api_contract_test.rs), and the GraphQL rows (graphql_test.rs: anonymous, another user's record, read-only token, payloaderrors, N+1 guard per association, REST and GraphQL agreeing on a record).
- Nav: add a link in the layout if the resource is top-level.
- Seeds: add 3–5 realistic records to
tasks/seed.rs: find-or-create each one by a natural key (email, slug, name), inside the seed's onekernel::db::begintransaction. Never gate the whole seed on "no rows yet" (cli). cargo offrails verify(the fast Definition of Done;--fullonce, when the last slice is done), then the real-browser smoke: create → edit → delete, Back after the delete (expect 404), and the console clean (test-standards §9).
Resource
resource = scaffold steps 1–8 with no views. Handlers return what the spec needs (often JSON, or a redirect). Use it for things like webhooks, or models only reachable through a parent page.
Model
cargo offrails g model <Name> field:type… = steps 2, 3 and 5, plus model-level tests (validation unit tests, each query fn against a real DB). Use it for join tables, internal records, and things managed by other controllers.
Nested resources
scaffold Review body:text stars:integer book:references --nested-in books: index and create live under /books/{book_id}/reviews, member actions under /reviews/{id} (shallow). The parent's show page lists children and embeds the new-child form. Load the parent first and 404 if it's missing, and authorize against the parent. A failed create re-renders the parent's page (with the child form's errors) through one shared page-building function. "One child per user per parent" (a review per reader) is a composite unique index, mapped to a base error (models). If the children should update live, follow with channel. Executed for Review in the reference app on 2026-10-12.