Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Generators

This page documents every ocre g generator: its arguments and flags, the naming rules, the files it creates and updates, what the generated code contains, and its errors. Generators write plain Rust, SQL and templates into the app, which the app then owns and edits.

Before you start

  • An Ocre app created with ocre new. Run generators from its root or any directory below it (the CLI looks for the nearest cloudflare.config.ts).
  • Generators only write files: they need no network, no cf and no Cloudflare account. Run ocre migrate after the ones that add migrations, and cargo check --target wasm32-unknown-unknown (or ocre test) to type-check the result.
  • Keep the // ocre:... marker comments of generated files (// ocre:modules and // ocre:routes in src/lib.rs, // ocre:models in src/models/mod.rs…): generators insert lines right after them.

The examples below were run with ocre 0.1.0 on an app created by ocre new blog --starter blog, in the order of this page.

How generators behave

  • All or nothing. A generator computes every change first and writes nothing until the whole generation succeeded, so a failure never leaves half a resource.
  • Never overwrite by default. A generator creates new files and only edits existing ones by inserting lines at markers (in cloudflare.config.ts, after // ocre:env, // ocre:triggers and // ocre:exports; see Configuration). When a file it would create exists, it stops with <path> already exists and the hint generators create new files only: pass --skip to keep the existing file, --force to overwrite it, or edit it (`ocre g migration` for tables). Lines already present after a marker are not inserted twice. To change a table after its migration was applied, add a migration with ocre g migration.
  • Report. The human output lists create <path>, update <path> and skip <path> lines, then Next: steps. With --json it is one object with command (generate <generator>), created, updated, skipped, pretend and next (see the –json contract).
  • Recorded. Every run that writes something saves what it changed in .ocre/generated/NNNN_<generator>_<name>.json (see Generation records and ocre destroy). Commit the directory with the code.
  • Full-stack or API-only. An app created with ocre new --api has [package.metadata.ocre] mode = "api" in Cargo.toml. There, ocre g scaffold generates a JSON API (like ocre g api), and auth and mailer generate no HTML.
  • Migrations are numbered after the highest existing number: migrations/0002_create_comments.sql, then 0003_....
  • Marker errors. When a marker a generator needs is missing, it fails with <file> is missing the `<marker>` marker and a hint saying where to put it back.
GeneratorCreates
ocre g modelMigration and src/models/<model>.rs
ocre g scaffoldModel (unless it exists) plus HTML CRUD pages; --realtime for live updates
ocre g apiModel (unless it exists) plus a JSON REST resource; --graphql for GraphQL
ocre g resourceModel (unless it exists) plus index and show actions to fill in
ocre g controllerA module of GET actions, with a page each (or JSON)
ocre g authUsers, sessions, password reset, magic links, email confirmation, JWT and API keys; --db-sessions, --oauth (once per app)
ocre g migrationOne numbered SQL migration, its SQL inferred from the name
ocre g mailerFunctions building emails, with templates
ocre g mailboxThe handler of incoming email (once per app)
ocre g jobA background job on Cloudflare Queues
ocre g scheduleA task run by a Cron Trigger
ocre g cacheThe CACHE Workers KV binding
ocre g ciThe GitHub Actions workflow: ocre ci’s checks, then ocre deploy on main
ocre g pwaWeb app manifest, service worker and icon, linked from the layout
ocre g localeTranslation files
ocre g overrideCopies of generator templates in .ocre/templates/, which then replace the built-in ones
ocre g generatorAn app generator in .ocre/generators/<name>/
ocre g <name>Runs the app generator .ocre/generators/<name>/

Generator flags

Every generator accepts these flags, before or after its arguments:

FlagEffect
--pretendComputes and reports the changes (create, update and skip lines, then (--pretend: nothing was written); "pretend": true in JSON), writes nothing and records nothing
--forceOverwrites files the generator creates when they already exist (they are reported as update)
--skipKeeps files that already exist (reported as skip) and generates the rest

--force and --skip cannot be combined. Neither changes how existing files such as src/lib.rs are edited: lines are inserted at markers once.

ocre g scaffold Draft title:string --pretend
  create  migrations/0004_create_drafts.sql
  create  src/models/draft.rs
  create  src/drafts.rs
  create  templates/drafts/index.html
  create  templates/drafts/show.html
  create  templates/drafts/new.html
  create  templates/drafts/edit.html
  create  templates/drafts/_form.html
  update  src/models/mod.rs
  update  src/lib.rs
(--pretend: nothing was written)

Next:
  ocre migrate
  ocre dev
  open http://localhost:8787/drafts

Running a controller generator again, keeping what exists:

ocre g controller Pages about --skip
  skip    src/pages.rs
  skip    templates/pages/about.html

Next:
  ocre dev
  open http://localhost:8787/pages/about

Generation records and ocre destroy

Each generator run that changes files writes a JSON record in .ocre/generated/, numbered like migrations (0003_controller_pages.json): the command as typed, the generator, its first argument, each file created with a SHA-256 of its contents, and for each file updated the lines added and removed around a context line. ocre destroy <generator> [NAME] reads the latest matching record and undoes the run: it deletes the files created and takes out the lines added (Cargo.toml, cloudflare.config.ts and package.json changes stay). It refuses when a created file changed since, unless --force.

ocre g scaffold Temp name:string
ocre destroy scaffold Temp
  update  src/models/mod.rs
  update  src/lib.rs
  remove  migrations/0004_create_temps.sql
  remove  src/models/temp.rs
  remove  src/temps.rs
  remove  templates/temps/index.html
  remove  templates/temps/show.html
  remove  templates/temps/new.html
  remove  templates/temps/edit.html
  remove  templates/temps/_form.html
  remove  .ocre/generated/0005_scaffold_temp.json

Next:
  if `ocre migrate` already applied migrations/0004_create_temps.sql, its tables and columns stay: undo them with a new migration (`ocre g migration ...`)

Apps created before records existed have none for their earlier runs: ocre destroy then says no recorded `ocre g ...` run to destroy.

Fields

model, scaffold, api, resource, migration and job take fields as name:type, with optional suffixes: ? makes the field optional (the column accepts NULL, the Rust type is an Option), ^ makes it unique (a unique index plus a “has already been taken” check). Both can be combined (slug:string?^). The types, detailed in Field types:

TypeSQL columnRust typeNotes
stringTEXTStringOne-line text; required unless ?
textTEXTStringMulti-line text (a textarea in forms); required unless ?
integer (int, small_int, big_int)INTEGERi64Validated within ±(2^53 - 1), the integers D1 returns exactly
float (double)REALf64
decimalTEXTStringExact number such as 19.99 (money); validated as a decimal
boolean (bool)INTEGER NOT NULL DEFAULT 0boolA checkbox; cannot be ?
dateTEXTStringValidated as YYYY-MM-DD
timeTEXTStringValidated as HH:MM[:SS]
datetime (date_time)TEXTStringValidated as a date and time
uuidTEXTStringValidated as a hyphenated UUID
references<name>_id INTEGER REFERENCES <plural>(id) ON DELETE CASCADE (SET NULL when ?), indexedi64author:references adds author_id, author:references:writer_id names the column; src/models/author.rs must exist; validated as “must exist”
attachmentfour columns: <name>_key, <name>_filename, <name>_content_type (TEXT), <name>_size (INTEGER)ocre::storage::Upload when received, Attachment when storedA file in R2; cannot be ^; cannot be named edit, delete or new; must be ? in JSON APIs; adds the STORAGE R2 binding to cloudflare.config.ts
json (jsonb)TEXT CHECK (json_valid(<name>))ocre::serde_json::ValueAny JSON value; cannot be ^
enum:<a>,<b>...TEXT CHECK (<name> IN ('a', 'b'))a Rust enum generated in the model (status gives Status)A <select> in forms; cannot be ^; not with --graphql
rich_textTEXTStringFormatted text (the Trix editor in forms), sanitized when saved; cannot be ^
polymorphic:<model>,<model>...<name>_type (an enum of the models) and <name>_id (INTEGER), indexed together<Name>Type and i64; record.<name>(ctx) returns an enum of the recordscommentable:polymorphic:post,photo; the models must exist; checked as “must exist”; cannot be ^
attachments (model, scaffold, api, resource only)a child table <model>_<singular> with a file attachmentthe child model, and attach_<name> / replace_<name> / purge_<name> on the parentphotos:attachments; plural name; no ? or ^ (see Files)

Field names are snake_case, start with a lowercase letter, and must not be reserved: id, created_at and updated_at (every table gets them), Rust keywords (type, match, mod, ref, self, use, where, yield…), and these SQL keywords: and, asc, by, case, check, default, desc, from, group, index, join, key, limit, not, null, offset, or, order, primary, references, select, table, unique, values. Two fields cannot produce the same column (an attachment avatar takes avatar_key, avatar_filename, avatar_content_type and avatar_size).

Field errors (shared by every generator that takes fields):

ErrorHint
field `title` has no typewrite fields as `name:type`, e.g. `title:string`
invalid field name `<name>` use snake_case starting with a letter, e.g. `published_at`
field name `type` is reserved`id`, `created_at` and `updated_at` are generated; Rust and SQL keywords are not allowed. Pick another name, e.g. `kind` for `type`
unknown field type `strng` for `title` types: string, text, rich_text, integer (int, small_int, big_int), float (double), decimal, boolean (bool), date, time, datetime (date_time), uuid, references, attachment, json (jsonb), enum:<value>,<value>..., polymorphic:<model>,<model>..., attachments (many files, `photos:attachments`); `lock_version:integer` turns on optimistic locking; add `?` for optional, `^` for unique
boolean field `done` cannot be optionalbooleans are true or false (a checkbox); drop the `?`
attachment `a` cannot be uniqueevery stored file gets its own random key already; drop the `^`
json field `v` cannot be uniquea unique index compares JSON text, where key order and spacing differ; drop the `^`
enum `s` has no valueslist them after the type, e.g. `s:enum:draft,published`
invalid values `A,b` for enum `s` list distinct snake_case values after the type, e.g. `status:enum:draft,published`
enum `s` cannot be uniquea few values cannot be unique across many rows; drop the `^`
invalid foreign key column `writer` for `author` name the column in snake_case ending in `_id`, e.g. `author:references:writer_id`
type `string` of `title` takes no `:long` only `references` (the foreign key column, e.g. `author:references:writer_id`) and `enum` (its values, e.g. `status:enum:draft,published`) take an argument
attachment name `edit` clashes with a scaffold route`/<plural>/{id}/edit` is taken; pick another name, e.g. `edit_file`
field `a_key` is listed twicenames must differ, and `<name>:attachment` also takes `<name>_key`, `<name>_filename`, `<name>_content_type` and `<name>_size`
src/models/owner.rs does not exist (for owner:references)generate the referenced model first, e.g. `ocre g model Owner name:string`

Model names

model, scaffold, api and resource take a singular model name, in PascalCase, snake_case, kebab-case or with spaces (BlogPost, blog_post, blog-post). It is split into words, which give every other name:

InputStructModule and fileTable, plural module, URL segmentHuman
PostPostpost, src/models/post.rspostsPost, Posts
BlogPostBlogPostblog_postblog_postsBlog post, Blog posts
categoryCategorycategorycategoriesCategory, Categories
BoxBoxboxboxesBox, Boxes
PersonPersonpersonpeoplePerson, People

Only the last word is pluralized: a consonant followed by y becomes ies; s, x, z, ch and sh take es; the irregular person, child, man, woman, mouse, goose, tooth and foot become people, children, men, women, mice, geese, teeth and feet; anything else takes s. Other irregular plurals (Status gives statuses, but Criterion gives criterions) are not known: rename the table in the migration and the model if needed. The name must start with a letter (invalid model name `1thing` with the hint use a singular name starting with a letter, e.g. `Post` or `BlogPost` ).

Model names are not checked against Rust keywords: ocre g model Type ... or ocre g model Box ... succeed but write pub mod type; or pub mod box; into src/models/mod.rs, which does not compile. Pick another name (Kind, Crate…).

ocre g model

ocre g model <NAME> <FIELDS>...
ArgumentRequiredMeaning
NAMEyesSingular model name (see Model names)
FIELDSyes, at least onename:type fields (see Fields)

Creates the table’s migration and the model: every query and rule about the table, which controllers, JSON APIs, GraphQL resolvers and jobs call instead of writing SQL.

ocre g model Author name:string^ bio:text?
  create  migrations/0006_create_authors.sql
  create  src/models/author.rs
  update  src/models/mod.rs

Next:
  ocre migrate
  cargo check --target wasm32-unknown-unknown
{"command":"generate model","created":["migrations/0006_create_authors.sql","src/models/author.rs"],"next":["ocre migrate","cargo check --target wasm32-unknown-unknown"],"ok":true,"updated":["src/models/mod.rs"]}

Files:

  • migrations/NNNN_create_<plural>.sql: CREATE TABLE with id INTEGER PRIMARY KEY AUTOINCREMENT, the field columns, created_at and updated_at (TEXT NOT NULL DEFAULT (datetime('now'))), then a CREATE UNIQUE INDEX per ^ field and a CREATE INDEX per reference. Skipped when a *_create_<plural>.sql migration already exists.
  • src/models/<model>.rs: the row struct (Author, deriving Deserialize and Serialize), NewAuthor (values for a new row), AuthorChanges (every field an Option; for optional fields Some(None) clears the value), validate() on both (presence of required text, dates, safe integers, files), and the functions all(ctx, page) (newest first), count, find, find_many (100 ids per query), create, update and delete. create and update add the checks that need the database: uniqueness and existing references.
  • src/models/mod.rs: pub mod <model>; after // ocre:models. The first model creates the file and adds mod models; to src/lib.rs.

A references field also updates the referenced model: ocre g scaffold Comment ... post:references adds pub async fn comments(&self, ctx, page) (has many, newest first) to src/models/post.rs after its // ocre:associations marker, and gives Comment a post(&self, ctx) method (belongs to). An attachment field adds a Rules constant per file (pub const IMAGE: Rules, 10 MB and common image, PDF and text types until you edit it), a method returning its Attachment, create/update that store files in R2 and delete replaced ones, and the STORAGE binding to cloudflare.config.ts (STORAGE: bindings.r2({ name: "<app>-storage" }),) unless it is there.

This is the migration of the fixture’s Setting model with a JSON field:

ocre g model Setting key_name:string^ value:json
CREATE TABLE settings (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    key_name TEXT NOT NULL,
    value TEXT NOT NULL CHECK (json_valid(value)),
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX index_settings_on_key_name ON settings (key_name);

Errors: the field and name errors, and src/models/<model>.rs already exists when the model exists. See Models and migrations and Validations.

ocre g scaffold

ocre g scaffold <NAME> <FIELDS>... [--realtime]
Argument or flagDefaultMeaning
NAMErequiredSingular model name
FIELDSrequired, at least onename:type fields
--realtimeoffLive index page: creates, edits and deletes appear in every open browser over a WebSocket (htmx ws extension, a Durable Object per channel). Full-stack apps only

Creates the model (like ocre g model, unless src/models/<model>.rs exists, in which case the fields only shape the pages), then HTML pages for the full create, read, update, delete cycle. With a public_id:token field, URLs carry the record’s random public id instead of its integer id (also for ocre g api and ocre g resource; see Field types).

ocre g scaffold Comment author:string body:text post:references
  create  migrations/0002_create_comments.sql
  create  src/models/comment.rs
  create  src/comments.rs
  create  templates/comments/index.html
  create  templates/comments/show.html
  create  templates/comments/new.html
  create  templates/comments/edit.html
  create  templates/comments/_form.html
  update  src/models/post.rs
  update  src/models/mod.rs
  update  src/lib.rs

Next:
  ocre migrate
  ocre dev
  open http://localhost:8787/comments
{"command":"generate scaffold","created":["migrations/0002_create_comments.sql","src/models/comment.rs","src/comments.rs","templates/comments/index.html","templates/comments/show.html","templates/comments/new.html","templates/comments/edit.html","templates/comments/_form.html"],"next":["ocre migrate","ocre dev","open http://localhost:8787/comments"],"ok":true,"updated":["src/models/post.rs","src/models/mod.rs","src/lib.rs"]}

src/<plural>.rs holds the routes and handlers; src/lib.rs gets mod <plural>; and .merge(<plural>::routes()). HTML forms can only send GET and POST, so updates and deletes are POSTs:

RouteHandlerEffect
GET /commentsindexList, newest first, paginated
GET /comments/newnewNew form
POST /commentscreateCreate; redirects with a flash notice, or shows the form with errors (422)
GET /comments/{id}showOne record
GET /comments/{id}/editeditEdit form
POST /comments/{id}updateUpdate
POST /comments/{id}/deletedeleteDelete, then redirect
GET /comments/{id}/<attachment><attachment>_fileFor each attachment field: serves the stored file

The handlers parse the form into a CommentForm whose fields are all text (numbers are validated, so a typo shows a field error instead of a failed request), then call the model. The templates extend layout.html; _form.html holds the fields shared by new.html and edit.html.

With --realtime, the scaffold also creates templates/<plural>/_row.html (one row, rendered for the page and for broadcasts), makes the handlers broadcast each change on the <plural> channel, and on first use creates src/realtime.rs (the GET /realtime/{channel} route and the list of channels anyone may listen to), turns on Ocre’s realtime feature in Cargo.toml, declares the CHANNELS Durable Object binding and the OcreChannel export (exports.durableObject({ storage: "sqlite" }), SQLite-backed, as the free plan requires) in cloudflare.config.ts, and loads htmx’s WebSocket extension in the <head> of templates/layout.html (after htmx’s script tag, or before </head>; a layout without </head> stops the generator with the line to add). The extension belongs in the layout: a page that loaded it itself would not connect when reached through an hx-boost link, because htmx processes the page before the script arrives. Later --realtime scaffolds add their channel after // ocre:channels in src/realtime.rs:

ocre g scaffold Message body:text --realtime --json
{"command":"generate scaffold","created":["migrations/0008_create_messages.sql","src/models/message.rs","src/messages.rs","templates/messages/index.html","templates/messages/show.html","templates/messages/new.html","templates/messages/edit.html","templates/messages/_form.html","templates/messages/_row.html","src/realtime.rs"],"next":["ocre migrate","ocre dev","open http://localhost:8787/messages","open http://localhost:8787/messages in a second window, then create a message"],"ok":true,"updated":["src/models/mod.rs","src/lib.rs","Cargo.toml","cloudflare.config.ts","templates/layout.html"]}

With attachments, the form becomes a file upload and cloudflare.config.ts gets the STORAGE bucket:

ocre g scaffold Photo title:string image:attachment notes:attachment?
  create  migrations/0009_create_photos.sql
  create  src/models/photo.rs
  create  src/photos.rs
  create  templates/photos/index.html
  create  templates/photos/show.html
  create  templates/photos/new.html
  create  templates/photos/edit.html
  create  templates/photos/_form.html
  update  src/models/mod.rs
  update  cloudflare.config.ts
  update  src/lib.rs

Next:
  ocre migrate
  ocre dev
  open http://localhost:8787/photos

In an API-only app, ocre g scaffold runs ocre g api (without GraphQL), and the report’s command is generate api:

  create  src/models/mod.rs
  create  migrations/0001_create_posts.sql
  create  src/models/post.rs
  create  src/posts_api.rs
  update  src/lib.rs

Next:
  ocre migrate
  ocre dev
  curl http://localhost:8787/api/posts

Errors: the field and name errors; src/<plural>.rs already exists (or a template) when the pages exist; in an API-only app, --realtime updates HTML pages; this app is API-only with the hint run `ocre g scaffold` without --realtime; to push JSON to clients, see https://ocre.rs/guides/realtime#without-the-scaffold-and-in-api-only-apps. See Controllers and routing, Views, helpers and forms, File storage and Realtime.

ocre g api

ocre g api <NAME> <FIELDS>... [--graphql]
Argument or flagDefaultMeaning
NAMErequiredSingular model name
FIELDSrequired, at least onename:type fields; attachments must be optional (file:attachment?)
--graphqloffAlso expose the resource on /graphql. Per the CLI’s help, it adds about 1.1 MB of WebAssembly and 20-60 ms of CPU when a Worker instance starts

Creates the model unless it exists (so ocre g api Post ... after ocre g scaffold Post ... adds a JSON API to the same model), then src/<plural>_api.rs, registered in src/lib.rs. Works in both kinds of apps.

ocre g api Product name:string^ price:float stock:integer? --graphql
  create  migrations/0007_create_products.sql
  create  src/models/product.rs
  create  src/products_api.rs
  create  src/graphql.rs
  update  src/models/mod.rs
  update  src/lib.rs
  update  Cargo.toml

Next:
  ocre migrate
  ocre dev
  curl http://localhost:8787/api/products
  open http://localhost:8787/graphql
{"command":"generate api","created":["migrations/0007_create_products.sql","src/models/product.rs","src/products_api.rs","src/graphql.rs"],"next":["ocre migrate","ocre dev","curl http://localhost:8787/api/products","open http://localhost:8787/graphql"],"ok":true,"updated":["src/models/mod.rs","src/lib.rs","Cargo.toml"]}

Routes of src/products_api.rs:

RouteEffect
GET /api/products?limit=&offset=List, newest first
GET /api/products/{id}One record
POST /api/productsCreate (every required field); 201
PATCH /api/products/{id}Update only the fields sent; null clears an optional field
DELETE /api/products/{id}Delete; 204
GET, PUT, DELETE /api/<plural>/{id}/<attachment>For each attachment: download, upload (multipart), remove the file

Failed validations answer 422 with {"error": {"fields": {"title": ["can't be blank"]}}}. With --graphql, the first API creates src/graphql.rs (the schema, served by ocre::graphql::routes on GET /graphql for GraphiQL and POST /graphql), turns on Ocre’s graphql feature and adds the async-graphql dependency in Cargo.toml; later --graphql APIs add their queries and mutations after the // ocre:graphql-queries and // ocre:graphql-mutations markers.

Errors: the field and name errors; attachment `f` must be optional in a JSON API with the hint JSON cannot carry a file, so create cannot require one: use `f:attachment?`, then upload with `curl -X PUT -F f=@file http://localhost:8787/api/<plural>/1/f` ; with --graphql, enum `state` is not supported with --graphql yet with the hint use `state:string` checked with `v.inclusion(...)` in the model, or generate the JSON API without --graphql; src/<plural>_api.rs already exists. See JSON APIs and GraphQL.

ocre g resource

ocre g resource <NAME> <FIELDS>... [--api]
Argument or flagDefaultMeaning
NAMErequiredSingular model name
FIELDSrequired, at least onename:type fields
--apioffJSON actions under /api/<plural> in a full-stack app (always JSON in an API-only app)

Lighter than a scaffold: the model (unless src/models/<model>.rs exists) and a controller with index (paginated list, newest first) and show over it, to fill in with the actions you need. In a full-stack app it writes src/<plural>.rs with a paths module and the pages templates/<plural>/index.html and show.html; in JSON mode src/<plural>_api.rs answers GET /api/<plural> and GET /api/<plural>/{id}. The module is registered in src/lib.rs.

ocre g resource Tag name:string^ color:enum:red,green,blue
  create  migrations/0003_create_tags.sql
  create  src/models/tag.rs
  create  src/tags.rs
  create  templates/tags/index.html
  create  templates/tags/show.html
  update  src/models/mod.rs
  update  src/lib.rs

Next:
  ocre migrate
  ocre dev
  open http://localhost:8787/tags
{"command":"generate resource","created":["migrations/0003_create_tags.sql","src/models/tag.rs","src/tags.rs","templates/tags/index.html","templates/tags/show.html"],"next":["ocre migrate","ocre dev","open http://localhost:8787/tags"],"ok":true,"updated":["src/models/mod.rs","src/lib.rs"]}

With --api, the last next step is curl http://localhost:8787/api/<plural>. The files come from the resource/ templates, which ocre g override copies for editing. Errors: the field and name errors, and <path> already exists.

ocre g controller

ocre g controller <NAME> [ACTIONS]... [--api] [--auth]
Argument or flagDefaultMeaning
NAMErequiredController name, PascalCase or snake_case; a Controller suffix is dropped (PagesController gives pages). Not a Rust keyword, not ending in api
ACTIONSindexAction names in snake_case, one GET route each; index answers at the controller’s root path
--apioffJSON actions under /api/<name> in a full-stack app (always JSON in an API-only app)
--authoffSigned-in users only: handlers take CurrentUser (HTML) or BearerUser (JSON). Needs src/auth.rs (or src/auth_api.rs) from ocre g auth

Creates a module of GET actions, like rails generate controller: in a full-stack app src/<name>.rs with one askama view struct per action, a paths module (paths::about()), and a page per action, templates/<name>/<action>.html, extending layout.html; in JSON mode src/<name>_api.rs with one ApiResult<Json<...>> handler per action. The module is registered in src/lib.rs.

ocre g controller Pages about contact
  create  src/pages.rs
  create  templates/pages/about.html
  create  templates/pages/contact.html
  update  src/lib.rs

Next:
  ocre dev
  open http://localhost:8787/pages/about
{"command":"generate controller","created":["src/pages.rs","templates/pages/about.html","templates/pages/contact.html"],"next":["ocre dev","open http://localhost:8787/pages/about"],"ok":true,"updated":["src/lib.rs"]}

ocre g controller Metrics summary --api creates src/metrics_api.rs with GET /api/metrics/summary, answering {"message": "Edit summary in src/metrics_api.rs"} until you change it; its next step is curl http://localhost:8787/api/metrics/summary. The files come from the controller/ templates.

Errors:

ErrorHint
invalid controller name `type` use a name starting with a letter that is not a Rust keyword and does not end in `api`, e.g. `Pages` or `Dashboard`
invalid action name `type` use snake_case starting with a letter, not a Rust keyword nor `routes`/`paths`, e.g. `about` or `contact_us`
action `about` is listed twicelist each action once
--auth needs src/auth.rs, which `ocre g auth` creates (src/auth_api.rs in JSON mode)run `ocre g auth` and `ocre migrate` first, or generate the controller without --auth
src/<name>.rs already existsthe generic hint (--skip, --force)

See Controllers, routing, views and htmx.

ocre g auth

ocre g auth [--db-sessions] [--oauth <provider,...>]

No positional arguments. Generates authentication into the app, like Rails 8’s authentication generator, so every rule is visible and editable there. It runs once per app.

OptionEffect
--db-sessionsTracks each sign-in in D1 (user_sessions: IP address, browser, last activity, expiry); /account/sessions lists the signed-in devices and signs them out. Costs one D1 read per signed-in request
--oauth github,google“Continue with GitHub / Google” buttons on the login page (OAuth 2.0 code flow with PKCE, ocre::oauth). Accepts github and google, comma-separated or repeated

In a new full-stack app:

ocre g auth
  create  migrations/0001_create_users.sql
  create  migrations/0002_create_auth_tokens.sql
  create  migrations/0003_create_api_keys.sql
  create  src/models/mod.rs
  create  src/models/user.rs
  create  src/models/api_key.rs
  create  src/models/auth_token.rs
  create  src/auth_api.rs
  create  src/auth.rs
  create  src/registrations.rs
  create  src/sessions.rs
  create  src/passwords.rs
  create  src/confirmations.rs
  create  templates/auth/signup.html
  create  templates/auth/login.html
  create  templates/auth/account.html
  create  templates/auth/magic_link_new.html
  create  templates/auth/magic_link_show.html
  create  templates/auth/password_new.html
  create  templates/auth/password_edit.html
  create  templates/auth/confirmation_show.html
  update  src/lib.rs
  update  cloudflare.config.ts

Next:
  ocre migrate
  ocre dev
  open http://localhost:8787/signup
{"command":"generate auth","created":["migrations/0001_create_users.sql","migrations/0002_create_auth_tokens.sql","migrations/0003_create_api_keys.sql","src/models/mod.rs","src/models/user.rs","src/models/api_key.rs","src/models/auth_token.rs","src/auth_api.rs","src/auth.rs","src/registrations.rs","src/sessions.rs","src/passwords.rs","src/confirmations.rs","templates/auth/signup.html","templates/auth/login.html","templates/auth/account.html","templates/auth/magic_link_new.html","templates/auth/magic_link_show.html","templates/auth/password_new.html","templates/auth/password_edit.html","templates/auth/confirmation_show.html"],"next":["ocre migrate","ocre dev","open http://localhost:8787/signup"],"ok":true,"updated":["src/lib.rs","cloudflare.config.ts"]}

In an app that already has models, src/models/mod.rs is updated instead of created, and the migrations take the next numbers.

FileContents
src/models/user.rs, api_key.rs, auth_token.rsUsers (email, password digest, confirmed_at), API keys, and single-use emailed tokens for password reset, magic links and email confirmation (full-stack only)
src/auth.rsThe CurrentUser, ConfirmedUser and OptionalUser extractors, sign_in/sign_out, SESSION_SECONDS (two weeks) and OAUTH_PROVIDERS (full-stack only)
src/registrations.rsGET/POST /signup, GET /account, POST /account/delete
src/sessions.rsGET/POST /login (“remember me”), POST /logout, magic-link login (/magic_link, /magic_link/{token})
src/passwords.rsPassword reset: /passwords/new, POST /passwords, /passwords/{token}
src/confirmations.rsEmail confirmation: POST /confirmations (send a new link), /confirmations/{token}
src/auth_api.rsIn every app: the BearerUser extractor, throttle, POST /api/auth/signup, POST /api/auth/token (JWT), GET/DELETE /api/auth/me, and API keys (GET/POST /api/auth/keys, DELETE /api/auth/keys/{id})
templates/auth/*.htmlThe pages (full-stack only)
cloudflare.config.tsThe AUTH_RATE_LIMITER: bindings.rateLimit(...) binding (10 requests a minute, namespace derived from the app name), used by throttle on every route that checks a password or sends an email; not added again when present

ocre g auth --db-sessions --oauth github,google also creates migrations/*_create_user_sessions.sql, migrations/*_create_identities.sql, src/models/user_session.rs, src/models/identity.rs, src/user_sessions.rs (GET /account/sessions, POST /account/sessions/{id}/delete, POST /account/sessions/others/delete), src/oauth.rs (POST /auth/{provider}, GET /auth/{provider}/callback) and templates/auth/user_sessions.html, links the sessions page from account.html, and appends commented GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET (and GOOGLE_...) lines to .dev.vars. One extra next step per provider:

  register an OAuth app with github (callback https://<your host>/auth/github/callback), put GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .dev.vars and their production values in .prod.vars, then `ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars`

In an API-only app, only the JSON part is generated: create_users and create_api_keys migrations, the user and api_key models, src/auth_api.rs and the rate limiter in cloudflare.config.ts; the last next step is a curl -X POST http://localhost:8787/api/auth/signup ... command.

Errors:

  • this app already has a User model or a users table when src/models/user.rs or a *_create_users.sql migration exists, with the hint `ocre g auth` creates both and runs once per app; to start over, remove src/models/user.rs and the create_users migration.
  • unknown OAuth provider `twitter` with the hint --oauth accepts github, google (comma-separated).
  • --db-sessions and --oauth need HTML pages, and this app is API-only, with the hint run `ocre g auth` without them: JSON clients use JWTs and API keys, which `DELETE /api/auth/keys/{id}` revokes.

See Authentication.

ocre g migration

ocre g migration <NAME> [FIELDS]...
ArgumentRequiredMeaning
NAMEyessnake_case migration name
FIELDSnoColumns as name:type, or column names for the index names below

Creates migrations/NNNN_<name>.sql, starting with -- Migration: <name> and a comment reminding that applied migrations must not be edited. The SQL is inferred from the name, like Rails:

NameSQL
create_<table>CREATE TABLE <table> with the fields, id, created_at, updated_at and indexes, as ocre g model writes it
add_<anything>_to_<table>One ALTER TABLE <table> ADD COLUMN per column, then the indexes. Fields are required
remove_<column>_from_<table>DROP INDEX IF EXISTS index_<table>_on_<column> (SQLite refuses to drop an indexed column), then ALTER TABLE <table> DROP COLUMN <column>; with fields, one DROP COLUMN per field column instead, after dropping the indexes of the unique and reference fields
add_index_to_<table> <column>...CREATE INDEX index_<table>_on_<a>_and_<b> ON <table> (<a>, <b>), columns in the order given (names only, no types)
add_unique_index_to_<table> <column>...The same with CREATE UNIQUE INDEX
remove_index_from_<table> <column>...DROP INDEX IF EXISTS index_<table>_on_<a>_and_<b>
rename_<column>_to_<new>_in_<table>ALTER TABLE <table> RENAME COLUMN <column> TO <new>
rename_<table>_to_<new>ALTER TABLE <table> RENAME TO <new>
drop_<table>DROP TABLE <table>
rebuild_<table>SQLite’s table rebuild, copied from the table’s definition in db/schema.sql (written by ocre db schema): create <table>_new, copy the rows, drop the old table, rename, recreate its indexes, between PRAGMA defer_foreign_keys lines. Edit its CREATE TABLE to change what ALTER TABLE cannot (a column’s type, NOT NULL, DEFAULT, CHECK, REFERENCES, an enum’s values)
anything else, no fieldsAn empty migration to fill in (data changes, custom SQL)

rename_..., drop_... and rebuild_... take no fields. After them, and after the field forms, the next steps remind you to update the model; the index forms only print ocre migrate.

Existing rows need a value for a new NOT NULL column, so add_..._to_... adds DEFAULT '' to required text, date, time, datetime, decimal and uuid columns, DEFAULT 0 to required numbers, DEFAULT '{}' to required json columns, the first value to required enum columns (optional columns stay NULL), and booleans already default to 0. References and attachments must be optional there.

ocre g migration add_slug_to_posts slug:string^
  create  migrations/0011_add_slug_to_posts.sql

Next:
  ocre migrate
  update the model in src/models/ to match the new columns
-- Migration: add_slug_to_posts
-- Applied once, in file-name order. Never edit a migration after it has been applied.
ALTER TABLE posts ADD COLUMN slug TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX index_posts_on_slug ON posts (slug);
ocre g migration add_slug_to_posts slug:string^ --json
{"command":"generate migration","created":["migrations/0011_add_slug_to_posts.sql"],"next":["ocre migrate","update the model in src/models/ to match the new columns"],"ok":true}

ocre g migration remove_slug_from_posts writes DROP INDEX IF EXISTS index_posts_on_slug; then ALTER TABLE posts DROP COLUMN slug;, and ocre g migration backfill_slugs an empty migration; without fields, the only next step is ocre migrate. A migration does not change the model: add or remove the fields in src/models/<model>.rs (the row struct, New..., ...Changes, and the SQL of create and update) yourself.

Index and rename migrations:

ocre g migration add_index_to_books pages released_on
ocre g migration rename_summary_to_blurb_in_books
-- Migration: add_index_to_books
-- Applied once, in file-name order. Never edit a migration after it has been applied.
CREATE INDEX index_books_on_pages_and_released_on ON books (pages, released_on);
-- Migration: rename_summary_to_blurb_in_books
-- Applied once, in file-name order. Never edit a migration after it has been applied.
ALTER TABLE books RENAME COLUMN summary TO blurb;

A table rebuild, after ocre migrate and ocre db schema:

ocre g migration rebuild_posts
-- Migration: rebuild_posts
-- Applied once, in file-name order. Never edit a migration after it has been applied.
-- Rebuilds `posts` to change what ALTER TABLE cannot (a column's type, NOT NULL,
-- DEFAULT, CHECK or REFERENCES): edit the CREATE TABLE below, and keep both
-- column lists of the INSERT in step with it.
PRAGMA defer_foreign_keys = true;
CREATE TABLE posts_new (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    published INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO posts_new (id, title, body, published, created_at, updated_at) SELECT id, title, body, published, created_at, updated_at FROM posts;
DROP TABLE posts;
ALTER TABLE posts_new RENAME TO posts;
PRAGMA defer_foreign_keys = false;

Errors:

ErrorHint
invalid migration name `AddX` use snake_case, e.g. `add_slug_to_posts`
cannot tell which table `fix_stuff` changes (fields with an unknown name form)name it `create_<table>`, `add_<columns>_to_<table>` or `remove_<columns>_from_<table>`
add_..._to_... needs the columns to addlist them like the model fields, e.g. `ocre g migration add_slug_to_posts slug:string^`
`owner_id` must be optional when added to an existing tableSQLite adds reference columns as NULL for existing rows: use `name:references?`
`<name>` must be optional when added to an existing table (attachment)existing rows have no file: use `name:attachment?`
`add_index_to_posts` needs the indexed column nameslist them in index order, without types, e.g. `ocre g migration add_index_to_posts author_id created_at`
`drop_tags` takes no fieldsthe name says it all, e.g. `rename_title_to_headline_in_posts`, `drop_tags`, `rebuild_posts`
cannot tell what `rename_title` renamesname it `rename_<column>_to_<new>_in_<table>` or `rename_<table>_to_<new>`
db/schema.sql not found (rebuild_...)run `ocre migrate` then `ocre db schema`: the rebuild copies the table's current definition
table `posts` is not in db/schema.sqlrun `ocre migrate` then `ocre db schema` to refresh it, and check the table name
`comments` references `posts`: rebuilding it would delete or clear their rowsD1 enforces foreign keys, so dropping `posts` runs ON DELETE on `comments`; add a new column and backfill it instead

ocre g mailer

ocre g mailer <NAME> <ACTIONS>...
ArgumentRequiredMeaning
NAMEyesMailer name, PascalCase or snake_case; a Mailer suffix is dropped (UserMailer, user_mailer and User all give user)
ACTIONSyes, at least oneEmail names in snake_case (welcome, password_reset), one function each

Creates src/mailers/<name>.rs with one function per action, pub fn welcome(to: &str) -> Result<Email>, building an ocre::mail::Email whose subject is the humanized action (Password reset) and passing it through defaults of src/mailers/mod.rs. In a full-stack app, each function renders two askama templates, templates/mailers/<name>/<action>.txt and .html, which extend templates/mailers/layout.txt and layout.html (created once, unless they exist); in an API-only app, the text is built with format! and there are no templates. Each action gets a preview in PREVIEWS of src/mailers/mod.rs, shown at /ocre/dev/mailers in ocre dev. The first mailer creates src/mailers/mod.rs (with defaults and PREVIEWS; keep the // ocre:mailers and // ocre:mailer-previews markers) and adds mod mailers; and .merge(ocre::mail::dev_routes(mailers::PREVIEWS)) to src/lib.rs (replacing the dev_routes(&[]) that ocre g mailbox adds).

ocre g mailer User welcome password_reset
  create  src/mailers/user.rs
  create  templates/mailers/user/welcome.txt
  create  templates/mailers/user/welcome.html
  create  templates/mailers/user/password_reset.txt
  create  templates/mailers/user/password_reset.html
  create  templates/mailers/layout.html
  create  templates/mailers/layout.txt
  create  src/mailers/mod.rs
  update  src/lib.rs

Next:
  send it from a handler: ocre::mail::send(&ctx, mailers::user::welcome(&address)?).await?
  ocre dev, then open http://localhost:8787/ocre/dev/mailers to preview it (MAIL_ADAPTER=log in .dev.vars prints each email sent instead of sending it)
{"command":"generate mailer","created":["src/mailers/user.rs","templates/mailers/user/welcome.txt","templates/mailers/user/welcome.html","templates/mailers/user/password_reset.txt","templates/mailers/user/password_reset.html","templates/mailers/layout.html","templates/mailers/layout.txt","src/mailers/mod.rs"],"next":["send it from a handler: ocre::mail::send(&ctx, mailers::user::welcome(&address)?).await?","ocre dev, then open http://localhost:8787/ocre/dev/mailers to preview it (MAIL_ADAPTER=log in .dev.vars prints each email sent instead of sending it)"],"ok":true,"updated":["src/lib.rs"]}

A src/mailers/mod.rs written by an older Ocre (without defaults or PREVIEWS) keeps working: the new mailer does not call defaults, and no preview is added.

Errors: invalid mailer name `type` (hint: use a name starting with a letter that is not a Rust keyword, e.g. `User` or `Billing` ); invalid action name `select` (hint: use snake_case starting with a letter, not a Rust or SQL keyword, e.g. `welcome` or `password_reset` ); action `receipt` is listed twice (hint: list each action once); src/mailers/<name>.rs already exists; src/lib.rs is missing the `// ocre:routes` marker (first mailer with previews). Names are checked against the same reserved words as fields. See Email.

ocre g mailbox

ocre g mailbox

No arguments. Creates src/mailbox.rs, the handler of email that Cloudflare Email Routing sends to the Worker (pub async fn receive(ctx, email: InboundEmail) -> Result<()>), and appends the Worker’s email event to src/lib.rs, which calls ocre::mail::receive(message, env, mailbox::receive). Unless a mailer added them, it also merges the development pages (.merge(ocre::mail::dev_routes(&[]))), whose /ocre/dev/mailbox form delivers test email in ocre dev. A Worker has one email entry point, so an app has one mailbox, which routes by email.to() itself.

ocre g mailbox
  create  src/mailbox.rs
  update  src/lib.rs

Next:
  ocre dev
  open http://localhost:8787/ocre/dev/mailbox to deliver a test email
  or: curl 'http://localhost:8787/cdn-cgi/local/email?from=ada@example.com&to=support@example.com' --data-binary @message.eml
  route addresses to the Worker: Cloudflare dashboard > Email Routing > Routing rules > Send to a Worker
{"command":"generate mailbox","created":["src/mailbox.rs"],"next":["ocre dev","open http://localhost:8787/ocre/dev/mailbox to deliver a test email","or: curl 'http://localhost:8787/cdn-cgi/local/email?from=ada@example.com&to=support@example.com' --data-binary @message.eml","route addresses to the Worker: Cloudflare dashboard > Email Routing > Routing rules > Send to a Worker"],"ok":true,"updated":["src/lib.rs"]}

Errors: src/mailbox.rs already exists; src/lib.rs already handles the email event with the hint a Worker has one email entry point: call `ocre::mail::receive(message, env, mailbox::receive)` from it. See Email.

ocre g webhook

ocre g webhook <name> [--standard]

An endpoint a service calls back, POST /webhooks/<name> in src/<name>_webhook.rs (a _webhook suffix in the name is dropped). It checks the signature (HMAC-SHA256 of the raw body in X-Signature; with --standard, Standard Webhooks headers and a whsec_ secret), records each event in webhook_events and runs handle(&ctx, &event) once per event id (ocre::webhooks::once). The first webhook adds the create_webhook_events migration; <NAME>_WEBHOOK_SECRET is appended to .dev.vars with a random value; tests/<name>_webhook.rs has request tests (a wrong signature is refused, the same event twice is processed once). See Webhooks and external services.

ocre g webhook payments
  create  src/payments_webhook.rs
  create  tests/payments_webhook.rs
  create  migrations/0001_create_webhook_events.sql
  update  .dev.vars
  update  src/lib.rs

Next:
  ocre migrate
  write the effect in `handle` (src/payments_webhook.rs), then `ocre test --e2e`
  give the sender https://<your host>/webhooks/payments and the secret; production: put PAYMENTS_WEBHOOK_SECRET in .prod.vars, then `ocre secrets push PAYMENTS_WEBHOOK_SECRET --file .prod.vars`
{"command":"generate webhook","created":["src/gpu_webhook.rs","tests/gpu_webhook.rs"],"next":["write the effect in `handle` (src/gpu_webhook.rs), then `ocre test --e2e`","give the sender https://<your host>/webhooks/gpu and the secret; production: put GPU_WEBHOOK_SECRET in .prod.vars, then `ocre secrets push GPU_WEBHOOK_SECRET --file .prod.vars`"],"ok":true,"updated":[".dev.vars","src/lib.rs"]}

That second run (ocre g webhook gpu --standard) reuses the table, so it has no migration. Errors: invalid webhook name `<name>` (snake_case, not a Rust keyword); src/<name>_webhook.rs already exists.

ocre g external_job

ocre g external_job <name> [FIELDS]... [--sweep <WHEN>] [--realtime]

Work done by an external service (a GPU on RunPod or Modal, a container, any HTTP API), tracked in the table <name>_jobs (a _job or _jobs suffix in the name is dropped). FIELDS are the job’s input (name:type, sent as JSON; no attachments, enums or rich text, and not the names of the table’s own columns). It creates:

  • src/<name>_jobs.rs: the <Name>Job row (status: queued, submitted, running, done or failed; external_id, progress, result, error, attempts), start(&ctx, <fields>) (insert and submit with a signed POST to <NAME>_URL), submit, find, sweep, the route POST /webhooks/<name>/{id} for the service’s events (signed with <NAME>_SECRET, or carrying the job’s token), and the functions to adapt: request_body, handle_event, changed;
  • the create_<name>_jobs migration;
  • src/schedules/<name>_jobs_sweep.rs, a Cron Trigger (--sweep, default */5 * * * *, plain English accepted) that fails jobs without news for an hour and resubmits failed submissions (3 attempts);
  • <NAME>_URL, <NAME>_SECRET and APP_URL in .dev.vars (those missing).

Each job also has a random public_id (find_by_public_id), for pages and channels. With --realtime, every change of a job broadcasts its progress bar (progress_html, built with ocre::helpers::progress_bar) on the channel <name>_jobs:<public_id> (added to src/realtime.rs, which the first use creates along with the realtime feature and the OcreChannel Durable Object), and GET /<name>_jobs/<public_id>/progress answers the bar subscribed to its updates. See Progress of a long task.

ocre g external_job upscale video_id:integer scale:float
  create  src/upscale_jobs.rs
  create  migrations/0001_create_upscale_jobs.sql
  create  src/schedules/upscale_jobs_sweep.rs
  create  src/schedules/mod.rs
  update  cloudflare.config.ts
  update  src/lib.rs
  update  .dev.vars

Next:
  ocre migrate
  set UPSCALE_URL in .dev.vars to the service's endpoint, and adapt `request_body` and `handle_event` in src/upscale_jobs.rs to its API
  start a job from a handler: upscale_jobs::start(&ctx, ...).await?
  production: UPSCALE_URL and APP_URL in worker.env of cloudflare.config.ts; UPSCALE_SECRET (and UPSCALE_TOKEN for a bearer API key) in .prod.vars, then `ocre secrets push UPSCALE_SECRET --file .prod.vars`

Errors: invalid external job name `<name>` ; external job field `<field>` cannot be of that type; external job field `<field>` is a column of the jobs table; src/<name>_webhook.rs already answers /webhooks/<name>; cron `<cron>` is already scheduled in cloudflare.config.ts (pass another --sweep). See Run work on another service.

ocre g seo

ocre g seo

No arguments. Creates src/seo.rs: PAGES, the list of public pages (path without locale prefix, title, one-sentence description), and the routes /sitemap.xml (ocre::seo::Sitemap; each page in every locale with hreflang alternates when src/lib.rs declares static LOCALES) and /llms.txt (ocre::seo::LlmsTxt). Adds APP_URL=http://localhost:8787 to .dev.vars when missing (the URLs are absolute). See Views: structured data, sitemap and llms.txt.

  create  src/seo.rs
  update  .dev.vars
  update  src/lib.rs

Next:
  list the public pages in PAGES (src/seo.rs), and their records in `sitemap`
  ocre dev, then open http://localhost:8787/sitemap.xml and http://localhost:8787/llms.txt
  production: APP_URL (the public origin) in worker.env of cloudflare.config.ts; add `Sitemap: https://<your host>/sitemap.xml` to public/robots.txt

ocre g job

ocre g job <NAME> [FIELDS]... [--queue <QUEUE>] [--steps <STEP,...>] [--lock <FIELD>]
ArgumentRequiredMeaning
NAMEyesJob name, PascalCase or snake_case, a verb phrase; a Job suffix is dropped (ImportCsvJob gives import_csv and ImportCsv)
FIELDSnoThe job’s arguments as name:type; no attachment, no ^
--queue <QUEUE>noThe queue the job is sent to, lowercase letters, digits and - (default default): its own Cloudflare queue and consumer, for jobs that must not wait behind others
--steps <STEP,...>noRun in steps, one queue message each, in this order (snake_case names): a failed step is retried on its own, from that step
--lock <FIELD>noOne run per value of this field at a time (a lock row in job_locks; the first such job adds its migration). Without --steps, the job has one work step

Creates src/jobs/<name>.rs: a struct holding the arguments (serialized as JSON in the queue message, 128 KB at most) with fn perform_later(self, ctx), which sends it to its queue, and an async fn perform(self, ctx: &Ctx) -> Result<()> to fill in. It adds a variant to the Job enum and an arm to the perform match in src/jobs/mod.rs. The first job creates src/jobs/mod.rs, adds mod jobs; and the Worker’s queue event to src/lib.rs, and, unless a JOBS producer exists, adds to cloudflare.config.ts the JOBS: bindings.queue({ name: "<app>-jobs" }) producer (after // ocre:env) and its triggers.queue(...) consumer (after // ocre:triggers) (batches of up to 10 messages, 5 retries, dead-letter queue <app>-jobs-failed). --queue urgent also adds, unless a JOBS_URGENT binding exists, JOBS_URGENT: bindings.queue({ name: "<app>-jobs-urgent" }) and its consumer (maxBatchTimeout: 1, dead-letter queue <app>-jobs-urgent-failed).

ocre g job SendWelcome user_id:integer
  create  src/jobs/send_welcome.rs
  create  src/jobs/mod.rs
  update  src/lib.rs
  update  cloudflare.config.ts

Next:
  enqueue it from a handler: jobs::SendWelcome { user_id }.perform_later(&ctx).await?
  ocre dev (jobs run locally; look for `[ocre jobs]` lines in the output)
  ocre deploy creates the queue blog-jobs and its dead-letter queue
{"command":"generate job","created":["src/jobs/send_welcome.rs","src/jobs/mod.rs"],"next":["enqueue it from a handler: jobs::SendWelcome { user_id }.perform_later(&ctx).await?","ocre dev (jobs run locally; look for `[ocre jobs]` lines in the output)","ocre deploy creates the queue blog-jobs and its dead-letter queue"],"ok":true,"updated":["src/lib.rs","cloudflare.config.ts"]}

Later jobs only create their file and update src/jobs/mod.rs; a deploy creates the queue step is printed for each queue a run adds to cloudflare.config.ts. Free plan (September 2026): 10,000 Queues operations a day, a job costing 3 (write, read, delete), and 10 ms of CPU per batch (Queues pricing, Workers limits).

Errors:

ErrorHint
invalid job name `Job` (also Rust keywords)use a verb phrase starting with a letter, not a Rust keyword, e.g. `SendWelcome` or `ImportCsv`
job field `f` cannot be an attachmentfiles do not fit in a queue message (128 KB): store the file first and pass its record id, e.g. `post_id:integer`
job field `f` cannot be unique`^` adds a unique index to a table column; jobs have no table: drop the `^`
src/jobs/<name>.rs already existsthe generic hint
invalid queue name `Urgent` use lowercase letters, digits and `-`, e.g. `--queue urgent`
src/lib.rs already handles the `queue` event (first job only)a Worker has one queue entry point: call `ocre::jobs::consume(batch, env, jobs::perform)` from it and create src/jobs/mod.rs by hand

With --steps (or --lock), the struct also has step (the next step) and run (the run’s id, the lock’s owner), new(<fields>) starts a run, perform runs the current step’s method (async fn <step>(&self, ctx) -> Result<()>, one per step) and enqueues the next, and --lock adds the lock: taken at each step for an hour, released after the last; a second run for the same value is enqueued again every 30 s until the lock is free. See Long jobs: continue in steps.

ocre g job ProcessVideo video_id:integer --steps fetch,split,upscale --lock video_id
  create  src/jobs/process_video.rs
  create  migrations/0003_create_job_locks.sql
  update  src/jobs/mod.rs

Next:
  ocre migrate
  enqueue it from a handler: jobs::ProcessVideo::new(video_id).perform_later(&ctx).await?
  ocre dev (jobs run locally; look for `[ocre jobs]` lines in the output)

More errors with --steps and --lock: --lock <field> is not a field of the job; invalid step name `<step>` (not snake_case, a Rust keyword, new, perform or perform_later); step `<step>` is listed twice; step `<step>` has the name of a field.

See Background jobs and schedules.

ocre g schedule

ocre g schedule <NAME> <WHEN>
ArgumentRequiredMeaning
NAMEyesTask name in snake_case (HourlyPing is accepted and becomes hourly_ping); not a reserved word
WHENyesWhen, in UTC, quoted for the shell: plain English ("every 15 minutes", "every day at 3am", "every monday at 9:30", "midnight on tuesdays", "every weekday at 18:00", "monthly") or a cron expression of five fields (minute, hour, day of month, month, day of week): "0 3 * * *"

Creates src/schedules/<name>.rs with pub async fn run(ctx: &Ctx) -> Result<()> to fill in (its comment keeps the English phrase), adds triggers.scheduled({ schedule: "<cron>" }), after the // ocre:triggers marker of cloudflare.config.ts, and adds "<cron>" => <name>::run(&ctx).await, to the dispatch match of src/schedules/mod.rs. The first schedule creates src/schedules/mod.rs and adds mod schedules; and the Worker’s scheduled event to src/lib.rs. English phrases are converted to cron (the full list is in When: English or cron); for a cron expression the CLI checks its shape (five fields of letters, digits and *,-/#), not its values; Cloudflare validates it at deploy (syntax).

ocre g schedule nightly_cleanup "every day at 3am"
  create  src/schedules/nightly_cleanup.rs
  create  src/schedules/mod.rs
  update  cloudflare.config.ts
  update  src/lib.rs

Next:
  ocre dev, then: ocre schedules run nightly_cleanup
  ocre deploy (Cron Triggers only fire on the deployed Worker; this one runs at `0 3 * * *`, UTC)
{"command":"generate schedule","created":["src/schedules/nightly_cleanup.rs","src/schedules/mod.rs"],"next":["ocre dev, then: ocre schedules run nightly_cleanup","ocre deploy (Cron Triggers only fire on the deployed Worker; this one runs at `0 3 * * *`, UTC)"],"ok":true,"updated":["cloudflare.config.ts","src/lib.rs"]}

Free plan (September 2026): 5 Cron Triggers per account and 10 ms of CPU per run (Workers limits). When the app’s [triggers] crons holds more than 5 expressions, the generator adds a next step: this app now has 6 crons; the free plan allows 5 per account: run several tasks from one cron.

Errors:

ErrorHint
invalid schedule name `<name>` use snake_case starting with a letter, not a Rust keyword, e.g. `nightly_cleanup`
invalid schedule `every 15 seconds` The accepted English phrases and the cron form, and that Cron Triggers run at most once a minute
cron `0 3 * * *` is already in [triggers] cronsone task per cron: call the new work from the existing task in src/schedules/, or pick another time (e.g. one minute later)
cron `0 3 * * *` is already scheduled in cloudflare.config.tsone task per cron: call the new work from the existing task in src/schedules/, or pick another time (e.g. one minute later)
cloudflare.config.ts is missing the `// ocre:triggers` markerput `// ocre:triggers` on its own line inside `worker.triggers`: Ocre adds its entries after it
src/lib.rs already handles the `scheduled` event (first schedule only)a Worker has one scheduled entry point: call `ocre::jobs::cron(event, env, schedules::run)` from it and create src/schedules/mod.rs by hand

See Background jobs and schedules.

ocre g cache

ocre g cache

No arguments. Adds CACHE: bindings.kv(), (no id) after the // ocre:env marker of cloudflare.config.ts, for ocre::cache::fetch (a read-through cache of JSON values). ocre dev uses a local namespace; the first ocre deploy creates the real one (titled <app>-cache) and writes its id into the entry (CACHE: bindings.kv({ id: "<id>" }),). The binding is opt-in because Workers KV allows 1,000 writes a day on the free plan (September 2026; 100,000 reads a day, 1 GB stored, KV limits).

ocre g cache
  update  cloudflare.config.ts

Next:
  use it: ocre::cache::fetch(&ctx, "key:v1", Duration::from_secs(3600), || async { ... }).await?
  ocre dev
  ocre deploy (creates the KV namespace)
{"command":"generate cache","next":["use it: ocre::cache::fetch(&ctx, \"key:v1\", Duration::from_secs(3600), || async { ... }).await?","ocre dev","ocre deploy (creates the KV namespace)"],"ok":true,"updated":["cloudflare.config.ts"]}

Error: cloudflare.config.ts already has the `CACHE` binding with the hint nothing to generate: call `ocre::cache::fetch(&ctx, key, ttl, || async { ... })` in a handler. See Caching.

ocre g data

ocre g data <NAME>

Read-only data shipped with the Worker (Loco’s data loaders): writes data/<name>.json (a sample [{ "name": "Example" }]) and src/data/<name>.rs, which compiles the file in with include_str! and parses it once per Worker instance into Vec<Entry> (declare the fields in Entry). The first one also writes src/data/mod.rs and mod data; in src/lib.rs. Read it with crate::data::<name>::all(). The module has a test checking that the file matches Entry. A Worker has no disk: the data changes with a deploy.

ocre g data countries
{"command":"generate data","created":["src/data/mod.rs","data/countries.json","src/data/countries.rs"],"next":["put the entries in data/countries.json and their fields in `Entry` (src/data/countries.rs)","read them with `crate::data::countries::all()`"],"ok":true,"updated":["src/lib.rs"]}

Errors: invalid data name `Bad-Name` (hint: snake_case), data/<name>.json already exists, and a missing // ocre:modules or // ocre:data marker.

ocre g system_test

ocre g system_test <NAME>
ocre g system-test <NAME>

A browser test (Rails’ system tests) in tests/system/<name>.spec.ts, run by Playwright against the test server of ocre test --e2e. The first one also writes playwright.config.ts (tests in tests/system/, baseURL from BASE_URL, one worker, a desktop and a phone screen, a screenshot and trace per failure), adds "@playwright/test": "1.63.0" to the devDependencies of package.json, and test-results/ and playwright-report/ to .gitignore.

ocre g system_test signing_up
{"command":"generate system_test","created":["playwright.config.ts","tests/system/signing_up.spec.ts"],"next":["npm install","npx playwright install chromium (once per machine)","edit tests/system/signing_up.spec.ts, then ocre test --e2e"],"ok":true,"updated":["package.json",".gitignore"]}

Errors: invalid system test name `Bad` , tests/system/<name>.spec.ts already exists, and package.json has no `"devDependencies": {` block. See Testing.

ocre g ci

ocre g ci

No arguments. Writes .github/workflows/ci.yml, the app’s GitHub Actions workflow (Rails’ generated CI config):

  • a check job on every push and pull request: checkout, rustup component add rustfmt clippy (rust-toolchain.toml adds the wasm32 target), a Rust cache, then the steps of ocre ci in the same order (cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test, cargo check --target wasm32-unknown-unknown), and ocre i18n missing when locales/*.yml exist (it installs the CLI first);
  • a deploy job after it, on pushes to main only, one at a time: Node.js 22, npm ci (the pinned cf and wrangler), cargo install --git https://github.com/tgeselle/ocre.rs ocre-cli, then ocre deploy --json with CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID from the repository secrets.

The comment at the top of the file lists the two secrets to add (Settings > Secrets and variables > Actions) and the token’s permissions: Account “Workers Scripts: Edit” and “D1: Edit”, plus “Queues: Edit”, “Workers KV Storage: Edit” and “Workers R2 Storage: Edit” when the app uses them, and “Workers Routes: Edit” on the zone of a custom domain. Commit package-lock.json and the KV ids the first ocre deploy writes into cloudflare.config.ts (see Deployment). Free plan: GitHub Actions minutes are free for public repositories; the deploy uses no paid Cloudflare feature.

  create  .github/workflows/ci.yml

Next:
  add the repository secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (see the comment at the top of the workflow)
  ocre ci (the same checks, locally)
  git add .github package-lock.json && git commit, then push to main

Error: .github/workflows/ci.yml already exists (the shared hint; --force rewrites it).

ocre g push

ocre g push

No arguments; needs ocre g pwa first (its service worker shows the messages). Creates src/push.rs (routes GET /push/key, POST /push/subscriptions, POST /push/subscriptions/delete; notify_all, or notify_user and a user_id column after ocre g auth), public/push.js (subscribes from a click on data-push-subscribe), tests/push.rs and the create_push_subscriptions migration; adds push.js to the layout, Ocre’s push feature to Cargo.toml and a VAPID key pair with VAPID_SUBJECT to .dev.vars. Run again with --skip, it adds nothing twice. Errors: public/service-worker.js not found (hint: run ocre g pwa first); templates/layout.html has no </head>. See Web push notifications.

ocre g pwa

ocre g pwa

No arguments; full-stack apps. Makes the app a Progressive Web App, like the manifest and service worker of new Rails 8 apps. Static files in the assets directory (public/), which Cloudflare serves before the Worker runs (free, not Worker requests):

FileContents
public/manifest.webmanifestName (the Worker’s), start_url, display: standalone, colors and the icon, so browsers offer to install the app
public/service-worker.jsCaches the home page at install and answers with it when a page cannot load offline; push and notificationclick handlers for web push, to fill in
public/pwa.jsRegisters the service worker (a file, so the default script-src 'self' Content-Security-Policy allows it)
public/icon.svgA placeholder icon with the app’s initial

It also inserts the <link rel="manifest">, theme-color, icon and pwa.js tags before </head> in templates/layout.html; ocre destroy pwa takes them out.

Errors: a PWA needs HTML pages; this app is API-only, templates/layout.html not found, templates/layout.html has no </head>, and templates/layout.html already links a web app manifest (hint: nothing to generate: edit manifest.webmanifest in the assets directory).

ocre g locale

ocre g locale <CODES>...
ArgumentRequiredMeaning
CODESyes, at least oneLocale codes: a 2- or 3-letter lowercase language, optionally followed by - and region or script parts of 2 to 8 letters or digits (en, fr, pt-BR, zh-Hant)

Creates locales/<code>.yml for each code and declares them in ocre::locales!(...) in src/lib.rs. The first run sets up translations: its first code becomes the default locale (its file gets a sample app.welcome key and comments on the syntax, the others are empty and point to it), src/lib.rs gets static LOCALES: ocre::i18n::Locales = ocre::locales!("en", "fr");, and routes() gets .layer(ocre::i18n::layer(&LOCALES)) as the last call of its Router chain, which the I18n extractor needs. Later runs add codes to the declaration.

ocre g locale en fr
  create  locales/en.yml
  create  locales/fr.yml
  update  src/lib.rs

Next:
  add keys to the locale files; take `i18n: ocre::i18n::I18n` in a handler and call i18n.t("key")
  ocre i18n missing
{"command":"generate locale","created":["locales/en.yml","locales/fr.yml"],"next":["add keys to the locale files; take `i18n: ocre::i18n::I18n` in a handler and call i18n.t(\"key\")","ocre i18n missing"],"ok":true,"updated":["src/lib.rs"]}

Then ocre g locale de adds a third locale. ocre i18n missing lists the keys each locale still lacks.

Errors:

ErrorHint
invalid locale code `EN` use a language code, optionally with a region or script: en, fr, pt-BR, zh-Hant
locale `fr` is already declared in src/lib.rsedit locales/fr.yml; `ocre i18n missing` lists keys to translate
ocre::locales!() in src/lib.rs lists no localeput the default locale in it: ocre::locales!("en")
src/lib.rs is missing the `// ocre:routes` marker (first run)put `// ocre:routes` on its own line at the end of the `Router::new()` chain in routes()

See Translations.

ocre g override

ocre g override [PATHS]...

Copies built-in generator templates into .ocre/templates/, where they replace the built-in ones for every later run until you delete them (Loco’s generate override, Rails’ lib/templates). A path names one template (controller/view.html) or every template of a generator (controller). Without paths, it lists the templates; overridden ones are marked (overridden in .ocre/templates/).

ocre g override
  controller/api.rs
  controller/html.rs
  controller/view.html
  resource/api.rs
  resource/html.rs
  resource/index.html
  resource/show.html
  scaffold/_form.html
  scaffold/_row.html
  scaffold/edit.html
  scaffold/index.html
  scaffold/new.html
  scaffold/show.html

Next:
  ocre g override <path> (e.g. controller/html.rs, or controller for all its files)

With --json, the list is "templates": [{"path": "controller/api.rs", "overridden": false}, ...].

ocre g override controller
  create  .ocre/templates/controller/api.rs
  create  .ocre/templates/controller/html.rs
  create  .ocre/templates/controller/view.html

Next:
  edit the files in .ocre/templates/ (<%= value %>, <% for x in xs %>...<% endfor %>); generators use them until deleted

Templates are minijinja with ERB-style delimiters, so the askama and Rust braces of the generated code stay literal: <%= value %> prints a value, <% for x in xs %>...<% endfor %> and <% if api %>...<% endif %> are blocks, <%# ... %> is a comment, and a newline right after a block tag is dropped. An unknown variable is an error. The variables each template sees:

TemplatesVariables
controller/html.rs, controller/api.rscommand, module (pages), file (pages or pages_api), human (Pages), auth, actions (each with name, pascal, human, path)
controller/view.htmlmodule, action (name, pascal, human, path)
resource/*command, model, singular, plural, human_singular, human_plural, fields (each with label and display, the askama expression showing the value)
scaffold/*.htmlmodel, singular, plural, human_singular, human_plural, lower, realtime, multipart, fields (each with name, label, attachment, optional, display, show, input)

Errors: no generator template `nope` with the list of templates as hint; .ocre/templates/<path> already exists (pass --force to copy the built-in template again); when an override does not render, .ocre/templates/<path> failed to render: ... with the hint fix .ocre/templates/<path>, or delete it to use the built-in template again.

ocre g generator

ocre g generator <NAME>

Creates an app generator to edit, in .ocre/generators/<name>/ (Rails’ generate generator): a generator.toml describing it and one example template.

ocre g generator service
  create  .ocre/generators/service/generator.toml
  create  .ocre/generators/service/src/services/<%= singular %>.rs

Next:
  edit the templates in .ocre/generators/service/
  ocre g service Example name:string --pretend

App generators (ocre g <name>)

ocre g <name> <Name> [ARGS]... [--key=value]... [--flag]...

Any generator name that is not built in runs .ocre/generators/<name>/. Every file of that directory except generator.toml is a template (same syntax as overrides), and so is its path: src/services/<%= singular %>.rs becomes src/services/billing.rs for ocre g service Billing. Templates see:

VariableValue for ocre g service BlogPost amount:integer total:decimal? --api --queue=urgent
model, singular, pluralBlogPost, blog_post, blog_posts
human_singular, human_pluralBlog post, Blog posts
argsthe arguments after the name: ["amount:integer", "total:decimal?"]
fieldswhen every argument is name:type (see Fields): each with name, label, rust_type (i64, Option<String>), optional, unique
options--key=value and --flag arguments: {"api": "true", "queue": "urgent"} (dashes in keys become _)

generator.toml has an optional description and [[insert]] tables adding a line after a marker line of an existing file (the three values are templates too):

description = "Service object in src/services/"

[[insert]]
file = "src/services/mod.rs"
after = "// ocre:services"
line = "pub mod <%= singular %>;"
ocre g service Billing amount:integer total:decimal? --pretend
  create  src/services/billing.rs
(--pretend: nothing was written)

The --pretend, --force, --skip and --json flags work as for built-in generators; the JSON command is generate custom, and the run is recorded, so ocre destroy service Billing undoes it.

Errors:

ErrorHint
unknown generator `nope` run `ocre g --help` for the built-in generators; app generators live in .ocre/generators/<name>/ (create one with `ocre g generator nope`)
`ocre g service` needs a namerun `ocre g service <Name> [args...]`, e.g. `ocre g service Invoice`
.ocre/generators/service/generator.toml is invalid: ...keys: `description`, and [[insert]] tables with `file`, `after` and `line`
<file> does not exist (an [[insert]] target)create it with the `<marker>` marker line, or change the [[insert]] of .ocre/generators/<name>/generator.toml
<file> is missing the `<marker>` markerput `<marker>` on its own line where the generated lines go
.ocre/generators/<name>/<file> failed to render: ...fix .ocre/generators/<name>/<file>

See also