Ocre
Ocre is a Rails-like Rust web framework for Cloudflare Workers, designed to run on the Workers free plan and to be written by AI agents: generators write plain, readable Rust (models, controllers, templates, migrations) into your app, and the ocre CLI builds, runs and deploys it.
An Ocre app is one Worker compiled to WebAssembly. It stores data in D1 (SQLite), renders HTML with askama templates and htmx, or serves JSON (and optionally GraphQL). Everything it uses is on Cloudflare’s free plan: Workers, D1, Queues (background jobs), Cron Triggers, R2 (files), Durable Objects (WebSockets), KV (cache) and Email Routing.
cargo install --git https://github.com/tgeselle/ocre.rs ocre-cli
ocre new qa --starter qa --yes
cd qa
ocre dev # http://localhost:8787: a live Q&A app
ocre deploy # https://qa.<your-subdomain>.workers.dev
Status: early. The APIs described here are the ones in the repository’s main branch; there is no stable release yet.
How these docs are organized
| Part | Read it when | Pages |
|---|---|---|
| Getting started | You are new: install the tools, then build and deploy a live Q&A app step by step | Installation, Tutorial |
| Guides | You need to do one thing (add a model, send email, run a job…) | One page per domain, from Models to Deployment |
| Reference | You need exact facts: every command and flag, field types, configuration keys, limits | CLI, Generators, Field types, Configuration, Limits, API index |
| Explanations | You want to know why Ocre works the way it does | Architecture, Generated code, Security model, Cost model |
The Rust API of the ocre crate is documented item by item in the rustdoc reference, and on one page in the API index.
For AI agents
- Every page is also plain Markdown: add
.mdto its URL (/guides/modelsis/guides/models.md). /llms.txtlists every page with a one-line description;/llms-full.txtis all pages in one file.- Pages are self-contained: prerequisites, complete code, the commands to run and their expected output. Rust examples marked as checked compile against a real generated app.
- Each generated app has an
AGENTS.mdwith the conventions, commands and free-plan limits of that app; read it first, then these docs for details. - With
--json, everyocrecommand prints exactly one JSON object on stdout and never prompts (see CLI commands).
Installation
This page installs the tools an Ocre app needs (Rust through rustup with the WebAssembly target, Node.js 22 for Cloudflare’s cf CLI, and the ocre CLI), checks the installation, and creates a first app with ocre new, either through the guided setup or with flags.
Before you start
You need:
- macOS, Linux or Windows with a terminal, and
gitif you wantocre new --gitor to install from a clone. - About 2 GB of disk space for the Rust toolchain and the Cargo build cache.
- A Cloudflare account only when you deploy. Everything on this page, and
ocre dev, works without one.
Install Rust with rustup
Ocre apps compile to WebAssembly (wasm32-unknown-unknown), so the Rust toolchain must be able to add that target. Install Rust with rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
Every generated app also has a rust-toolchain.toml that asks rustup for the stable toolchain with this target:
[toolchain]
channel = "stable"
targets = ["wasm32-unknown-unknown"]
Do not use Homebrew’s rust formula (brew install rust). It installs rustc and cargo without rustup: they ship only the standard library of your own machine, cannot add the wasm32-unknown-unknown target, and ignore rust-toolchain.toml. ocre dev and ocre deploy detect it and stop (see Troubleshooting). On macOS, brew install rustup is fine: it is rustup, and its rustup and cargo commands work as above once $(brew --prefix rustup)/bin is on your PATH.
Install Node.js
The CLI runs Cloudflare’s cf CLI for local development, deploys and every Cloudflare API call. Install Node.js 22 or newer (cf’s minimum), which provides npm and npx. You do not install cf yourself: each app pins it in its own package.json, with wrangler (which cf delegates the build to, and which Ocre uses for local database commands) and typescript, and ocre new runs npm install in the new app. Outside an app (ocre login before your first app), the CLI runs npx --yes cf@1.0.0-beta.5.
cf sends anonymous usage telemetry by default; it prints a notice about it on stderr. To opt out, run npx cf cli telemetry disable once, or set CF_SEND_TELEMETRY=false in your environment. Ocre does not change that choice for you.
If you used an earlier Ocre, whose apps ran wrangler with a wrangler.toml: cf keeps its own login, separate from wrangler’s, so run ocre login once again, and convert each app with Upgrading from wrangler.toml.
Install the ocre CLI
Install the ocre binary from the Git repository:
cargo install --git https://github.com/tgeselle/ocre.rs ocre-cli
The package is ocre-cli; the command it installs is ocre (in ~/.cargo/bin, which rustup puts on your PATH). From a clone of the repository, install the same binary with:
git clone https://github.com/tgeselle/ocre.rs
cd ocre.rs
cargo install --path crates/ocre-cli
Run the same command again to upgrade. Ocre has no release on crates.io yet; apps depend on the ocre crate from the same Git repository.
Check the installation
ocre --version
ocre 0.2.0
ocre --help
Ocre: Rails-like Rust web framework for Cloudflare Workers, free plan first.
Usage: ocre [OPTIONS] <COMMAND>
Commands:
new Create a new app in ./<NAME>. In a terminal, asks for anything not given by flags
login Log in to Cloudflare (opens a browser) unless already logged in
generate Generate code (alias: `g`)
migrate Apply D1 migrations (local database unless --remote)
db Database tasks: seed, reset
sql Run SQL on the D1 database (local unless --remote) and print the rows
dev Apply local migrations, then run the app with `cf dev`
deploy Deploy to Cloudflare and apply remote migrations
secret Print a new random secret, like `rails secret`: a value for SECRET_KEY_BASE
routes List the app's HTTP routes, read from its source (no build)
i18n Translations: checks of the locale files (see `ocre g locale`)
help Print this message or the help of the given subcommand(s)
Options:
--json Print one JSON object on stdout; send tool logs to stderr. Never prompts
-h, --help Print help
-V, --version Print version
ocre <command> --help describes each command and its flags; the CLI reference documents them all.
Create an app with the guided setup
In a terminal, ocre new asks for everything its flags did not answer:
ocre new
The questions, in order (each one is skipped when the matching flag was given):
| Question | Answers | Flag that skips it |
|---|---|---|
| What is your app called? | A name, checked as you type: lowercase letters, digits and dashes, starting with a letter, at most 63 characters, and no existing directory of that name | ocre new <name> |
| What are you building? | Full-stack app (HTML pages with askama and htmx) or API only (JSON endpoints, no HTML) | --full-stack, --api |
| Pick a starter | Empty (a home page, or a status endpoint in API mode), Live Q&A (hosts create events, the audience asks and votes, live on every screen; full-stack apps only) or Blog (posts with title, body and published, full CRUD) | --starter empty, --starter qa, --starter blog |
| Connect your Cloudflare account? | Log in now (cf auth login, opens your browser) or Later (run ocre login when you are ready). Asked only when cf auth whoami finds no login; otherwise the setup prints Logged in to Cloudflare as <email> | --login, --no-login |
| Which Cloudflare account should host it? | One of the accounts of your login, when it has several | --account-id <id> |
| Initialize a git repository? | Yes (default) or no | --git, --no-git |
| Deploy it now? The first build takes about a minute. | Yes (default) or no. Asked only when you are logged in and the npm install is on | --deploy, --no-deploy |
The setup then creates the app, runs npm install in it, deploys it if you said yes, and ends with a “Next steps” note (cd <name>, ocre dev, ocre deploy, plus ocre login when you skipped the login) and either Your app is live at <url> or Happy building!. Esc or Ctrl-C cancels with error: cancelled.
Create an app with flags (agents and scripts)
The guided setup only runs when stdin and stdout are a terminal and neither --yes nor --json was given. Otherwise ocre new never prompts: flags decide, and anything not given takes an opt-in default (full-stack, empty starter, no Cloudflare login, no git, no deploy; the npm install still runs).
| Flag | Effect | Default without prompts |
|---|---|---|
<name> | App name and directory (./<name>) | required |
--api / --full-stack | API only (JSON, no templates, Ocre’s html feature off), or HTML pages | full-stack |
--starter empty|blog | blog adds a Post resource (title, body, published) | empty |
--login / --no-login | Log in to Cloudflare if needed (opens a browser) | no login |
--account-id <id> | Account to deploy to; required when the login has several | none |
--git / --no-git | Run git init | no git |
--deploy / --no-deploy | Deploy right away (implies --login) | no deploy |
--yes, -y | Never prompt, even in a terminal | |
--ocre-path <dir> | Depend on a local crates/ocre checkout instead of the Git repository | Git dependency |
--no-install | Skip npm install in the new app (offline); run it yourself before ocre dev. ocre doctor reports it until you do | install |
--json | Print one JSON object on stdout; tool output goes to stderr |
ocre new blog --yes
create blog/Cargo.toml
create blog/cloudflare.config.ts
create blog/wrangler.config.ts
create blog/package.json
create blog/tsconfig.json
create blog/rust-toolchain.toml
create blog/.gitignore
create blog/AGENTS.md
create blog/migrations/.gitkeep
create blog/public/robots.txt
create blog/src/lib.rs
create blog/templates/layout.html
create blog/templates/home.html
create blog/.dev.vars
npm install (cf 1.0.0-beta.5, wrangler 4.144.0)
Next:
cd blog
ocre dev
ocre deploy
With --json, the same result is one JSON object on stdout, and the exit code is 0 on success, 1 on failure:
ocre new other --json --yes
{"command":"new","created":["other/Cargo.toml","other/cloudflare.config.ts","other/wrangler.config.ts","other/package.json","other/tsconfig.json","other/rust-toolchain.toml","other/.gitignore","other/AGENTS.md","other/migrations/.gitkeep","other/public/robots.txt","other/src/lib.rs","other/templates/layout.html","other/templates/home.html","other/.dev.vars"],"next":["cd other","ocre dev","ocre deploy"],"ok":true,"ran":["npm install (cf 1.0.0-beta.5, wrangler 4.144.0)"]}
--starter blog also runs the scaffold generator for Post title:string body:text published:boolean and lists its files (src/models/post.rs, migrations/0001_create_posts.sql, src/posts.rs, templates/posts/*.html…). --starter qa builds the app of the tutorial: it runs ocre g auth, ocre g scaffold Event name:string public_id:token user:references and ocre g scaffold Question event:references body:text votes:integer answered:boolean --realtime, then writes the room over the generated files; --api --starter qa stops with the qa starter has HTML pages; it cannot be API-only. --api writes an API-only src/lib.rs, no templates/, and adds [package.metadata.ocre] mode = "api" to Cargo.toml.
What ocre new creates
| File | Contents |
|---|---|
Cargo.toml | The app crate (cdylib), depending on ocre, worker, axum, askama (full-stack only) and serde; a standalone [workspace]; release profile tuned for size |
cloudflare.config.ts | The Worker’s Cloudflare configuration: name, logs, the DB D1 database and MAIL_FROM in env, and the // ocre:env, // ocre:triggers, // ocre:exports markers where generators add entries (see Configuration) |
wrangler.config.ts | The build command (installs worker-build and compiles to WebAssembly) and public/ as static assets |
package.json, package-lock.json | The pinned cf, wrangler and typescript, installed in node_modules/ by npm install; commit both files |
tsconfig.json | Type checking of the two .ts files, for your editor and npx tsc -p . |
rust-toolchain.toml | Stable Rust with the wasm32-unknown-unknown target |
.gitignore | Build output, node_modules/, .wrangler/ (local database), .cloudflare/, .dev.vars, .prod.vars and .env* |
AGENTS.md | Conventions, commands and free-plan limits for AI agents working on the app |
migrations/ | D1 SQL migrations, applied in order (empty for now) |
public/robots.txt | Static files, served by Cloudflare before the Worker runs |
src/lib.rs | The Worker entry point and router: GET / (home page) and GET /up (health check) |
templates/layout.html, templates/home.html | askama templates: the page layout (with htmx) and the home page |
.dev.vars | Local-only settings for ocre dev: a random SECRET_KEY_BASE and MAIL_ADAPTER=log. Never commit it |
The app has no Cloudflare resources yet: the first ocre deploy creates the D1 database and the SECRET_KEY_BASE secret. The tutorial continues from here.
Troubleshooting
These are the errors the CLI prints for a broken setup, with the hint it gives. With --json they come as {"ok": false, "error": ..., "hint": ...}.
Homebrew’s rust, or any toolchain without the WebAssembly target (ocre dev, ocre deploy):
error: the wasm32-unknown-unknown target is not installed for rustc at /opt/homebrew/Cellar/rust/1.90.0
hint: use a rustup toolchain (Homebrew's `rust` has no wasm target) and run `rustup target add wasm32-unknown-unknown`
Fix: brew uninstall rust, install rustup as above, then rustup target add wasm32-unknown-unknown. which rustc should print a path under ~/.cargo/bin (or Homebrew’s rustup prefix).
With rustup installed but another Rust first in PATH, the hint says so (this rustc is not rustup's but comes first in PATH ...). ocre dev and ocre deploy stop before building, but a plain cargo check --target wasm32-unknown-unknown in that shell fails with error[E0463]: can't find crate for core``: the same cause. Put export PATH="$HOME/.cargo/bin:$PATH" last in your shell profile (Homebrew’s rustup formula: /opt/homebrew/opt/rustup/bin), or uninstall Homebrew’s rust; ocre doctor runs the same check.
No Rust at all:
error: rustc not found
hint: install Rust with rustup: https://rustup.rs
No Node.js (ocre new without --no-install):
error: npm not found
hint: install Node.js 22 or newer (it provides npm), or pass --no-install and run `npm install` in the app later
The app’s npm packages not installed (after ocre new --no-install, or in a fresh clone), for ocre dev and ocre deploy (local database commands such as ocre migrate say the app's wrangler is not installed (node_modules/.bin/wrangler) with the same fix):
error: the app's npm packages are not installed (node_modules/.bin/cf, node_modules/.bin/wrangler)
hint: run `npm install` in /path/to/blog (needs Node.js 22 or newer)
An app command run outside an app:
error: no cloudflare.config.ts found in this directory or its parents
hint: run this command inside an Ocre app, or create one with `ocre new <name>`
ocre new errors: an invalid name (here ocre new Blog --yes), no name without a terminal (ocre new --yes), and a directory that already exists (the error shows its absolute path):
error: invalid app name `Blog`
hint: use lowercase letters, digits and dashes, starting with a letter (max 63), e.g. `my-blog`
error: missing app name
hint: run `ocre new <name>`, or run `ocre new` in a terminal for the guided setup
error: `/path/to/blog` already exists
hint: choose another name or remove the directory
See also
- Tutorial: a live Q&A app: build, run and deploy a first app.
- CLI commands: every command and flag, including
ocre new. - Deployment: what
ocre deploycreates on Cloudflare. - Configuration:
cloudflare.config.ts,.dev.vars, variables and secrets. - Upgrading from wrangler.toml: converting an app made by an earlier Ocre.
Tutorial: a live Q&A app
This tutorial builds a live Q&A app with Ocre, step by step: hosts sign up and create events, anyone with an event’s link asks questions and votes for them, and the list reorders live on every open screen over WebSockets. Along the way it covers generators, accounts, ownership checks, realtime channels, validations, request tests and a deploy to the Cloudflare Workers free plan.
Before you start
- The tools from Installation: Rust through rustup with the
wasm32-unknown-unknowntarget, Node.js 22 or newer, and theocreCLI (ocre --versionprintsocre 0.2.0). - About 30 minutes. Nothing here needs a Cloudflare account until Deploy; the whole app runs locally.
curl, to follow along from a terminal, and a browser for the live part: every page is a normal HTML page athttp://localhost:8787.
The commands and outputs on this page come from a real run, except Deploy, which is described from the CLI’s code. Outputs that change from run to run (dates, random ids, cookie values) will differ on your machine. The finished app is also a starter: ocre new qa --starter qa creates it in one command.
Create the app
ocre new qa --yes
cd qa
create qa/Cargo.toml
create qa/cloudflare.config.ts
create qa/wrangler.config.ts
create qa/package.json
create qa/tsconfig.json
create qa/rust-toolchain.toml
create qa/rustfmt.toml
create qa/.gitignore
create qa/AGENTS.md
create qa/migrations/.gitkeep
create qa/public/robots.txt
create qa/tests/app.rs
create qa/src/lib.rs
create qa/templates/layout.html
create qa/templates/home.html
create qa/templates/error.html
create qa/.dev.vars
npm install (cf 1.0.0-beta.5, wrangler 4.144.0)
Next:
cd qa
ocre dev
ocre deploy
--yes skips the guided setup and keeps its defaults: a full-stack app (HTML pages), the empty starter, no Git repository, no Cloudflare login. ocre new then runs npm install in the app, which installs Cloudflare’s cf CLI (with the wrangler it delegates builds to, and typescript) at the versions pinned in package.json; pass --no-install to skip it when offline. Without --yes, in a terminal, ocre new qa asks those questions instead (see Installation).
The app is a Rust crate compiled to WebAssembly and run as one Cloudflare Worker. src/lib.rs is the entry point and the router; generators add modules under its // ocre:modules line and routes under // ocre:routes. templates/layout.html loads htmx and sets hx-boost="true" on <body>, so links and forms swap the page body instead of reloading it, and every page still works without JavaScript (see htmx). AGENTS.md summarizes the app’s conventions and commands for AI agents.
Hosts: accounts
Hosts sign up to create events; the audience never needs an account. ocre g auth writes sign-up, login, logout, password reset and magic links into the 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
The code is the app’s own: src/auth.rs holds the extractors handlers take, CurrentUser (signed in, otherwise redirected to /login) and OptionalUser (signed in or not). See Authentication for every file.
Events
An event belongs to a host and has a public link. Generate it with a user reference and a random public id:
ocre g scaffold Event name:string public_id:token user:references
create migrations/0004_create_events.sql
create src/models/event.rs
create tests/factories/mod.rs
create tests/factories/event.rs
create src/events.rs
create templates/events/index.html
create templates/events/show.html
create templates/events/new.html
create templates/events/edit.html
create templates/events/_form.html
create tests/events.rs
update src/models/user.rs
update src/models/mod.rs
update src/lib.rs
Next:
ocre migrate
ocre dev
open http://localhost:8787/events
public_id:tokengives each event 22 random URL-safe characters, set bycreate, and the generated pages use it in URLs instead of the integer id:/events/c1wWvvdHUcKW080CDwgf6A, which nobody can guess or enumerate. That link is what a host shares.user:referencesaddsuser_idwith a foreign key tousers,event.user(&ctx)on the event anduser.events(&ctx, page)on the user.
Questions, live
Questions belong to an event. --realtime makes the generated pages live: every create, edit and delete is pushed to every open list over a WebSocket.
ocre g scaffold Question event:references body:text votes:integer answered:boolean --realtime
create migrations/0005_create_questions.sql
create src/models/question.rs
create tests/factories/question.rs
create src/questions.rs
create templates/questions/index.html
create templates/questions/show.html
create templates/questions/new.html
create templates/questions/edit.html
create templates/questions/_form.html
create templates/questions/_row.html
create tests/questions.rs
create src/realtime.rs
update src/models/event.rs
update src/models/mod.rs
update tests/factories/mod.rs
update src/lib.rs
update Cargo.toml
update cloudflare.config.ts
update templates/layout.html
Next:
ocre migrate
ocre dev
open http://localhost:8787/questions
open http://localhost:8787/questions in a second window, then create a question
The first --realtime scaffold sets up the pieces every live page needs:
| File | What changed |
|---|---|
Cargo.toml | Ocre’s realtime feature |
cloudflare.config.ts | The CHANNELS Durable Object binding and the OcreChannel export: one Durable Object per channel holds the browsers’ WebSockets, and hibernates between messages so idle connections cost nothing |
src/realtime.rs | GET /realtime/{channel}, where browsers connect, and connect, which decides who may listen to which channel |
templates/layout.html | htmx’s WebSocket extension, after htmx |
src/questions.rs | After each create, update and delete, the handler broadcasts the changed row on the questions channel |
Apply the five migrations and start the app:
ocre migrate
ocre dev
ocre dev builds the Worker and serves it at http://localhost:8787 with a local D1 database and the Durable Objects running in workerd, Cloudflare’s runtime.
Try the generated pages: open http://localhost:8787/signup and create an account, then http://localhost:8787/events/new and create an event, then open http://localhost:8787/questions in two windows and create a question (event 1) in one of them: it appears in the other without a reload. From a terminal, with the same steps done by curl:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -c jar.txt http://localhost:8787/signup -d 'email=ada@example.com&password=correct horse'
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8787/events -d 'name=Friday all-hands&user_id=1'
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8787/questions -d 'event_id=1&body=Will there be pizza?&votes=0'
curl -s http://localhost:8787/ocre/dev/realtime/sent.json
303 http://localhost:8787/
303 http://localhost:8787/events/1SPmf4grPih32uDXg8GP-g
303 http://localhost:8787/questions/1
[{"id":1,"channel":"questions","message":"<tbody hx-swap-oob=\"afterbegin:#questions\"><tr id=\"question_1\"><td>1</td><td>Will there be pizza?</td><td>0</td><td>false</td><td><a href=\"/questions/1\">Show</a> <a href=\"/questions/1/edit\">Edit</a></td></tr></tbody>"}]
/ocre/dev/realtime/sent.json lists the recent broadcasts (in ocre dev only; deployed apps answer 404). The message is HTML: htmx’s WebSocket extension reads its hx-swap-oob attribute and inserts the row at the top of the element with id questions in every open page.
Two things are wrong with these generated pages for a Q&A app, and the next sections fix them. The second request created an event for user 1 without being signed in, because the form takes user_id like any other field. And every question of every event lands on one page, /questions, with edit buttons for everyone.
Events belong to their host
The host of an event is the signed-in user, never a form field. In src/events.rs, the form keeps only the name, and to_new takes the host’s id:
/// What the new and edit forms submit. The host is the signed-in user, never
/// a form field.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EventForm {
pub name: String,
}
impl EventForm {
fn from_record(record: &Event) -> Self {
Self { name: record.name.clone() }
}
/// Runs the model's checks, so the form shows every error at once
/// (database checks run in `create`).
fn to_new(&self, user_id: i64) -> Result<NewEvent> {
let new = NewEvent { name: self.name.clone(), user_id };
new.validate().finish()?;
Ok(new)
}
fn to_changes(&self) -> Result<EventChanges> {
let changes = EventChanges { name: Some(self.name.clone()), user_id: None };
changes.validate().finish()?;
Ok(changes)
}
}
Remove the User field from templates/events/_form.html, so it only has the name:
{% if !errors.is_empty() %}
<ul class="errors">
{% for error in errors %}<li>{{ error.full_message() }}</li>{% endfor %}
</ul>
{% endif %}
<label>Name <input name="name" value="{{ form.name }}" required placeholder="Friday all-hands"></label>
The handlers that create or change an event take CurrentUser, and a small helper refuses other users with a 403:
/// The event `id`, when `user` hosts it: other users get a 403.
async fn hosted(ctx: &Ctx, user: &User, id: i64) -> Result<Event> {
let record = event::find(ctx, id).await?.or_404()?;
if record.user_id != user.id {
return Err(Error::Forbidden);
}
Ok(record)
}
/// The events the signed-in user hosts, newest first.
async fn index(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser, flash: Flash) -> Result<Html<String>> {
render(&IndexView { flash, events: event::for_users(&ctx, &[user.id]).await? })
}
async fn new(CurrentUser(_): CurrentUser) -> Result<Html<String>> {
render(&NewView { form: EventForm::default(), errors: vec![] })
}
async fn create(
State(ctx): State<Ctx>,
CurrentUser(user): CurrentUser,
session: Session,
Form(form): Form<EventForm>,
) -> Result<Response> {
let created = match form.to_new(user.id) {
Ok(new) => event::create(&ctx, new).await,
Err(err) => Err(err),
};
match created {
Ok(record) => {
session.flash("notice", "Your event is ready: share its link.")?;
Ok(Redirect::to(&paths::show(&record.public_id)).into_response())
}
Err(Error::Invalid(errors)) => {
Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&NewView { form, errors })?).into_response())
}
Err(err) => Err(err),
}
}
edit, update and delete take CurrentUser(user): CurrentUser too and start with hosted(&ctx, &user, id).await?;. The use lines gain crate::auth::{CurrentUser, OptionalUser} and crate::models::user::User. The complete file is src/events.rs of the qa starter.
IndexView loses its page field: event::for_users, generated by user:references, returns the host’s events without pagination. templates/events/index.html becomes the host’s list:
{% extends "layout.html" %}
{% block title %}Your events{% endblock %}
{% block content %}
<h1>Your events</h1>
{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
{% if let Some(alert) = flash.alert() %}<p class="alert">{{ alert }}</p>{% endif %}
<p><a class="button" href="{{ paths::new() }}">New event</a></p>
{% if events.is_empty() %}
<p class="empty">No events yet: create one, then share its link with your audience.</p>
{% else %}
<ul class="events">
{% for event in events %}
<li><a href="{{ paths::show(event.public_id) }}">{{ event.name }}</a> <span class="muted">created {{ event.created_at }}</span></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
The room
An event’s page becomes its room: the event’s name, the link to share, a form to ask, and the live list of its questions, most voted first. Questions get their own routes under the event, and the generated question pages go away:
rm templates/questions/index.html templates/questions/show.html templates/questions/new.html \
templates/questions/edit.html templates/questions/_form.html templates/questions/_row.html
Two queries go in the model, src/models/question.rs, after for_events:
/// The questions of an event as the room shows them: open ones first, most
/// voted first (oldest first on a tie), then answered ones. At most 200.
pub async fn for_event(ctx: &Ctx, event_id: i64) -> Result<Vec<Question>> {
query()
.eq("event_id", event_id)
.order_asc("answered")
.order_desc("votes")
.order_asc("id")
.limit(200)
.all(&ctx.db()?)
.await
}
/// Adds one vote. A single UPDATE, so votes arriving at the same moment are
/// all counted. `None` when there is no question with this id.
pub async fn vote(ctx: &Ctx, id: i64) -> Result<Option<Question>> {
let sql = "UPDATE questions SET votes = votes + 1, updated_at = datetime('now') WHERE id = ?1 RETURNING *";
ctx.db()?.first(sql, params![id]).await
}
src/questions.rs is rewritten for the room. Replace it with:
//! Questions in an event's room: the audience asks and votes, the host marks
//! questions answered or deletes them. After each change the event's list is
//! rendered again and broadcast (channels in src/realtime.rs), so it reorders
//! live in every open room.
use askama::Template;
use axum::{
Form, Router,
extract::{Path, State},
http::{StatusCode, Uri},
response::{IntoResponse, Redirect, Response},
routing::post,
};
use ocre::{Ctx, Error, Flash, Htmx, OptionExt, Result, Session, realtime, render};
use serde::Deserialize;
use crate::auth::{CurrentUser, OptionalUser};
use crate::events;
use crate::models::event::{self, Event};
use crate::models::question::{self, NewQuestion, Question, QuestionChanges};
use crate::models::user::User;
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/events/{id}/questions", post(ask))
.route("/questions/{id}/vote", post(vote))
.route("/questions/{id}/answer", post(answer))
.route("/questions/{id}/delete", post(delete))
}
pub mod paths {
use std::fmt::Display;
/// `id` is the event's public id.
pub fn ask(id: impl Display) -> String {
format!("/events/{id}/questions")
}
pub fn vote(id: impl Display) -> String {
format!("/questions/{id}/vote")
}
pub fn answer(id: impl Display) -> String {
format!("/questions/{id}/answer")
}
pub fn delete(id: impl Display) -> String {
format!("/questions/{id}/delete")
}
}
/// What the ask form submits.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct QuestionForm {
pub body: String,
}
/// The question list of a room, also sent to open rooms on every change.
/// `host` adds the moderation buttons.
#[derive(Template)]
#[template(path = "questions/_list.html")]
struct ListView<'a> {
questions: &'a [Question],
host: bool,
}
/// The channel every visitor of the room listens to.
pub fn channel(event: &Event) -> String {
format!("event:{}", event.public_id)
}
/// The host's channel: the same list, with moderation buttons.
pub fn host_channel(event: &Event) -> String {
format!("host:{}", event.public_id)
}
/// Sends the event's current list to both channels. Best effort: a failed
/// broadcast is logged by Ocre and never fails the request.
async fn broadcast(ctx: &Ctx, event: &Event) -> Result<()> {
let questions = question::for_event(ctx, event.id).await?;
let audience = render(&ListView { questions: &questions, host: false })?.0;
let host = render(&ListView { questions: &questions, host: true })?.0;
realtime::broadcast(ctx, &channel(event), &realtime::update("questions", &audience)).await.ok();
realtime::broadcast(ctx, &host_channel(event), &realtime::update("questions", &host)).await.ok();
Ok(())
}
async fn ask(
State(ctx): State<Ctx>,
session: Session,
flash: Flash,
OptionalUser(user): OptionalUser,
uri: Uri,
Path(key): Path<String>,
Form(form): Form<QuestionForm>,
) -> Result<Response> {
let event = event::find_by_public_id(&ctx, &key).await?.or_404()?;
let new = NewQuestion { event_id: event.id, body: form.body.trim().to_owned(), votes: 0, answered: false };
match question::create(&ctx, new).await {
Ok(_) => {
broadcast(&ctx, &event).await?;
session.flash("notice", "Your question is in.")?;
Ok(Redirect::to(&events::paths::show(&key)).into_response())
}
Err(Error::Invalid(errors)) => {
let room = events::room(&ctx, flash, &uri, event, user, form, errors).await?;
Ok((StatusCode::UNPROCESSABLE_ENTITY, room).into_response())
}
Err(err) => Err(err),
}
}
/// Session key: the questions this browser voted for.
const VOTED: &str = "voted";
/// One vote per question and browser; voting again changes nothing.
async fn vote(State(ctx): State<Ctx>, session: Session, Htmx(is_htmx): Htmx, Path(id): Path<i64>) -> Result<Response> {
let record = question::find(&ctx, id).await?.or_404()?;
let event = record.event(&ctx).await?.or_404()?;
let mut voted: Vec<i64> = session.get(VOTED)?.unwrap_or_default();
if !voted.contains(&id) {
question::vote(&ctx, id).await?;
// The session is a cookie (4 KB at most): remember the last 200 votes.
if voted.len() >= 200 {
voted.remove(0);
}
voted.push(id);
session.insert(VOTED, &voted)?;
broadcast(&ctx, &event).await?;
}
Ok(done(is_htmx, &event))
}
/// Marks a question answered, or open again.
async fn answer(
State(ctx): State<Ctx>,
CurrentUser(user): CurrentUser,
Htmx(is_htmx): Htmx,
Path(id): Path<i64>,
) -> Result<Response> {
let (record, event) = hosted(&ctx, &user, id).await?;
let changes = QuestionChanges { answered: Some(!record.answered), ..Default::default() };
question::update(&ctx, id, changes).await?;
broadcast(&ctx, &event).await?;
Ok(done(is_htmx, &event))
}
async fn delete(
State(ctx): State<Ctx>,
CurrentUser(user): CurrentUser,
Htmx(is_htmx): Htmx,
Path(id): Path<i64>,
) -> Result<Response> {
let (_, event) = hosted(&ctx, &user, id).await?;
question::delete(&ctx, id).await?;
broadcast(&ctx, &event).await?;
Ok(done(is_htmx, &event))
}
/// The question `id` and its event, when `user` hosts that event.
async fn hosted(ctx: &Ctx, user: &User, id: i64) -> Result<(Question, Event)> {
let record = question::find(ctx, id).await?.or_404()?;
let event = record.event(ctx).await?.or_404()?;
if event.user_id != user.id {
return Err(Error::Forbidden);
}
Ok((record, event))
}
/// htmx buttons get a 204 (the broadcast updates the page); a plain form
/// post goes back to the room.
fn done(is_htmx: bool, event: &Event) -> Response {
if is_htmx {
StatusCode::NO_CONTENT.into_response()
} else {
Redirect::to(&events::paths::show(&event.public_id)).into_response()
}
}
What it does:
- One list, rendered on the server. After any change,
broadcastloads the event’s questions in their room order and sends the whole list, rendered bytemplates/questions/_list.html, wrapped byrealtime::update("questions", ...): htmx replaces the contents of#questionsin every open room. Sending the list rather than one row is what makes it reorder live when votes change the ranking. It costs one D1 query and two Durable Object requests per change; the messages measured about 350 bytes per question for the audience and 800 for the host (with its buttons), so about 70 KB and 160 KB at the 200-question cap. - Two channels. The audience and the host see the same list, except that the host’s has “Mark answered” and “Delete” buttons. A broadcast reaches everyone on a channel, so each version goes to its own channel:
event:<public id>andhost:<public id>. - One vote per browser. The session (a signed, encrypted cookie that every visitor has) remembers the questions this browser voted for. An anonymous visitor who clears their cookies can vote again, as in most live Q&A tools; votes that must be unique per person need accounts.
- htmx buttons. The vote and moderation buttons post with htmx and get a
204 No Content: the layout tells htmx not to swap anything for a 204, and the broadcast updates the page, the voter’s included. Without JavaScript, the same forms post normally and the handler redirects back to the room. - Moderation is the host’s.
answeranddeletetakeCurrentUser(visitors are sent to/login) andhostedanswers 403 to other users.
The room page is the event’s show. In src/events.rs, ShowView gets the room’s data and show renders it through room, which ask also calls to show the form’s errors (the file’s use lines gain http::Uri, crate::models::question::{self, Question} and crate::questions::{self, QuestionForm}):
/// The room: the event, the ask form and the live question list.
#[derive(Template)]
#[template(path = "events/show.html")]
struct ShowView {
flash: Flash,
event: Event,
/// The signed-in user hosts this event: moderation buttons and the
/// host's channel.
host: bool,
/// The full link to share with the audience.
link: String,
questions: Vec<Question>,
form: QuestionForm,
errors: Vec<FieldError>,
}
impl ShowView {
/// The channel this page listens to (src/realtime.rs).
fn channel(&self) -> String {
if self.host { questions::host_channel(&self.event) } else { questions::channel(&self.event) }
}
}
async fn show(
State(ctx): State<Ctx>,
flash: Flash,
OptionalUser(user): OptionalUser,
uri: Uri,
Id(id, ..): Id,
) -> Result<Html<String>> {
let record = event::find(&ctx, id).await?.or_404()?;
room(&ctx, flash, &uri, record, user, QuestionForm::default(), vec![]).await
}
/// Renders the room of `event`; the ask form shows `form` and `errors`.
pub async fn room(
ctx: &Ctx,
flash: Flash,
uri: &Uri,
event: Event,
user: Option<User>,
form: QuestionForm,
errors: Vec<FieldError>,
) -> Result<Html<String>> {
let host = user.is_some_and(|user| user.id == event.user_id);
let link = format!("{}{}", crate::auth::origin(uri), paths::show(&event.public_id));
let questions = question::for_event(ctx, event.id).await?;
render(&ShowView { flash, event, host, link, questions, form, errors })
}
templates/events/show.html:
{% extends "layout.html" %}
{% block title %}{{ event.name }}{% endblock %}
{% block content %}
{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
{% if let Some(alert) = flash.alert() %}<p class="alert">{{ alert }}</p>{% endif %}
<section class="room-head">
<p class="eyebrow">Live Q&A</p>
<h1>{{ event.name }}</h1>
<p class="share">Share this link: <code>{{ link }}</code></p>
{% if host %}
<div class="host-tools">
<p>You host this event. <a href="{{ paths::edit(event.public_id) }}">Rename</a> · <a href="{{ paths::index() }}">Your events</a></p>
<form action="{{ paths::delete(event.public_id) }}" method="post" hx-confirm="Delete this event and its questions?">
<button class="link danger" type="submit">Delete event</button>
</form>
</div>
{% endif %}
</section>
<form class="ask" action="{{ crate::questions::paths::ask(event.public_id) }}" method="post">
{% if !errors.is_empty() %}
<ul class="errors">
{% for error in errors %}<li>{{ error.full_message() }}</li>{% endfor %}
</ul>
{% endif %}
<label>Your question <textarea name="body" rows="2" maxlength="280" required placeholder="Ask anything…">{{ form.body }}</textarea></label>
<button type="submit">Ask</button>
</form>
{# Live updates: htmx's WebSocket extension (loaded by layout.html) swaps in the list every change broadcasts (src/questions.rs, channels in src/realtime.rs). #}
<div hx-ext="ws" ws-connect="/realtime/{{ self.channel() }}">
<div id="questions">{% include "questions/_list.html" %}</div>
</div>
{% endblock %}
And the list both the page and the broadcasts use, templates/questions/_list.html:
{# The room's question list: rendered in the page and broadcast to open rooms on every change (src/questions.rs). `host` adds the moderation buttons. #}
{% if questions.is_empty() %}
<p class="empty">No questions yet. Be the first to ask.</p>
{% else %}
<ol class="questions">
{% for question in questions %}
{% let vote = crate::questions::paths::vote(question.id) %}
<li id="question_{{ question.id }}" class="question{% if question.answered %} answered{% endif %}">
<form action="{{ vote }}" method="post" hx-post="{{ vote }}" hx-swap="none">
<button class="vote" type="submit" title="Vote for this question"{% if question.answered %} disabled{% endif %}>▲<span>{{ question.votes }}</span></button>
</form>
<p class="body">{{ question.body }}</p>
{% if question.answered %}<span class="badge">Answered</span>{% endif %}
{% if host %}
{% let answer = crate::questions::paths::answer(question.id) %}
{% let delete = crate::questions::paths::delete(question.id) %}
<div class="moderate">
<form action="{{ answer }}" method="post" hx-post="{{ answer }}" hx-swap="none">
<button class="link" type="submit">{% if question.answered %}Reopen{% else %}Mark answered{% endif %}</button>
</form>
<form action="{{ delete }}" method="post" hx-post="{{ delete }}" hx-swap="none" hx-confirm="Delete this question?">
<button class="link danger" type="submit">Delete</button>
</form>
</div>
{% endif %}
</li>
{% endfor %}
</ol>
{% endif %}
askama escapes {{ question.body }}, so a question containing HTML shows as text, in the page and in broadcasts. The starter’s templates/events/index.html (the host’s events), templates/home.html (the landing page) and templates/layout.html (navigation and styles) are plain HTML; copy them from ocre new demo --starter qa or write your own.
Who may listen
src/realtime.rs decides who may open which channel. The room’s channels are named after the event’s public id, so knowing the link is what lets a visitor in; the host’s channel also needs the host’s session. Replace connect (and add OptionExt to the ocre imports, with use crate::auth::OptionalUser; and use crate::models::event;):
/// Opens a WebSocket on `channel` for whoever may listen to it:
/// `event:<public id>`, an event's room, is open to everyone with the link;
/// `host:<public id>`, the same list with moderation buttons, only to the
/// event's host. Unknown channels and events are a 404.
async fn connect(
State(ctx): State<Ctx>,
Path(channel): Path<String>,
OptionalUser(user): OptionalUser,
upgrade: WebSocketUpgrade,
) -> Result<Response> {
match channel.as_str() {
// ocre:channels
name => {
let (kind, key) = name.split_once(':').or_404()?;
let record = event::find_by_public_id(&ctx, key).await?.or_404()?;
match kind {
"event" => {}
"host" => {
let user = user.ok_or(Error::Unauthorized)?;
if user.id != record.user_id {
return Err(Error::Forbidden);
}
}
_ => return Err(Error::NotFound),
}
}
}
upgrade.connect(&ctx, &channel).await
}
Browsers send the session cookie with the WebSocket handshake, so OptionalUser works there like in any handler. Keep the // ocre:channels line: later --realtime scaffolds add their channel under it, before the catch-all arm.
Try the room
With ocre dev running (it rebuilds on every change), sign in as the host, create an event, then ask and vote as a visitor with another cookie jar:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -c host.txt -b host.txt http://localhost:8787/login -d 'email=ada@example.com&password=correct horse'
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -c host.txt -b host.txt http://localhost:8787/events -d 'name=Friday all-hands'
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8787/events -d 'name=Sneaky'
303 http://localhost:8787/
303 http://localhost:8787/events/c1wWvvdHUcKW080CDwgf6A
303 http://localhost:8787/login
The visitor without an account is sent to the login page. Now the audience, with the event’s public id from the redirect:
EVENT=c1wWvvdHUcKW080CDwgf6A
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -c guest.txt -b guest.txt http://localhost:8787/events/$EVENT/questions -d 'body=Will there be pizza?'
curl -s -o /dev/null -w "%{http_code}\n" -c guest.txt -b guest.txt http://localhost:8787/events/$EVENT/questions -d 'body=When is the next release?'
curl -s -o /dev/null -w "%{http_code}\n" -c guest.txt -b guest.txt -H 'HX-Request: true' -X POST http://localhost:8787/questions/3/vote
curl -s -o /dev/null -w "%{http_code}\n" -c guest.txt -b guest.txt -H 'HX-Request: true' -X POST http://localhost:8787/questions/3/vote
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -b guest.txt -X POST http://localhost:8787/questions/2/answer
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -b host.txt -X POST http://localhost:8787/questions/2/answer
303 http://localhost:8787/events/c1wWvvdHUcKW080CDwgf6A
303
204
204
303 http://localhost:8787/login
303 http://localhost:8787/events/c1wWvvdHUcKW080CDwgf6A
ocre sql runs a query on the local database:
ocre sql "SELECT id, event_id, body, votes, answered FROM questions"
id | event_id | body | votes | answered
---+----------+---------------------------+-------+---------
1 | 1 | Will there be pizza? | 0 | 0
2 | 2 | Will there be pizza? | 0 | 1
3 | 2 | When is the next release? | 1 | 0
(3 rows)
Question 1 is the one asked on the generated /questions page earlier. The second vote of the same browser changed nothing, and only the host’s request marked question 2 answered. In a browser, open the event’s link in two windows (one signed in as the host, one private window): a question asked in one appears in the other, and a vote moves it up on both.
Validate questions
A question needs at least 3 characters, and at most 280 so the room stays readable. In src/models/question.rs, NewQuestion::validate gets two rules:
impl NewQuestion {
/// Checks that need no database; `create` adds uniqueness and references.
pub fn validate(&self) -> Validator {
let mut v = Validator::new();
v.required("body", &self.body);
v.min_length("body", &self.body, 3);
v.max_length("body", &self.body, 280);
v.safe_integer("votes", self.votes);
v
}
}
question::create runs it, so every way of creating a question gets the rules. ask turns the error into a 422 that shows the room again with the message above the form:
curl -s -c guest.txt -b guest.txt http://localhost:8787/events/$EVENT/questions -d 'body=hi' -w "%{http_code}\n" | grep -E "<li>Body|^4"
<li>Body is too short (minimum is 3 characters)</li>
422
See Validations for every rule.
Test it
Request tests send HTTP requests to the app running in workerd, with a fresh local database. Replace the generated tests/events.rs and tests/questions.rs, which tested the CRUD pages, with tests of the room. tests/questions.rs, shortened to its helpers and two tests:
use ocre::testing::{Client, sequence, sql};
/// A signed-up host and the public id of their new event.
fn host_with_event() -> (Client, String) {
let mut client = Client::new();
// Unique across test files, which share the test database.
let email = format!("host{}-{}@example.com", std::process::id(), sequence());
client.post("/signup", &[("email", email.as_str()), ("password", "correct horse")]).assert_status(303);
let created = client.post("/events", &[("name", "Q&A")]);
let location = created.assert_status(303).location().unwrap_or_default().to_owned();
(client, location.trim_start_matches("/events/").to_owned())
}
/// `client` asks `body` in the room `event`; returns the question's id.
fn ask(client: &mut Client, event: &str, body: &str) -> i64 {
client.post(&format!("/events/{event}/questions"), &[("body", body)]).assert_redirect_to(&format!("/events/{event}"));
let rows = sql(&format!("SELECT id FROM questions WHERE body = {}", ocre::testing::quote(body)));
rows[0]["id"].as_i64().expect("the question is saved")
}
#[test]
#[ignore = "request test: run with `ocre test --e2e`"]
fn a_question_appears_live_in_every_room() {
let (_, event) = host_with_event();
let mut visitor = Client::new();
ask(&mut visitor, &event, "Will there be pizza?");
visitor.get(&format!("/events/{event}")).assert_contains("Will there be pizza?");
let sent = visitor.broadcasts();
let audience = sent.iter().rev().find(|b| b.channel == format!("event:{event}")).expect("sent to the room");
assert!(audience.message.contains("Will there be pizza?"), "{}", audience.message);
assert!(!audience.message.contains("Mark answered"), "no moderation for the audience");
let host = sent.iter().rev().find(|b| b.channel == format!("host:{event}")).expect("sent to the host");
assert!(host.message.contains("Mark answered"), "{}", host.message);
}
#[test]
#[ignore = "request test: run with `ocre test --e2e`"]
fn each_browser_votes_once_per_question() {
let (_, event) = host_with_event();
let mut first = Client::new().htmx();
let id = ask(&mut first, &event, "Can we get the slides?");
first.post(&format!("/questions/{id}/vote"), &()).assert_status(204);
first.post(&format!("/questions/{id}/vote"), &()).assert_status(204);
assert_eq!(votes(id), 1);
Client::new().htmx().post(&format!("/questions/{id}/vote"), &()).assert_status(204);
assert_eq!(votes(id), 2);
}
Each Client keeps its own cookies, like a browser; .htmx() adds the HX-Request header; broadcasts() returns what the app sent to its channels. The starter’s test files also check validation, ordering by votes, moderation rights, the host’s pages and that visitors need no account. Run them:
ocre test --e2e
The command checks the build for wasm32-unknown-unknown, migrates a separate test database, starts the app once and runs every test file (12 request tests here); cargo’s output ends with:
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.77s
See Testing an Ocre app for unit tests, factories and browser tests.
List the routes
ocre routes reads the routes from src/lib.rs and the modules it merges, without building. A filter keeps the routes whose method, path or handler contains it; here, the app’s own routes without the auth pages:
ocre routes
METHOD PATH HANDLER
GET / home
GET /events events::index
POST /events events::create
GET /events/new events::new
GET /events/{id} events::show
POST /events/{id} events::update
POST /events/{id}/delete events::delete
GET /events/{id}/edit events::edit
POST /events/{id}/questions questions::ask
POST /questions/{id}/answer questions::answer
POST /questions/{id}/delete questions::delete
POST /questions/{id}/vote questions::vote
GET /realtime/{channel} realtime::connect
GET /up up
The full list also has the account routes of ocre g auth (/signup, /login, /logout, /account, /passwords/..., /magic_link/..., /confirmations/..., /api/auth/...).
Deploy
Deploying needs a Cloudflare account (the free plan is enough) and a login of Cloudflare’s cf CLI. The commands in this section were not run for this page: the behavior below is described from the CLI’s code.
Log in
ocre login
If cf already has a login (or CLOUDFLARE_API_TOKEN is set), nothing opens. Otherwise it runs cf auth login, which opens the browser to approve access. The command then prints Logged in to Cloudflare as <email>. A login with several accounts needs accountId: "...", at the top of cloudflare.config.ts (ocre new --account-id writes it). cf also sends anonymous usage telemetry by default; npx cf cli telemetry disable turns it off.
Email in production
Sign-up, login and the room send no email and work without it. The magic-link and password-reset forms do: in production .dev.vars does not apply, and with MAIL_ADAPTER unset they answer 500 and the log names the fix. To turn them on, pick an adapter; on the free plan, Resend sends to any recipient (free: 100 emails a day, 3,000 a month, one domain, September 2026):
- In
cloudflare.config.ts, inworker.env, uncommentMAIL_ADAPTER: bindings.text("resend"),and setMAIL_FROMto an address on a domain verified in Resend, for exampleMAIL_FROM: bindings.text("Live Q&A <noreply@yourdomain.com>"),. - Store the API key as a secret: put
RESEND_API_KEY=<key>in.prod.vars(git-ignored), then runocre secrets push RESEND_API_KEY --file .prod.vars. A Worker must exist before it can have secrets, so run this after the firstocre deployif the Worker is new.
See Email for the cloudflare adapter and Configuration for every variable.
ocre deploy
ocre deploy
In order, ocre deploy:
- Checks the locale files (when the app has translations) and the
wasm32-unknown-unknowntarget, asocre devdoes. - Looks up the D1 database named in
cloudflare.config.ts(qa) withcf d1 listand creates it the first time. - Creates the other Cloudflare resources
cloudflare.config.tsnames that are missing: queues, R2 buckets, KV namespaces without anid. This app uses none of them; the Durable Object namespace of the realtime channels is created by the deploy itself, from theOcreChannelexport. - Asks
cf workers secrets listwhether the Worker hasSECRET_KEY_BASE. A new Worker has none, so a fresh random one is uploaded with the deploy and saved in.prod.vars. An existing secret is never replaced, since that would sign every user out. - Applies the pending migrations to the production database (
cf d1 migrations apply), before the new code goes live. - Deploys with
cf deploy --secrets-file, which builds the Worker in release mode (optimized for size, slower to compile thanocre dev) and uploads it. The secrets file (.wrangler/ocre-secrets.json, deleted afterwards) holds the newSECRET_KEY_BASE, or{}: it is passed on every deploy because cf keeps the Worker’s other secrets only when one is given.
cf’s own output is shown as it runs; the command then ends with:
Created the SECRET_KEY_BASE secret on Cloudflare
Saved it in .prod.vars (git-ignored): back it up, Cloudflare never gives it back
https://qa.<your-subdomain>.workers.dev
The first two lines appear only on the deploy that created the secret. With --json, the result is {"command": "deploy", "ok": true, "secret_created": true, "secret_saved": ".prod.vars", "url": "https://qa.<your-subdomain>.workers.dev"}. Run ocre deploy again after each change: every deploy migrates the database first, so the new code never runs against an old schema.
Free-plan limits that matter for this app (September 2026, Workers limits, Durable Objects pricing): 100,000 Worker requests a day and 10 ms of CPU per request, and 100,000 Durable Object requests a day. Each question, vote or moderation is one Worker request and two Durable Object requests (one per channel); each browser that opens a room adds one of each for its WebSocket, then costs nothing while it waits, since the channel’s object hibernates, and messages the server sends are free. A room of 300 people who each vote ten times uses about 3,300 Worker requests (plus page loads) and 6,300 Durable Object requests. Free-plan limits lists the rest.
Next steps
- Realtime: channels, broadcasts from jobs, presence, relaying client messages.
- Models and migrations: queries, associations, changing columns with
ocre g migration. - Authentication:
CurrentUser, ownership checks, OAuth, JWTs and API keys. - Controllers and routing, Views, helpers and forms and htmx: handlers, templates, partial updates with htmx.
- Background jobs and schedules: send a summary to the host after the event, clean up old events every night.
- Deployment: environments, secrets, custom domains.
- CLI commands and Generators: every command and flag.
Models and migrations
A model is a generated Rust file, src/models/<model>.rs, that holds every query and rule about one D1 table, and migrations are numbered SQL files that create and change those tables. This page explains the generated model section by section (queries, callbacks, associations, enums), the ocre::Query builder, transactions, how to change the schema and undo a change, encrypted columns, several databases, and what is not supported.
Before you start
- An Ocre app created with
ocre new(see Installation). The examples use the blog starter (ocre new blog --starter blog), whosePostmodel hastitle:string body:text published:boolean. ocre devorocre migrateapplies migrations to the local database; nothing here needs a Cloudflare account until you add--remote.- Free plan (September 2026, D1 pricing): 5 million rows read and 100,000 rows written a day, 5 GB of storage in total; D1 limits: 500 MB per database, 50 queries per Worker invocation, 100 bound parameters per query. Rows read counts every row a query scans, not only those it returns.
Generate a model
ocre g model <Model> field:type... writes the migration that creates the table and the model file, and registers the module in src/models/mod.rs:
ocre g model Author name:string^ bio:text?
create migrations/0002_create_authors.sql
create src/models/author.rs
update src/models/mod.rs
Next:
ocre migrate
cargo check --target wasm32-unknown-unknown
Field types are string, text, rich_text, integer, float, decimal, boolean, date, time, datetime, uuid, references, attachment, json and enum:<value>,<value>...; the suffix ? makes a field optional (NULL allowed) and ^ unique, and a lock_version:integer field turns on optimistic locking; public_id:token gives each row a random id that the generated pages and APIs use in URLs instead of the integer id (see Field types). Field types lists the SQL and Rust type of each. ocre g scaffold and ocre g api create the model the same way when it does not exist yet (see Generators). The suffixes change what a references field generates (see Associations): author:references? is an optional parent (ON DELETE SET NULL), user:references^ a one-to-one link (has one).
The migration:
CREATE TABLE authors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bio TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX index_authors_on_name ON authors (name);
Every table gets id, created_at and updated_at (UTC text such as 2026-09-29 04:36:41). Apply it with ocre migrate (or just run ocre dev, which migrates first).
The model file, section by section
src/models/author.rs is plain Rust you own: read it, change it, add to it. Controllers (HTML pages, JSON API, GraphQL resolvers) call its functions instead of writing SQL, so a rule written here applies everywhere.
The record struct
/// A row of the `authors` table.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Author {
pub id: i64,
pub name: String,
pub bio: Option<String>,
pub created_at: String,
pub updated_at: String,
}
One field per column, deserialized from D1 rows by column name. Optional fields are Option. Booleans need a helper, because SQLite stores them as INTEGER 0/1; the blog starter’s Post has:
#[serde(deserialize_with = "ocre::bool_from_sql")]
pub published: bool,
JSON columns use ocre::json_from_sql (ocre::optional_json_from_sql for optional ones) the same way.
New and Changes
/// Values for a new author.
#[derive(Debug, Clone, Deserialize)]
pub struct NewAuthor {
pub name: String,
#[serde(default, deserialize_with = "ocre::optional")]
pub bio: Option<String>,
}
/// Changes to a author: absent fields keep their value; for optional fields
/// `Some(None)` clears it.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AuthorChanges {
pub name: Option<String>,
#[serde(default, deserialize_with = "ocre::patch")]
pub bio: Option<Option<String>>,
}
NewAuthor is the input of create: every required field, optional fields as Option. AuthorChanges is the input of update: every field is Option, and None keeps the stored value. Optional columns are Option<Option<T>>: None keeps, Some(None) clears, Some(Some(value)) sets. Both structs deserialize from JSON bodies, so the JSON API takes them directly; ocre::optional and ocre::patch also treat an empty string as “no value”, which is what HTML forms send (see JSON APIs).
AuthorChanges::changed() lists the fields a change sets (["name"]), Rails’ changed and *_changed?: a before_update callback can act only when a field changes (if changes.changed().contains(&"email") { ... }). The previous values are one find(ctx, id) away when a callback needs them (*_was). There is no other dirty tracking: records are plain values, and nothing saves them behind your back.
validate()
impl NewAuthor {
/// Checks that need no database; `create` adds uniqueness and references.
pub fn validate(&self) -> Validator {
let mut v = Validator::new();
v.required("name", &self.name);
v
}
}
validate() returns an ocre::Validator holding every failed check, without touching the database. Generated rules: required for non-optional string/text, format checks for date, time, datetime, decimal and uuid, safe_integer for integers, file rules for attachments. AuthorChanges::validate() checks only the fields being changed. Add your own rules here; Validations lists every check.
Queries
| Function | SQL | Returns |
|---|---|---|
query() | SELECT * FROM authors, as an ocre::Query<Author> to refine (see The query builder) | Query<Author> |
all(ctx, page) | SELECT * FROM authors ORDER BY id DESC LIMIT ?1 OFFSET ?2 | Vec<Author>, newest first |
count(ctx) | SELECT COUNT(*) AS count FROM authors | i64 |
find(ctx, id) | SELECT * FROM authors WHERE id = ?1 LIMIT ?2 | Option<Author> |
find_many(ctx, &ids) | SELECT * FROM authors WHERE id IN (?1, ?2, ...), 100 ids per query | Vec<Author>, in no particular order |
create(ctx, new) | before_create, validate(), database checks, INSERT ... RETURNING *, after_create | the new Author, or Error::Invalid (422) |
update(ctx, id, changes) | before_update, validate(), database checks, UPDATE ... RETURNING *, after_update | Some(Author), None when the id does not exist, or Error::Invalid |
delete(ctx, id) | before_delete, DELETE FROM authors WHERE id = ?1 RETURNING *, after_delete | true, or false when the id does not exist |
all, count, find and find_many are one line each on top of query():
pub fn query() -> Query<Author> {
Query::table("authors")
}
pub async fn find(ctx: &Ctx, id: i64) -> Result<Option<Author>> {
query().eq("id", id).first(&ctx.db()?).await
}
page is an ocre::Page (limit 1 to 100, default 50; offset from 0); handlers get it from ?limit=&offset=, other code builds one with Page::new(limit, offset)?. Every function returns ocre::Result, so handlers use ?. Missing records are None/false, not errors: the handler decides, usually with .or_404()?.
create and update add the checks that need the database to the validate() errors, then fail with all of them at once:
pub async fn create(ctx: &Ctx, mut new: NewAuthor) -> Result<Author> {
before_create(ctx, &mut new).await?;
let db = ctx.db()?;
let mut v = new.validate();
{
let name = &new.name;
v.check("name", db.exists("SELECT 1 FROM authors WHERE name = ?1 LIMIT 1", params![name]).await?, "has already been taken");
}
v.finish()?;
let record: Author = db
.first("INSERT INTO authors (name, bio) VALUES (?1, ?2) RETURNING *", params![new.name, new.bio])
.await?
.ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))?;
after_create(ctx, &record).await?;
Ok(record)
}
A unique field (^) gets the “has already been taken” check (in update, AND id != ?2 excludes the record itself); a references field gets “must exist”. The UNIQUE index and the REFERENCES constraint in the migration stay as the last line of defense.
update writes only the fields that are Some, with one CASE WHEN per column, and refreshes updated_at:
let updated: Option<Author> = db
.first(
"UPDATE authors SET name = CASE WHEN ?1 THEN ?2 ELSE name END, bio = CASE WHEN ?3 THEN ?4 ELSE bio END, updated_at = datetime('now') WHERE id = ?5 RETURNING *",
params![changes.name.is_some(), changes.name, changes.bio.is_some(), changes.bio.flatten(), id],
)
.await?;
Callbacks
The end of every model file holds six private functions that create, update and delete call, like Rails’ callbacks. They do nothing until you fill them in:
| Function | Called | Typical use |
|---|---|---|
before_create(ctx, &mut new) | before validate() and the INSERT | normalize input (trim, lowercase an email), fill in defaults |
after_create(ctx, &record) | after the INSERT | send an email, enqueue a job |
before_update(ctx, id, &mut changes) | before validate() and the UPDATE | normalize the changed fields |
after_update(ctx, &record) | after a successful UPDATE (not when the id does not exist) | refresh a cache, broadcast |
before_delete(ctx, id) | before the DELETE | refuse to delete (return an error) |
after_delete(ctx, &record) | after the DELETE, with the deleted row (RETURNING *) | delete files, clean up other tables |
Every controller, JSON API and GraphQL resolver goes through create/update/delete, so a callback runs for all of them. An Err returned by a before_* callback stops the operation before anything is written; for example, keeping authors that still have tasks (Task from ocre g model Task title:string status:enum:open,done author:references?):
/// Before the DELETE of the author `id`: return an error to keep it.
async fn before_delete(ctx: &Ctx, id: i64) -> Result<()> {
let has_tasks = crate::models::task::query().eq("author_id", id).exists(&ctx.db()?).await?;
let mut v = Validator::new();
v.check("tasks", has_tasks, "must be deleted first");
v.finish()
}
delete then answers Error::Invalid (422, “Tasks must be deleted first”) and the author stays. The HTML scaffold shows it on the error page; JSON clients get it in fields.
An Err from an after_* callback is returned to the caller, but the write it follows is already done: D1 keeps no transaction open between two queries. Writes that must succeed or fail together go into one db.batch. Bulk operations (update_all, delete_all, hand-written SQL) and ON DELETE CASCADE run no callback.
The rest of Rails’ callback API maps to plain code in these functions:
| Rails | In Ocre |
|---|---|
before_validation, after_validation | before_create / before_update run before validate(); code after v.finish()? in create / update runs after it |
before_save, after_save (create and update) | a private function both before_create and before_update (or both after_*) call; around_*: code before and after in the same function |
after_commit, after_create_commit, after_update_commit, after_destroy_commit | the after_* functions: each generated write is committed when it returns (D1 auto-commits every statement) |
after_rollback | the Err of the write or of db.batch: nothing was applied |
if: / unless: conditions, on: | an if in the function; changes.changed() tells which fields an update sets |
throw :abort | return an Err from a before_* function |
| callback objects, shared callbacks | a function in a module of your own, called from several models’ callbacks |
dependent: :destroy running the children’s callbacks | in before_delete, delete the children through their model’s delete (each runs its callbacks and deletes its files) instead of relying on ON DELETE CASCADE |
association callbacks (before_add, after_remove) | the join or child model’s before_create / after_delete |
skipping callbacks (update_column, update_all, delete, insert_all) | query().update_all(..), delete_all, db.execute, db.batch: they run no model code and no validation |
after_initialize, after_find, after_touch, Model.suppress | none: rows are plain values deserialized by serde (derive values in a method), and nothing saves implicitly |
Associations
A references field connects two models. With the blog starter:
ocre g scaffold Comment author:string body:text post:references
The migration adds post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE and an index on post_id. Deleting a post deletes its comments in the database (attached files of cascaded rows are not deleted from R2; see File storage). The generator writes both sides:
// src/models/comment.rs: belongs to
impl Comment {
/// The post this comment belongs to.
pub async fn post(&self, ctx: &Ctx) -> Result<Option<crate::models::post::Post>> {
crate::models::post::find(ctx, self.post_id).await
}
// ocre:associations
}
// src/models/post.rs: has many, added under the `// ocre:associations` marker
impl Post {
// ocre:associations
/// Comments of this post, newest first.
pub async fn comments(&self, ctx: &Ctx, page: Page) -> Result<Vec<crate::models::comment::Comment>> {
ctx.db()?
.all(
"SELECT * FROM comments WHERE post_id = ?1 ORDER BY id DESC LIMIT ?2 OFFSET ?3",
params![self.id, page.limit, page.offset],
)
.await
}
}
In a handler: let comments = post.comments(&ctx, page).await?; and let post = comment.post(&ctx).await?;. Keep the // ocre:associations and // ocre:models markers: later generators insert code after them.
The suffixes and the shape of the model choose the kind of association:
| Fields | Migration | Generated |
|---|---|---|
post:references | post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, index | belongs to: comment.post(ctx); has many: post.comments(ctx, page) |
author:references? | author_id INTEGER REFERENCES authors(id) ON DELETE SET NULL, index | the same, with task.author(ctx) returning None when author_id is NULL; deleting the author keeps its tasks and clears their author_id |
user:references^ (in ocre g model Profile bio:text user:references^) | NOT NULL ... ON DELETE CASCADE plus a unique index | has one: user.profile(ctx) returns Option<Profile> instead of a list |
two references or more and no other field, e.g. ocre g model Tagging post:references tag:references | the references, plus a unique index on the pair | a join model: has many through, post.tags(ctx, page) and tag.posts(ctx, page) (a JOIN on taggings), and a “has already been taken” check on the pair in create |
author:references:writer_id (in ocre g model Book title:string author:references:writer_id) | writer_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE, index | belongs to named after the column, book.writer(ctx); has many author.books(ctx, page) |
employee:references:manager_id? in ocre g model Employee name:string employee:references:manager_id? | manager_id INTEGER REFERENCES employees(id) ON DELETE SET NULL, index | a self join: belongs to employee.manager(ctx), has many employee.employees(ctx, page) (rename it reports in the file if you like) |
With Tagging from the command above, the blog starter’s Post gets:
/// Tags of this post, through taggings, most recently linked first.
pub async fn tags(&self, ctx: &Ctx, page: ocre::Page) -> Result<Vec<crate::models::tag::Tag>> {
ctx.db()?
.all(
"SELECT tags.* FROM tags JOIN taggings ON taggings.tag_id = tags.id WHERE taggings.post_id = ?1 ORDER BY taggings.id DESC LIMIT ?2 OFFSET ?3",
params![self.id, page.limit, page.offset],
)
.await
}
Tag a post with tagging::create(&ctx, NewTagging { post_id, tag_id }).await? and untag it with tagging::query().eq("post_id", post_id).eq("tag_id", tag_id).delete_all(&ctx.db()?).await?.
Each references field also writes two eager-loading functions in the model that holds it, to load the association of a whole list in one query per 100 ids (see Avoid N+1 queries):
| Function | In | Returns |
|---|---|---|
preload_<parents>(ctx, &records) | comment::preload_posts(&ctx, &comments) | HashMap<i64, Post>: the parent of every record, by id |
for_<parents>(ctx, &parent_ids) | comment::for_posts(&ctx, &post_ids) | Vec<Comment>: every child of these parents, newest first (not paginated: keep the id list short) |
Association options, the Ocre way
Rails configures associations with options; in Ocre each one is a line of code in the model, where you can read it:
| Rails option | In Ocre |
|---|---|
dependent: :destroy / :nullify / :restrict_with_error | ON DELETE CASCADE (required reference), ON DELETE SET NULL (optional), or an error from before_delete (see Callbacks) |
class_name:, foreign_key: | author:references:writer_id names the column; the target is the model before :references |
counter_cache: true | a comments_count INTEGER NOT NULL DEFAULT 0 column on the parent, updated by the child’s after_create / after_delete (below), or a COUNT(*) on the indexed foreign key when the list is short |
touch: true | crate::models::post::touch(ctx, comment.post_id).await? in the child’s after_create / after_update / after_delete: every model has touch(ctx, id), which sets updated_at (and bumps lock_version) without validation or callbacks |
inverse_of, association caching, reload_<name> | not needed: an association is an async function returning plain values; call it again to reload |
validate: true (validates_associated) | call the other model’s validate() and v.merge(..) it in validate() or create |
autosave: true, nested attributes | call the other model’s create/update from the handler or a callback; several writes that must succeed together go into one db.batch |
scopes on an association (-> { where(...) }) | crate::models::comment::query().eq("post_id", post.id).eq("approved", true), or a scope function in the child model |
polymorphic: true | commentable:polymorphic:post,photo (see Polymorphic references) |
has_many_attached | photos:attachments (see Files) |
A counter cache and touch, in the child model (src/models/comment.rs, with comments_count added to posts by ocre g migration add_comments_count_to_posts comments_count:integer):
/// After the INSERT: count the comment on its post and touch the post.
async fn after_create(ctx: &Ctx, comment: &Comment) -> Result<()> {
let sql = "UPDATE posts SET comments_count = comments_count + 1, updated_at = datetime('now') WHERE id = ?1";
ctx.db()?.execute(sql, params![comment.post_id]).await?;
Ok(())
}
/// After the DELETE, with the deleted row.
async fn after_delete(ctx: &Ctx, comment: &Comment) -> Result<()> {
let sql = "UPDATE posts SET comments_count = comments_count - 1, updated_at = datetime('now') WHERE id = ?1";
ctx.db()?.execute(sql, params![comment.post_id]).await?;
Ok(())
}
Each callback costs one row written. Bulk deletes (delete_all, ON DELETE CASCADE) skip it: recount with UPDATE posts SET comments_count = (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id) when you use them.
Polymorphic references
A record that may belong to one of several models (Rails’ belongs_to :commentable, polymorphic: true) takes a polymorphic field listing them:
ocre g scaffold Comment body:text commentable:polymorphic:post,photo
It becomes two fields: commentable_type, an enum of the models’ singular names (CommentableType::Post, stored as 'post'), and commentable_id, an integer, with an index on the pair. SQLite cannot declare a foreign key to two tables, so create (and update, when a change sets both) checks that the record exists: Commentable must exist otherwise. The model gets an enum of the records and an accessor:
// src/models/comment.rs
pub enum Commentable {
Post(crate::models::post::Post),
Photo(crate::models::photo::Photo),
}
let parent = comment.commentable(&ctx).await?; // Option<Commentable>: None once the record is deleted
match parent {
Some(Commentable::Post(post)) => { /* ... */ }
Some(Commentable::Photo(photo)) => { /* ... */ }
None => {}
}
Each listed model gets the has-many side, post.comments(ctx, page) (a query on commentable_type = 'post'). Add ? for an optional reference (commentable:polymorphic:post,photo?). Deleting a post does not delete its comments (there is no foreign key): delete them in the post’s before_delete. The models must exist before the field names them; a model may list itself (subject:polymorphic:note,post? on Note).
Enum fields
status:enum:open,done stores one of a fixed list of values. The migration keeps the text and refuses anything else:
status TEXT NOT NULL CHECK (status IN ('open', 'done')),
and the model gets a Rust enum named after the field, used in the row, New and Changes structs:
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
pub enum Status {
#[default]
#[serde(rename = "open")]
Open,
#[serde(rename = "done")]
Done,
}
Status::ALL lists the values in order (for select boxes), as_str() and Display give the stored text, FromStr parses it, and IntoParam binds it, so task::query().eq("status", Status::Done) is the scope Rails generates as Task.done. The first value is the Default. JSON bodies send the text ("status": "done"); an unknown value fails deserialization with a 400. HTML scaffold forms parse the text with v.one_of("status", &form.status), which adds “is not included in the list” (see Validations). Values must be distinct snake_case words; an enum cannot be unique (^), and --graphql does not support enum fields yet.
Adding a value later means changing the CHECK, which SQLite cannot alter: add the variant, then rebuild the table with ocre g migration rebuild_tasks (see Generate a migration).
Rich text fields
body:rich_text is Action Text without its extra table: formatted text (bold, links, headings, lists, quotes, code) typed in the Trix editor, the one Rails uses, and kept as HTML in a TEXT column of the record.
ocre g scaffold Article title:string body:rich_text
- Stored safe.
createandupdaterun the HTML throughocre::security::sanitizebefore writing it: scripts, styles, event handlers andjavascript:links are gone before the row exists, whatever the client sent (the JSON API included). - Validated on its text. A required rich text field checks
v.required("body", &ocre::security::strip_tags(body)), so an empty editor (<div><br></div>) is “can’t be blank”. - Forms. The scaffold’s
_form.htmlloads Trix 2.1.19 from unpkg.com (allowed by the generated Content-Security-Policy:script-srcandstyle-srclisthttps://unpkg.com) and renders<input type="hidden" id="article_body" name="body"><trix-editor input="article_body"></trix-editor>; the editor fills the hidden input, which the form submits like any field. The toolbar’s file button is hidden: files belong in anattachmentfield. - Rendering.
{{ article.body|rich_text }}prints the HTML (sanitized again on the way out, a few microseconds of CPU);{{ article.body|plain_text|truncate(80) }}prints its text, as the index page does (Rails’to_plain_text). Both filters come fromocre::filters(use ocre::filters;in the controller, which the scaffold writes). - Style. Trix’s stylesheet styles the editor; style the rendered HTML with your own CSS (
dd div,.content h1…), the equivalent of Rails’action_text/contents/_contentpartial.
Apps created before rich_text existed need "https://unpkg.com" added to style_src in content_security_policy() (src/lib.rs), or the editor shows without its styles.
Migrations
Migrations live in migrations/, named NNNN_<name>.sql (four digits, one more than the highest existing number). ocre migrate applies them in file-name order and records each applied name in the d1_migrations table:
ocre sql "SELECT id, name FROM d1_migrations"
id | name
---+---------------------------
1 | 0001_create_posts.sql
2 | 0002_create_authors.sql
3 | 0003_create_comments.sql
4 | 0004_add_slug_to_posts.sql
5 | 0005_create_products.sql
(5 rows)
A migration runs once per database. Editing an applied file changes nothing where it already ran (its name is recorded) but changes what a new database gets, so local and production schemas drift. Never edit an applied migration; write a new one.
Generate a migration
ocre g migration <name> [arguments...] infers the SQL from the name, as Rails does. The first names take fields (name:type), the others column names or nothing:
| Name | SQL | Arguments |
|---|---|---|
create_<table> | CREATE TABLE with id, the fields, created_at, updated_at, plus indexes | fields |
add_<anything>_to_<table> | ALTER TABLE <table> ADD COLUMN ... per column, plus indexes | fields, 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> | optional fields: when given, one DROP COLUMN per field instead |
add_index_to_<table> | CREATE INDEX index_<table>_on_<a>_and_<b> ON <table> (a, b) | column names, in index order, required |
add_unique_index_to_<table> | the same with CREATE UNIQUE INDEX | column names, required |
remove_index_from_<table> | DROP INDEX IF EXISTS index_<table>_on_<a>_and_<b> (the name the two above give) | column names, required |
rename_<column>_to_<new>_in_<table> | ALTER TABLE <table> RENAME COLUMN <column> TO <new> | none |
rename_<table>_to_<new> | ALTER TABLE <table> RENAME TO <new> | none |
drop_<table> | DROP TABLE <table> | none |
rebuild_<table> | SQLite’s table rebuild (create a new table, copy the rows, drop, rename, recreate the indexes), from db/schema.sql (see Change a column: rebuild the table) | none |
| anything else | empty file to fill in (data changes, custom SQL) | none allowed |
Index, rename and drop migrations need no schema file:
ocre g migration add_index_to_posts published created_at
ocre g migration rename_body_to_content_in_posts
-- Migration: add_index_to_posts
-- Applied once, in file-name order. Never edit a migration after it has been applied.
CREATE INDEX index_posts_on_published_and_created_at ON posts (published, created_at);
-- Migration: rename_body_to_content_in_posts
-- Applied once, in file-name order. Never edit a migration after it has been applied.
ALTER TABLE posts RENAME COLUMN body TO content;
Renames, drops and rebuilds end with the “update the model in src/models/ to match the new columns” step: the struct fields and the SQL of the model still use the old names. A renamed table keeps its index names (index_tags_on_label on labels), so remove_index_from_labels label would not find it; drop it by its real name in a hand-written migration. add_index_to_posts without columns fails with hint: list them in index order, without types, e.g. `ocre g migration add_index_to_posts author_id created_at` .
ocre g migration add_slug_to_posts slug:string?
create migrations/0004_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;
Existing rows need a value for a new column. Optional columns get NULL; required ones get a default ('' for text, dates and times, 0 for numbers, '{}' for JSON, 0 for booleans, the first value for an enum). References and attachments added to an existing table must be optional:
ocre g migration add_author_to_posts author:references
error: `author_id` must be optional when added to an existing table
hint: SQLite adds reference columns as NULL for existing rows: use `name:references?`
A required unique column (slug:string^) fails on a table with two rows or more: every row gets the default '', and the unique index refuses the duplicates (UNIQUE constraint failed). Add it as slug:string?^ (a unique index allows many NULLs), fill it, then enforce presence in validate().
Other names give an empty migration, with only the two comment lines; write the SQL yourself:
ocre g migration backfill_slugs
-- Migration: backfill_slugs
-- Applied once, in file-name order. Never edit a migration after it has been applied.
UPDATE posts SET slug = lower(replace(title, ' ', '-')) WHERE slug IS NULL;
A name the generator cannot read with fields is refused:
error: cannot tell which table `fix_things` changes
hint: name it `create_<table>`, `add_<columns>_to_<table>` or `remove_<columns>_from_<table>`
Apply migrations
Check what is pending, then apply it. Here after ocre g migration add_views_to_posts views:integer:
ocre migrate --status
⛅️ wrangler 4.143.0
────────────────────
Resource location: local
...
Migrations to be applied:
┌─────────────────────────────┐
│ Name │
├─────────────────────────────┤
│ 0006_add_views_to_posts.sql │
└─────────────────────────────┘
Next:
ocre migrate
With --json, the pending names are in pending:
ocre migrate --status --json
{"command":"migrate","next":["ocre migrate"],"ok":true,"pending":["0006_add_views_to_posts.sql"]}
ocre migrate runs the app’s wrangler on the local database (wrangler d1 migrations apply DB --local, see Why wrangler still appears) and shows its output:
ocre migrate
...
? About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue?
🤖 Using fallback value in non-interactive context: yes
🌀 Executing on local database blog (DB) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
🚣 2 commands executed successfully.
┌─────────────────────────────┬────────┐
│ name │ status │
├─────────────────────────────┼────────┤
│ 0006_add_views_to_posts.sql │ ✅ │
└─────────────────────────────┴────────┘
Each migration file runs as one unit: when a statement fails, none of the file’s statements stay applied and the migration stays pending. For a file whose second statement inserts into a missing table:
✘ [ERROR] no such table: nope_table: SQLITE_ERROR
...
error: `wrangler d1 migrations apply DB --local` failed (exit status: 1)
hint: read the wrangler output above; the first error line names the cause
Fix the file (it was never applied) and run ocre migrate again. Add --remote to either command for the production database on Cloudflare. ocre deploy applies remote migrations itself (see Deployment), and ocre dev applies local ones before starting.
The schema file
ocre db schema writes the database’s current CREATE statements to db/schema.sql (Rails’ structure.sql), read from sqlite_master; --remote dumps the production database instead:
ocre migrate && ocre db schema
create db/schema.sql
-- Schema of the local D1 database, written by `ocre db schema` from sqlite_master.
-- A snapshot for reading: migrations/ are the source of truth. Run `ocre db schema` again after `ocre migrate`.
CREATE TABLE authors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bio TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX index_authors_on_name ON authors (name);
...
It is the quickest way for a person or an agent to see every table at once. Nothing loads it: a new database is always built by replaying migrations/ (Rails’ schema.rb/structure.sql in one SQL format). Commit it if you want schema changes visible in code review, and regenerate it after each ocre migrate.
Column options, constraints and comments
Migrations are SQL, so Rails’ column modifiers and table options are SQLite syntax in the file (edit a generated migration before applying it, or write one with a name the generator does not read):
| Rails | SQLite, in the migration |
|---|---|
default:, null: false | views INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft' |
limit:, precision:, scale: | a CHECK (length(code) <= 8); SQLite ignores declared sizes (VARCHAR(8) is TEXT); exact numbers are the decimal field type |
collation: | email TEXT NOT NULL COLLATE NOCASE (case-insensitive comparisons and unique index) |
add_check_constraint | CHECK (price >= 0) on the column or the table; to add one to an existing table, rebuild it (below) |
comment: | an SQL comment inside CREATE TABLE: price REAL NOT NULL, -- in euros; SQLite keeps the statement’s text, so ocre db schema shows it in db/schema.sql |
if_not_exists:, force:, id: false, primary_key: | CREATE TABLE IF NOT EXISTS, DROP TABLE IF EXISTS first, any primary key you declare (PRIMARY KEY (a, b), id TEXT PRIMARY KEY for UUIDs); generated models expect an INTEGER id, so such tables get a hand-written model module |
change_table (several changes at once) | several ALTER TABLE statements in one migration file, applied together |
change_column, change_column_null, change_column_default | a table rebuild (below) |
Change a column: rebuild the table
SQLite’s ALTER TABLE can add, drop and rename columns, but not change a column’s type, NOT NULL, DEFAULT, CHECK or REFERENCES. For those, SQLite’s documented answer is to rebuild the table. ocre g migration rebuild_<table> writes that migration from the table’s definition in db/schema.sql (run ocre db schema first):
ocre g migration rebuild_authors
-- Migration: rebuild_authors
-- Applied once, in file-name order. Never edit a migration after it has been applied.
-- Rebuilds `authors` 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 authors_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bio TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO authors_new (id, name, bio, created_at, updated_at) SELECT id, name, bio, created_at, updated_at FROM authors;
DROP TABLE authors;
ALTER TABLE authors_new RENAME TO authors;
CREATE UNIQUE INDEX index_authors_on_name ON authors (name);
PRAGMA defer_foreign_keys = false;
Edit the CREATE TABLE authors_new (for example bio TEXT NOT NULL DEFAULT '', or a new value in an enum’s CHECK), keep the two column lists of the INSERT in step, then ocre migrate. D1 applies the file as one unit, so a failed copy (a row that breaks the new NOT NULL) leaves the old table untouched. Test it on the local database with real-looking data before --remote.
A table that other tables reference cannot be rebuilt this way: dropping it would run their ON DELETE CASCADE or SET NULL, because D1 enforces foreign keys. The generator refuses:
error: `comments` references `posts`: rebuilding it would delete or clear their rows
hint: D1 enforces foreign keys, so dropping `posts` runs ON DELETE on `comments`; add a new column and backfill it instead
Without db/schema.sql it answers hint: run `ocre migrate` then `ocre db schema`: the rebuild copies the table's current definition.
Undo a migration
D1 migrations only go forward: D1 (and cf) have d1 migrations apply and list, and no command that runs a migration backwards, so Ocre has no down migrations, no rollback and no redo. Undo a change the way it was made, with a new migration:
| Mistake | Fix |
|---|---|
| A column should not exist | ocre g migration remove_<column>_from_<table> |
| A column has the wrong name | ocre g migration rename_<column>_to_<new>_in_<table> |
| A column has the wrong type or constraint | ocre g migration rebuild_<table>, then edit it |
| A table should not exist | ocre g migration drop_<table> |
| An index is missing or wrong | add_index_to_<table> / remove_index_from_<table> |
| A migration changed data wrongly | a migration with UPDATE statements that repair it |
Locally, when the migration was never deployed, the shortcut is to fix the file and start over: ocre db reset deletes the local database, applies every migration and loads the seeds.
In production, when a migration or a bad UPDATE/DELETE destroyed data, D1 Time Travel restores the whole database to any minute of the last 7 days on the Workers Free plan (30 days on Workers Paid), at no cost and without any setup:
npx cf d1 list --name <database_name> # its uuid
npx cf d1 time-travel get-bookmark <uuid> --timestamp 2026-09-29T10:00:00+00:00
npx cf d1 time-travel restore <uuid> --timestamp 2026-09-29T10:00:00+00:00
A restore overwrites everything written since that minute, including the d1_migrations rows: the migrations applied after it become pending again. Delete or fix those files before the next ocre deploy, which would apply them again. Note the current bookmark (get-bookmark without --timestamp) first, to go back to if the restore itself was a mistake (restore <uuid> --bookmark <it>). <database_name> is the name of the DB binding in cloudflare.config.ts; these commands act on the remote database only.
Data migrations
Keep schema changes and data changes apart (Rails’ maintenance-task advice):
- A small, one-off fix of existing rows (a backfill with a single
UPDATE ... SET slug = lower(replace(title, ' ', '-'))) can be its own migration:ocre g migration backfill_slugswrites an empty numbered file for the SQL. It runs once, locally and onocre deploy, in order with the schema changes. - A change that needs Rust (computing values, calling an API) or touches many rows is a job: it processes a batch per run within the CPU limit and enqueues itself with a cursor for the rest (see Background jobs), started once with
ocre schedules runor from an admin route. It can run again safely if it skips rows already done. - Change the code to accept both shapes before the data moves, and remove the old shape in a later deploy.
Seeds, reset and ad-hoc SQL
ocre db seed runs db/seeds.sql (you create it; ocre new does not):
-- db/seeds.sql
INSERT INTO posts (title, body, published) VALUES ('Hello', 'First post', 1);
INSERT INTO posts (title, body, published) VALUES ('Draft', 'Not yet', 0);
INSERT INTO authors (name, bio) VALUES ('Ada', 'Mathematician');
ocre db seed # local; --remote for production
ocre db reset # local only: delete .wrangler/state/v3/d1, apply every migration, load db/fixtures and db/seeds.sql
ocre sql "SELECT id, title, published, slug FROM posts"
id | title | published | slug
---+-------+-----------+-----
1 | Hello | 1 | NULL
2 | Draft | 0 | NULL
(2 rows)
ocre sql accepts several statements separated by ;, runs on the local database unless --remote, and with --json returns D1’s results in rows:
{"command":"sql","ok":true,"rows":[{"meta":{"duration":1},"results":[{"id":1,"title":"Hello"}],"success":true}]}
Seeds run every time you call ocre db seed: write them so a second run does no harm (INSERT OR IGNORE, or run them after ocre db reset). Queries from the CLI count toward the D1 quotas like any other. See CLI commands for every flag.
The other database commands: ocre db create (--remote creates the D1 database on Cloudflare when missing), ocre db version (the last applied migration; --remote too), and, on the local database only, ocre db drop, ocre db truncate (empties every app table, keeps the schema and d1_migrations), ocre db seed --replant (truncate, then seed; Loco’s seed --reset) and ocre db prepare (applies pending migrations, and seeds a database it just created; safe to run any time). See CLI commands.
Fixtures
Fixtures are Rails-style named rows, one YAML (or JSON) file per table in db/fixtures/ (Loco’s src/fixtures). ocre db seed loads them first, then runs db/seeds.sql; either may be missing. ocre db reset and a fresh ocre db prepare load both too.
# db/fixtures/authors.yml
ada:
name: Ada
bio: Mathematician
# db/fixtures/posts.yml
DEFAULTS: &defaults # never inserted: values shared with `<<: *defaults`
published: true
hello:
<<: *defaults
title: Hello from $LABEL # $LABEL is the row's label: "Hello from hello"
author: ada # author_id = the id of the row labelled `ada`
draft:
<<: *defaults
id: 42 # explicit id
title: Draft
published: false
tags: [rust, d1] # sequences and mappings are stored as JSON text
Each file’s table is emptied first (DELETE FROM, foreign keys deferred), then gets one INSERT per row, so loading twice gives the same rows. A row without id gets Rails’ stable id for its label (crc32(label) % (2^30 - 1)), so author: ada works without knowing ada’s id. author: ada becomes author_id when the word author_id appears in migrations/*.sql; the label is looked up in the authors fixtures first, else in the only file that defines it. --from <dir> loads another directory, e.g. ocre db seed --from test/fixtures.
Fixtures load into the local database only: they replace table rows, and Ocre never deletes production data. ocre db seed --remote refuses when there are fixtures to load; put production data in db/seeds.sql.
ocre db dump writes the other way: each app table (or --tables posts,authors) to db/fixtures/<table>.yml, one row per label <table>_<id> with its explicit id, readable by ocre db seed. It never overwrites a file without --force; --dir <dir> writes elsewhere, --remote reads the production database (one SELECT * per table, a D1 row read per row). BLOB columns come back as JSON arrays of bytes, loaded as text.
Static data
Read-only data that never changes at runtime (countries, plans, a price list; Loco’s data/ loaders) belongs in the binary, not in D1: include_str!("../data/countries.json") compiles the file in, and a LazyLock parses it once per Worker instance. Reading it costs no D1 row and no request; the file adds its size to the WebAssembly binary (3 MB compressed on the free plan), and changing it is a deploy.
// src/models/countries.rs
use std::sync::LazyLock;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Country {
pub code: String,
pub name: String,
}
/// In an app: `include_str!("../../data/countries.json")`.
const COUNTRIES_JSON: &str = r#"[{"code": "FR", "name": "France"}, {"code": "JP", "name": "Japan"}]"#;
/// Parsed on first use, then shared by every request of this Worker instance.
static COUNTRIES: LazyLock<Vec<Country>> =
LazyLock::new(|| ocre::serde_json::from_str(COUNTRIES_JSON).expect("data/countries.json is valid"));
pub fn country(code: &str) -> Option<&'static Country> {
COUNTRIES.iter().find(|country| country.code == code)
}
Data migrations
Migrations change the schema; changing existing rows (backfilling a new column, splitting a name) is a data migration. A single UPDATE ... WHERE ... in the SQL migration is fine for small tables. For large ones, write a job that walks the table with query().batches(..) a few batches per run and enqueues itself with the last id until next returns None: each run stays within the 50 queries and 10 ms of CPU of a free-plan invocation, and D1 never runs one statement over millions of rows.
Update the model after a migration
A migration changes the table, not the Rust code. After add_slug_to_posts slug:string?, change src/models/post.rs in five places (the generator’s “Next” line reminds you):
// 1. The record struct: one field per column.
pub struct Post {
// ...
pub slug: Option<String>,
}
// 2. NewPost: optional input.
pub struct NewPost {
// ...
#[serde(default, deserialize_with = "ocre::optional")]
pub slug: Option<String>,
}
// 3. PostChanges: keep / clear / set.
pub struct PostChanges {
// ...
#[serde(default, deserialize_with = "ocre::patch")]
pub slug: Option<Option<String>>,
}
// 4. create: the column and its placeholder.
db.first(
"INSERT INTO posts (title, body, published, slug) VALUES (?1, ?2, ?3, ?4) RETURNING *",
params![new.title, new.body, new.published, new.slug],
)
// 5. update: one CASE WHEN per column, placeholders renumbered.
db.first(
"UPDATE posts SET title = CASE WHEN ?1 THEN ?2 ELSE title END, body = CASE WHEN ?3 THEN ?4 ELSE body END, published = CASE WHEN ?5 THEN ?6 ELSE published END, slug = CASE WHEN ?7 THEN ?8 ELSE slug END, updated_at = datetime('now') WHERE id = ?9 RETURNING *",
params![changes.title.is_some(), changes.title, changes.body.is_some(), changes.body, changes.published.is_some(), changes.published, changes.slug.is_some(), changes.slug.flatten(), id],
)
Add rules for the new field to both validate() functions if it needs any. Then fix what the compiler reports in the controllers: the HTML scaffold’s PostForm in src/posts.rs (a slug: String field, from_record, to_new, to_changes) and templates/posts/_form.html; a GraphQL node and input in src/<plural>_api.rs. Check with:
cargo check --target wasm32-unknown-unknown
A removed column goes the other way: delete the field from the three structs, the SQL and the forms. SELECT * keeps working as long as the struct has no field the table lacks (serde ignores extra columns).
The query builder
ocre::Query<T> builds one SELECT on one table, step by step, and runs it on D1. Every model’s query() starts one with the row type set (post::query() is Query::<Post>::table("posts")), so terminal methods return Posts. It is a thin layer over SQL, not an ORM: each method adds one clause, nothing runs until a terminal method, and the SQL it sends is predictable.
Two rules keep it safe from SQL injection:
- Values (
eq,is_in,contains…) are always bound parameters, never written into the SQL. - Column names and SQL fragments are
&'static str: they come from your code, not from a request. To sort by a column the visitor picks,matchtheir input onto a fixed name, as below.
Scopes and a search
Scopes are plain functions taking and returning a Query; scope(f) applies one, and they take arguments like any function. Put them in the model file, below query():
// src/models/post.rs, below query() (shown as a module of its own)
use ocre::{Ctx, Direction, Page, Paginated, Query, Result};
use crate::models::post::{self, Post};
/// Published posts only.
pub fn published(query: Query<Post>) -> Query<Post> {
query.eq("published", true)
}
/// Titles containing `term`; `%` and `_` in it match literally.
pub fn titled(query: Query<Post>, term: &str) -> Query<Post> {
query.contains("title", term)
}
/// A page of published posts, optionally filtered, sorted by a column the
/// visitor picks from a fixed list.
pub async fn search(ctx: &Ctx, term: Option<&str>, sort: &str, direction: Direction, page: Page) -> Result<Paginated<Post>> {
let mut query = post::query().scope(published);
if let Some(term) = term {
query = query.scope(|q| titled(q, term));
}
// The column comes from this match, never from the request.
let column = match sort {
"title" => "title",
_ => "created_at",
};
query.order_by(column, direction).order_desc("id").paginate(&ctx.db()?, page).await
}
search(&ctx, Some("50% off"), "title", Direction::Desc, page) with ?limit=20 sends two queries, the rows and the total:
SELECT * FROM posts WHERE published = ?1 AND title LIKE ?2 ESCAPE '\' ORDER BY title DESC, id DESC LIMIT ?3 OFFSET ?4
-- params: 1, '%50\% off%', 20, 0
SELECT COUNT(*) AS count FROM posts WHERE published = ?1 AND title LIKE ?2 ESCAPE '\'
contains escapes %, _ and \ with ocre::escape_like (Rails’ sanitize_sql_like); use escape_like yourself in hand-written LIKE SQL.
ocre::Paginated<T> serializes as {"items": [...], "total": 42, "limit": 20, "offset": 0} and has current_page(), total_pages(), has_next(), has_previous(), next_page(), previous_page() (each an Option<Page>) and map(f) to turn rows into view structs. The total costs a second query that reads every matching row: on a large table, prefer all with page.next(rows.len()) links (see Controllers).
ocre::Direction deserializes from asc or desc (any case; asc by default), so a handler takes it straight from the query string:
// src/post_search.rs
use axum::{
Router,
extract::{Query, State},
routing::get,
};
use ocre::{ApiResult, Ctx, Direction, Json, Page, Paginated};
use serde::Deserialize;
use crate::models::post::{self, Post};
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/posts/search", get(search))
}
#[derive(Deserialize)]
pub struct Search {
#[serde(default)]
pub q: String,
#[serde(default)]
pub sort: String,
#[serde(default)]
pub direction: Direction,
}
/// `GET /api/posts/search?q=rust&sort=title&direction=desc&limit=20`
async fn search(State(ctx): State<Ctx>, page: Page, Query(search): Query<Search>) -> ApiResult<Json<Paginated<Post>>> {
let mut query = post::query().eq("published", true);
let term = search.q.trim();
if !term.is_empty() {
query = query.any(|q| q.contains("title", term).contains("body", term));
}
let column = if search.sort == "title" { "title" } else { "created_at" };
Ok(Json(query.order_by(column, search.direction).paginate(&ctx.db()?, page).await?))
}
(axum::extract::Query reads the query string; ocre::Query is the builder. Import only one of them under that name.)
Conditions
Conditions combine with AND, in the order they are added. The column argument may be qualified (comments.author) after a join.
| Method | SQL |
|---|---|
eq(col, v), ne(col, v) | col = ?, col != ? (eq(col, None) matches nothing: use is_null) |
gt, gte, lt, lte | col > ?, >=, <, <= |
between(col, low, high) | col BETWEEN ? AND ? (inclusive) |
is_in(col, values), not_in(col, values) | col IN (?, ?...), col NOT IN (...); an empty is_in matches nothing (D1 binds 100 parameters at most per query) |
is_null(col), is_not_null(col) | col IS NULL, col IS NOT NULL |
like(col, pattern), not_like(col, pattern) | col LIKE ? with your pattern (% and _ are wildcards) |
contains(col, text), starts_with, ends_with | col LIKE ? ESCAPE '\' with %text%, text%, %text, the text escaped |
where_sql(fragment, params![..]) | (fragment), with bare ? placeholders bound in order |
any(|q| q.a(..).b(..)) | (a OR b) |
not(|q| q.a(..).b(..)) | NOT (a AND b) |
none() | WHERE 0: no row, and no row read (Rails’ none) |
where_associated(table, fk), where_missing(table, fk) | EXISTS (SELECT 1 FROM table WHERE table.fk = <this table>.id), or NOT EXISTS: rows with / without a child (Rails’ where.associated / where.missing on a has-many side; for a belongs-to side, is_not_null / is_null on the column) |
date_range(col, from, to) | col BETWEEN ? AND ? with both bounds, col > ? or col < ? with one, nothing with none (Loco’s DateRangeBuilder, for ?from=&to= filters) |
unscope_where() | removes the conditions added so far (a model’s default conditions, see below); chain new ones for Rails’ rewhere |
The builder numbers every placeholder ?1, ?2... in the final SQL, including those of where_sql and having, so fragments mix freely with the other methods:
post::query()
.any(|q| q.is_null("slug").eq("slug", ""))
.not(|q| q.eq("published", false))
.where_sql("created_at >= datetime('now', ?)", params!["-7 days"])
// SELECT * FROM posts WHERE (slug IS NULL OR slug = ?1) AND NOT (published = ?2) AND (created_at >= datetime('now', ?3))
Order, limits, columns, joins, groups
| Method | SQL |
|---|---|
order_asc(col), order_desc(col), order_by(col, direction) | ORDER BY col ASC/DESC, appended after any order already set |
order_in(col, values) | ORDER BY CASE col WHEN ? THEN 0 ... ELSE n END (Rails’ in_order_of; unlisted values last) |
order_sql(term) | a raw term, e.g. lower(title) ASC |
reorder() | removes the order set so far (a scope’s default order) |
reverse_order() | flips every ASC/DESC (Rails’ reverse_order); ORDER BY <table>.id DESC when no order is set |
limit(n), offset(n), page(page) | LIMIT ?, OFFSET ?; page sets both from ?limit=&offset= |
unscope_limit() | removes the limit and offset set so far |
select::<U>(columns) | SELECT columns instead of *; the rows become U, a struct whose fields match the column names (AS for expressions) |
distinct() | SELECT DISTINCT |
join(clause) | the clause as written: JOIN ... or LEFT JOIN ... |
group_by(columns), having(fragment, params![..]) | GROUP BY columns HAVING (fragment) |
Running a query
Terminal methods take &Db (&ctx.db()?, or &ctx.db_named("...")? for another database) and return ocre::Result:
| Method | Runs | Returns |
|---|---|---|
all(&db) | the query | Vec<T>, every row in memory: bound it with limit or page |
first(&db) | the query with LIMIT 1 | Option<T> (Rails’ find_by/first; add an order for “first”) |
paginate(&db, page) | the rows and COUNT(*) | Paginated<T> |
count(&db) | SELECT COUNT(*), ignoring order and limits | i64 |
exists(&db) | SELECT 1 ... LIMIT 1 | bool (Rails’ exists?) |
pluck::<V>(&db, expr) | SELECT expr AS value, keeping order and limits | Vec<V> (Rails’ pluck, ids) |
aggregate::<V>(&db, expr) | SELECT SUM(price) AS value ..., without order or limits | Option<V>: None when SQL gives NULL (the SUM of no row) |
update_all(&db, vec![(col, value.into_param())]) | UPDATE table SET col = ? WHERE ... | rows changed |
delete_all(&db) | DELETE FROM table WHERE ... | rows deleted |
first_or_create(&db, || async { .. }) | the query with LIMIT 1, then your create when nothing matched | T (Rails’ find_or_create_by; two racing requests can both create) |
create_or_first(&db, || async { .. }) | your create, then the query when it failed with “has already been taken” or UNIQUE constraint failed | T (Rails’ create_or_find_by: safe under races, needs a UNIQUE index) |
batches(size, |row| row.id), then next(&db) | WHERE id > <last id> ORDER BY id LIMIT size, one query per call | Option<Vec<T>>, None when done (Rails’ find_in_batches; loop over each batch for find_each) |
explain(&db) | EXPLAIN QUERY PLAN SELECT ... | Vec<String>, one line per step (Rails’ explain) |
update_all and delete_all ignore joins, order and limits, skip validations and callbacks, do not touch updated_at unless you list it, and, without any condition, change every row of the table. delete_all leaves the R2 files of attachment columns in place.
For batches and custom calls, to_statement(), count_statement(), exists_statement(), value_statement(expr), aggregate_statement(expr), update_statement(sets), delete_statement() and explain_statement() return the ocre::Statement (SQL plus parameters) without running it: put several in one transaction, or check the SQL in a unit test.
batches pages by id (keyset pagination), so the hundredth batch costs the same as the first, unlike OFFSET. A Worker invocation may run 50 queries on the free plan: walk a large table from a job or a scheduled task, a few batches per run, and store batches.after() (the last id) to resume the next run with .resume_after(Some(id)).
A model’s default scope (Rails’ default_scope) is its query(): add the condition there (Query::table("posts").is_null("deleted_at") for soft-deleted rows) and every generated function, controller and API applies it; Query::table("posts") or unscope_where() is the unscoped query. Block-level scoping (Post.where(..).scoping { }) has no equivalent: pass the query or a scope function to the code that needs it.
A few calculations, eager loads and bulk changes on the blog’s tables:
// src/models/stats.rs
use ocre::{Ctx, IntoParam, Result, params};
use serde::{Deserialize, Serialize};
use crate::models::{
comment,
post::{self, Post},
product,
};
/// One row per post: columns of the SELECT, by name.
#[derive(Debug, Deserialize, Serialize)]
pub struct CommentCount {
pub post_id: i64,
pub comments: i64,
}
/// The ten posts with the most comments (two or more).
pub async fn busiest_posts(ctx: &Ctx) -> Result<Vec<CommentCount>> {
comment::query()
.select::<CommentCount>("post_id, COUNT(*) AS comments")
.group_by("post_id")
.having("COUNT(*) >= ?", params![2])
.order_desc("comments")
.limit(10)
.all(&ctx.db()?)
.await
}
/// Posts `author` commented on, newest first (a JOIN, one query).
pub async fn commented_by(ctx: &Ctx, author: &str) -> Result<Vec<Post>> {
post::query()
.select::<Post>("posts.*")
.distinct()
.join("JOIN comments ON comments.post_id = posts.id")
.eq("comments.author", author)
.order_desc("posts.id")
.limit(20)
.all(&ctx.db()?)
.await
}
/// The average price, `None` when there is no product.
pub async fn average_price(ctx: &Ctx) -> Result<Option<f64>> {
product::query().aggregate(&ctx.db()?, "AVG(price)").await
}
/// Ids of the latest drafts.
pub async fn draft_ids(ctx: &Ctx) -> Result<Vec<i64>> {
post::query().eq("published", false).order_desc("id").limit(100).pluck(&ctx.db()?, "id").await
}
/// Whether a post has any comment: stops at the first one.
pub async fn has_comments(ctx: &Ctx, post_id: i64) -> Result<bool> {
comment::query().eq("post_id", post_id).exists(&ctx.db()?).await
}
/// Unpublishes the given posts in one statement; returns how many changed.
pub async fn unpublish(ctx: &Ctx, ids: &[i64]) -> Result<usize> {
post::query()
.is_in("id", ids.iter().copied())
.update_all(&ctx.db()?, vec![("published", false.into_param())])
.await
}
/// Deletes the comments of an author (no callbacks run).
pub async fn purge_author(ctx: &Ctx, author: &str) -> Result<usize> {
comment::query().eq("author", author).delete_all(&ctx.db()?).await
}
/// Posts nobody commented on yet (`NOT EXISTS`, one index lookup per post).
pub async fn uncommented(ctx: &Ctx) -> Result<Vec<Post>> {
post::query().where_missing("comments", "post_id").order_desc("id").limit(20).all(&ctx.db()?).await
}
/// Walks every post, 100 at a time (a job or scheduled task: one query per batch).
pub async fn count_words(ctx: &Ctx) -> Result<usize> {
let db = ctx.db()?;
let mut batches = post::query().batches(100, |post| post.id);
let mut words = 0;
while let Some(posts) = batches.next(&db).await? {
words += posts.iter().map(|post| post.body.split_whitespace().count()).sum::<usize>();
}
Ok(words)
}
/// The product with this name, created when missing. `name` has a UNIQUE
/// index: when two requests race, the loser reads the winner's row.
pub async fn product_named(ctx: &Ctx, name: &str) -> Result<product::Product> {
let db = ctx.db()?;
product::query()
.eq("name", name)
.create_or_first(&db, || async {
let sql = "INSERT INTO products (name, price) VALUES (?1, 0) RETURNING *";
db.first(sql, params![name]).await?.ok_or_else(|| ocre::Error::internal("no row returned"))
})
.await
}
busiest_posts sends SELECT post_id, COUNT(*) AS comments FROM comments GROUP BY post_id HAVING (COUNT(*) >= ?1) ORDER BY comments DESC LIMIT ?2, and commented_by sends SELECT DISTINCT posts.* FROM posts JOIN comments ON comments.post_id = posts.id WHERE comments.author = ?1 ORDER BY posts.id DESC LIMIT ?2.
Rows read are what D1 bills: count, aggregate and paginate’s total read every matching row, exists and first stop early, and a filter or order on a column without an index scans the whole table. references and ^ columns are indexed; add others with ocre g migration add_index_to_<table> <columns>.
SQLite tells whether a query uses an index (query.explain(&db).await? returns the same detail lines from code). Paste the SQL (with a sample value in place of each ?N) into EXPLAIN QUERY PLAN on the local database; SEARCH ... USING INDEX is good, SCAN <table> reads every row:
ocre sql "EXPLAIN QUERY PLAN SELECT * FROM comments WHERE post_id = 1"
id | parent | notused | detail
---+--------+---------+--------------------------------------------------------------
3 | 0 | 61 | SEARCH comments USING INDEX index_comments_on_post_id (post_id=?)
(1 row)
Custom queries
When the builder does not fit (a subquery, UNION, INSERT ... ON CONFLICT, a window function, a report across tables), write the SQL. Every query goes through ctx.db()?, the DB binding of cloudflare.config.ts, with ?1, ?2... placeholders and params![...]. Never build SQL with format! from user input: values bound as parameters cannot change the statement.
| Method | Use for | Returns |
|---|---|---|
db.all::<T>(sql, params) | SELECT returning rows | Vec<T> (all rows in memory: always LIMIT) |
db.first::<T>(sql, params) | one row, INSERT/UPDATE ... RETURNING * | Option<T> |
db.execute(sql, params) | INSERT, UPDATE, DELETE without rows | rows changed (usize) |
db.exists(sql, params) | SELECT 1 ... LIMIT 1 | bool |
db.batch(statements) | several ocre::Statements in one transaction | rows changed per statement |
params! accepts strings, integers, f64, bool (bound as 1/0), Option (None binds NULL) and serde_json::Value (bound as JSON text), mixed freely. A failed query is Error::Internal: the client gets a 500 “Internal server error”, and the Worker log gets the D1 message and the SQL. The full API is in the rustdoc of Db.
Queries on one table belong in its model file, next to the generated functions. Queries across tables can go in a module of their own under src/models/ (add pub mod reports; under // ocre:models in src/models/mod.rs):
// src/models/reports.rs
use ocre::{Ctx, Page, Result, escape_like, params};
use serde::{Deserialize, Serialize};
use crate::models::post::Post;
/// One row of `posts_with_comment_counts`: columns of the SELECT, by name.
#[derive(Debug, Deserialize, Serialize)]
pub struct PostSummary {
pub id: i64,
pub title: String,
#[serde(deserialize_with = "ocre::bool_from_sql")]
pub published: bool,
pub comments: i64,
}
/// Posts with their number of comments, in one query (a JOIN, not one
/// COUNT per post).
pub async fn posts_with_comment_counts(ctx: &Ctx, page: Page) -> Result<Vec<PostSummary>> {
ctx.db()?
.all(
"SELECT posts.id, posts.title, posts.published, COUNT(comments.id) AS comments
FROM posts LEFT JOIN comments ON comments.post_id = posts.id
GROUP BY posts.id ORDER BY posts.id DESC LIMIT ?1 OFFSET ?2",
params![page.limit, page.offset],
)
.await
}
/// Published posts whose title contains `term`.
pub async fn search_published(ctx: &Ctx, term: &str) -> Result<Vec<Post>> {
let pattern = format!("%{}%", escape_like(term));
ctx.db()?
.all(
"SELECT * FROM posts WHERE published = ?1 AND title LIKE ?2 ESCAPE '\\' ORDER BY id DESC LIMIT 20",
params![true, pattern],
)
.await
}
/// Unpublishes every post older than `days`; returns how many changed.
pub async fn unpublish_older_than(ctx: &Ctx, days: i64) -> Result<usize> {
ctx.db()?
.execute(
"UPDATE posts SET published = 0, updated_at = datetime('now')
WHERE published = 1 AND created_at < datetime('now', '-' || ?1 || ' days')",
params![days],
)
.await
}
format! builds the LIKE pattern, a value bound to ?2, not the SQL; escape_like makes a % or _ typed by the user match literally (with ESCAPE '\' in the SQL). Returned as JSON from a handler (Ok(Json(reports::posts_with_comment_counts(&ctx, page).await?))), on the seeded database with three comments added:
[{"id":2,"title":"Draft","published":false,"comments":1},{"id":1,"title":"Hello","published":true,"comments":2}]
Things to know about D1 values:
- Booleans are INTEGER 0/1: bind a
boolwithparams!, read it with#[serde(deserialize_with = "ocre::bool_from_sql")], compare in SQL withpublished = 1. - Integers come back as JavaScript numbers, exact only within ±
ocre::MAX_SAFE_INTEGER(2^53 - 1 = 9007199254740991). A larger value cannot be read back intoi64, so generated models validate integer fields withv.safe_integer(..), and ani64beyond it is bound as decimal text. - Dates are TEXT:
created_atisYYYY-MM-DD HH:MM:SSin UTC; compare with SQLite’sdatetime('now', '-7 days').ocre::now()gives the current Unix time in seconds (std::time::SystemTime::now()panics in WebAssembly). - JSON columns hold JSON text: bind a
serde_json::Value, read it withocre::json_from_sql. - Indexes: rows read counts every row scanned.
WHEREandORDER BYon an unindexed column scan the whole table;referencesand^fields are indexed, add others withCREATE INDEXin a migration.
Avoid N+1 queries
Loading a list and then one related record per item runs 1 + N queries: slow, N times the rows read, and limited to 50 queries per request on the free plan. Ocre has no lazy loading to detect (an association is an async function you call), so the rule is simply: never call an association or find in a loop. Load the whole list’s associations at once with the generated preload_<parents> (belongs to), for_<parents> (has many) or find_many, which use WHERE id IN (...) (100 ids per query):
// src/models/feed.rs
use ocre::{Ctx, Page, Result};
use serde::Serialize;
use crate::models::{
comment::{self, Comment},
post::{self, Post},
};
#[derive(Serialize)]
pub struct CommentWithPost {
pub comment: Comment,
pub post: Option<Post>,
}
/// The latest comments with their post: 2 queries for the whole page,
/// instead of 1 + one `comment.post(ctx)` per comment.
pub async fn recent_comments(ctx: &Ctx, page: Page) -> Result<Vec<CommentWithPost>> {
let comments = comment::all(ctx, page).await?;
let posts = comment::preload_posts(ctx, &comments).await?;
Ok(comments
.into_iter()
.map(|comment| {
let post = posts.get(&comment.post_id).cloned();
CommentWithPost { comment, post }
})
.collect())
}
#[derive(Serialize)]
pub struct PostWithComments {
pub post: Post,
pub comments: Vec<Comment>,
}
/// A page of posts with all their comments: 2 queries, whatever the page size.
pub async fn posts_with_comments(ctx: &Ctx, page: Page) -> Result<Vec<PostWithComments>> {
let posts = post::all(ctx, page).await?;
let ids: Vec<i64> = posts.iter().map(|post| post.id).collect();
let mut comments = comment::for_posts(ctx, &ids).await?;
Ok(posts
.into_iter()
.map(|post| {
let (mine, rest) = comments.drain(..).partition(|comment| comment.post_id == post.id);
comments = rest;
PostWithComments { post, comments: mine }
})
.collect())
}
Returned as JSON with ?limit=2:
[{"comment":{"id":3,"author":"Cy","body":"On two","post_id":2,"created_at":"2026-09-29 04:50:08","updated_at":"2026-09-29 04:50:08"},"post":{"id":2,"title":"Draft","body":"Not yet","published":false,"created_at":"2026-09-29 04:48:44","updated_at":"2026-09-29 04:49:13"}},{"comment":{"id":2,"author":"Bob","body":"Second","post_id":1,"created_at":"2026-09-29 04:50:08","updated_at":"2026-09-29 04:50:08"},"post":{"id":1,"title":"Hello","body":"First post","published":true,"created_at":"2026-09-29 04:48:44","updated_at":"2026-09-29 04:48:44"}}]
preload_posts returns a HashMap<i64, Post> keyed by id (rows come in no particular order, and missing ids are skipped). for_posts is not paginated: use it for a page of parents, not for a whole table. For aggregates (counts, sums), prefer one JOIN ... GROUP BY query as in busiest_posts and posts_with_comment_counts above.
Many rows in one query
A free-plan invocation runs 50 D1 queries, and D1 binds at most 100 parameters per statement, so writing rows one by one does not scale. ocre::bulk writes many rows in one statement: the rows go as one JSON array parameter, unpacked by SQLite’s json_each.
| Builder | SQL | Rails |
|---|---|---|
bulk::insert(table, &columns, &rows) | INSERT INTO t (a, b) SELECT value ->> '$.a', value ->> '$.b' FROM json_each(?1) | insert_all |
bulk::upsert(table, key, &columns, &rows) | the same, ON CONFLICT (key) DO UPDATE SET the other columns | upsert_all |
bulk::update(table, key, &columns, &rows, touch) | UPDATE t SET a = row.value ->> '$.a' ... FROM json_each(?1) AS row WHERE t.key = row.value ->> '$.key', plus updated_at when touch | update_all with a value per row |
#[derive(serde::Serialize)]
struct Play {
track_id: i64,
played_at: String,
}
let insert = ocre::bulk::insert("plays", &["track_id", "played_at"], &plays)?;
let inserted = ctx.db()?.execute(&insert.sql, insert.params).await?; // one query, however many rows
Rows are structs or maps (anything serializing to a JSON object); each listed column is read by name, a missing one is NULL. Booleans are stored as 1/0, nested values as JSON text. The builders return a Statement, so several go into one db.batch to apply together. They skip the models’ validations and callbacks, like Rails’ insert_all: validate first when the data comes from users. Table and column names must be plain identifiers (never user input); a D1 statement is limited in size, so send a few thousand small rows at a time (rows.chunks(500)).
Transactions
D1 runs every statement in auto-commit mode, and refuses BEGIN TRANSACTION and SAVEPOINT from a Worker. The unit of atomicity is db.batch(statements): D1 runs the statements in order, in one round trip, and when one fails none of them is applied. There is no transaction left open while Rust code runs, so a batch cannot read a value and decide what to write next.
// src/models/merging.rs
use ocre::{Ctx, IntoParam, Result, Statement, params};
use crate::models::{comment, post};
/// Moves every comment of post `from` to post `into`, then deletes `from`:
/// all three statements are applied, or none. `false` when `from` did not exist.
pub async fn merge_posts(ctx: &Ctx, from: i64, into: i64) -> Result<bool> {
let move_comments = comment::query().eq("post_id", from).update_statement(vec![("post_id", into.into_param())]);
let touch = Statement::new("UPDATE posts SET updated_at = datetime('now') WHERE id = ?1", params![into]);
let delete_post = post::query().eq("id", from).delete_statement();
// The rows changed by each statement, in order.
let changed = ctx.db()?.batch(vec![move_comments, touch, delete_post]).await?;
Ok(changed[2] == 1)
}
/// Takes `quantity` from a product's stock only if that much is left. The
/// check and the write are one statement, so two requests at the same moment
/// cannot both take the last item.
pub async fn take_stock(ctx: &Ctx, product_id: i64, quantity: i64) -> Result<bool> {
let changed = ctx
.db()?
.execute(
"UPDATE products SET stock = stock - ?1, updated_at = datetime('now') WHERE id = ?2 AND stock >= ?1",
params![quantity, product_id],
)
.await?;
Ok(changed == 1)
}
What this means in practice:
- Each generated
create,updateanddeletewrites with one statement, so it is atomic on its own. Their uniqueness and “must exist” checks are separate reads: keep theUNIQUEindex andREFERENCESconstraint as the real guarantee. - Writes to several rows or tables that must succeed together go into one
batch: build the statements withStatement::new(sql, params![..])or the builder’s*_statement()methods. Callbacks and validations do not run for them. - A decision based on a value (“only if enough stock is left”, “only if still a draft”) goes into the
WHEREof the write, and the number of rows changed tells whether it happened. Optimistic locking is the generated form of this rule. - A batch counts the rows read and written by each statement, as if each ran alone; it saves round trips, not quota.
- Work that must happen after a write but may fail independently (an email, a call to another API) goes in a background job enqueued after the write, not in the batch.
Optimistic locking
Two people open the same record, both save: without a check, the second save silently overwrites the first. A lock_version:integer field (Rails’ magic column) makes update refuse the stale one:
ocre g scaffold Article title:string body:rich_text lock_version:integer
# or on an existing table:
ocre g migration add_lock_version_to_articles lock_version:integer
The column is lock_version INTEGER NOT NULL DEFAULT 0. The record has pub lock_version: i64; NewArticle has no such field (a new row starts at 0); ArticleChanges has pub lock_version: Option<i64>, the version the change was made from. update bumps and checks it in the same statement:
UPDATE articles SET ..., lock_version = lock_version + 1, updated_at = datetime('now')
WHERE id = ?4 AND (?5 IS NULL OR lock_version = ?5) RETURNING *
When no row comes back although the id exists, update returns Error::Conflict (409, “This article was changed by someone else since you opened it: reload it and apply your changes again.”), Rails’ StaleObjectError. lock_version: None skips the check (code that updates without having read the row).
- HTML scaffolds put
<input type="hidden" name="lock_version" value="{{ form.lock_version }}">in the edit form; a stale submit shows the 409 error page. - JSON APIs return
lock_versionwith every record; clients send it back inPATCHbodies ({"title": "New", "lock_version": 3}) and get a JSON 409 when someone else saved first. GraphQL patches takelockVersion. - Cost: nothing extra on success; one
SELECT 1(one row read) to tell a conflict from a missing id.
Pessimistic locking (SELECT ... FOR UPDATE, with_lock) has no D1 equivalent: D1 never holds a transaction open while Rust runs. For “check then write” on one row, put the check in the WHERE of the write, as in take_stock under Transactions.
Encrypted columns
ocre::encryption encrypts a column’s value in Rust before it reaches D1 and decrypts it when the row is read, like Rails’ encrypts. Someone who reads the database (a leaked export, the D1 console) sees only ciphertext. The cipher is AES-256-GCM, with keys derived from SECRET_KEY_BASE (HKDF-SHA256, separate from the cookie key); a changed value fails to decrypt instead of giving a wrong result. Two field types go in the row struct:
| Type | Stored text | Can be searched |
|---|---|---|
ocre::encryption::Encrypted | a new random nonce each write: the same value never gives the same text | no: only read and written |
ocre::encryption::Deterministic | the nonce comes from the value (HMAC-SHA256): equal values give equal texts | yes, with eq/is_in and a UNIQUE index; the price is that anyone can see which rows share a value |
Both deserialize by decrypting, bind as a parameter by encrypting (IntoParam), serialize to JSON as the plain value, and print Encrypted(..) in Debug output and logs. Declare the column TEXT (a value takes about 4/3 of its length plus 42 characters) and write the model by hand or edit a generated one: the generators have no encrypted field type. For a contacts table made with ocre g migration create_contacts email:string^ phone:string?:
// src/models/contact.rs
use ocre::{
Ctx, Error, Query, Result,
encryption::{self, Deterministic, Encrypted},
params,
};
use serde::{Deserialize, Serialize};
/// A row of `contacts`: `email` and `phone` are stored encrypted.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Contact {
pub id: i64,
pub email: Deterministic,
pub phone: Option<Encrypted>,
pub created_at: String,
pub updated_at: String,
}
pub fn query() -> Query<Contact> {
Query::table("contacts")
}
/// Emails are normalized before encryption, so lookups ignore case.
fn normalize(email: &str) -> String {
email.trim().to_lowercase()
}
pub async fn create(ctx: &Ctx, email: &str, phone: Option<String>) -> Result<Contact> {
ctx.db()?
.first(
"INSERT INTO contacts (email, phone) VALUES (?1, ?2) RETURNING *",
params![Deterministic::from(normalize(email)), phone.map(Encrypted::from)],
)
.await?
.ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))
}
/// Deterministic encryption makes `WHERE email = ?1` work.
pub async fn find_by_email(ctx: &Ctx, email: &str) -> Result<Option<Contact>> {
query().eq("email", Deterministic::from(normalize(email))).first(&ctx.db()?).await
}
/// During a key rotation, rows written with the previous key have another
/// text: look for every candidate.
pub async fn find_by_email_during_rotation(ctx: &Ctx, email: &str) -> Result<Option<Contact>> {
let candidates = encryption::installed()?.deterministic_candidates(&normalize(email));
query().is_in("email", candidates).first(&ctx.db()?).await
}
/// Rewrites a batch of rows with the current key (run it from a job until
/// it returns 0).
pub async fn reencrypt(ctx: &Ctx, after_id: i64) -> Result<usize> {
let db = ctx.db()?;
let rows = query().gt("id", after_id).order_asc("id").limit(20).all(&db).await?;
for row in &rows {
db.execute(
"UPDATE contacts SET email = ?1, phone = ?2 WHERE id = ?3",
params![row.email.clone(), row.phone.clone(), row.id],
)
.await?;
}
Ok(rows.len())
}
contact.email.as_str() (or {{ contact.email }} in a template, through Display) is the plain value. Things to know:
- Keys.
Ctxinstalls them the first time a Worker instance handles a request: one key derivation, then microseconds of CPU per value. Without a validSECRET_KEY_BASE(64 characters or more), reading an encrypted row fails with a 500 whose log names the fix, and binding one writesNULL. LosingSECRET_KEY_BASEloses the data: keep a copy. - Rotation. Put the old secret in
SECRET_KEY_BASE_PREVIOUS(comma-separated for several) when you changeSECRET_KEY_BASE(see Security): old values still decrypt, new writes use the new key. Deterministic lookups needdeterministic_candidatesuntil a job such asreencrypthas rewritten every row; then drop the previous secret. - Existing plain rows. To encrypt a column that already has data, read it with
encryption::installed()?.decrypt_or_plaintext(&text)(plain text passes through, like Rails’support_unencrypted_data) while a job rewrites the rows. - What does not work on encrypted columns:
LIKE, ranges,ORDER BYand aggregates (the database sees only ciphertext), and aUNIQUEindex on anEncryptedcolumn. Validate the plain value before encrypting it. - Tests. Native code that reads encrypted values calls
encryption::install(Encryptor::new(&secret, &[])?)first.
Several databases
An app can use several D1 databases, for example to keep analytics or audit logs out of the main one (each database has its own 500 MB limit on the free plan; the account’s 5 GB of storage and daily row quotas are shared). Each is a bindings.d1(...) entry in worker.env of cloudflare.config.ts, with its own binding name, and its own migrations folder:
DB: bindings.d1({ name: "blog" }),
ANALYTICS: bindings.d1({ name: "blog-analytics" }), // migrations in migrations/analytics/
ctx.db()? is the DB binding; ctx.db_named("ANALYTICS")? returns the other one as the same ocre::Db, so every query method and the builder’s terminal methods work on it:
// src/models/page_view.rs
use ocre::{Ctx, Query, Result, params};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct PageView {
pub id: i64,
pub path: String,
pub created_at: String,
}
pub fn query() -> Query<PageView> {
Query::table("page_views")
}
pub async fn record(ctx: &Ctx, path: &str) -> Result<()> {
ctx.db_named("ANALYTICS")?.execute("INSERT INTO page_views (path) VALUES (?1)", params![path]).await?;
Ok(())
}
pub async fn views_of(ctx: &Ctx, path: &str) -> Result<i64> {
query().eq("path", path).count(&ctx.db_named("ANALYTICS")?).await
}
A missing binding is a 500 whose log says which bindings.d1(...) entry to add. The Ocre CLI manages only the DB database (ocre migrate, ocre sql, ocre db ..., and ocre deploy, which creates and migrates it); manage the others with cf in production:
mkdir -p migrations/analytics
npx cf d1 migrations create create_page_views --dir migrations/analytics # an empty numbered file to fill in
npx cf d1 create --name blog-analytics # once, before the first deploy
npx cf d1 list --name blog-analytics # its uuid
npx cf d1 migrations apply <uuid> --dir migrations/analytics # production
Locally, cf 1.0.0-beta.5 cannot migrate a local database yet (see Why wrangler still appears); do what Ocre does for DB and run the app’s wrangler with a small config of its own, db/analytics-d1.json:
{"name": "blog", "compatibility_date": "2026-09-01", "d1_databases": [{"binding": "ANALYTICS", "database_name": "blog-analytics", "migrations_dir": "../migrations/analytics"}]}
node_modules/.bin/wrangler d1 migrations apply ANALYTICS --local -c db/analytics-d1.json --persist-to .wrangler/state
--persist-to .wrangler/state is the state ocre dev uses, so the local Worker sees the tables.
Queries cannot join tables of two databases and a batch runs on one database: load ids from one, then find_many-style is_in queries on the other.
Read replicas
D1 can keep read-only copies of a database near the Workers that use it (read replication, free). Ocre routes queries through D1 sessions so each visitor still reads what they wrote (Rails’ automatic role switching):
- turn replication on for the database (dashboard: D1 > database > Settings);
- set the variable in
cloudflare.config.ts(andD1_REPLICAS=onin.dev.varsto try it locally, where there is no replica):
D1_REPLICAS: bindings.text("on"),
Then every request served by ocre::serve uses one session per database: a GET or HEAD may start on any replica, other methods start on the primary, and later queries of the request see the earlier ones. After a request that wrote, the response sets ocre_d1_<binding> (HttpOnly, 5 minutes) with the session’s bookmark, and the visitor’s next requests resume from it, so the page shown after a form has the new row. Read-only responses set no cookie and stay cacheable. Jobs, crons and email handlers query the primary. Nothing changes in the models: ctx.db() returns the session. See ocre::replicas.
Form objects and plain structs
Rails’ Active Model makes a plain Ruby class behave like a model: attributes, validations, callbacks, naming, serialization. In Ocre any struct already does, with serde and ocre::Validator, so a form that is not a table (a contact form, a sign-up that writes two tables, a search) is a struct with a validate() function:
// src/models/contact_form.rs
use ocre::{Result, Validator};
use serde::{Deserialize, Serialize};
/// The contact form: no table, but typed fields, defaults and validations.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ContactForm {
pub name: String,
pub email: String,
pub message: String,
pub accept_terms: bool,
/// Never serialized back to a client (Rails' `except:`).
#[serde(skip_serializing)]
pub honeypot: String,
}
impl ContactForm {
/// Rails' `valid?` / `errors`: every failed check at once.
pub fn validate(&self) -> Validator {
let mut v = Validator::new();
v.required("name", &self.name).email("email", &self.email).min_length("message", &self.message, 10);
v.acceptance("accept_terms", self.accept_terms).absence("honeypot", &self.honeypot);
v
}
/// Rails' `validate!`: `Error::Invalid` (422) with every message.
pub fn validate_strict(&self) -> Result<()> {
self.validate().finish()
}
}
A handler takes it with Form(form): Form<ContactForm> (or Json), calls form.validate().finish()?, then does the work: send the email, or call two models’ create in turn. How each Active Model module maps:
| Active Model | In Ocre |
|---|---|
API, Model, AttributeAssignment, Attributes | a struct deriving Deserialize (mass assignment from a form, JSON or query string), with typed fields and #[serde(default)] defaults |
Validations, validates_with, validates_each, custom validators | validate() -> Validator; a reusable rule is a function fn slug(v: &mut Validator, field: &str, value: &str); v.merge(other.validate()) combines validators (see Validations) |
Callbacks, before_validation | plain code before validate() (the generated before_create / before_update run before validation) |
Conversion (to_param, to_key), Naming (model_name, route_key) | the generated paths module of each controller (paths::show(post.id)) and the model file’s name; no reflection |
Dirty | <Model>Changes and its changed() (see New and Changes) |
Serialization, as_json(only:, except:, methods:, include:) | serde: #[serde(skip_serializing)], rename, flatten, or a view struct built from the record (Paginated::map for lists) holding exactly what the response shows |
SecurePassword (has_secure_password) | ocre g auth: ocre::password digests with a confirmation check and password resets (see Authentication) |
Translation (human_attribute_name) | FieldError::full_message humanizes field names; translated names come from locale files (see I18n) |
Lint::Tests | not needed: the compiler checks what a view or handler uses |
What is not supported
Ocre models are generated Rust over SQL, not an ORM, and D1 is SQLite behind an HTTP API. These Rails features have no equivalent; the second column is what to do instead:
| Not supported | Instead |
|---|---|
down migrations, db:rollback, db:migrate:redo, reversible change | D1 migrations are forward-only: a new migration, or D1 Time Travel (see Undo a migration) |
Model.transaction do ... end, savepoints, after_commit | db.batch (see Transactions); a job enqueued after the write |
Lazy loading, strict_loading | associations are explicit async functions; includes / preload / eager_load are preload_<parents>, for_<parents>, find_many, or one join + select into a row struct |
Pessimistic locking (lock, SELECT ... FOR UPDATE) | conditions in the UPDATE’s WHERE, checking the rows changed; lock_version for optimistic locking |
| Composite primary keys, single-table inheritance, delegated types | a plain id key and a unique index on the pair; a kind enum column (one table), or one table per type pointed to by a polymorphic reference |
Tables without id/created_at/updated_at from the generators | an empty migration with a name the generator does not read (ocre g migration events_table), your own CREATE TABLE, and a hand-written module |
ActiveModel modules on plain structs | serde and Validator (see Form objects and plain structs) |
Fixtures’ created_at/updated_at filled automatically, ERB in fixture files | write the values in db/fixtures/*.yml; YAML anchors and << share them |
readonly records | records are plain values: nothing saves them except the model’s update |
Enum fields with --graphql | a string field with v.inclusion(..) |
| Joins across databases | two queries, one per database |
See also
- Validations: every
Validatorcheck, and how errors reach forms and JSON - Controllers and routing: calling the model from handlers
- JSON APIs and GraphQL: the model behind
/api/<plural>and/graphql - Generators, Field types, CLI commands
- Free-plan limits
- API index and the rustdoc
Validations
Validations check input before it reaches the database: ocre::Validator collects every failed rule with Rails’ messages, and finish() turns them into Error::Invalid, a 422 that HTML forms show next to the typed values and JSON APIs report per field. This page lists every check, where rules belong, and what clients receive.
Before you start
- An Ocre app from
ocre new, with a model fromocre g model,ocre g scaffoldorocre g api(see Models and migrations). The examples use the blog starter (ocre new blog --starter blog),ocre g scaffold Comment author:string body:text post:referencesandocre g api Product name:string^ price:float stock:integer? --graphql. - Validations run in the Worker and cost a little CPU and no binding call, except the database checks (uniqueness, references), which each read about one row with the indexes the generators create.
How a validation works
// src/models/signup_rules.rs
use ocre::{Result, Validator};
pub fn check_signup(name: &str, email: &str, age: i64) -> Result<()> {
let mut v = Validator::new();
v.required("name", name).max_length("name", name, 50);
v.email("email", email);
v.range("age", age, 13..=120);
v.finish()
}
Each check records an error when it fails and returns &mut Validator, so checks chain. Nothing stops at the first failure: finish() returns Ok(()) when every check passed, or Err(Error::Invalid(errors)) with all of them. An error is an ocre::FieldError { field, message }: the message has no field name ("can't be blank"), and full_message() adds the humanized name ("Name can't be blank"; post_id becomes Post).
Messages are English. Each check also records Rails’ translation key (error.key(): blank, too_long…), so i18n.full_message("post", &error) and i18n.error_message("post", &error) give them in the visitor’s language, from the app’s locale files or the built-in French, German, Spanish, Italian, Portuguese and Dutch messages: see Translations.
Every check
Messages are the exact strings Ocre adds (Rails’ wording).
| Method | Passes when | Message |
|---|---|---|
v.required("title", &title) | not empty after trimming whitespace (Rails’ presence) | can't be blank |
v.absence("nickname", &nickname) | empty or only whitespace | must be blank |
v.max_length("title", &title, 100) | at most 100 characters (Unicode characters, not bytes) | is too long (maximum is 100 characters) |
v.min_length("code", &code, 6) | at least 6 characters | is too short (minimum is 6 characters) |
v.length("zip", &zip, 5) | exactly 5 characters | is the wrong length (should be 5 characters) |
v.range("guests", guests, 1..=12) | within the inclusive range; any PartialOrd + Display type (i64, f64…) | must be greater than or equal to 1 / must be less than or equal to 12 |
v.greater_than("quantity", quantity, 0) | value > 0 | must be greater than 0 |
v.greater_than_or_equal_to("age", age, 18) | value >= 18 | must be greater than or equal to 18 |
v.less_than("discount", discount, 100) | value < 100 | must be less than 100 |
v.less_than_or_equal_to("guests", guests, 12) | value <= 12 | must be less than or equal to 12 |
v.other_than("floor", floor, 13) | value != 13 | must be other than 13 |
v.safe_integer("stock", stock) | within ±ocre::MAX_SAFE_INTEGER (2^53 - 1), what D1 returns exactly | must be less than or equal to 9007199254740991 (or greater than or equal to the negative bound) |
v.inclusion("slot", &slot, &["lunch", "dinner"]) | one of the listed values | is not included in the list |
v.exclusion("username", &username, &["admin", "root"]) | none of the listed values | is reserved |
v.format("slug", &slug, |c| c.is_ascii_lowercase() || c == '-') | every character passes the function (no regular expressions: no regex engine in the WebAssembly binary) | is invalid |
v.email("email", &email) | one @, text on both sides, a dot in the domain, no spaces or <>, | is invalid |
v.confirmation("password", &password, &password_confirmation) | both texts are equal; the error is on password_confirmation | doesn't match Password |
v.acceptance("terms_of_service", accepted) | the checkbox bool is true | must be accepted |
v.date("date", &date) | YYYY-MM-DD, a real calendar date (month lengths, leap years) | is not a valid date |
v.time("opens_at", &opens_at) | HH:MM or HH:MM:SS (what <input type="time"> sends) | is not a valid time |
v.datetime("at", &at) | YYYY-MM-DD HH:MM[:SS] with a space or T (what <input type="datetime-local"> sends) | is not a valid date and time |
v.decimal("price", &price) | an optional sign, digits, optionally a dot and digits (19.99, -3); no exponent | is not a decimal number |
v.uuid("token", &token) | hyphenated UUID, any case | is not a valid UUID |
v.check("guests", failed, "message") | failed is false: any rule of your own | your message |
v.number::<f64>("price", &text) | the text parses as the target type; returns Option<T> | is not a number |
v.optional_number::<i64>("stock", &text) | blank, or parses; blank returns None without an error | is not a number |
v.one_of::<Status>("status", &text) | the text parses with FromStr (a generated enum); returns Option<T> | is not included in the list |
v.optional_one_of::<Status>("status", &text) | blank, or parses; blank returns None | is not included in the list |
v.json("data", &text) | the text is valid JSON; returns Option<serde_json::Value> | is not valid JSON |
v.optional_json("data", &text) | blank, or valid JSON; blank returns None | is not valid JSON |
v.file("image", &upload, &IMAGE) | the upload is within Rules::max_bytes and has an allowed content type | is too large (maximum is 10 MB) / has an unsupported type (allowed: ...) |
The comparisons take any PartialOrd + Display value, so they also compare YYYY-MM-DD dates as text (v.greater_than("ends_on", &ends_on, &starts_on)). For a length range, chain min_length and max_length.
.message("...") right after a check replaces its message when it failed (Rails’ message:): v.required("body", &body).message("write something first"). It only changes the check just before it.
The other methods: Validator::new(), v.merge(other) (adds the errors another validator collected), v.is_valid() (no error so far), v.errors() (the FieldErrors so far, in order) and v.finish(). File rules and v.file are covered in File storage. The API reference is in the rustdoc of Validator.
A complete set of rules, with a custom one, behind a JSON endpoint:
// src/reservations.rs
use axum::{Router, routing::post};
use ocre::{ApiResult, Created, Ctx, Json, Validator};
use serde::{Deserialize, Serialize};
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/reservations", post(create))
}
const SLOTS: &[&str] = &["lunch", "dinner"];
#[derive(Debug, Deserialize, Serialize)]
pub struct NewReservation {
pub name: String,
pub email: String,
pub guests: i64,
pub slot: String,
/// `YYYY-MM-DD`
pub date: String,
/// `YYYY-MM-DD HH:MM`, optional
#[serde(default)]
pub arrives_at: Option<String>,
#[serde(default)]
pub promo_code: Option<String>,
#[serde(default)]
pub deposit_cents: Option<i64>,
}
impl NewReservation {
pub fn validate(&self) -> Validator {
let mut v = Validator::new();
v.required("name", &self.name).max_length("name", &self.name, 100);
v.email("email", &self.email);
v.range("guests", self.guests, 1..=12);
v.inclusion("slot", &self.slot, SLOTS);
v.date("date", &self.date);
if let Some(arrives_at) = &self.arrives_at {
v.datetime("arrives_at", arrives_at);
}
if let Some(code) = &self.promo_code {
v.min_length("promo_code", code, 6);
}
if let Some(cents) = self.deposit_cents {
v.safe_integer("deposit_cents", cents);
}
// A rule of your own: any condition plus a message.
v.check("guests", self.slot == "lunch" && self.guests > 8, "must be 8 or fewer at lunch");
v
}
}
async fn create(Json(new): Json<NewReservation>) -> ApiResult<Created<NewReservation>> {
new.validate().finish()?;
Ok(Created(new))
}
Registered in src/lib.rs (mod reservations; under // ocre:modules, .merge(reservations::routes()) under // ocre:routes) and called while ocre dev runs:
curl -s -X POST http://localhost:8787/api/reservations -H 'Content-Type: application/json' \
-d '{"name": "", "email": "ada@", "guests": 10, "slot": "lunch", "date": "2026-02-30", "arrives_at": "2026-02-01 25:00", "promo_code": "abc", "deposit_cents": 9007199254740992}'
{"error":{"fields":{"arrives_at":["is not a valid date and time"],"date":["is not a valid date"],"deposit_cents":["must be less than or equal to 9007199254740991"],"email":["is invalid"],"guests":["must be 8 or fewer at lunch"],"name":["can't be blank"],"promo_code":["is too short (minimum is 6 characters)"]},"message":"Validation failed","status":422}}
Status 422, every failed field at once. The other messages:
curl -s -X POST http://localhost:8787/api/reservations -H 'Content-Type: application/json' \
-d '{"name": "Ada", "email": "ada@example.com", "guests": 2, "slot": "brunch", "date": "2026-10-01"}'
{"error":{"fields":{"slot":["is not included in the list"]},"message":"Validation failed","status":422}}
With a 101-character name and "guests": 0:
{"error":{"fields":{"guests":["must be greater than or equal to 1"],"name":["is too long (maximum is 100 characters)"]},"message":"Validation failed","status":422}}
A valid body is echoed with 201 Created:
curl -s -X POST http://localhost:8787/api/reservations -H 'Content-Type: application/json' \
-d '{"name": "Ada", "email": "ada@example.com", "guests": 2, "slot": "dinner", "date": "2026-10-01", "arrives_at": "2026-10-01T19:30"}'
{"name":"Ada","email":"ada@example.com","guests":2,"slot":"dinner","date":"2026-10-01","arrives_at":"2026-10-01T19:30","promo_code":null,"deposit_cents":null}
A body that does not deserialize (a missing required field, a string where a number is expected, invalid JSON) never reaches validate(): ocre::Json answers 400 with serde’s explanation (see JSON APIs).
Forms: confirmation, acceptance, formats and enums
A signup form uses the checks Rails apps reach for on accounts, and a filter form parses a generated enum (Status from ocre g model Task title:string status:enum:open,done author:references?):
// src/signup_form.rs
use ocre::{Result, Validator};
use serde::Deserialize;
use crate::models::task::Status;
const RESERVED: &[&str] = &["admin", "root", "support"];
/// What the signup form sends: every field is text, the checkbox a bool.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct SignupForm {
pub username: String,
pub password: String,
pub password_confirmation: String,
pub age: String,
pub zip: String,
/// Hidden with CSS: people leave it empty, bots fill it in.
pub website: String,
/// An unticked checkbox sends nothing, hence `#[serde(default)]`.
pub terms_of_service: bool,
}
impl SignupForm {
pub fn validate(&self) -> Result<()> {
let mut v = Validator::new();
let username_char = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_';
v.min_length("username", &self.username, 3).max_length("username", &self.username, 20);
v.format("username", &self.username, username_char).message("may only contain a-z, 0-9 and _");
v.exclusion("username", &self.username, RESERVED);
v.min_length("password", &self.password, 12);
v.confirmation("password", &self.password, &self.password_confirmation);
if let Some(age) = v.number::<i64>("age", &self.age) {
v.greater_than_or_equal_to("age", age, 16);
}
v.length("zip", &self.zip, 5);
v.absence("website", &self.website);
v.acceptance("terms_of_service", self.terms_of_service);
v.finish()
}
}
/// A filter such as `?status=done`; blank means no filter.
pub fn status_filter(text: &str) -> Result<Option<Status>> {
let mut v = Validator::new();
let status = v.optional_one_of::<Status>("status", text);
v.finish()?;
Ok(status)
}
With username=Admin!, password=short, password_confirmation=shorter, age=15, zip=7500, website=http://spam.example and no terms_of_service, validate() fails with these messages, in this order (full_message()):
Username may only contain a-z, 0-9 and _
Password is too short (minimum is 12 characters)
Password confirmation doesn't match Password
Age must be greater than or equal to 16
Zip is the wrong length (should be 5 characters)
Website must be blank
Terms of service must be accepted
The confirmation error is on password_confirmation, so a form shows it next to the second field. status_filter("archived") fails with “Status is not included in the list”; status_filter("done") returns Some(Status::Done).
Where rules live
Data rules belong in the model, src/models/<model>.rs, so the HTML pages, the JSON API and GraphQL enforce the same ones:
| Rule | Where | Generated for |
|---|---|---|
| Checks on the values alone (presence, length, format, ranges, custom rules) | New<Model>::validate() and <Model>Changes::validate() | required (non-optional string/text), date, time, datetime, decimal, uuid, safe_integer (integer), v.file (attachment); enum fields need none (serde refuses unknown values, the CHECK backs it) |
Uniqueness: has already been taken | create and update, with db.exists(..) | fields marked ^, and the pair of references of a join model |
Reference: must exist | create and update, with db.exists(..) | references fields (optional ones only when set) |
| Normalizing input before the checks (trim, lowercase) | before_create / before_update callbacks | none: empty functions to fill in (see Callbacks) |
A generated create, for Comment (post:references):
pub async fn create(ctx: &Ctx, mut new: NewComment) -> Result<Comment> {
before_create(ctx, &mut new).await?;
let db = ctx.db()?;
let mut v = new.validate();
{
let post_id = &new.post_id;
v.check("post_id", !db.exists("SELECT 1 FROM posts WHERE id = ?1 LIMIT 1", params![*post_id]).await?, "must exist");
}
v.finish()?;
let record: Comment = db
.first("INSERT INTO comments (author, body, post_id) VALUES (?1, ?2, ?3) RETURNING *", params![new.author, new.body, new.post_id])
.await?
.ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))?;
after_create(ctx, &record).await?;
Ok(record)
}
The database checks add to the validate() errors, so a JSON client gets both kinds in one answer:
curl -s -X POST http://localhost:8787/api/products -H 'Content-Type: application/json' \
-d '{"name": "Teapot", "price": 3, "stock": 9007199254740992}'
{"error":{"fields":{"name":["has already been taken"],"stock":["must be less than or equal to 9007199254740991"]},"message":"Validation failed","status":422}}
In update, the uniqueness check excludes the record itself (AND id != ?2): renaming product 3 to an existing name is refused, saving product 1 with its own name is not. update checks only the fields being changed.
Add rules to validate(); add database rules to create/update next to the generated ones, with v.check(field, db.exists(sql, params).await?, message). Keep the UNIQUE index and REFERENCES constraint in the migration too: two requests at the same moment can both pass the check, and the constraint then fails the second insert with a 500.
What clients receive
Error::Invalid has status 422. How it is shown depends on the handler.
HTML forms re-render
The scaffold’s create and update handlers (src/<plural>.rs) match Error::Invalid and render the form again with the errors and the values as typed:
Err(Error::Invalid(errors)) => {
Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&NewView { form, errors })?).into_response())
}
templates/<plural>/_form.html lists them with full_message():
{% if !errors.is_empty() %}
<ul class="errors">
{% for error in errors %}<li>{{ error.full_message() }}</li>{% endfor %}
</ul>
{% endif %}
curl -s -X POST http://localhost:8787/comments -d 'author=Ada&body=Nice&post_id=99'
...
<form action="/comments" method="post">
<ul class="errors">
<li>Post must exist</li>
</ul>
<label>Author <input name="author" value="Ada" required></label>
<label>Body <textarea name="body" rows="5" required>Nice</textarea></label>
<label>Post <input type="number" step="1" name="post_id" value="99" required></label>
<button type="submit">Create comment</button>
</form>
...
The form struct keeps every field as text (PostForm, CommentForm), so a typo in a number is a field error rather than a failed request: to_new() parses with v.number(..), then merges the model’s validate(). The form’s own checks run first; the database checks run in create only when they pass, so a form with a blank body and an unknown post shows “Body can’t be blank” first, then “Post must exist” on the next submit.
The same pattern, written by hand for the API’s Product model (price:float stock:integer?) plus a free-form JSON field:
// src/product_form.rs
use ocre::{Result, Validator, serde_json::Value};
use serde::Deserialize;
use crate::models::product::NewProduct;
/// What an HTML form sends: every field is text.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProductForm {
pub name: String,
pub price: String,
pub stock: String,
pub specs: String,
}
impl ProductForm {
/// Parses the text, then runs the model's rules, so the form shows every
/// error at once.
pub fn to_new(&self) -> Result<(NewProduct, Option<Value>)> {
let mut v = Validator::new();
let new = NewProduct {
name: self.name.clone(),
price: v.number("price", &self.price).unwrap_or_default(),
stock: v.optional_number("stock", &self.stock),
};
let specs = v.optional_json("specs", &self.specs);
v.merge(new.validate()).finish()?;
Ok((new, specs))
}
}
Posted as name=&price=abc&stock=12x&specs={bad to a JSON handler that calls form.to_new()?, it answers:
{"error":{"fields":{"name":["can't be blank"],"price":["is not a number"],"specs":["is not valid JSON"],"stock":["is not a number"]},"message":"Validation failed","status":422}}
and name=Pan&price=12.5&stock=&specs={"size":"L"} parses to price 12.5, stock None and specs {"size":"L"}.
An HTML handler that returns Error::Invalid with ? instead of re-rendering gets Ocre’s plain error page, status 422:
<h1>422</h1><p>Validation failed</p><ul><li>Title can't be blank</li><li>Contact email is invalid</li></ul>
JSON APIs report fields
Handlers returning ocre::ApiResult turn Error::Invalid into a 422 whose fields object maps each field to its messages, as shown above. The same shape comes back from create and update in every generated JSON API (ocre g api); see JSON APIs.
GraphQL puts fields in extensions
GraphQL resolvers convert the error with ?; the response is a 200 with the error in errors, the status and fields in extensions:
curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \
-d '{"query": "mutation { createProduct(input: {name: \"\", price: 30}) { id } }"}'
{"data":null,"errors":[{"message":"Validation failed","locations":[{"line":1,"column":12}],"path":["createProduct"],"extensions":{"fields":{"name":["can't be blank"]},"status":422}}]}
Custom rules
v.check(field, failed, message) covers any rule: pass the condition that means failure and a message without the field name. Cross-field rules, rules on the current time and rules needing the database all use it:
// src/models/event_rules.rs
use ocre::{Ctx, Result, Validator, params};
/// `starts_on` and `ends_on` are `YYYY-MM-DD`, so text order is date order.
pub fn check_dates(v: &mut Validator, starts_on: &str, ends_on: &str) {
let mut dates = Validator::new();
dates.date("starts_on", starts_on).date("ends_on", ends_on);
let both_valid = dates.is_valid();
v.merge(dates);
v.check("ends_on", both_valid && ends_on < starts_on, "must be on or after the start date");
}
/// Database rule: at most 20 comments per post.
pub async fn check_comment_quota(ctx: &Ctx, post_id: i64) -> Result<()> {
let full = ctx
.db()?
.exists("SELECT 1 FROM comments WHERE post_id = ?1 LIMIT 1 OFFSET 19", params![post_id])
.await?;
let mut v = Validator::new();
v.check("post_id", full, "has too many comments");
v.finish()
}
The separate dates validator tells whether both dates parsed (is_valid()), so a malformed date reports one error, not two; merge then adds its errors to v. Call such functions from the model’s validate() (checks without the database) or create/update (database checks), before finish().
Rails’ validation options, the Ocre way
Rails declares validations with options; in Ocre a rule is a line of Rust in validate(), so the options are ordinary code:
| Rails | In Ocre |
|---|---|
on: :create / on: :update | New<Model>::validate() runs for create, <Model>Changes::validate() for update |
custom contexts (valid?(:publish), on: :publish) | another function: fn validate_for_publish(&self) -> Validator, called where that context applies |
if: / unless:, with_options | an if around the checks: if self.paid { v.required("card_number", &self.card_number); } |
allow_nil: / allow_blank: | optional fields are Option: checks run inside if let Some(value); for blank text, if !value.trim().is_empty() |
validates_each, validates_with, ActiveModel::Validator / EachValidator classes | a function taking &mut Validator (check_dates above), reused from any model |
validates_associated | v.merge(other.validate()) for a nested value |
numericality (only_integer, in:, odd, even) | v.number::<i64>(..) / v.number::<f64>(..) for form text, the comparison checks and range, and v.check(field, n % 2 == 0, "must be odd") |
uniqueness with scope:, case_sensitive: false, conditions: | the generated db.exists(..) check with the SQL you need: WHERE lower(email) = lower(?1) AND account_id = ?2 AND deleted_at IS NULL, plus a matching UNIQUE index (CREATE UNIQUE INDEX ... ON users (account_id, lower(email))) |
strict: true | return an error directly (Err(Error::internal(..)) for a programmer error) instead of adding it to the validator |
save(validate: false), update_column(s), update_all, insert_all | query().update_all(..), db.execute(..), db.batch(..): no validation runs |
validators, validators_on(:attr) | read validate(): rules are code, not declarations to list |
See also
- Models and migrations: the generated
validate(),createandupdate - Controllers and routing and Views, helpers and forms: the scaffold’s form handling
- JSON APIs and GraphQL: error JSON, 400 versus 422
- File storage:
Rulesandv.filefor uploads - Field types: which checks each type gets
- API index and the rustdoc of
Validator
Controllers and routing
Controllers in Ocre are plain axum handlers grouped in one module per resource, each with a routes() function merged into the router in src/lib.rs, and a paths module that builds the URLs of its pages. This page walks through the generated scaffold controller, then covers routing (nested resources, namespaces, redirects), what a handler can read from the request and answer, error pages, and middleware. Templates and forms are in Views, helpers and forms, interactivity in htmx, CSS and JavaScript in Assets.
Before you start
- A full-stack Ocre app from
ocre new(not--api: API-only apps have no templates; see JSON APIs). The examples use the blog starter,ocre new blog --starter blog, whosesrc/posts.rsis the scaffold ofPost title:string body:text published:boolean. ocre devrunning to try the pages (it serveshttp://localhost:8787unless you pass--port).- Free plan (September 2026, Workers limits): 100,000 requests a day and 10 ms of CPU per request. Awaiting D1 does not count as CPU; rendering a template is plain string building, a fraction of a millisecond for a normal page.
Generate a scaffold
ocre g scaffold Comment author:string body:text post:references
create migrations/0003_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
The model is explained in Models and migrations; this page covers src/comments.rs (the controller); the templates are described in Views. In an API-only app, ocre g scaffold generates a JSON API instead.
Routes
routes() at the top of the controller lists every route of the resource (Rails’ resources :posts):
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/posts", get(index).post(create))
.route("/posts/new", get(new))
.route("/posts/{id}", get(show).post(update))
.route("/posts/{id}/edit", get(edit))
.route("/posts/{id}/delete", post(delete))
}
| Method | Path | Handler | Does |
|---|---|---|---|
| GET | /posts | index | List, newest first (?limit=&offset=, 50 by default), with Previous / Next links |
| GET | /posts/new | new | Empty form |
| POST | /posts | create | Create, then redirect to /posts/{id} with a flash; 422 and the form again when invalid |
| GET | /posts/{id} | show | One record, or 404 |
| GET | /posts/{id}/edit | edit | Form filled with the record |
| POST | /posts/{id} | update | Update, then redirect; 422 and the form again when invalid |
| POST | /posts/{id}/delete | delete | Delete, then redirect to /posts |
Update and delete use POST because HTML forms can only send GET and POST. Rails simulates PATCH/DELETE with a hidden _method field; Ocre keeps the routes the browser actually sends, so the pages work without JavaScript and without method overriding. JSON APIs use real PATCH and DELETE (see JSON APIs). To drop an action (Rails’ only:/except:), delete its route and handler; to rename a path (path:), edit the strings here and in paths.
Paths use axum 0.8 syntax: {id} is a parameter, {*rest} a wildcard. A path no route matches gets the app’s 404 page (see Error pages).
Path helpers
Below routes(), the paths module builds the URL of each page (Rails’ posts_path, new_post_path, post_path(post), edit_post_path(post)):
pub mod paths {
use std::fmt::Display;
pub fn index() -> &'static str {
"/posts"
}
pub fn new() -> &'static str {
"/posts/new"
}
pub fn show(id: impl Display) -> String {
format!("/posts/{id}")
}
pub fn edit(id: impl Display) -> String {
format!("/posts/{id}/edit")
}
pub fn delete(id: impl Display) -> String {
format!("/posts/{id}/delete")
}
}
Handlers redirect with them (Redirect::to(&paths::show(record.id))) and templates link with them: askama resolves paths next to the template’s struct, so the controller’s templates write <a href="{{ paths::edit(post.id) }}">, and any other module crate::posts::paths::show(id). Changing a URL is then one edit in routes() and one in paths. Attachment fields add one function per file (paths::avatar(id)). They are plain functions, so a custom URL helper (Rails’ direct, default_url_options such as a locale prefix) is a function you add, with the parameters it needs.
List the routes
ocre routes reads src/lib.rs and the modules it merges or nests, without building the app, and prints every route; an argument filters by method, path or handler (case-insensitive substring):
ocre routes comments
METHOD PATH HANDLER
GET /comments comments::index
POST /comments comments::create
GET /comments/new comments::new
GET /comments/{id} comments::show
POST /comments/{id} comments::update
POST /comments/{id}/delete comments::delete
GET /comments/{id}/edit comments::edit
It finds .route("<path>", ...) calls, follows .merge(<module>::routes()) and .nest("<prefix>", <module>::routes()) into src/<module>.rs (or src/<module>/mod.rs), and knows that ocre::graphql::routes(..) serves GET and POST /graphql. Routes whose handler is a closure, and routers built inline or in variables, are skipped. --json returns the list in routes:
{"command":"routes","ok":true,"routes":[{"handler":"posts::index","method":"GET","path":"/posts"},{"handler":"posts::create","method":"POST","path":"/posts"},...]}
Register a module in src/lib.rs
src/lib.rs is the Worker’s entry point and the app’s router. Generators add modules and routes after two marker comments, which must stay in place:
// ocre:modules
mod comments;
mod posts;
mod models;
#[event(fetch)]
async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> worker::Result<worker::web_sys::Response> {
ocre::serve(routes(), req, env).await
}
fn routes() -> Router<Ctx> {
Router::new()
.route("/", get(home))
.route("/up", get(up))
// ocre:routes
.merge(comments::routes())
.merge(posts::routes())
.fallback(not_found)
.layer(map_response(error_page))
.layer(content_security_policy())
.layer(permissions_policy())
}
For a module you write by hand, add mod <name>; under // ocre:modules and .merge(<name>::routes()) under // ocre:routes. ocre::serve wraps the router with sessions, CSRF protection, CORS and security headers (see Sessions, flash and security), and builds the per-request Ctx. / is the root route (Rails’ root), /up the health check. .fallback and the error-page layer come after the routes (see Error pages), followed by the Content-Security-Policy and Permissions-Policy layers (see Security); after ocre g locale, .layer(ocre::i18n::layer(&LOCALES)) must stay the very last call.
Routing recipes
axum’s Router covers Rails’ routing DSL with ordinary method calls. One module showing the common shapes:
// src/library.rs
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Redirect, Response},
routing::get,
};
use ocre::{Ctx, OptionExt, Result, Session};
use crate::models::{comment, post};
pub fn routes() -> Router<Ctx> {
Router::new()
// Nested resource (Rails' `resources :posts do resources :comments end`).
.route("/posts/{post_id}/comments/{id}", get(post_comment))
// Singular resource (Rails' `resource :profile`): no id, the session says whose.
.route("/profile", get(profile))
// Member and collection routes: more paths under the resource.
.route("/posts/{id}/preview", get(preview))
.route("/posts/drafts", get(drafts))
// Redirects (Rails' `get "/articles/:id", to: redirect("/posts/%{id}")`).
.route("/articles", get(|| async { Redirect::permanent("/posts") }))
.route("/articles/{id}", get(|Path(id): Path<i64>| async move { Redirect::permanent(&format!("/posts/{id}")) }))
// Glob (Rails' `get "pages/*path"`): `path` is the rest, e.g. `help/billing`.
.route("/pages/{*path}", get(page))
// Namespace (Rails' `namespace :admin`): every admin route under /admin.
.nest("/admin", admin_routes())
}
fn admin_routes() -> Router<Ctx> {
Router::new().route("/", get(|| async { "Admin home" })).route("/posts/{id}", get(preview))
}
/// Both ids come from the path, in order; a comment of another post is a 404.
async fn post_comment(State(ctx): State<Ctx>, Path((post_id, id)): Path<(i64, i64)>) -> Result<String> {
let comment = comment::find(&ctx, id).await?.filter(|c| c.post_id == post_id).or_404()?;
Ok(comment.body)
}
async fn profile(session: Session) -> Result<Response> {
match session.get::<i64>("user_id")? {
Some(id) => Ok(format!("user {id}").into_response()),
None => Ok(Redirect::to("/login").into_response()),
}
}
async fn preview(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<String> {
Ok(post::find(&ctx, id).await?.or_404()?.title)
}
async fn drafts() -> &'static str {
"drafts"
}
async fn page(Path(path): Path<String>) -> Response {
match path.as_str() {
"help/billing" => "Billing help".into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
/posts/drafts and /posts/{id} can coexist: axum prefers the static segment. A typed parameter is its constraint (Rails’ constraints: { id: /\d+/ }): Path<i64> answers 400 Bad Request to /posts/abc, with the app’s error page. Everything else:
| Rails | Ocre (axum) |
|---|---|
resources :posts (seven actions, helpers) | ocre g scaffold: the routes, handlers and paths |
resources :photos, :books | one module per resource, each merged |
get "x", to: "c#a", match via: | .route("/x", get(a).post(b)); unmatched methods answer 405 |
Optional segments (/:page) | two routes to the same handler, or a query parameter |
defaults: { format: "json" } | #[serde(default)] on the Query struct |
as: named routes, direct, resolve | functions in paths |
scope "/:account_id" | .nest("/{account_id}", accounts::routes()); handlers read Path |
scope module: / path: | the Rust module is independent of the prefix you nest at |
concern :commentable | a function returning a Router<Ctx>, merged or nested in each resource |
shallow: true | declare the member routes without the parent prefix |
| Subdomain / request constraints | check Host or headers in a middleware or the handler (below) |
.:format segments | the Accept header with ocre::Format (below), or a .json route of its own |
mount a Rack app | .merge(other_router) or .nest_service("/x", service); ocre::graphql::routes is one |
draw split route files | one routes() per module |
| Unicode paths | wrap the path in ocre::encode_path: .route(&ocre::encode_path("/café/{id}"), ..). axum matches the raw path, which browsers send percent-encoded (/caf%C3%A9), so a bare "/café" route never matches; Path parameters arrive decoded (café) |
| Translated path segments | one route per language, pointing to the same handler (see Translations) |
rails routes | ocre routes |
Handlers and extractors
A handler is an async fn whose arguments are axum extractors and whose return type implements IntoResponse. Every Ocre type is Send, so handlers need no annotation unless they await a future from another crate that is not (see Futures that are not Send). Params are typed per source instead of Rails’ merged params hash, and a form or JSON struct lists exactly the fields it accepts, which replaces strong parameters.
| Extractor | From | Gives | On failure |
|---|---|---|---|
State(ctx): State<Ctx> | the router state | Ctx: ctx.db()?, ctx.env() and the bindings | never fails |
Path(id): Path<i64> | {id} in the path | the parsed value; a tuple or struct for several | 400 Invalid URL: Cannot parse ... |
Query(q): Query<T> | the query string, into a serde struct | T | 400 |
Form(form): Form<T> | a URL-encoded form body | T | 415 without Content-Type: application/x-www-form-urlencoded; 422 when it does not deserialize (e.g. published=maybe for a bool) |
NestedForm(order): ocre::NestedForm<T> | a URL-encoded form (or, on GET, the query string) with bracketed names: order[name], tag_ids[], lines[0][qty] | T with nested structs and Vecs (see Views: lists and nested records) | 400 naming the field |
ocre::Json(body): ocre::Json<T> | a JSON body | T | JSON 400 (see JSON APIs) |
page: ocre::Page | ?limit=&offset= | page.limit (1-100, default 50), page.offset | JSON 400 limit must be between 1 and 100 |
session: ocre::Session | the encrypted session cookie | get, insert, remove, clear, flash | its methods fail with a 500 (logged) when SECRET_KEY_BASE is missing |
cookies: ocre::Cookies | the request’s cookies | get/set, signed/set_signed, encrypted/set_encrypted, remove (see Security: cookies) | never fails; the signed and encrypted methods fail with a 500 when SECRET_KEY_BASE is missing |
flash: ocre::Flash | messages set by the previous request (read once, then removed) | flash.notice(), flash.alert(), flash.get(kind) | 500 when SECRET_KEY_BASE is missing |
format: ocre::Format | the Accept header | Format::Html, Json, Xml, Text or Other | never fails |
Htmx(is_htmx): ocre::Htmx | the HX-Request: true header | bool | never fails |
RemoteIp(ip): ocre::RemoteIp | Cloudflare’s CF-Connecting-IP | Option<IpAddr> | never fails |
RequestId(id): ocre::RequestId | Cloudflare’s CF-Ray, else X-Request-Id, else random | String | never fails |
headers: HeaderMap | all request headers | headers.get("user-agent"), cookies… | never fails |
OriginalUri(uri), method: Method | the request line | the full URI (path and query), the method | never fails |
ocre::storage::Multipart<LIMIT> | a multipart/form-data body | text fields and files | 413 above LIMIT bytes (see File storage) |
i18n: ocre::i18n::I18n | the request’s locale | translations (see Translations) |
The extractor that reads the body (Form, Json, Multipart) must be the last argument, an axum rule. axum’s Form, Json and body extractors refuse bodies over 2 MB with 413 (Loco’s limit_payload); a route that needs more adds .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)). Workers accept request bodies up to 100 MB. After ocre g auth, CurrentUser and BearerUser are extractors too (see Authentication); an extractor is also how Ocre does Rails’ before_action: a handler that takes CurrentUser only runs for signed-in users.
Cookies other than the session go through the Cookies extractor (Rails’ cookies, cookies.signed, cookies.encrypted); values tied to the visitor’s session belong in the Session, which is signed and encrypted.
Futures that are not Send
axum requires a handler’s future to be Send. Ocre’s types are, but a future holding a JavaScript value is not: worker::Fetch, reqwest in WebAssembly, wasm_bindgen_futures::JsFuture, a D1 or KV handle from worker itself. Awaiting one in a handler fails to compile at the route, far from the cause:
error[E0277]: the trait bound `fn() -> impl Future<Output = Result<..., ...>> {remote}: Handler<_, _>` is not satisfied
|
5 | Router::new().route("/remote", get(remote))
| --- ^^^^^^ unsatisfied trait bound
= note: Consider using `#[axum::debug_handler]` to improve the error message
Put #[worker::send] on the handler: it wraps its future in worker::send::SendFuture, which is sound because a Worker runs your code on one thread. For a single call inside a larger function, wrap only that future: worker::send::SendFuture::new(async { ... }).await. Functions that Ocre’s generated code calls from handlers (a model method, a job’s perform) need the same wrapping when they await such a future.
Calling other services
Ocre has no HTTP client of its own; two work in a Worker:
- reqwest with
default-features = false: in WebAssembly it calls the Worker’sfetch, with its usual API (json, headers, query strings).cargo add reqwest --no-default-features --features json. worker::Fetch, theworkercrate’s thin wrapper overfetch.
use axum::Json;
use ocre::{Error, Result};
#[worker::send] // reqwest's futures hold JavaScript values
async fn rates() -> Result<Json<serde_json::Value>> {
let rates = reqwest::get("https://api.example.com/rates")
.await
.map_err(|err| Error::internal(format!("rates API: {err}")))?
.json()
.await
.map_err(|err| Error::internal(format!("rates API: {err}")))?;
Ok(Json(rates))
}
Each call is a subrequest: 50 per invocation on the free plan (counted with the other calls a job makes, see Long jobs). Cache answers that change slowly with ocre::cache::fetch, and put secrets (API keys) in Worker secrets read with ctx.config().
Responses
| Return | Response |
|---|---|
Result<Html<String>> from ocre::render(&view) | 200 with the rendered template |
Redirect::to("/posts/1") | 303 See Other with Location (the browser follows with a GET) |
Redirect::permanent(..), Redirect::temporary(..) | 308, 307 |
ocre::redirect_back(&headers, "/") | 303 to the referring page of this app, else to the fallback (Rails’ redirect_back_or_to) |
ocre::HxRedirect(path) | 200 with HX-Redirect: htmx loads the page in full (see htmx) |
(StatusCode::UNPROCESSABLE_ENTITY, render(&view)?) | the page with another status |
StatusCode::NO_CONTENT | a status without a body (Rails’ head) |
([(header::CACHE_CONTROL, "no-store")], body) | extra headers before the body |
"text" / String, Html(..) | text/plain, text/html |
ocre::storage::send_data(..), ocre::storage::serve(..) | a file download (below) |
Result<Response> with .into_response() on each branch | handlers that answer differently per case |
Err(ocre::Error) | the error page with the error’s status |
ocre::Error and its statuses:
| Variant | Status | Page shows |
|---|---|---|
Error::NotFound | 404 | Not found |
Error::BadRequest(msg), built with Error::bad_request("...") | 400 | the message |
Error::Unauthorized | 401 | Unauthorized |
Error::Forbidden | 403 | Forbidden |
Error::Invalid(fields) | 422 | Validation failed and each field error |
Error::PayloadTooLarge(msg) | 413 | the message |
Error::Internal(msg), built with Error::internal("...") | 500 | Internal server error; the message goes to the Worker log only |
? converts worker::Error and askama errors into Error::Internal. For a missing record, option.or_404()? (trait ocre::OptionExt) turns None into Error::NotFound. Mapping errors to responses (Rails’ rescue_from) is a match on the Error in the handler, as the scaffold does for Error::Invalid, or a map_response layer for the whole app, as for error pages.
Formats (respond_to)
ocre::Format reads the Accept header, so one action can serve a page to browsers and JSON to API clients (Rails’ respond_to, Loco’s RespondTo):
use askama::Template;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use ocre::{Ctx, Format, Json, OptionExt, Result, render};
use crate::models::post::{self, Post};
#[derive(Template)]
#[template(source = "<h1>{{ post.title }}</h1><p>{{ post.body }}</p>", ext = "html")]
struct PostPage {
post: Post,
}
/// GET /posts/{id}: HTML for browsers, JSON for `Accept: application/json`.
async fn show(State(ctx): State<Ctx>, format: Format, Path(id): Path<i64>) -> Result<Response> {
let post = post::find(&ctx, id).await?.or_404()?;
Ok(match format {
Format::Html => render(&PostPage { post })?.into_response(),
Format::Json => Json(post).into_response(),
_ => StatusCode::NOT_ACCEPTABLE.into_response(),
})
}
curl -s http://localhost:8787/posts/1 -H 'Accept: application/json'
{"id":1,"title":"Hello","body":"First","published":true,"created_at":"2026-09-29 10:00:00","updated_at":"2026-09-29 10:00:00"}
A missing Accept, */* and text/html are Html; browsers get HTML. Other representations (Rails’ request variants, a phone layout) are another match arm or another view struct chosen from a header.
Format::Markdown (Accept: text/markdown) pairs with the ocre::Markdown response (text/markdown; charset=utf-8), to offer a page to LLM clients and command-line readers as Markdown (Rails 8.1’s format.md and render markdown:):
use axum::response::{Html, IntoResponse, Response};
use ocre::{Format, Markdown};
/// GET /about: HTML, or Markdown for `Accept: text/markdown`.
async fn about(format: Format) -> Response {
let (title, body) = ("About", "We make ochre.");
match format {
Format::Markdown => Markdown(format!("# {title}\n\n{body}\n")).into_response(),
_ => Html(format!("<h1>{title}</h1><p>{body}</p>")).into_response(),
}
}
Files and downloads
ocre::storage::send_data sends bytes the handler built as a file (Rails’ send_data), with the right Content-Type, Content-Length and a sanitized Content-Disposition file name:
use axum::{extract::State, response::Response};
use ocre::{Ctx, Page, Result, storage::{Disposition, send_data}};
use crate::models::post;
/// GET /posts.csv: the latest 100 posts as a spreadsheet.
async fn export(State(ctx): State<Ctx>) -> Result<Response> {
let mut csv = String::from("id,title,published\n");
for post in post::all(&ctx, Page::new(100, 0)?).await? {
csv.push_str(&format!("{},\"{}\",{}\n", post.id, post.title.replace('"', "\"\""), post.published));
}
Ok(send_data(csv, "posts.csv", "text/csv", Disposition::Download))
}
Files stored in R2 go out with ocre::storage::serve (Rails’ send_file and Rack::Sendfile): the Worker hands R2’s stream to Cloudflare without copying it through WebAssembly, and answers Range and If-None-Match requests (see File storage). Generated data should stay small: the body is built in memory (128 MB per Worker) and within the 10 ms of CPU.
Streaming: Server-Sent Events
ocre::sse::stream(state, step) streams events while they are produced (Rails’ ActionController::Live with SSE): the handler returns at once, then Ocre calls step for each event and the Worker sends it to the client right away, until step returns None. Pace events with ocre::sleep(duration), which waits without using CPU:
use std::time::Duration;
use axum::{Router, extract::Path, response::IntoResponse, routing::get};
use ocre::{Ctx, sse::{self, Event}};
pub fn routes() -> Router<Ctx> {
Router::new().route("/imports/{id}/progress", get(progress))
}
/// `data: 25`, `data: 50`... one second apart, then `event: done`.
async fn progress(Path(id): Path<i64>) -> impl IntoResponse {
sse::stream(Some(0u32), move |percent: Option<u32>| async move {
let percent = percent?;
ocre::sleep(Duration::from_secs(1)).await;
Some(match percent {
100 => (Event::default().event("done").data(format!("import {id} finished")), None),
p => (Event::default().data((p + 25).to_string()), Some(p + 25)),
})
})
}
<progress id="bar" max="100"></progress>
<script>
const source = new EventSource("/imports/1/progress");
source.onmessage = (event) => { document.getElementById("bar").value = event.data; };
source.addEventListener("done", () => source.close());
</script>
On the free plan: only each step’s work counts toward the 10 ms of CPU, not the waits, and a response may stream as long as the client stays connected. The whole stream is one request, so its binding calls share the per-request limits (50 subrequests, D1 queries included): a stream cannot poll D1 every second for minutes. EventSource reconnects about 3 s after a stream ends, each time a new request, so send a last event on which the page calls source.close(). To push changes to many open pages (a new comment for everyone), use WebSockets on a Durable Object instead (see Realtime).
Error pages
ocre new generates templates/error.html and two functions in src/lib.rs (Rails’ public/404.html, 422.html and 500.html, rendered with the app’s layout):
/// Paths no route matches: the 404 error page.
async fn not_found() -> Error {
Error::NotFound
}
#[derive(Template)]
#[template(path = "error.html")]
struct ErrorView<'a> {
error: &'a ErrorPage,
}
/// Renders error responses (404, 422, 500...) with templates/error.html.
async fn error_page(response: Response) -> Response {
ocre::error_page(response, |error| render(&ErrorView { error }))
}
routes() ends with .fallback(not_found) and .layer(map_response(error_page)). ocre::error_page renders the template for every ocre::Error a handler returns, for the fallback’s 404, and for axum’s plain-text rejections (a path that does not parse, a form that does not deserialize), whose message is the status name (Bad Request). It keeps the status and headers, and leaves pages that set their own status (a form answered with 422) and JSON errors alone. The template gets an ocre::ErrorPage: error.status ({{ error.status.as_u16() }}), error.message and, for a 422, error.fields:
{% extends "layout.html" %}
{% block title %}{{ error.message }}{% endblock %}
{% block content %}
<h1>{{ error.message }}</h1>
{% if !error.fields.is_empty() %}
<ul class="errors">
{% for field in error.fields %}<li>{{ field.full_message() }}</li>{% endfor %}
</ul>
{% endif %}
<p>Error {{ error.status.as_u16() }}. <a href="/">Back to the home page</a></p>
{% endblock %}
curl -s -w '\n%{http_code}\n' http://localhost:8787/posts/999
...<h1>Not found</h1>...
404
A 500 shows Internal server error; the cause goes to the Worker logs only. If the template itself fails, the plain page (<h1>500</h1><p>Internal server error</p>) is sent and the failure logged. Requests refused by Ocre’s own middleware (a cross-site form post, an unknown host) keep a short text answer. A Rust panic aborts the Worker instance, which Cloudflare answers with its own 500 page: return Err instead of unwrap() in handlers. API-only apps have a JSON fallback: {"error": {"status": 404, "message": "Not found"}}.
The scaffold controller
src/posts.rs, as generated by the blog starter, in order.
The form struct holds what the browser submits, as text, so a typo in a number becomes a field error rather than a rejected request:
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PostForm {
pub title: String,
pub body: String,
/// Unchecked checkboxes are not submitted.
pub published: bool,
}
from_record fills it for the edit page; to_new and to_changes parse it into the model’s NewPost and PostChanges and run the model’s validate() (see Validations).
One template struct per page, each bound to a file in templates/posts/:
#[derive(Template)]
#[template(path = "posts/index.html")]
struct IndexView {
flash: Flash,
page: Page,
posts: Vec<Post>,
}
Reading handlers call the model and render:
async fn index(State(ctx): State<Ctx>, flash: Flash, page: Page) -> Result<Html<String>> {
render(&IndexView { flash, page, posts: post::all(&ctx, page).await? })
}
async fn show(State(ctx): State<Ctx>, flash: Flash, Path(id): Path<i64>) -> Result<Html<String>> {
render(&ShowView { flash, post: post::find(&ctx, id).await?.or_404()? })
}
The index page links to the neighbouring pages with Page::previous and Page::next, which guesses from a full page that more rows follow (no COUNT(*), which would read every row):
<nav class="pagination">
{% if let Some(previous) = page.previous() %}<a href="?{{ previous.query() }}" rel="prev">Previous</a>{% endif %}
{% if let Some(next) = page.next(posts.len()) %}<a href="?{{ next.query() }}" rel="next">Next</a>{% endif %}
</nav>
Writing handlers follow Rails’ pattern: on success, set a flash and redirect; on Error::Invalid, render the form again with status 422 and the typed values; any other error goes up with its status:
async fn create(State(ctx): State<Ctx>, session: Session, Form(form): Form<PostForm>) -> Result<Response> {
let created = match form.to_new() {
Ok(new) => post::create(&ctx, new).await,
Err(err) => Err(err),
};
match created {
Ok(record) => {
session.flash("notice", "Post was successfully created.")?;
Ok(Redirect::to(&paths::show(record.id)).into_response())
}
Err(Error::Invalid(errors)) => {
Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&NewView { form, errors })?).into_response())
}
Err(err) => Err(err),
}
}
curl -si -X POST http://localhost:8787/posts -d 'title=Third&body=Hello+again'
HTTP/1.1 303 See Other
Location: /posts/3
Set-Cookie: _ocre_session=...; HttpOnly; SameSite=Lax; Path=/
...
The next GET /posts/3 with that cookie shows <p class="notice">Post was successfully created.</p>, and the flash is gone after it. delete returns Error::NotFound when the model’s delete returns false.
A controller of your own
A page listing unpublished posts, with a search box, in a new module. Templates can also be inline (source = "...", ext = "html"); files under templates/ are usual for anything longer:
// src/drafts.rs
use askama::Template;
use axum::{
Router,
extract::{Path, Query, State},
response::Html,
routing::get,
};
use ocre::{Ctx, Flash, OptionExt, Page, Result, params, render};
use serde::Deserialize;
use crate::models::post::Post;
pub fn routes() -> Router<Ctx> {
Router::new().route("/drafts", get(index)).route("/drafts/{id}", get(show))
}
/// `?q=` filters by title; `?limit=&offset=` come from `Page`.
#[derive(Deserialize)]
struct Search {
#[serde(default)]
q: String,
}
#[derive(Template)]
#[template(
source = r#"{% extends "layout.html" %}
{% block title %}Drafts{% endblock %}
{% block content %}
<h1>Drafts</h1>
{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
<form method="get"><input name="q" value="{{ q }}"> <button>Search</button></form>
<ul>
{% for post in posts %}<li><a href="/drafts/{{ post.id }}">{{ post.title }}</a></li>{% endfor %}
</ul>
{% endblock %}"#,
ext = "html"
)]
struct IndexView {
flash: Flash,
q: String,
posts: Vec<Post>,
}
#[derive(Template)]
#[template(
source = r#"{% extends "layout.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}<h1>{{ post.title }}</h1><p>{{ post.body }}</p>{% endblock %}"#,
ext = "html"
)]
struct ShowView {
post: Post,
}
async fn index(
State(ctx): State<Ctx>,
flash: Flash,
page: Page,
Query(search): Query<Search>,
) -> Result<Html<String>> {
let posts = ctx
.db()?
.all(
"SELECT * FROM posts WHERE published = 0 AND title LIKE ?1 ORDER BY id DESC LIMIT ?2 OFFSET ?3",
params![format!("%{}%", search.q), page.limit, page.offset],
)
.await?;
render(&IndexView { flash, q: search.q, posts })
}
async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<Html<String>> {
let post: Post = ctx
.db()?
.first("SELECT * FROM posts WHERE id = ?1 AND published = 0", params![id])
.await?
.or_404()?;
render(&ShowView { post })
}
Register it (mod drafts; and .merge(drafts::routes()) in src/lib.rs), then with the seeded posts “Hello” (published) and “Draft”:
curl -s 'http://localhost:8787/drafts?q=Dra'
...
<main>
<h1>Drafts</h1>
<form method="get"><input name="q" value="Dra"> <button>Search</button></form>
<ul>
<li><a href="/drafts/2">Draft</a></li>
</ul>
</main>
...
GET /drafts/1 (published) answers the 404 page, and GET /drafts/abc the 400 page (Bad Request). The SQL is here to keep the example in one file; in an app, put it in src/models/post.rs as a drafts(ctx, term, page) function (see Models).
Middleware
Middleware wraps handlers to run code before and after them. ocre::serve always runs, outermost first: security headers, host authorization, CORS, cross-origin request protection, and the session cookie (see Sessions, flash and security). The app adds its own with .layer(..) on the whole router or .route_layer(..) on the routes declared so far; the last layer added runs first. axum’s middleware::from_fn turns an async fn into a layer; its arguments are extractors followed by the request and next. ocre::serve puts the Ctx in the request’s extensions, so a middleware reaches the bindings with Extension<Ctx>:
use axum::{
Extension, Router,
extract::Request,
http::{HeaderValue, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
};
use ocre::{Ctx, RequestId};
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/admin/stats", get(|| async { "stats" }))
// Only the routes above: an admin check (Rails' `before_action` on a controller).
.route_layer(middleware::from_fn(admin_only))
.route("/status", get(|| async { "OK" }))
// Every route of this router.
.layer(middleware::from_fn(request_id_header))
.layer(middleware::from_fn(maintenance))
}
async fn admin_only(req: Request, next: Next) -> Response {
let is_admin = req.headers().get("x-admin-token").is_some_and(|token| token == "let-me-in");
if !is_admin {
return StatusCode::FORBIDDEN.into_response();
}
next.run(req).await
}
/// Echoes the request id so a user can quote it in a bug report (Loco's `request_id`).
async fn request_id_header(RequestId(id): RequestId, req: Request, next: Next) -> Response {
let mut response = next.run(req).await;
if let Ok(value) = HeaderValue::from_str(&id) {
response.headers_mut().insert("x-request-id", value);
}
response
}
/// Maintenance mode: `MAINTENANCE: bindings.text("on")` in cloudflare.config.ts (or
/// in the dashboard) answers 503 everywhere but /up, without a new build.
async fn maintenance(Extension(ctx): Extension<Ctx>, req: Request, next: Next) -> Response {
let on = ctx.env().var("MAINTENANCE").is_ok_and(|value| value.to_string() == "on");
if on && req.uri().path() != "/up" {
let headers = [("retry-after", "600")];
return (StatusCode::SERVICE_UNAVAILABLE, headers, "Down for maintenance, back soon.").into_response();
}
next.run(req).await
}
To turn away outdated browsers (Rails’ allow_browser versions: :modern), add ocre::security::AllowBrowser to the page routes. modern() allows Safari 17.2, Chrome and Edge 120, Firefox 121 and Opera 106 and later, and refuses Internet Explorer; older browsers get 406 Not Acceptable and a short “please upgrade your browser” page. Requests without a User-Agent, or from clients that are not browsers (bots, curl, uptime checks), always pass. It reads one header: no binding call, microseconds of CPU.
use axum::{Router, routing::get};
use ocre::{Ctx, security::{AllowBrowser, Browser}};
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/dashboard", get(|| async { "dashboard" }))
// Rails' `allow_browser versions: :modern`, with Safari 16.4 accepted too.
.route_layer(AllowBrowser::modern().minimum(Browser::Safari, 16, 4))
// Declared after the layer: API clients are never turned away.
.route("/api/status", get(|| async { "OK" }))
}
Replace the page with .page(html) (an HTML string, e.g. a rendered template).
Rate limiting per action uses the Workers Rate Limiting binding through ocre::security::rate_limit (see Security). Loco’s and Rails’ other middleware map to the platform:
| Middleware | On Ocre |
|---|---|
Request logging (Loco’s logger, Rails’ request log) | Workers Logs records every request (method, URL, status, CPU time, console lines): observability: { enabled: true }, in the generated cloudflare.config.ts. Free plan: 200,000 events a day, kept 3 days |
request_id | ocre::RequestId: Cloudflare’s CF-Ray, shown next to each request in Workers Logs |
remote_ip | ocre::RemoteIp, from CF-Connecting-IP, which Cloudflare sets and clients cannot forge |
compression | Cloudflare compresses responses at the edge (Brotli, gzip) |
etag / conditional GET | ocre::cache::Conditional and ETag (see Caching) |
limit_payload | axum’s 2 MB DefaultBodyLimit, per route with .layer(DefaultBodyLimit::max(n)) |
cors | the ALLOWED_ORIGINS variable, or tower-http’s CorsLayer on a router |
secure_headers | set by ocre::serve; a handler’s own value wins |
catch_panic | not possible: a panic aborts the WebAssembly instance; return Err |
timeout_request | Cloudflare ends requests over the CPU limit (10 ms on the free plan); subrequests have their own timeouts |
fallback | .fallback(not_found) in src/lib.rs |
powered_by / X-Powered-By | not sent; add it with a layer if you want it |
Server timing, benchmark | the Workers clock only moves on I/O, so in-Worker timings are meaningless; Workers Logs reports CPU and wall time per request |
Config-driven stack, insert_before, delete | the .layer(..) calls in routes(), in code |
Static files and the health check
Files in public/ (CSS, images, robots.txt, favicon.ico) are served by Workers Static Assets before the Worker runs: they cost no Worker request and no CPU. See Assets for caching, CSS and JavaScript tooling.
GET /up answers 200 OK with the body OK whenever the Worker runs, like Rails’ /up; point uptime monitors at it. Keep it free of database queries:
curl -si http://localhost:8787/up
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/plain; charset=utf-8
content-security-policy: default-src 'self'; script-src 'self' https://unpkg.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
OK
See also
- Views, helpers and forms: templates, layouts, partials, view helpers and forms
- htmx: boosted navigation, partial responses, inline editing
- Assets: CSS, JavaScript and images
- Models and migrations: the functions controllers call
- Validations: errors and form re-rendering
- JSON APIs and GraphQL:
ocre g api,ApiResult,Json - Sessions, flash and security: sessions, flash, CSRF, CORS, headers
- Authentication:
CurrentUserand protected pages - Realtime: live updates with htmx and WebSockets
- Generators, CLI commands
- API index and the rustdoc
Views, helpers and forms
Views in Ocre are askama templates compiled into the Worker: a template is a Rust struct whose fields are the template’s variables, so a typo in a variable name or a missing value is a compile error, not a blank page in production. This page covers layouts and partials, the view helpers (numbers, dates, text), forms with validation errors, and responses in other formats (XML, CSV, plain text). Interactivity with htmx has its own page, htmx.
Before you start
- A full-stack app from
ocre new(API-only apps have no templates). The examples use the blog starter (ocre new blog --starter blog), whosePosthastitle,body,published,created_atandupdated_at. - Rendering is plain string building inside the Worker: a normal page takes a fraction of a millisecond of the free plan’s 10 ms of CPU per request, and costs no binding call. Templates are compiled in, so
ocre devrebuilds the Worker to show a change (save a.rsfile undersrc/if only a template changed).
Templates
A view is a struct deriving askama::Template, rendered with ocre::render, which returns Result<Html<String>>:
#[derive(Template)]
#[template(path = "posts/show.html")]
struct ShowView {
flash: Flash,
post: Post,
}
async fn show(State(ctx): State<Ctx>, flash: Flash, Path(id): Path<i64>) -> Result<Html<String>> {
render(&ShowView { flash, post: post::find(&ctx, id).await?.or_404()? })
}
path = "posts/show.html" is a file under templates/. Short templates can live in the Rust file with source = "..." and ext = "html" (the extension decides escaping: html escapes, txt does not). Rails’ conventions map to askama as follows:
| Rails | Ocre (askama) |
|---|---|
ERB <%= %> / <% %> | {{ expression }} / {% statement %}; values are HTML-escaped |
Implicit rendering of show.html.erb | explicit: render(&ShowView { .. }); the struct names the file |
render "other_action", render template: | render another view struct |
render inline: | #[template(source = "...", ext = "html")] |
render plain: / html: / body: | return a &str/String, Html(..), or ([(header::CONTENT_TYPE, "...")], body) |
render json: | ocre::Json(value) (see JSON APIs) |
render xml:, Builder templates, atom_feed | a template with ext = "xml" (below) |
render status: :unprocessable_entity | (StatusCode::UNPROCESSABLE_ENTITY, render(&view)?) |
head :no_content | return StatusCode::NO_CONTENT |
DoubleRenderError | impossible: a handler returns one response |
local_assigns, strict locals | struct fields; optional ones are Option<T> ({% if let Some(x) = x %}) |
| Template compilation and caching | done by cargo build: templates are Rust code in the Worker |
render file: | not available (no filesystem on Workers): put the file in public/ or R2 |
Layouts
templates/layout.html is the application layout. Pages extend it and fill its blocks, the equivalent of yield and content_for:
{% extends "layout.html" %}
{% block title %}Post {{ post.id }}{% endblock %}
{% block content %}
<h1>{{ post.title }}</h1>
{% endblock %}
A block the page does not fill keeps the layout’s default ({% block title %}blog{% endblock %} in the layout). Add named regions (Rails’ yield :sidebar) by adding blocks to the layout: {% block sidebar %}{% endblock %}. A section layout (Rails’ nested layouts) is a template that extends layout.html and defines new blocks for its pages:
{# templates/admin/layout.html #}
{% extends "layout.html" %}
{% block content %}
<nav><a href="/admin/posts">Posts</a> · <a href="/admin/users">Users</a></nav>
{% block admin %}{% endblock %}
{% endblock %}
Admin pages then {% extends "admin/layout.html" %} and fill admin. A page that needs no layout (an email, a fragment for htmx) simply does not extend one.
templates/error.html is the page for errors (404, 422, 500…); see Controllers.
Partials and components
{% include "posts/_form.html" %} inserts another template, which sees the including template’s variables (Rails’ partials; the scaffold’s new.html and edit.html share _form.html this way). To give a partial a differently named variable (Rails’ locals: and as:), bind it first with {% let %}:
{% for comment in post_comments %}
{% let item = comment %}
{% include "comments/_comment.html" %}
{% endfor %}
Macros are components with arguments (Rails’ partials with locals, view components):
{% macro field(name, label, value, errors) %}
<label>{{ label }} <input name="{{ name }}" value="{{ value }}"></label>
{% for error in errors %}{% if error.field == name %}<span class="errors">{{ error.message }}</span>{% endif %}{% endfor %}
{% endmacro %}
{% call field("title", "Title", form.title, errors) %}{% endcall %}
{% call %} needs its {% endcall %}; anything between the two is the caller body, which the macro prints with {{ caller() }} (a block component, like a Rails partial rendered with a block). Macros defined in another file are imported with {% import "forms.html" as forms %} and called with {% call forms::field(...) %}{% endcall %}.
Collections
| Rails | askama |
|---|---|
render @posts / collection: | {% for post in posts %}...{% endfor %} |
| Empty-collection fallback | {% for %}...{% else %}No posts yet.{% endfor %} |
post_counter, post_iteration.first?/last? | loop.index (from 1), loop.index0, loop.first, loop.last |
spacer_template: | {% if !loop.last %}<hr>{% endif %} |
| Heterogeneous collections | an enum and {% match item %}{% when Item::Post with (post) %}...{% endmatch %} |
| Partial layouts | wrap the loop body in a macro |
use askama::Template;
use axum::{extract::State, response::Html};
use ocre::{Ctx, Page, Result, filters, render};
use crate::models::post::{self, Post};
#[derive(Template)]
#[template(
source = r#"{% extends "layout.html" %}
{% block content %}
<ol>
{% for post in posts %}
<li class="{{ ocre::helpers::class_names([("post", true), ("draft", !post.published)]) }}">
<a href="/posts/{{ post.id }}">{{ post.title|truncate(60) }}</a>
· {{ post.body|wordcount }} words · {{ post.created_at|time_ago_in_words }} ago
</li>
{% if !loop.last %}<hr>{% endif %}
{% else %}
<li>No posts yet.</li>
{% endfor %}
</ol>
{% endblock %}"#,
ext = "html"
)]
struct IndexView {
posts: Vec<Post>,
}
async fn index(State(ctx): State<Ctx>, page: Page) -> Result<Html<String>> {
render(&IndexView { posts: post::all(&ctx, page).await? })
}
View helpers
ocre::helpers has Rails’ formatting helpers as plain functions, and ocre::filters exposes them to templates as askama filters. askama finds custom filters in a module named filters where the template struct is defined, so a controller brings them in with use ocre::filters; (as the example above does):
| Filter | Output | Rails |
|---|---|---|
{{ views|number_with_delimiter }} | 1,234,567 | number_with_delimiter |
{{ ratio|number_with_precision(2) }} | 3.14 | number_with_precision |
{{ price|number_to_currency("$") }} | $1,234.50 | number_to_currency |
{{ rate|number_to_percentage(1) }} | 12.3% | number_to_percentage |
{{ size|number_to_human_size }} | 1.5 KB | number_to_human_size |
{{ visits|number_to_human }} | 1.23 Million | number_to_human |
{{ post.created_at|time_ago_in_words }} ago | about 3 hours ago | time_ago_in_words |
{{ from|distance_of_time_in_words(to) }} | 2 days | distance_of_time_in_words |
{{ post.created_at|strftime("%b %-d, %Y") }} | Sep 29, 2026 | l / to_fs |
{{ post.body|excerpt(q, 40) }} | ...text around q... | excerpt |
{{ post.body|highlight(q) }} | the text with each match in <mark> | highlight |
{{ post.body|word_wrap(72) }} | lines of at most 72 characters | word_wrap |
{{ ocre::helpers::class_names([("active", current)]) }} | active when current | class_names / token_list |
{% if ocre::helpers::current_page(current, "/posts") %} | true when current (the request’s Uri as text, set by the handler) is /posts, whatever its query | current_page? |
<select name="time_zone">{{ ocre::helpers::time_zone_options(user.time_zone.as_str())|safe }}</select> | an <option> per IANA zone (418, as browsers list them), the current one selected; ocre time-zones prints them | time_zone_select |
askama’s own filters cover the rest: truncate(n), wordcount, linebreaks / linebreaksbr / paragraphbreaks (simple_format), pluralize ({{ n }} post{{ n|pluralize }}), filesizeformat, urlencode, upper, lower, title, capitalize, json, fmt ({{ ratio|fmt("{:.2}") }}) and format ({{ "{:?}"|format(value) }}, Rails’ debug inside a <pre>). Time filters read Unix seconds or the TEXT timestamps D1 stores (2026-09-29 14:05:00, UTC); number filters read any number. Other text passes through unchanged. They are English; translated text comes from Translations.
Your own helpers (Rails’ app/helpers) are either methods on the view struct, called as {{ self.method() }} or {{ method() }}, or filters. To add filters, make the filters module yours and re-export Ocre’s:
use askama::Template;
mod filters {
pub use ocre::filters::*;
/// `{{ post.title|shout }}`: `HELLO!`.
#[askama::filter_fn]
pub fn shout(value: impl std::fmt::Display, _: &dyn askama::Values) -> askama::Result<String> {
Ok(format!("{}!", value.to_string().to_uppercase()))
}
}
#[derive(Template)]
#[template(source = "<h1>{{ title|shout }}</h1><p>{{ views|number_with_delimiter }} views</p>", ext = "html")]
struct Banner {
title: String,
views: i64,
}
impl Banner {
/// A helper method, called as `{{ reading_time() }}` in the template.
fn reading_time(&self) -> String {
format!("{} min", self.views / 200 + 1)
}
}
Links and buttons are HTML: <a href="{{ paths::show(post.id) }}"> (the scaffold’s path helpers replace link_to and url_for), a <form method="post"> with a button replaces button_to, <a href="mailto:{{ user.email }}"> replaces mail_to. There is no tag builder: templates are HTML. sanitize and strip_tags for user-supplied HTML are in ocre::security (see Security).
Forms
A form posts to a handler that deserializes it into a struct with axum’s Form extractor. The scaffold’s pattern keeps every field as text until it is validated, so a typo in a number becomes a field error, and renders the form again with status 422 and the typed values when validation fails:
use askama::Template;
use axum::{
Form, Router,
http::StatusCode,
response::{IntoResponse, Redirect, Response},
routing::get,
};
use ocre::{FieldError, Result, Validator, render};
use serde::Deserialize;
pub fn routes() -> Router<ocre::Ctx> {
Router::new().route("/newsletter", get(new).post(create))
}
/// What the form submits. `#[serde(default)]`: a missing field is empty and
/// an unchecked checkbox is `false`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct SignupForm {
email: String,
frequency: String,
age: String,
terms: bool,
}
#[derive(Template)]
#[template(
source = r#"{% extends "layout.html" %}
{% macro errors_for(errors, name) %}{% for error in errors %}{% if error.field == name %}<small class="errors">{{ error.message }}</small>{% endif %}{% endfor %}{% endmacro %}
{% block content %}
<form action="/newsletter" method="post">
<label>Email <input type="email" name="email" value="{{ form.email }}" required></label>
{% call errors_for(errors, "email") %}{% endcall %}
<label>Frequency
<select name="frequency">
{% for (value, label) in self.frequencies() %}
<option value="{{ value }}"{% if self.chosen(value) %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Age <input type="number" name="age" value="{{ form.age }}"></label>
{% call errors_for(errors, "age") %}{% endcall %}
<label><input type="checkbox" name="terms" value="true"{% if form.terms %} checked{% endif %}> I accept the terms</label>
{% call errors_for(errors, "terms") %}{% endcall %}
<button type="submit">Subscribe</button>
</form>
{% endblock %}"#,
ext = "html"
)]
struct SignupView {
form: SignupForm,
errors: Vec<FieldError>,
}
impl SignupView {
/// The choices of the select (a helper method of the view).
fn frequencies(&self) -> [(&'static str, &'static str); 2] {
[("weekly", "Every week"), ("monthly", "Every month")]
}
/// Whether `value` is the submitted frequency.
fn chosen(&self, value: &str) -> bool {
self.form.frequency == value
}
}
async fn new() -> Result<Response> {
Ok(render(&SignupView { form: SignupForm::default(), errors: vec![] })?.into_response())
}
async fn create(Form(form): Form<SignupForm>) -> Result<Response> {
let mut v = Validator::new();
v.email("email", &form.email);
v.inclusion("frequency", &form.frequency, &["weekly", "monthly"]);
let age: Option<i64> = v.optional_number("age", &form.age);
if let Some(age) = age {
v.greater_than_or_equal_to("age", age, 13);
}
v.acceptance("terms", form.terms);
if !v.is_valid() {
let errors = v.errors().to_vec();
return Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&SignupView { form, errors })?).into_response());
}
// ...store the subscription...
Ok(Redirect::to("/").into_response())
}
Rails’ form helpers map to HTML inputs:
| Rails | HTML in an Ocre template |
|---|---|
form_with url: / model: | <form action="{{ paths::index() }}" method="post">; the edit form posts to {{ paths::show(id) }} (record identification: the scaffold generates both pages) |
text_field, email_field, number_field, date_field, time_field, datetime_local_field, color_field, range_field, search_field, telephone_field, url_field, password_field, hidden_field | `<input type=“text |
text_area | <textarea name="body">{{ form.body }}</textarea> |
check_box (with its hidden 0 input) | <input type="checkbox" name="x" value="true"> and #[serde(default)] x: bool: an unchecked box is simply absent |
radio_button | <input type="radio" name="kind" value="a"{% if form.kind == "a" %} checked{% endif %}> |
select, options_for_select, collection_select, grouped_options_for_select | a <select> with a {% for %} over a slice or the records, <optgroup> for groups |
date_select, time_select (multi-parameter attributes) | type="date" / type="time" inputs; the browser’s picker; Validator::date checks the text |
file_field | <input type="file"> in a enctype="multipart/form-data" form; see File storage |
fields_for, nested attributes | bracketed names read by ocre::NestedForm: post[title], comments[0][body] (below), saved together with batch |
time_zone_select | a <select name="time_zone"> filled in the browser from Intl.supportedValuesOf("timeZone"), with the visitor’s zone (Intl.DateTimeFormat().resolvedOptions().timeZone) selected first; store the IANA name as text and show times in it with Intl.DateTimeFormat in the page. Ocre’s own time helpers work in UTC |
label, submit | <label>, <button type="submit"> |
Custom FormBuilder | askama macros, as errors_for above |
_method override for PATCH/DELETE | not used: HTML routes use POST /posts/{id} and POST /posts/{id}/delete (see Controllers) |
authenticity_token | not needed: Ocre refuses cross-site form posts by origin (see Security) |
axum’s Form reads one value per name. For a list of values or of records in one form (Rails’ tag_ids[], fields_for and accepts_nested_attributes_for), use ocre::NestedForm, which reads Rails’ bracketed names.
Lists and nested records in one form
ocre::NestedForm<T> is axum’s Form with Rails’ naming rules: order[note] fills the field note of the struct in order, repeated tag_ids[] make a Vec, and lines[0][qty], lines[1][qty] make a Vec of structs in index order. A name sent twice keeps the last value, so the hidden 0 before a checkbox works as in Rails, and numbers and booleans are parsed from the text (1, true, on are true; empty is false, or None for an Option). A body that does not fit T is a 400 naming the field.
Nested attributes are plain code: the form sends each line with its id (empty for a new one) and a _destroy checkbox, and the handler turns them into statements that batch applies together, all or none:
use axum::{Router, extract::{Path, State}, response::Redirect, routing::post};
use ocre::{Ctx, NestedForm, Result, Statement, Validator, params};
use serde::Deserialize;
pub fn routes() -> Router<Ctx> {
Router::new().route("/orders/{id}/lines", post(update_lines))
}
/// `<input name="lines[0][id]" type="hidden" value="7">`,
/// `<input name="lines[0][product]">`, `<input name="lines[0][qty]" type="number">`,
/// `<input name="lines[0][_destroy]" type="checkbox" value="1">`; then `lines[1][...]`...
#[derive(Deserialize)]
struct LinesForm {
#[serde(default)]
lines: Vec<LineFields>,
}
#[derive(Deserialize)]
struct LineFields {
id: Option<i64>,
product: String,
qty: i64,
#[serde(default, rename = "_destroy")]
destroy: bool,
}
async fn update_lines(
State(ctx): State<Ctx>,
Path(order_id): Path<i64>,
NestedForm(form): NestedForm<LinesForm>,
) -> Result<Redirect> {
let mut v = Validator::new();
let mut statements = Vec::new();
for line in form.lines {
match (line.id, line.destroy) {
(Some(id), true) => statements
.push(Statement::new("DELETE FROM lines WHERE id = ?1 AND order_id = ?2", params![id, order_id])),
// Rails' `reject_if: :all_blank`: an empty new line is ignored.
(None, _) if line.product.trim().is_empty() => {}
(id, _) => {
v.required("product", &line.product);
v.greater_than("qty", line.qty, 0);
statements.push(match id {
Some(id) => Statement::new(
"UPDATE lines SET product = ?1, qty = ?2 WHERE id = ?3 AND order_id = ?4",
params![line.product, line.qty, id, order_id],
),
None => Statement::new(
"INSERT INTO lines (order_id, product, qty) VALUES (?1, ?2, ?3)",
params![order_id, line.product, line.qty],
),
});
}
}
}
v.finish()?;
ctx.db()?.batch(statements).await?;
Ok(Redirect::to(&format!("/orders/{order_id}")))
}
Every statement names the order, so a form cannot touch another order’s lines. A search form with filter[status]=open in the query string uses the same rules: NestedForm reads the query on GET.
Other formats
A template with ext = "xml" escapes for XML, which covers RSS and Atom feeds (Rails’ Builder templates and atom_feed):
use askama::Template;
use axum::{extract::State, http::header, response::IntoResponse};
use ocre::{Ctx, Page, Result};
use crate::models::post::{self, Post};
#[derive(Template)]
#[template(
source = r#"<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Blog</title>
<id>https://blog.example/</id>
<updated>{% if let Some(post) = posts.first() %}{{ post.updated_at|strftime("%FT%TZ") }}{% endif %}</updated>
{% for post in posts %}
<entry>
<id>https://blog.example/posts/{{ post.id }}</id>
<title>{{ post.title }}</title>
<updated>{{ post.updated_at|strftime("%FT%TZ") }}</updated>
<content type="text">{{ post.body }}</content>
</entry>
{% endfor %}
</feed>"#,
ext = "xml"
)]
struct Feed {
posts: Vec<Post>,
}
mod filters {
pub use ocre::filters::*;
}
async fn feed(State(ctx): State<Ctx>) -> Result<impl IntoResponse> {
let posts = post::all(&ctx, Page::new(20, 0)?).await?;
let xml = Feed { posts }.render()?;
Ok(([(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")], xml))
}
Plain text is a String (text/plain), JavaScript is ([(header::CONTENT_TYPE, "text/javascript")], body), and a CSV or any generated file goes out with ocre::storage::send_data (see Controllers). One handler answering HTML or JSON uses ocre::Format (Rails’ respond_to, see Controllers).
Structured data, sitemap and llms.txt
ocre::seo covers what search engines and language models read besides the page itself:
ocre::seo::json_ld(&data)turns any serializable value (serde_json::json!({...})or a struct) into<script type="application/ld+json">...</script>for the page’s<head>: Organization, FAQPage, BreadcrumbList… The JSON is escaped for HTML (<,>,&become\u003c,\u003e,\u0026), so a value containing</script>cannot end the element and the data parses back unchanged. In a template:{{ ocre::seo::json_ld(&faq)|safe }}.ocre::seo::Sitemapbuilds/sitemap.xml(add, oradd_localizedfor a page in every locale withhreflangalternates andx-default; 50,000 URLs per file). It is a response: return it from a handler.ocre::seo::LlmsTxtbuilds/llms.txt(llmstxt.org): a title, a one-line summary, optional details and sections of links.
ocre g seo writes src/seo.rs with both routes and PAGES, the list of public pages (path, title, description) they share; records with a page of their own (posts, products) go in sitemap with their updated_at as lastmod. The base URL comes from APP_URL (added to .dev.vars; set it in worker.env for production), and public/robots.txt should name the sitemap (Sitemap: https://<your host>/sitemap.xml). For translated pages, see canonical and hreflang.
Localized views
Translated text comes from locales/*.yml through the I18n extractor, passed to the view as a field ({{ i18n.t("posts.title") }}; see Translations). For pages whose whole markup differs per language (Rails’ show.fr.html.erb), define one view struct per language and pick it with match i18n.locale().
See also
- htmx: boosted navigation, partial responses, inline editing, live validation
- Controllers and routing: routes, paths, requests, error pages
- Validations: model rules and error messages
- Assets: CSS, JavaScript and images in
public/ - The askama book for the full template syntax
htmx
Ocre pages get their interactivity from htmx: HTML attributes send requests, and the HTML fragments handlers answer are swapped into the page. There is no JavaScript to write or build for the common cases, every page still works without JavaScript, and handlers stay ordinary axum handlers rendering askama templates. This page covers boosted navigation (Rails’ Turbo Drive), partial responses (Turbo Frames), inline editing, validation as you type, live search, infinite scroll and redirects from htmx requests.
Before you start
- A full-stack app from
ocre new; itstemplates/layout.htmlloads htmx 2 from unpkg and turns on boosted navigation. The examples use the blog starter (ocre new blog --starter blog). - Every htmx request is a normal Worker request: it counts toward the free plan’s 100,000 requests a day and 10 ms of CPU each. Swapping a fragment instead of a page saves bytes and rendering time, not requests: trigger requests on user actions (
click,change,revealed,delay:300ms), not on timers.
Boosted navigation
The generated layout puts hx-boost="true" on <body>: links and forms load the next page with an AJAX request and swap its <body> without a full reload, then update the URL and history (Rails’ Turbo Drive). Redirects are followed, so the scaffold’s “create, then redirect to the record” works unchanged. Without JavaScript, the same links and forms work as usual.
<meta name="htmx-config" content='{"responseHandling": [{"code": "204", "swap": false}, {"code": "...", "swap": true}]}'>
...
<body hx-boost="true">
The htmx-config line makes htmx swap error responses too (by default it ignores 4xx and 5xx bodies), so a form answered with 422 and its errors, or the error page of a 404, shows up like any page. Opt a link or form out with hx-boost="false": the scaffold does it for file links, so the browser downloads or displays the file itself.
hx-confirm asks before sending (Rails’ data-turbo-confirm); the scaffold’s delete button uses it:
<form action="{{ paths::delete(post.id) }}" method="post" hx-boost="true" hx-confirm="Delete this post?">
<button type="submit">Delete post</button>
</form>
Partial responses
Every htmx request carries HX-Request: true; the ocre::Htmx extractor reads it, so one handler can answer a fragment to htmx and a redirect to a plain form post (Rails’ Turbo Frames). A “Publish” button that updates the post’s status in place:
// src/publishing.rs
use askama::Template;
use axum::{
Router,
extract::{Path, State},
response::{IntoResponse, Redirect, Response},
routing::post,
};
use ocre::{Ctx, Htmx, OptionExt, Result, Session, render};
use crate::models::post::{self, Post, PostChanges};
pub fn routes() -> Router<Ctx> {
Router::new().route("/posts/{id}/publish", post(publish))
}
/// The fragment htmx swaps into the page: the same element, updated.
#[derive(Template)]
#[template(
source = r#"<span id="post-{{ post.id }}-status">{% if post.published %}Published{% else %}Draft{% endif %}</span>"#,
ext = "html"
)]
struct StatusPartial {
post: Post,
}
async fn publish(
State(ctx): State<Ctx>,
Htmx(is_htmx): Htmx,
session: Session,
Path(id): Path<i64>,
) -> Result<Response> {
let changes = PostChanges { published: Some(true), ..Default::default() };
let post = post::update(&ctx, id, changes).await?.or_404()?;
if is_htmx {
// htmx request: answer with the fragment only.
return Ok(render(&StatusPartial { post })?.into_response());
}
// Plain form post (JavaScript disabled): full redirect, as usual.
session.flash("notice", "Post was published.")?;
Ok(Redirect::to(&format!("/posts/{}", post.id)).into_response())
}
In templates/posts/show.html, the status and a form that htmx takes over:
<p><span id="post-{{ post.id }}-status">{% if post.published %}Published{% else %}Draft{% endif %}</span></p>
<form action="/posts/{{ post.id }}/publish" method="post"
hx-post="/posts/{{ post.id }}/publish" hx-target="#post-{{ post.id }}-status" hx-swap="outerHTML">
<button type="submit">Publish</button>
</form>
curl -s -X POST http://localhost:8787/posts/2/publish -H 'HX-Request: true'
<span id="post-2-status">Published</span>
Without the header, the same request gets 303 See Other with Location: /posts/2. Keep fragments in their own template (a _status.html file, or an inline template as above) and reuse them from the full page, so both render the same markup. htmx requests from your own pages are same-origin, so Ocre’s CSRF check lets them through with no token (see Security).
Inline editing
Click a title to edit it in place; save or cancel swaps the title back. The same TitleForm fragment comes back with status 422 and the errors when the title is invalid:
// src/titles.rs
use askama::Template;
use axum::{
Form, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use ocre::{Ctx, Error, FieldError, OptionExt, Result, render};
use serde::Deserialize;
use crate::models::post::{self, PostChanges};
pub fn routes() -> Router<Ctx> {
Router::new().route("/posts/{id}/title", get(show).post(update)).route("/posts/{id}/title/edit", get(edit))
}
#[derive(Template)]
#[template(
source = r#"<h1 id="title" hx-get="/posts/{{ id }}/title/edit" hx-swap="outerHTML" title="Click to edit">{{ title }}</h1>"#,
ext = "html"
)]
struct TitleView {
id: i64,
title: String,
}
#[derive(Template)]
#[template(
source = r##"<form id="title" hx-post="/posts/{{ id }}/title" hx-swap="outerHTML">
<input name="title" value="{{ title }}" autofocus>
{% for error in errors %}<small class="errors">{{ error.message }}</small>{% endfor %}
<button type="submit">Save</button>
<button type="button" hx-get="/posts/{{ id }}/title" hx-target="#title" hx-swap="outerHTML">Cancel</button>
</form>"##,
ext = "html"
)]
struct TitleForm {
id: i64,
title: String,
errors: Vec<FieldError>,
}
#[derive(Deserialize)]
struct TitleParams {
title: String,
}
async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<Response> {
let post = post::find(&ctx, id).await?.or_404()?;
Ok(render(&TitleView { id, title: post.title })?.into_response())
}
async fn edit(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<Response> {
let post = post::find(&ctx, id).await?.or_404()?;
Ok(render(&TitleForm { id, title: post.title, errors: vec![] })?.into_response())
}
async fn update(State(ctx): State<Ctx>, Path(id): Path<i64>, Form(params): Form<TitleParams>) -> Result<Response> {
let changes = PostChanges { title: Some(params.title.clone()), ..Default::default() };
let checked = changes.validate().finish();
let updated = match checked {
Ok(()) => post::update(&ctx, id, changes).await,
Err(err) => Err(err),
};
match updated {
Ok(post) => Ok(render(&TitleView { id, title: post.or_404()?.title })?.into_response()),
Err(Error::Invalid(errors)) => {
let form = TitleForm { id, title: params.title, errors };
Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&form)?).into_response())
}
Err(err) => Err(err),
}
}
The show page includes the title fragment: <h1 id="title" hx-get="/posts/{{ post.id }}/title/edit" hx-swap="outerHTML">{{ post.title }}</h1>.
Validation as you type
A field checks itself when it loses focus: htmx posts the whole form to a validation endpoint (hx-include="closest form") and swaps the field’s message. The endpoint runs the model’s own rules, so the messages are the ones the form shows on submit:
use askama::Template;
use axum::{Form, extract::Path, response::Html};
use ocre::{FieldError, Result, render};
use serde::Deserialize;
use crate::models::post::NewPost;
#[derive(Default, Deserialize)]
#[serde(default)]
struct Draft {
title: String,
body: String,
published: bool,
}
#[derive(Template)]
#[template(source = "{% for error in errors %}{{ error.message }} {% endfor %}", ext = "html")]
struct FieldMessages {
errors: Vec<FieldError>,
}
/// POST /posts/validate/{field}: the messages of one field, empty when it is valid.
async fn validate(Path(field): Path<String>, Form(draft): Form<Draft>) -> Result<Html<String>> {
let new = NewPost { title: draft.title, body: draft.body, published: draft.published };
let errors = new.validate().errors().iter().filter(|error| error.field == field).cloned().collect();
render(&FieldMessages { errors })
}
<label>Title
<input name="title" value="{{ form.title }}"
hx-post="/posts/validate/title" hx-include="closest form" hx-trigger="blur changed"
hx-target="next .field-error">
</label>
<small class="field-error errors"></small>
Register it with .route("/posts/validate/{field}", post(validate)). Checks that need the database (uniqueness) stay in create, where the form shows them after submit; a validation endpoint that queries D1 costs rows read on every blur.
Live search
hx-trigger="input changed delay:300ms" waits for a pause in typing, so a search costs one request per pause, not per keystroke:
<input type="search" name="q" placeholder="Search posts"
hx-get="/posts/search" hx-trigger="input changed delay:300ms, search" hx-target="#results">
<ul id="results"></ul>
The handler returns the <li> rows only. Escape the user’s text in LIKE patterns with ocre::escape_like (see Models).
Infinite scroll
The last item of a page asks for the next page when it scrolls into view (hx-trigger="revealed"), and the answer is inserted after it. The same handler serves the full page to a first visit and the items only to htmx:
use askama::Template;
use axum::{extract::State, response::Html};
use ocre::{Ctx, Htmx, Page, Result, render};
use crate::models::post::{self, Post};
#[derive(Template)]
#[template(
source = r#"{% for post in posts %}
<li{% if loop.last %}{% if let Some(next) = page.next(posts.len()) %} hx-get="/feed?{{ next.query() }}" hx-trigger="revealed" hx-swap="afterend"{% endif %}{% endif %}>{{ post.title }}</li>
{% endfor %}"#,
ext = "html"
)]
struct Items {
page: Page,
posts: Vec<Post>,
}
#[derive(Template)]
#[template(source = r#"{% extends "layout.html" %}{% block content %}<ul>{{ items|safe }}</ul>{% endblock %}"#, ext = "html")]
struct FeedView {
items: String,
}
/// GET /feed: 50 posts, then 50 more each time the last one scrolls into view.
async fn feed(State(ctx): State<Ctx>, Htmx(is_htmx): Htmx, page: Page) -> Result<Html<String>> {
let posts = post::all(&ctx, page).await?;
let Html(items) = render(&Items { page, posts })?;
if is_htmx {
return Ok(Html(items));
}
render(&FeedView { items })
}
items|safe is fine here: items is HTML askama rendered and escaped. Page::next guesses from a full page that more rows follow, without a COUNT(*) query (D1 bills every row a count reads). A classic “Previous / Next” navigation uses the same Page methods; the scaffold’s index pages have one.
Redirecting from an htmx request
htmx follows a 303 inside the request and swaps the result into the target, which is right for boosted navigation but wrong when an inline widget should end on another page. ocre::HxRedirect answers 200 with the HX-Redirect header, and htmx performs a full navigation:
use axum::response::{IntoResponse, Redirect, Response};
use ocre::{Htmx, HxRedirect};
/// After an inline "Create" in a modal, go to the new record's page.
async fn created(Htmx(is_htmx): Htmx) -> Response {
let target = "/posts/7".to_owned();
if is_htmx { HxRedirect(target).into_response() } else { Redirect::to(&target).into_response() }
}
Updating several parts of a page
A response can update elements outside its target with out-of-band swaps (Rails’ Turbo Streams over HTTP): any element with hx-swap-oob="true" and an id replaces the element with the same id. ocre::realtime sends the same kind of fragments over WebSockets to every open page (see Realtime).
<li id="post_7">Updated title</li>
<span id="post-count" hx-swap-oob="true">12 posts</span>
JavaScript beyond htmx
For behaviour htmx does not cover (a date picker, a chart), put the script in public/ and load it with <script src="/app.js" defer> (see Assets). Prefer script files to inline <script> blocks and onclick= attributes, which a Content Security Policy blocks (see Security). Rails’ Stimulus controllers map to small scripts that attach to data- attributes, or to a library such as Alpine.js loaded the same way.
| Rails | Ocre |
|---|---|
| Turbo Drive | hx-boost="true" (on in the layout) |
| Turbo Frames | hx-get/hx-post with hx-target, and the Htmx extractor |
| Turbo Streams | hx-swap-oob fragments; ocre::realtime over WebSockets |
data-turbo-method, data-turbo-confirm | a <form method="post"> button, hx-confirm |
request.js with the CSRF header | htmx requests; no token needed |
| Stimulus | script files in public/, or Alpine.js |
| Import maps, jsbundling | see Assets |
See also
- Views, helpers and forms: templates, layouts, partials and forms
- Controllers and routing: handlers and extractors
- Realtime: live updates for every visitor
- The htmx reference for every attribute
Assets: CSS, JavaScript and images
Files in an Ocre app’s public/ directory are served by Workers Static Assets: Cloudflare answers them from its edge before the Worker runs, so they cost no Worker request, no CPU and no free-plan quota. There is no asset pipeline to run: a file in public/ is deployed as it is by ocre deploy, and build tools (Tailwind, esbuild) are optional steps that write into public/. This page covers serving, caching, CSS and JavaScript tooling, and single-page apps.
Before you start
-
An app from
ocre new. Itswrangler.config.tshas:assetsDirectory: "public", -
Free plan (September 2026): static asset requests are free and unlimited, and do not count toward the Worker’s 100,000 requests a day. Each file can be up to 25 MiB, and a Worker version up to 20,000 files (limits).
Serving files
public/robots.txt is /robots.txt, public/css/app.css is /css/app.css. A file wins over a route with the same path, and requests no file matches go to the Worker. Reference files by their path in templates:
<link rel="stylesheet" href="/css/app.css">
<script src="/js/app.js" defer></script>
<link rel="icon" href="/favicon.ico">
<img src="/images/logo.svg" alt="Logo" width="120" height="40">
These are Rails’ stylesheet_link_tag, javascript_include_tag, favicon_link_tag and image_tag: templates are HTML, and there is no digest to resolve (see Caching below). ocre dev serves public/ the same way, uncached, and picks up changes without a rebuild.
Uploaded files (avatars, attachments) do not belong in public/: they go to R2 (see File storage). Static asset responses do not pass through the Worker, so they do not get Ocre’s security headers; add headers to them with _headers below.
Caching
By default Cloudflare serves every static file with Cache-Control: public, max-age=0, must-revalidate and an ETag (a hash of the file). Browsers keep the file and revalidate it on each use; Cloudflare answers 304 Not Modified from its edge while the file is unchanged, and the new version as soon as you deploy. That is why Ocre needs no fingerprinted file names (Rails’ digests and .manifest.json): a deploy is never hidden by a stale cache, and a revalidation is a small free request.
For files that never change under a given name (vendored libraries with a version in the path, fonts), long caching saves even the revalidation. A public/_headers file sets headers per path; it is not served itself:
/vendor/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
Access-Control-Allow-Origin: *
Put a version in those paths (/vendor/chart-4.4.1.min.js) so a new version gets a new URL. Cloudflare compresses text files (HTML, CSS, JavaScript, JSON, SVG) with Brotli or gzip on its own, for static files and Worker responses alike; there is no compression to configure. Cloudflare is also the CDN: there is no asset_host to set.
CSS
Plain CSS in public/ needs no tooling. The generated layout keeps its few rules in a <style> block; move them to public/css/app.css as they grow.
Tailwind CSS runs without Node through its standalone CLI (Rails’ tailwindcss-rails). Download the binary for your platform from the Tailwind releases, then:
/* assets/app.css */
@import "tailwindcss";
@source "../templates";
./tailwindcss -i assets/app.css -o public/css/app.css --minify # once
./tailwindcss -i assets/app.css -o public/css/app.css --watch # while `ocre dev` runs
@source "../templates" makes Tailwind scan the askama templates for class names. To build CSS on every build, chain the command before the Worker build in wrangler.config.ts:
build: { command: './tailwindcss -i assets/app.css -o public/css/app.css --minify && cargo install -q "worker-build@^0.8" && worker-build ${OCRE_BUILD:---release}' },
Sass, PostCSS or Bootstrap builds (Rails’ cssbundling-rails) work the same way: any command that writes into public/, chained in build.command. Commit the tool’s configuration, not its output, if you build on deploy; commit the output if you would rather not install the tool on every machine.
JavaScript
htmx covers most interactivity with no JavaScript of your own (see htmx). The layout loads it from unpkg; to serve it yourself, download htmx.min.js into public/vendor/htmx-2.0.4.min.js and change the <script src>.
For your own scripts, plain ES modules need no bundler. An import map (Rails’ importmap-rails) maps bare names to files in public/:
<script type="importmap" nonce="{{ nonce }}">
{ "imports": { "chart.js": "/vendor/chart-4.4.1.js", "app": "/js/app.js" } }
</script>
<script type="module" src="/js/main.js"></script>
The generated Content-Security-Policy (content_security_policy() in src/lib.rs) allows scripts only from the app and https://unpkg.com, and no inline scripts, which includes inline import maps. Put the module entry point in a file (/js/main.js above, which starts with import "app";), and for the import map add NONCE to script_src and pass nonce: ocre::security::CspNonce from the handler to the template, as the page’s nonce field. A CDN other than unpkg goes into script_src too (see Security). To bundle npm packages (Rails’ jsbundling-rails), run esbuild, Bun or Rollup in build.command of wrangler.config.ts, writing into public/js/ (add the bundler to package.json with npm install --save-dev esbuild):
build: { command: 'npx esbuild assets/js/app.js --bundle --minify --outfile=public/js/app.js && cargo install -q "worker-build@^0.8" && worker-build ${OCRE_BUILD:---release}' },
Single-page apps
An app whose front end is a single-page app (React, Vue, Svelte) builds it into public/, serves JSON from the Worker (see JSON APIs), and lets client-side routes survive a reload with Cloudflare’s SPA fallback: every navigation request no file matches gets public/index.html.
// cloudflare.config.ts, in worker
assets: { notFoundHandling: "single-page-application" },
Requests to the API still reach the Worker: they are not navigations (fetch() sends Sec-Fetch-Mode: cors). Ocre’s generators produce htmx pages, not SPA code.
Rails comparison
| Rails | Ocre |
|---|---|
public/ files | public/, served by Cloudflare before the Worker |
| Propshaft load paths | one directory, public/ |
Digests, .manifest.json, assets:precompile | not needed: ETag revalidation after each deploy; _headers for long caching of versioned paths |
asset_host / CDN | Cloudflare’s edge |
stylesheet_link_tag, javascript_include_tag, image_tag | <link>, <script>, <img> with the path |
tailwindcss-rails, cssbundling-rails, jsbundling-rails | the tool’s CLI in [build] command |
importmap-rails | an <script type="importmap"> |
| Development serving | ocre dev serves public/ uncached |
See also
- Views, helpers and forms: templates and the layout
- htmx: interactivity without writing JavaScript
- Security: Content Security Policy for scripts and styles
- Deployment: what
ocre deployuploads
JSON APIs and GraphQL
The ocre g api generator writes a JSON REST resource under /api/<plural> whose handlers call the model, and --graphql exposes the same resource on /graphql; errors are JSON with the HTTP status, field errors included. This page covers the generated API, hand-written JSON endpoints, GraphQL, API-only apps and cross-origin clients.
Before you start
- An Ocre app from
ocre new(full-stack or--api). The examples useocre g api Product name:string^ price:float stock:integer? --graphqlin the blog starter app, andocre devservinghttp://localhost:8787. - Nothing else: no binding beyond the
DBdatabase every app has. - Free plan (September 2026): 100,000 requests a day and 10 ms of CPU per request (Workers limits); D1 counts every row a query scans (D1 pricing), which is why lists are capped at 100 rows. GraphQL costs more, see GraphQL.
Generate an API
ocre g api Product name:string^ price:float stock:integer? --graphql
create migrations/0005_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
Without --graphql, only the migration, the model and src/products_api.rs are written and registered. When the model already exists (after ocre g model or ocre g scaffold), it is reused and only the API is added; the fields are still required on the command line, so pass the model’s. In an API-only app, ocre g scaffold does the same as ocre g api. See Generators.
Routes
| Method | Path | Handler | Success |
|---|---|---|---|
| GET | /api/products?limit=&offset= | index | 200, a JSON array, newest first; limit 1-100 (default 50), offset from 0; a Link header to the other pages |
| GET | /api/products/{id} | show | 200, the record |
| POST | /api/products | create | 201, the created record; every required field must be sent |
| PATCH | /api/products/{id} | update | 200, the updated record; only the fields sent change |
| DELETE | /api/products/{id} | delete | 204, no body |
The whole controller:
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/api/products", get(index).post(create))
.route("/api/products/{id}", get(show).patch(update).delete(delete))
}
/// One page (`?limit=&offset=`), with a `Link` header to the next and previous pages.
async fn index(State(ctx): State<Ctx>, page: Page) -> ApiResult<(PageLinks, Json<Vec<Product>>)> {
let products = product::all(&ctx, page).await?;
Ok((page.links("/api/products", products.len()), Json(products)))
}
async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>) -> ApiResult<Json<Product>> {
Ok(Json(product::find(&ctx, id).await?.or_404()?))
}
async fn create(State(ctx): State<Ctx>, Json(new): Json<NewProduct>) -> ApiResult<Created<Product>> {
Ok(Created(product::create(&ctx, new).await?))
}
async fn update(
State(ctx): State<Ctx>,
Path(id): Path<i64>,
Json(changes): Json<ProductChanges>,
) -> ApiResult<Json<Product>> {
Ok(Json(product::update(&ctx, id, changes).await?.or_404()?))
}
async fn delete(State(ctx): State<Ctx>, Path(id): Path<i64>) -> ApiResult<StatusCode> {
if product::delete(&ctx, id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(Error::NotFound.into()) }
}
Every rule is in the model (src/models/product.rs, see Models), so the API, HTML pages and GraphQL share it. The request and response bodies are the model’s structs: NewProduct for POST, ProductChanges for PATCH, Product for responses.
Try it with curl
Create (201):
curl -s -X POST http://localhost:8787/api/products \
-H 'Content-Type: application/json' \
-d '{"name": "Teapot", "price": 24.5, "stock": 12}'
{"id":1,"name":"Teapot","price":24.5,"stock":12,"created_at":"2026-09-29 04:49:11","updated_at":"2026-09-29 04:49:11"}
An optional field can be left out (-i shows the status line and headers):
curl -si -X POST http://localhost:8787/api/products \
-H 'Content-Type: application/json' \
-d '{"name": "Mug", "price": 8}'
HTTP/1.1 201 Created
Transfer-Encoding: chunked
Content-Type: application/json
...
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
...
{"id":2,"name":"Mug","price":8.0,"stock":null,"created_at":"2026-09-29 04:49:11","updated_at":"2026-09-29 04:49:11"}
List and show:
curl -s 'http://localhost:8787/api/products?limit=10'
[{"id":2,"name":"Mug","price":8.0,"stock":null,"created_at":"2026-09-29 04:49:11","updated_at":"2026-09-29 04:49:11"},{"id":1,"name":"Teapot","price":24.5,"stock":12,"created_at":"2026-09-29 04:49:11","updated_at":"2026-09-29 04:49:11"}]
curl -s http://localhost:8787/api/products/1
{"id":1,"name":"Teapot","price":24.5,"stock":12,"created_at":"2026-09-29 04:49:11","updated_at":"2026-09-29 04:49:11"}
Update the price and clear the stock; name is not sent, so it stays:
curl -s -X PATCH http://localhost:8787/api/products/1 \
-H 'Content-Type: application/json' \
-d '{"price": 19.9, "stock": null}'
{"id":1,"name":"Teapot","price":19.9,"stock":null,"created_at":"2026-09-29 04:49:11","updated_at":"2026-09-29 04:49:11"}
Delete (204, empty body), then the record is gone:
curl -si -X DELETE http://localhost:8787/api/products/2
HTTP/1.1 204 No Content
...
curl -s -w '\n%{http_code}\n' -X DELETE http://localhost:8787/api/products/2
{"error":{"message":"Not found","status":404}}
404
Errors
Every error is a JSON object {"error": {"status", "message"}} with the same HTTP status; a failed validation adds fields. Internal errors answer Internal server error and log the details (D1 message, SQL) to the Worker log, never to the client.
| Status | When | Body |
|---|---|---|
| 400 | Body is not JSON, lacks Content-Type: application/json, misses a required field or has a wrong type; bad limit/offset | {"error":{"message":"<explanation>","status":400}} |
| 401 | Error::Unauthorized (e.g. missing Bearer token after ocre g auth); adds WWW-Authenticate: Bearer | {"error":{"message":"Unauthorized","status":401}} |
| 403 | Error::Forbidden | {"error":{"message":"Forbidden","status":403}} |
| 404 | Unknown id | {"error":{"message":"Not found","status":404}} |
| 413 | Error::PayloadTooLarge (uploads over the limit) | the message |
| 422 | Failed validation | {"error":{"fields":{...},"message":"Validation failed","status":422}} |
| 500 | Anything unexpected | {"error":{"message":"Internal server error","status":500}} |
Validation errors list every field at once, including the database checks (unique names):
curl -s -X POST http://localhost:8787/api/products \
-H 'Content-Type: application/json' \
-d '{"name": "Teapot", "price": 3, "stock": 9007199254740992}'
{"error":{"fields":{"name":["has already been taken"],"stock":["must be less than or equal to 9007199254740991"]},"message":"Validation failed","status":422}}
curl -s -X POST http://localhost:8787/api/products \
-H 'Content-Type: application/json' \
-d '{"name": " ", "price": 3}'
{"error":{"fields":{"name":["can't be blank"]},"message":"Validation failed","status":422}}
A body that does not fit the struct is a 400 with serde’s explanation, before any validation:
curl -s -X POST http://localhost:8787/api/products -H 'Content-Type: application/json' -d '{"name": "Cup"}'
{"error":{"message":"Failed to deserialize the JSON body into the target type: missing field `price` at line 1 column 15","status":400}}
curl -s -X POST http://localhost:8787/api/products -H 'Content-Type: application/json' -d '{"name": "Cup", "price": "cheap"}'
{"error":{"message":"Failed to deserialize the JSON body into the target type: price: invalid type: string \"cheap\", expected f64 at line 1 column 32","status":400}}
curl -s -X POST http://localhost:8787/api/products -H 'Content-Type: application/json' -d '{"name": "Cup",'
{"error":{"message":"Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 15","status":400}}
curl -s -X POST http://localhost:8787/api/products -d 'name=Cup&price=2'
{"error":{"message":"Expected request with `Content-Type: application/json`","status":400}}
Pagination errors:
curl -s 'http://localhost:8787/api/products?limit=500'
curl -s 'http://localhost:8787/api/products?offset=-1'
curl -s 'http://localhost:8787/api/products?limit=abc'
{"error":{"message":"limit must be between 1 and 100","status":400}}
{"error":{"message":"offset must be 0 or more","status":400}}
{"error":{"message":"Failed to deserialize query string: limit: invalid digit found in string","status":400}}
A PATCH to an unknown id is a 404 ({"error":{"message":"Not found","status":404}}), as is GET.
Pagination, versions and formats
List endpoints read ?limit=&offset= with ocre::Page and answer a Link header (RFC 8288, GitHub’s convention) pointing to the neighbouring pages:
curl -si 'http://localhost:8787/api/products?limit=2&offset=2'
HTTP/1.1 200 OK
Content-Type: application/json
link: </api/products?limit=2&offset=4>; rel="next", </api/products?limit=2&offset=0>; rel="prev", </api/products?limit=2&offset=0>; rel="first"
...
next is there when the page is full (Page::next): Ocre does not run a COUNT(*), which would read every row of the table from D1’s daily 5 million. A client follows next until it is absent; the last page may be empty when the total is a multiple of limit. An endpoint that must report a total runs its own count (see Models).
Versions are path prefixes: keep the current routes in a module and nest it, then nest the next version’s module beside it when the contract changes (the routes then read /products inside the module):
fn routes() -> Router<Ctx> {
Router::new()
.nest("/api/v1", products_v1::routes())
.nest("/api/v2", products_v2::routes())
// ocre:routes
}
ocre routes lists nested routes with their prefix. One action that serves HTML to browsers and JSON to API clients reads the Accept header with ocre::Format (see Controllers). Every error has the same shape, {"error": {"status": ..., "message": ..., "fields": ...}} (see Errors), including unmatched paths in API-only apps.
Optional fields and partial updates
The model’s input structs decide what a JSON body may contain:
pub struct NewProduct {
pub name: String,
pub price: f64,
#[serde(default, deserialize_with = "ocre::optional")]
pub stock: Option<i64>,
}
pub struct ProductChanges {
pub name: Option<String>,
pub price: Option<f64>,
#[serde(default, deserialize_with = "ocre::patch")]
pub stock: Option<Option<i64>>,
}
Body of PATCH | stock in ProductChanges | Effect |
|---|---|---|
{} (field missing) | None | keep |
{"stock": null} or {"stock": ""} | Some(None) | clear (NULL) |
{"stock": 7} or {"stock": "7"} | Some(Some(7)) | set |
{"stock": "lots"} | 400 (does not parse) |
ocre::optional (in NewProduct) reads the same inputs into Option<i64>: missing, null and "" are None. Accepting numbers as strings and empty strings as “no value” lets the same structs read HTML forms. For required fields in ProductChanges (name, price), a missing key or null both keep the value; {"name": ""} is sent to validation and refused with can't be blank. Optional json fields are the exception: they use #[serde(default)] in New<Model> and #[serde(default, deserialize_with = "ocre::patch_json")] in <Model>Changes, so a JSON string stays a string instead of being parsed (see Field types).
Hand-written JSON endpoints
Any handler can be a JSON endpoint: return ApiResult<T> (an alias of Result<T, ApiError>), take bodies with ocre::Json<T>, and use ? on anything returning ocre::Result, since ApiError converts from ocre::Error and worker::Error.
| Type | Use |
|---|---|
ocre::ApiResult<T> | return type; errors become the JSON error body with their status |
ocre::ApiError(Error) | the error type; Err(Error::NotFound.into()) builds one |
ocre::Json<T> | extractor: requires Content-Type: application/json, rejects with a JSON 400 (unlike axum::Json, whose rejections are plain text); response: 200 with T serialized |
ocre::Created<T> | response: 201 with T serialized |
ocre::Page | extractor: ?limit=&offset=, JSON 400 when out of range |
axum::http::StatusCode | response without body, e.g. StatusCode::NO_CONTENT |
An endpoint with counts across tables, and one that changes a single field through the model:
// src/stats_api.rs
use axum::{
Router,
extract::{Path, State},
routing::{get, put},
};
use ocre::{ApiResult, Ctx, Error, Json, OptionExt, params};
use serde::{Deserialize, Serialize};
use crate::models::post::{self, Post, PostChanges};
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/api/stats", get(stats))
.route("/api/posts/{id}/published", put(set_published))
}
#[derive(Deserialize, Serialize)]
struct Stats {
posts: i64,
published: i64,
comments: i64,
}
/// GET /api/stats: three counts in one query.
async fn stats(State(ctx): State<Ctx>) -> ApiResult<Json<Stats>> {
let sql = "SELECT (SELECT COUNT(*) FROM posts) AS posts,
(SELECT COUNT(*) FROM posts WHERE published = 1) AS published,
(SELECT COUNT(*) FROM comments) AS comments";
let stats = ctx.db()?.first::<Stats>(sql, params![]).await?;
Ok(Json(stats.ok_or_else(|| Error::internal("SELECT of counts returned no row"))?))
}
#[derive(Deserialize)]
struct Publish {
published: bool,
}
/// PUT /api/posts/{id}/published with {"published": true}: goes through the
/// model, so its validations apply.
async fn set_published(
State(ctx): State<Ctx>,
Path(id): Path<i64>,
Json(body): Json<Publish>,
) -> ApiResult<Json<Post>> {
let changes = PostChanges { published: Some(body.published), ..Default::default() };
Ok(Json(post::update(&ctx, id, changes).await?.or_404()?))
}
Register it in src/lib.rs (mod stats_api; under // ocre:modules, .merge(stats_api::routes()) under // ocre:routes). In the blog app, with the Comment scaffold:
curl -s http://localhost:8787/api/stats
{"posts":2,"published":1,"comments":0}
curl -s -X PUT http://localhost:8787/api/posts/2/published -H 'Content-Type: application/json' -d '{"published": true}'
{"id":2,"title":"Draft","body":"Not yet","published":true,"created_at":"2026-09-29 04:48:44","updated_at":"2026-09-29 04:49:13"}
curl -s -X PUT http://localhost:8787/api/posts/2/published -H 'Content-Type: application/json' -d '{"published": "yes"}'
curl -s -X PUT http://localhost:8787/api/posts/42/published -H 'Content-Type: application/json' -d '{"published": true}'
{"error":{"message":"Failed to deserialize the JSON body into the target type: published: invalid type: string \"yes\", expected a boolean at line 1 column 19","status":400}}
{"error":{"message":"Not found","status":404}}
Handlers of HTML pages return ocre::Result and render errors as HTML; JSON handlers return ApiResult. Use one or the other per handler.
GraphQL
--graphql adds, for each resource, these operations on POST /graphql, and the GraphiQL editor on GET /graphql (open http://localhost:8787/graphql in a browser):
| Operation | Arguments | Returns |
|---|---|---|
products | limit (default 50), offset (default 0) | [Product!]!, newest first |
product | id | Product or null |
createProduct | input: NewProductInput! | Product! |
updateProduct | id, patch: ProductPatch! | Product!, error with status 404 for an unknown id |
deleteProduct | id | true, error with status 404 for an unknown id |
The resolvers are in src/products_api.rs, below the REST handlers, and call the same model functions. src/graphql.rs builds the schema once per Worker instance and merges each resource’s query and mutation objects after the // ocre:graphql-queries and // ocre:graphql-mutations markers. In ProductPatch, optional fields are MaybeUndefined: omitted keeps, null clears.
curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \
-d '{"query": "{ products(limit: 5) { id name price stock } }"}'
{"data":{"products":[{"id":1,"name":"Teapot","price":19.9,"stock":null}]}}
curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \
-d '{"query": "mutation { createProduct(input: {name: \"Kettle\", price: 30}) { id name stock } }"}'
{"data":{"createProduct":{"id":3,"name":"Kettle","stock":null}}}
curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \
-d '{"query": "mutation { updateProduct(id: 3, patch: {stock: null, price: 28}) { id price stock } }"}'
{"data":{"updateProduct":{"id":3,"price":28.0,"stock":null}}}
Errors follow GraphQL conventions: the HTTP status is 200 and the error is in errors, with Ocre’s status (and validation fields) in extensions:
curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \
-d '{"query": "mutation { createProduct(input: {name: \"\", price: 30}) { id } }"}'
{"data":null,"errors":[{"message":"Validation failed","locations":[{"line":1,"column":12}],"path":["createProduct"],"extensions":{"fields":{"name":["can't be blank"]},"status":422}}]}
curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \
-d '{"query": "mutation { deleteProduct(id: 99) }"}'
{"data":null,"errors":[{"message":"Not found","locations":[{"line":1,"column":12}],"path":["deleteProduct"],"extensions":{"status":404}}]}
product(id: 99) returns {"data":{"product":null}}. A body that is not a GraphQL request is a 400: {"errors":[{"message":"invalid GraphQL request: expected ident at line 1 column 2"}]}.
Cost on the free plan: GraphQL is opt-in (the graphql feature of ocre in Cargo.toml, which the first --graphql enables, plus the async-graphql dependency). It adds about 1.1 MB to the WebAssembly binary and 20-60 ms of CPU each time a new Worker instance starts (measured with wrangler tail), against 10 ms per request on the free plan, which Cloudflare tolerates when overruns are infrequent. Add it only when a client needs GraphQL; each resolver’s queries cost the same D1 rows as the REST handler.
API-only apps
ocre new <name> --api creates an app without templates or askama: ocre is built with default-features = false (no html feature), Cargo.toml records [package.metadata.ocre] mode = "api", and ocre g scaffold generates JSON APIs. Its src/lib.rs answers GET / with a JSON status:
#[derive(Serialize)]
struct Status {
app: &'static str,
status: &'static str,
}
async fn status() -> Json<Status> {
Json(Status { app: "shop", status: "ok" })
}
ocre new shop --api --yes
cd shop
ocre g scaffold Item name:string
create src/models/mod.rs
create migrations/0001_create_items.sql
create src/models/item.rs
create src/items_api.rs
update src/lib.rs
Next:
ocre migrate
ocre dev
curl http://localhost:8787/api/items
Without the html feature, errors that would otherwise be HTML pages (such as a missing session secret) are JSON too.
Calling the API from another origin (CORS)
A browser frontend served from another origin (a separate SPA, a mobile web view) needs that origin listed in the ALLOWED_ORIGINS Worker variable, comma-separated, in cloudflare.config.ts:
// in worker.env
ALLOWED_ORIGINS: bindings.text("https://app.example.com"),
Listed origins get CORS headers (methods GET, POST, PUT, PATCH, DELETE; headers Content-Type, Authorization, Accept; credentials allowed) and pass the CSRF check; unlisted ones get neither. A preflight from the listed origin:
curl -si -X OPTIONS http://localhost:8787/api/products \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
HTTP/1.1 200 OK
Content-Length: 0
Access-Control-Allow-Origin: https://app.example.com
Allow: GET,HEAD,POST
Vary: origin
access-control-allow-credentials: true
access-control-allow-headers: content-type,authorization,accept
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE
...
A browser request from an unlisted site that changes data is refused with a plain-text 403:
curl -s -X DELETE http://localhost:8787/api/products/3 -H 'Origin: https://evil.example' -H 'Sec-Fetch-Site: cross-site'
Forbidden: cross-site request. Add the origin to ALLOWED_ORIGINS to allow it.
Clients that are not browsers (curl, servers, mobile apps) send no Origin or Sec-Fetch-Site header and are not affected. See Sessions, flash and security and Configuration.
Authentication
APIs are public until you protect them. After ocre g auth, take BearerUser(user): BearerUser (from crate::auth_api) in a handler, before Json(..): it accepts Authorization: Bearer <JWT or API key> and answers a JSON 401 with WWW-Authenticate: Bearer otherwise. Tokens come from POST /api/auth/token; API keys from POST /api/auth/keys. Filter queries by user.id for records a user owns. See Authentication.
See also
- Models and migrations: the model behind every API
- Validations: rules and the 422
fieldsobject - Controllers and routing: routing and extractors in general
- Authentication:
BearerUser, JWTs and API keys - File storage: attachments in JSON APIs (
PUT /api/<plural>/{id}/<file>) - Generators, Configuration, Free-plan limits
- API index and the rustdoc
Sessions, flash and security
This guide shows how an Ocre app keeps per-visitor state in an encrypted session cookie, shows one-time flash messages, and what ocre::serve does for every request to protect it: host checks, cross-site request (CSRF) checks, CORS, security headers and cookie flags. It then covers the helpers an app calls itself (rate limiting, safe queries and parameters, files, user HTML) and ends with what is not included, so you know what to add yourself.
Before you start
- An app created with
ocre new. Everything on this page is built into theocrecrate and applied byocre::serveinsrc/lib.rs; no generator is needed. - The
SECRET_KEY_BASEsecret (see Configuration).ocre newwrites a random one to.dev.varsforocre dev;ocre deployuploads a new one to the Worker the first time (see Keys and rotation). - The outputs below come from an app created with
ocre new shop --starter blog, served byocre devon the default port 8787.
What every request goes through
ocre::serve(routes(), req, env) wraps the app’s router with five layers, outermost first:
| Order | Layer | Effect |
|---|---|---|
| 1 | Security headers | Adds nosniff, SAMEORIGIN framing, a referrer policy and more to every response; HSTS on HTTPS |
| 2 | Host authorization | Only when ALLOWED_HOSTS is set: requests for other host names get 403 |
| 3 | CORS | Only when ALLOWED_ORIGINS is set: answers preflights and adds Access-Control-* headers for those origins |
| 4 | Cross-origin protection (CSRF) | Refuses unsafe requests a browser sends from another site, with 403 |
| 5 | Session | Decrypts the session cookie on first use and sends Set-Cookie when the handler changed it |
None of these layers makes a D1, KV or network call: they cost a little CPU and no free-plan quota. Generated full-stack apps add a Content-Security-Policy and a Permissions-Policy layer in src/lib.rs (see Content-Security-Policy and Permissions-Policy).
Sessions
A handler takes session: ocre::Session as an argument (an axum extractor) and reads or writes values by key. Values are anything serde can serialize; they are stored as JSON.
| Method | Returns | Effect |
|---|---|---|
session.get::<T>("key")? | Option<T> | The value, or None when missing or not of type T |
session.insert("key", value)? | () | Stores value (any Serialize) |
session.remove("key")? | bool | Removes the key; true when it was there |
session.clear()? | () | Empties the session (sign out); pending flash messages are kept |
session.flash("notice", "...")? | () | A message for the next request (see Flash messages) |
session.flashes()? | Flash | The flash messages that arrived with this request |
Every method returns ocre::Result, so use ?. Changes are sent back as one Set-Cookie header when the handler returns. A request that does not change the session sends no cookie; taking pending flash messages out counts as a change.
The session is a cookie, like Rails’ default cookie store: no D1 rows, no KV operations, nothing on the server.
- Encrypted and authenticated. The cookie value is JSON encrypted with AES-256-GCM, with a key derived from
SECRET_KEY_BASE. Clients can neither read nor change it. A cookie that does not decrypt (tampered, truncated, or encrypted with a key that is neitherSECRET_KEY_BASEnor listed inSECRET_KEY_BASE_PREVIOUS) is ignored: the request starts with an empty session, no error. - 4 KB at most. Browsers drop cookies over 4096 bytes (name, value and attributes). When a change would make the cookie larger, the response is a 500 and the log says
the session cookie would be <n> bytes; browsers drop cookies over 4096. Fix: store ids in the session, not records. Store ids and short strings, never records, and never secrets such as tokens or passwords. - Cookie flags. The cookie is
_ocre_session,Path=/,HttpOnly(JavaScript cannot read it),SameSite=Lax, andSecurewhen the request came over HTTPS (always on*.workers.dev; not onhttp://localhost). By default it has noMax-Age: it lasts until the browser session ends or the app clears it;session.remember_for(seconds)makes it persistent (see Expiry and remember me). - Clearing. When the session becomes empty, the cookie is deleted (
Max-Age=0).
Store a value in the session
This module stores a visitor’s theme in the session and confirms the change with a flash message. It needs nothing but the ocre new app: add mod preferences; under // ocre:modules and .merge(preferences::routes()) under // ocre:routes in src/lib.rs.
// src/preferences.rs
use askama::Template;
use axum::{
Form, Router,
response::{Html, Redirect},
routing::{get, post},
};
use ocre::{Ctx, Error, Flash, Result, Session, render};
use serde::Deserialize;
pub fn routes() -> Router<Ctx> {
Router::new().route("/preferences", get(show).post(update)).route("/preferences/reset", post(reset))
}
/// Session key; the value is a short string, never a record.
const THEME: &str = "theme";
#[derive(Template)]
#[template(
source = r#"{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
<p>Theme: {{ theme }}</p>
<form method="post" action="/preferences">
<select name="theme"><option>light</option><option>dark</option></select>
<button>Save</button>
</form>"#,
ext = "html"
)]
struct PreferencesView {
flash: Flash,
theme: String,
}
#[derive(Deserialize)]
struct PreferencesForm {
theme: String,
}
async fn show(session: Session, flash: Flash) -> Result<Html<String>> {
let theme = session.get::<String>(THEME)?.unwrap_or_else(|| "light".to_owned());
render(&PreferencesView { flash, theme })
}
async fn update(session: Session, Form(form): Form<PreferencesForm>) -> Result<Redirect> {
if form.theme != "light" && form.theme != "dark" {
return Err(Error::bad_request("theme must be light or dark"));
}
session.insert(THEME, &form.theme)?;
session.flash("notice", "Preferences saved.")?;
Ok(Redirect::to("/preferences"))
}
async fn reset(session: Session) -> Result<Redirect> {
if session.remove(THEME)? {
session.flash("notice", "Preferences reset.")?;
}
Ok(Redirect::to("/preferences"))
}
In a real page, put the markup in templates/preferences/show.html with {% extends "layout.html" %} and use #[template(path = "preferences/show.html")]; the inline source keeps the example in one file.
Try it with ocre dev running and a cookie jar (-c saves cookies, -b sends them):
curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/preferences -d theme=dark -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt -D - http://localhost:8787/preferences
curl -s -c jar.txt -b jar.txt http://localhost:8787/preferences
303 http://localhost:8787/preferences
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html; charset=utf-8
Set-Cookie: _ocre_session=1ik14hJ5am+ra2dQbLBTz921QqhYB+TbkHC+SAM1DpFW8Tv9OVP7cOV74D8%3D; HttpOnly; SameSite=Lax; Path=/
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
<p class="notice">Preferences saved.</p>
<p>Theme: dark</p>
...
<p>Theme: dark</p>
...
The first page shows the flash message and sends a new cookie (the message was taken out of the session); the second shows the theme without it. A forged cookie is simply ignored:
curl -s -H 'Cookie: _ocre_session=abc' http://localhost:8787/preferences
<p>Theme: light</p>
...
And the 400 from Error::bad_request is rendered as an HTML error page:
curl -s -b jar.txt -X POST http://localhost:8787/preferences -d theme=pink
<h1>400</h1><p>theme must be light or dark</p>
Flash messages
A flash message is shown once, on the next page, usually after a redirect (“Post was successfully created.”). Set it with session.flash(kind, message)? before returning a Redirect; the next handler takes flash: ocre::Flash and passes it to its template. Taking Flash removes the messages from the session, so a reload does not show them again.
Flash method | Returns |
|---|---|
flash.notice() | Option<&str>: the "notice" message (success) |
flash.alert() | Option<&str>: the "alert" message (failure) |
flash.get("kind") | Option<&str>: any other kind |
flash.iter() | (kind, message) pairs |
flash.is_empty() | bool |
Kinds are free-form; generated code uses notice and alert, and generated templates show them like this:
{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
{% if let Some(alert) = flash.alert() %}<p class="alert">{{ alert }}</p>{% endif %}
Setting a second message of the same kind in one request replaces the first. session.clear() keeps pending flash messages, so “Signed out.” survives the logout that empties the session. Flash messages live in the session cookie, so they count toward its 4 KB: keep them to a sentence.
For a message on the page being rendered (Rails’ flash.now), add it to the Flash the view gets, without touching the session: render(&EditView { flash: flash.now("alert", "Check the highlighted fields."), .. }). To carry the messages one more request (Rails’ flash.keep), for example when a page redirects again before showing them, call session.keep_flash(&flash)?.
Cookies
The session is the place for per-visitor state. For cookies that outlive it or that other parts of the site read, take ocre::Cookies in the handler (Rails’ cookies, cookies.signed and cookies.encrypted):
use std::time::Duration;
use ocre::{Cookies, Result};
async fn preferences(cookies: Cookies) -> Result<String> {
cookies.set("theme", "dark", Some(Duration::from_secs(365 * 86_400)))?; // readable and changeable by the browser
cookies.set_signed("seen_banner", "1", None)?; // readable, tamper-proof (HMAC)
cookies.set_encrypted("remember_token", "a1b2c3", Some(Duration::from_secs(30 * 86_400)))?; // hidden and tamper-proof
Ok(format!("{:?} {:?}", cookies.get("theme"), cookies.signed("seen_banner")?))
}
get, signed and encrypted read the request’s cookies (None when absent, or changed by the client for the last two); remove(name) deletes one. Cookies set by a handler go out with the response, HttpOnly, SameSite=Lax, Path=/ and Secure over HTTPS. Signed and encrypted cookies use keys derived from SECRET_KEY_BASE, and values made with a key of SECRET_KEY_BASE_PREVIOUS still read. The session cookie’s name (_ocre_session) is refused.
Keys and rotation
SECRET_KEY_BASE must be set and at least 64 characters long. ocre secret prints a new random value (128 hex characters):
ocre secret
Where it comes from:
| Environment | Source |
|---|---|
ocre dev | .dev.vars (git-ignored), written by ocre new |
| Production | A Worker secret. ocre deploy checks cf workers secrets list and, when the Worker has no SECRET_KEY_BASE, uploads a new random one with the deploy; it never replaces an existing one |
Two keys are derived from it: the session cookie key, and (with a fixed label) the HS256 key of ocre::jwt (see Authentication). When the secret is missing or shorter than 64 characters, requests that write the session, or read a session cookie they received, answer 500, and the log names the fix: the SECRET_KEY_BASE secret is not set. Fix: run `ocre secret`, put the value in .dev.vars as SECRET_KEY_BASE=... for `ocre dev` (`ocre new` does this), and deploy with `ocre deploy`, which uploads it.
Replacing the secret alone signs everyone out. Existing cookies no longer decrypt, so every visitor starts with an empty session, and every JWT fails to verify. This is the way to invalidate all cookie sessions at once, for example after a leak: put a new value from ocre secret in .prod.vars (git-ignored) as SECRET_KEY_BASE=..., then
ocre secrets push SECRET_KEY_BASE --file .prod.vars
Changing the value in .dev.vars and restarting ocre dev shows the effect: a browser that was signed in is redirected to /login by the next CurrentUser page, and an old JWT gets 401 {"error":{"message":"Unauthorized","status":401}}. For a planned rotation that keeps everyone signed in, see Rotating SECRET_KEY_BASE without signing everyone out.
Cross-site request forgery (CSRF)
Forms need no token. Instead, Ocre checks where each request comes from, the check Go 1.25 ships as http.CrossOriginProtection:
GET,HEADandOPTIONSrequests pass (they must not change data), except WebSocket handshakes, which are checked like forms because browsers send cookies with them.- A request whose
Originis listed inALLOWED_ORIGINSpasses. - When the browser sent
Sec-Fetch-Site(all current browsers do),same-originandnone(typed in the address bar, bookmarks) pass; anything else (cross-site,same-site) is refused. - Older browsers that send only
Origin: it must match theHostheader, else the request is refused. - A request with neither header passes: it does not come from a web page, so it cannot carry a victim’s cookies by accident (curl, server-to-server calls, mobile apps).
A refused request never reaches the handler. It gets a plain-text 403:
curl -si -X POST http://localhost:8787/preferences -H 'Sec-Fetch-Site: cross-site' -d theme=dark
HTTP/1.1 403 Forbidden
Transfer-Encoding: chunked
Content-Type: text/plain; charset=utf-8
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
Forbidden: cross-site request. Add the origin to ALLOWED_ORIGINS to allow it.
With only an Origin that does not match the host (older browsers):
curl -si -X POST http://localhost:8787/preferences -H 'Origin: https://evil.example' -d theme=dark
HTTP/1.1 403 Forbidden
...
Forbidden: cross-origin request. Add the origin to ALLOWED_ORIGINS to allow it.
The same form posted from the app’s own pages carries Sec-Fetch-Site: same-origin and goes through:
curl -si -X POST http://localhost:8787/preferences -H 'Sec-Fetch-Site: same-origin' -d theme=dark
HTTP/1.1 303 See Other
Content-Length: 0
Location: /preferences
Set-Cookie: _ocre_session=w+0WnN%2FE6WOBxLRb+F69geJp1HvSBUuO9%2FQlgsaoC1cNBkPpegmJbFic45skI6OmixjEaRl52s1TSas1fRpkqRqL0k8otB9akF60KBkTSKGQ+7DTYQ%3D%3D; HttpOnly; SameSite=Lax; Path=/
...
Consequences for app code:
- Change data only in
POST,PUT,PATCHandDELETEhandlers. AGEThandler that changes data is not protected. Generated scaffolds follow this (POST /posts/{id}/delete,POST /logout). - htmx requests (
hx-post,hx-delete…) and your ownfetch()calls from the app’s pages are same-origin and pass. There is no token to put in a<meta>tag or send in a header (Rails’csrf_meta_tags): the browser’sSec-Fetch-Siteheader does that job. - The session cookie is also
SameSite=Lax: browsers do not send it with cross-sitePOSTs at all, a second layer for the same attack. - A refused request never reaches a handler, so nothing needs undoing when the check fails: no session is read, and a “remember me” cookie cannot sign it in.
ALLOWED_ORIGINS: another site calling the app
When a separate frontend (another domain, a local dev server on another port) must call the app from the browser, list its origins in the ALLOWED_ORIGINS Worker variable, comma-separated, in worker.env of cloudflare.config.ts (or in .dev.vars for ocre dev):
// cloudflare.config.ts, in worker.env
ALLOWED_ORIGINS: bindings.text("https://app.example.com, https://admin.example.com"),
Spaces and a trailing / are ignored; invalid entries are skipped. The listed origins get two things:
- CORS: preflights are answered, with credentials allowed (the browser may send cookies), methods
GET, POST, PUT, PATCH, DELETE, request headersContent-Type,Authorization,Accept. - CSRF trust: their unsafe requests pass the cross-origin check.
With ALLOWED_ORIGINS=https://app.example.com in .dev.vars, a preflight from that origin:
curl -si -X OPTIONS http://localhost:8787/api/notes \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization, content-type'
HTTP/1.1 200 OK
Content-Length: 0
Access-Control-Allow-Origin: https://app.example.com
Allow: GET,HEAD,POST
Vary: origin
access-control-allow-credentials: true
access-control-allow-headers: content-type,authorization,accept
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
A cross-site POST from https://app.example.com now gets 303 See Other with Access-Control-Allow-Origin: https://app.example.com; the same request from any other origin still gets 403. When the variable is unset or empty, no CORS layer is added and only same-origin browser requests may change data. List only origins you control: a listed origin can act with the visitor’s cookies.
Security headers
Every response gets Rails’ default headers. Captured from GET /up:
curl -sI http://localhost:8787/up
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: text/plain; charset=utf-8
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
| Header | Value | Effect |
|---|---|---|
X-Content-Type-Options | nosniff | Browsers trust Content-Type instead of guessing |
X-Frame-Options | SAMEORIGIN | Other sites cannot put the app in a frame (clickjacking) |
Referrer-Policy | strict-origin-when-cross-origin | Other sites see only the origin, never paths with tokens |
X-XSS-Protection | 0 | Turns off the obsolete, exploitable XSS auditor of old browsers |
X-Permitted-Cross-Domain-Policies | none | No Flash/PDF cross-domain policy files |
Strict-Transport-Security | max-age=63072000 | HTTPS requests only (two years); ocre dev over HTTP does not get it |
A handler that sets one of these headers keeps its own value. The same way, a handler can add headers Ocre does not send, such as a Content-Security-Policy:
// src/report.rs
use axum::{
Router,
http::header,
response::{Html, IntoResponse},
routing::get,
};
use ocre::Ctx;
pub fn routes() -> Router<Ctx> {
Router::new().route("/report", get(report))
}
async fn report() -> impl IntoResponse {
(
[
// Not sent by Ocre: added here.
(header::CONTENT_SECURITY_POLICY, "default-src 'self'"),
// Sent by Ocre as strict-origin-when-cross-origin: this value wins.
(header::REFERRER_POLICY, "no-referrer"),
],
Html("<p>Quarterly report</p>"),
)
}
Static files in public/ are served by Cloudflare before the Worker runs, so they do not get these headers; add them with a _headers file if needed.
Escaping in templates
askama escapes every {{ value }} in .html templates. A post titled <script>alert(1)</script> is shown as text:
<tr><td><script>alert(1)</script></td><td>Hi</td>...
Rules:
- Never apply
|safeto user input; it turns escaping off. .txttemplates (plain-text email bodies) are not escaped, which is correct for plain text; do not reuse them for HTML.- HTML built with
format!is not escaped. Build HTML in templates, or escape values yourself. - Values inside
<script>or event attributes need JSON encoding, not HTML escaping; avoid putting user input there.
SQL injection
D1 queries take ?1, ?2 placeholders and a params![...] list; the values never become SQL text, so they cannot inject SQL:
ctx.db()?.first("SELECT * FROM users WHERE email = ?1", params![email]).await
The Query builder (post::query().eq("published", true)...) binds every value the same way, and takes column names, joins, where_sql fragments and sort terms as &'static str: they can only come from your code, never from request text. is_in with an empty list matches no row instead of writing invalid or unbounded SQL, and contains, starts_with and ends_with escape % and _ in the searched text.
Never build SQL with format! from user input. When a column or sort order comes from the request, match it onto a fixed column name:
// src/post_search.rs
use axum::{
Json, Router,
extract::{Query as Params, State},
routing::get,
};
use ocre::{Ctx, Direction, Result};
use serde::Deserialize;
use crate::models::post::{self, Post};
pub fn routes() -> Router<Ctx> {
Router::new().route("/posts/search", get(search))
}
/// The only two parameters read from the query string; others are ignored.
#[derive(Deserialize)]
struct SearchParams {
#[serde(default)]
q: String,
sort: Option<String>,
}
async fn search(State(ctx): State<Ctx>, Params(params): Params<SearchParams>) -> Result<Json<Vec<Post>>> {
// The column comes from this list, never from the request text.
let (column, direction) = match params.sort.as_deref() {
Some("title") => ("title", Direction::Asc),
_ => ("created_at", Direction::Desc),
};
let posts = post::query()
.eq("published", true)
.contains("title", ¶ms.q)
.order_by(column, direction)
.limit(20)
.all(&ctx.db()?)
.await?;
Ok(Json(posts))
}
GET /posts/search?q=rust&sort=title runs SELECT * FROM posts WHERE published = ?1 AND title LIKE ?2 ESCAPE '\' ORDER BY title ASC LIMIT ?3; sort=id;DROP TABLE posts sorts by created_at.
Only the fields you declare (strong parameters)
Handlers read forms and JSON bodies into a struct (Form<PostForm>, Json<NewPost>, Query<SearchParams>). serde fills only the fields the struct declares and ignores the others, so the struct is the allowlist that Rails’ params.require(...).permit(...) builds: a request that adds user_id=1 or "admin": true changes nothing unless the struct has that field. Keep fields a visitor must not choose out of the form structs, and set them in the handler instead, like the owner from CurrentUser (see Records that belong to a user). Add #[serde(deny_unknown_fields)] to a struct to reject such requests instead of ignoring the extra fields.
Types do the rest of Rails’ parameter checks: a missing field, or text where the struct wants a number, a bool or a list, is rejected before the handler runs (400 or 422), so a handler never receives nil or an array where it expected one value. Optional fields are Option<T> (or #[serde(default)]), which you handle explicitly.
Validating formats
Validator::format(field, value, |c| ...) checks every character of the whole value against a predicate. There is no regular expression engine in Ocre, so Rails’ pitfall of ^ and $ matching at line breaks (a value with a newline and a script after a valid first line) does not exist: the whole value passes or it does not. For position rules, add a check (value.starts_with("https://")), and for common formats use email, uuid, date, datetime, decimal and the other Validator methods. If you add the regex crate yourself, anchor patterns with \A and \z (or ^/$ without the multi-line flag), which match only the start and end of the text.
Expiry and remember me
session.expire_in(seconds)? stores an expiry time inside the encrypted cookie; after it the session starts empty (a copied or replayed cookie stops working too). session.remember_for(seconds)? does the same and makes the cookie persistent (Max-Age), for “Remember me”. session.expires_at()? reads it; session.clear()? removes it. Keep balances, nonces and other state that must not be replayed in D1, not in the cookie. Server-side revocation of individual sessions is generated by ocre g auth --db-sessions.
Rotating SECRET_KEY_BASE without signing everyone out
List old values (newest first, comma-separated) in the SECRET_KEY_BASE_PREVIOUS secret: cookies encrypted with them are still read and re-encrypted with the current key, and JWTs signed with them still verify. Worker secrets cannot be read back, so keep the value you replace. In .prod.vars (git-ignored):
SECRET_KEY_BASE_PREVIOUS=<the current value>
SECRET_KEY_BASE=<a new value from `ocre secret`>
ocre secrets push SECRET_KEY_BASE_PREVIOUS SECRET_KEY_BASE --file .prod.vars
Remove SECRET_KEY_BASE_PREVIOUS after the longest session lifetime. ocre secrets list shows which secrets are set locally and in production; ocre secrets push NAME... --file .prod.vars uploads values.
Content-Security-Policy and Permissions-Policy
Generated apps add both in src/lib.rs (content_security_policy() and permissions_policy()): scripts only from the app and unpkg.com (htmx), no inline scripts or on*= handlers, object-src 'none', frame-ancestors 'self'; camera, microphone, geolocation, payment and USB off. Policies are ocre::security::ContentSecurityPolicy and PermissionsPolicy values added with .layer(...); a handler’s own header, or a nested router’s layer, overrides them for a route. .report_only() sends Content-Security-Policy-Report-Only; .report_uri("/csp-reports") / .report_to("csp") collect violations. For an inline script add NONCE to script_src, take nonce: ocre::security::CspNonce in the handler and write <script nonce="{{ nonce }}"> (a new random nonce per request).
Rate limiting
ocre::security::rate_limit(&ctx, "BINDING", &key).await? counts one request for key against a Workers Rate Limiting binding and returns Error::TooManyRequests (429, “Too many requests. Try again later.”) when the key is over the binding’s limit. Rails’ rate_limit to:, within:, by: maps onto the binding’s limit, its period (10 or 60 seconds) and the key you build. ocre g auth already limits its login, sign-up, emailed-link, token and deletion routes with the AUTH_RATE_LIMITER binding (see Authentication).
For your own route, add a binding with its own name and namespace to worker.env of cloudflare.config.ts:
// `namespace`: any integer, unique in your Cloudflare account
FEEDBACK_RATE_LIMITER: bindings.rateLimit({ namespace: "4242", simple: { limit: 3, period: 60 } }),
and call it before the expensive part of the handler:
// src/feedback.rs
use axum::{
Form, Router,
extract::State,
http::HeaderMap,
response::Redirect,
routing::post,
};
use ocre::{Ctx, Error, Result, Session, security::rate_limit};
use serde::Deserialize;
pub fn routes() -> Router<Ctx> {
Router::new().route("/feedback", post(create))
}
#[derive(Deserialize)]
struct FeedbackForm {
message: String,
}
async fn create(
State(ctx): State<Ctx>,
headers: HeaderMap,
session: Session,
Form(form): Form<FeedbackForm>,
) -> Result<Redirect> {
// One counter per client IP address (Cloudflare's CF-Connecting-IP).
let ip = ocre::remote_ip(&headers).map_or_else(|| "unknown".to_owned(), |ip| ip.to_string());
rate_limit(&ctx, "FEEDBACK_RATE_LIMITER", &format!("feedback:{ip}")).await?;
if form.message.trim().is_empty() {
return Err(Error::bad_request("write a message"));
}
// ... store or email the message ...
session.flash("notice", "Thanks for your feedback.")?;
Ok(Redirect::to("/"))
}
Past three requests a minute from the same address, POST /feedback gets the 429 error page (JSON handlers answer {"error":{"status":429,"message":"Too many requests. Try again later."}}). Key by user id (format!("export:{}", user.id)) for signed-in actions. The binding is on the free plan, costs no D1, KV or Durable Object operation, and ocre dev simulates it. Counters are per Cloudflare location and approximate, so it is a brake, not an exact quota. A missing binding is a 500 whose log names the bindings.rateLimit(...) entry to add.
HTTPS and HSTS
Workers are reached over HTTPS: *.workers.dev and custom domains get certificates from Cloudflare. On HTTPS requests Ocre adds Strict-Transport-Security: max-age=63072000 (two years) and marks the session cookie Secure. What Rails’ force_ssl does beyond that is configured outside the app or by hand:
- Redirect HTTP to HTTPS: on a custom domain, turn on Always Use HTTPS in the Cloudflare dashboard (SSL/TLS > Edge Certificates); Cloudflare then redirects before the Worker runs.
- Other HSTS options (
includeSubDomains,preload): set the header yourself, in a handler or a layer insrc/lib.rs; a header set by the app wins over Ocre’s.
Files: downloads and uploads
- Sending data (Rails’
send_data):ocre::storage::send_data(bytes, "report.csv", "text/csv", Disposition::Download)setsContent-Type,Content-Lengthand aContent-Dispositionwith a cleaned file name. Types a browser could run (HTML, SVG, XML, JavaScript) are sent asapplication/octet-stream, even withDisposition::Inline. When cells come from users, prefix values starting with=,+,-or@with'so spreadsheets do not run them as formulas. - Sending stored files (Rails’
send_file): files live in R2, not on a disk, so there is no path to traverse.ocre::storage::serve(&ctx, &attachment, &headers, Disposition::Inline)streams an attachment by its random key, with the same type rules,RangeandIf-None-Match. - Upload names: the uploader’s file name loses its directories (
../../x,C:\x) and control characters and is cut to 200 characters; it is only shown inContent-Disposition, never used as the storage key (<prefix>/<22 random characters>). The R2 bucket is not public and nothing in it is executed; files reach browsers only through your handlers, so check that the user may see the record first. See File storage.
User-supplied CSS and markup
ocre::security::sanitizedropsstyleattributes and<style>elements, so user HTML cannot restyle the page (CSS injection, as in the MySpace worm) or load images throughurl(...). Keepstyleout of the lists you pass tosanitize_with.- Never put user text inside a
<style>element or astyle="..."attribute; offer choices from a fixed list instead (a theme name that selects one of your CSS classes). - Markup languages (Markdown, Textile): convert with a library of your choice, then pass the resulting HTML through
sanitizebefore inserting it with|safe, since these converters let raw HTML through. - The generated Content-Security-Policy is a second layer if some HTML slips through: scripts only from the app and
unpkg.com, nojavascript:URLs oron*=handlers, and fonts only from the app, so injected CSS cannot run code or load remote fonts (it still allows inline styles, which the generated layout uses).
Shell commands
A Worker cannot start processes: there is no system, exec or shell, so command-line injection has no target. Code that must call another program does it over HTTP (worker::Fetch) with arguments encoded as JSON or form data, never pasted into a command string on the other side.
Dependency and code scanning
Rails runs Brakeman and bundler-audit by default. For an Ocre app, run in the app directory, for example in CI next to cargo test:
cargo install cargo-audit --locked
cargo audit # dependencies with known vulnerabilities (RustSec advisory database)
cargo clippy --target wasm32-unknown-unknown -- -D warnings # Rust lints on the Worker code
cargo deny check advisories (from cargo-deny) does the same check as cargo audit and can also enforce licenses and banned crates. Enable Dependabot or Renovate on the repository to get pull requests for updated crates. Ocre does not set these up in new apps.
More helpers
| Need | Use |
|---|---|
Only answer on your own host names (Rails’ config.hosts) | ALLOWED_HOSTS: bindings.text("example.com, .example.com") in cloudflare.config.ts; other hosts get 403 (localhost always passes) |
| Redirect to a URL taken from the request | ocre::security::url_from(&uri, &target) returns it only for paths or URLs of this app (no open redirect, no CR/LF) |
| Show user HTML | ocre::security::sanitize(&html) (Rails’ safe list) or sanitize_with, strip_tags; insert with |safe |
Data in a <script> | ocre::security::json_escape, escape_javascript |
| Log parameters | ocre::security::filter_parameters(query) / filter_json(&value) hide passwords, tokens, keys, emails |
| Password-protect a staging page | ocre::security::BasicAuth extractor, auth.matches(user, password), BasicAuth::challenge() |
What is not included
- A
force_sslswitch: see HTTPS and HSTS. - Roles, two-factor authentication and account lockout: see Authentication.
- Dependency scanning in new apps: see Dependency and code scanning.
See also
- Authentication: users, login, JWTs, API keys, ownership checks.
- Security model: why Ocre chose these defaults.
- Configuration:
SECRET_KEY_BASE,ALLOWED_ORIGINSand other variables. - Controllers and routing and Views, helpers and forms: handlers, templates and forms.
ocre secretandocre deploy.- Rust API:
Session,Flash,ALLOWED_ORIGINS, or the API index.
Authentication
This guide adds users to an Ocre app with ocre g auth: sign-up, login (“remember me”) and logout pages, magic links, password resets and email confirmation by email, account deletion, rate limits, JWTs and API keys for JSON clients, and optionally sessions tracked in D1 (--db-sessions) and “Continue with GitHub / Google” (--oauth). It then shows how to protect pages and endpoints and restrict records to their owner. All of it is generated app code you can read and change; the framework only provides small primitives (password hashing, tokens, JWTs, rate limits, OAuth).
Before you start
- An app created with
ocre new, full-stack or API-only (--api).ocre g authruns once per app and refuses to run when aUsermodel or auserstable exists. - The
SECRET_KEY_BASEsecret, used for session cookies and JWTs;ocre newwrites one to.dev.vars(see Sessions, flash and security). - For magic links and password resets in production: a mail adapter (
MAIL_ADAPTER, see Email). Inocre devthe emails are printed in the console. - The outputs below come from an app created with
ocre new shop --starter blog, served byocre devon the default port 8787.
Generate authentication
ocre g auth
In a full-stack app:
create migrations/0002_create_users.sql
create migrations/0003_create_auth_tokens.sql
create migrations/0004_create_api_keys.sql
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/models/mod.rs
update src/lib.rs
update cloudflare.config.ts
Next:
ocre migrate
ocre dev
open http://localhost:8787/signup
cloudflare.config.ts gets the rate limiter used by every route that checks a password or sends an email (see Rate limiting), after the // ocre:env marker:
// `ocre g auth`: login, sign-up, token and emailed-link routes allow 10 attempts
// a minute per IP address and Cloudflare location (Workers Rate Limiting,
// free plan, no storage used). `period` is 10 or 60 seconds.
AUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "3530462", simple: { limit: 10, period: 60 } }),
The namespace is derived from the app name; two Workers of an account with the same id share counters.
Then apply the migrations and start the app:
ocre migrate
ocre dev
What each file holds, and which ones an API-only app (ocre new --api) gets:
| File | Full-stack | API-only | Contents |
|---|---|---|---|
migrations/*_create_users.sql | yes | yes | users: email (unique, COLLATE NOCASE), password_digest (empty for OAuth-only users), confirmed_at, timestamps |
migrations/*_create_auth_tokens.sql | yes | Single-use emailed tokens: user_id, purpose (password_reset, magic_link or email_confirmation), digest (unique), expires_at | |
migrations/*_create_api_keys.sql | yes | yes | api_keys: user_id, name, digest (unique), last_used_at |
src/models/user.rs | yes | yes | User (never serializes password_digest; confirmed(), has_password()), NewUser (email format, password 8 to 128 characters), find, find_by_email, create, create_confirmed, authenticate, update_password, confirm, delete, deletion_confirmed; emails trimmed and lowercased |
src/models/auth_token.rs | yes | issue, peek, consume (single use; valid 15 minutes, a day for email confirmation: valid_minutes) | |
src/models/api_key.rs | yes | yes | create (returns the key once), for_user, revoke, authenticate |
src/auth.rs | yes | CurrentUser, ConfirmedUser, OptionalUser, sign_in, sign_out, origin, SESSION_SECONDS, OAUTH_PROVIDERS | |
src/registrations.rs | yes | GET/POST /signup, GET /account (an example protected page), POST /account/delete | |
src/sessions.rs | yes | GET/POST /login, POST /logout, GET/POST /magic_link, GET/POST /magic_link/{token} | |
src/passwords.rs | yes | GET /passwords/new, POST /passwords, GET/POST /passwords/{token} | |
src/confirmations.rs | yes | send_confirmation; POST /confirmations (a new link), GET/POST /confirmations/{token} | |
templates/auth/*.html | yes | The pages | |
src/auth_api.rs | yes | yes | BearerUser, throttle, TOKEN_LOCATIONS; POST /api/auth/signup, POST /api/auth/token, GET/DELETE /api/auth/me, GET/POST /api/auth/keys, DELETE /api/auth/keys/{id} |
cloudflare.config.ts | yes | yes | The AUTH_RATE_LIMITER binding |
Two options add more, in full-stack apps only:
| Option | Adds |
|---|---|
--db-sessions | migrations/*_create_user_sessions.sql, src/models/user_session.rs, src/user_sessions.rs and templates/auth/user_sessions.html; src/auth.rs stores sessions in D1 (see Sessions tracked in D1) |
--oauth github,google | migrations/*_create_identities.sql, src/models/identity.rs, src/oauth.rs, the providers’ buttons on the login page, commented secrets in .dev.vars (see Continue with GitHub or Google) |
In an API-only app the output is:
create migrations/0001_create_users.sql
create migrations/0002_create_api_keys.sql
create src/models/mod.rs
create src/models/user.rs
create src/models/api_key.rs
create src/auth_api.rs
update src/lib.rs
update cloudflare.config.ts
Next:
ocre migrate
ocre dev
curl -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' -d '{"email":"ada@example.com","password":"correct horse"}'
Running it a second time fails:
error: this app already has a User model or a users table
hint: `ocre g auth` creates both and runs once per app; to start over, remove src/models/user.rs and the create_users migration
Run ocre routes to see every route the generator added.
Sign-up, login and logout
The HTML flows are ordinary forms; the examples use curl with a cookie jar (-c saves cookies, -b sends them) to show what a browser gets.
A visitor who opens a protected page is sent to /login, and the page is remembered:
curl -si -c jar.txt -b jar.txt http://localhost:8787/account
HTTP/1.1 303 See Other
Content-Length: 0
Location: /login
Set-Cookie: _ocre_session=OomyKZrKiIV5EQpPSEG9Ce4hmLjenLFK8MCStDD4c0RVxh4hgPGVu4hMn%2FKFAmNCCW0v0WmFNC3IfOX6ch2l1qvsfg+X%2FjJmaAexvIoNgANOYmXUhTSzyuvNoO2FZK+D0+8D2Q%3D%3D; HttpOnly; SameSite=Lax; Path=/
...
Signing up creates the user, signs them in and returns to that page:
curl -si -c jar.txt -b jar.txt http://localhost:8787/signup -d 'email=ada@example.com&password=correct horse'
HTTP/1.1 303 See Other
Content-Length: 0
Location: /account
Set-Cookie: _ocre_session=HZlSU2bCVeNT7CWPgmbw2p6DaYDY9S%2FOB3S8Wu%2FiKTdXxippgjk2zgYIuqY6JvzqswPId3bRHmAF7SYVfWT0MZI0Hrv6legGNBfwImUsSW06alc2QQRbh5tC6livVjw%3D; HttpOnly; SameSite=Lax; Path=/
...
GET /account now shows the flash message, the user, and a reminder to confirm the address (see Email confirmation):
<h1>Your account</h1>
<p class="notice">Welcome! Your account is ready. Check your email to confirm your address.</p>
...
<dt>Email</dt><dd>ada@example.com</dd>
<dt>Member since</dt><dd>2026-09-29 04:32:55</dd>
...
<p class="alert">Your email address is not confirmed yet.</p>
...
Invalid input re-renders the form with status 422 and every message at once (emails are compared without case):
curl -s http://localhost:8787/signup -d 'email=ADA@example.com&password=short'
...
<ul class="errors">
<li>Password is too short (minimum is 8 characters)</li><li>Email has already been taken</li>
</ul>
...
Log out (POST, so other sites cannot log users out with a link), then log in:
curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/logout -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=ada@example.com&password=wrong' -o /dev/null -w '%{http_code}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=Ada@Example.com&password=correct horse' -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/
422
303 http://localhost:8787/
The failed login re-renders the form with “Invalid email or password.” whether the email or the password was wrong. After a successful login the user goes back to the page that asked for it (only a path of this app: ocre::security::url_from refuses anything else), or /.
Remember me and session expiry
Every sign-in lasts two weeks (SESSION_SECONDS in src/auth.rs). The expiry time is stored inside the encrypted session cookie and checked on every request, so a copied cookie stops working after it too. Without “Remember me” the cookie ends with the browser session; with the box ticked (remember_me=1) it is persistent:
curl -si -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=ada@example.com&password=correct horse&remember_me=1' | grep -i set-cookie
Set-Cookie: _ocre_session=...; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600
sign_in calls session.remember_for(SESSION_SECONDS) or session.expire_in(SESSION_SECONDS); use the same methods of ocre::Session for other expiring state (see Sessions, flash and security).
Magic links
GET /magic_link shows a form asking for an email; POST /magic_link emails a sign-in link valid 15 minutes. The answer is the same redirect to /login (“If an account exists for that email, a sign-in link is on its way.”) whether or not the address has an account:
curl -s http://localhost:8787/magic_link -d 'email=ada@example.com' -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s http://localhost:8787/magic_link -d 'email=nobody@example.com' -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/login
303 http://localhost:8787/login
With MAIL_ADAPTER=log (what ocre new puts in .dev.vars) the email is printed in the ocre dev output:
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Your sign-in link
Open this link within 15 minutes to sign in:
http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E
If you did not ask for it, ignore this email.
[ocre mail] HTML version:
<p><a href="http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E">Sign in</a> (valid 15 minutes).</p><p>If you did not ask for it, ignore this email.</p>
[ocre mail] end
Opening the link (GET) shows a page with a “Sign in” button; the button POSTs to the same URL, which uses the token up and signs in. Mail scanners that follow links therefore do not consume it. A second use fails:
curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/
303 http://localhost:8787/magic_link
The second redirect carries the alert “That sign-in link is invalid or has expired.” Requesting a new link cancels the previous one.
Password reset
GET /passwords/new asks for an email; POST /passwords emails a reset link (same answer whether or not the account exists):
curl -s http://localhost:8787/passwords -d 'email=ada@example.com' -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/login
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Reset your password
Open this link within 15 minutes to choose a new password:
http://localhost:8787/passwords/B8iQKwfm0L86-KOJuVtrep857CdZO0yfERAlZ3ntLFs
...
GET /passwords/{token} shows the form (an invalid or expired token redirects to /passwords/new with an alert). POST /passwords/{token} checks password and password_confirmation, then uses the token up, changes the password and redirects to /login:
T=B8iQKwfm0L86-KOJuVtrep857CdZO0yfERAlZ3ntLFs
curl -s http://localhost:8787/passwords/$T -d 'password=new password&password_confirmation=typo' -w '\n%{http_code}\n'
curl -s http://localhost:8787/passwords/$T -d 'password=new password 2&password_confirmation=new password 2' -o /dev/null -w '%{http_code} %{redirect_url}\n'
...
<ul class="errors">
<li>Password confirmation doesn't match Password</li>
</ul>
...
422
303 http://localhost:8787/login
A validation error does not use the token up, so the user can correct the form. The reset also confirms the email address (the link was opened from the mailbox). A password change does not sign out other browsers with cookie sessions; with --db-sessions, “Sign out everywhere else” does (see Sessions tracked in D1).
Email confirmation
Sign-up emails a confirmation link valid 24 hours (src/confirmations.rs); the account works before it is confirmed:
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Confirm your email address
Confirm your email address by opening this link within 24 hours:
http://localhost:8787/confirmations/<token>
...
Like magic links, GET /confirmations/{token} shows a button and POST uses the token up, sets users.confirmed_at and redirects to /account with “Thanks, your email address is confirmed.” An invalid, expired or used link gets “That confirmation link is invalid or has expired.” The account page of an unconfirmed user has a “Send a new confirmation link” button (POST /confirmations). Signing in with a magic link, resetting the password or signing in with an OAuth provider whose email is verified also confirms the address.
Pages that need a confirmed address take ConfirmedUser instead of CurrentUser (next section).
Protect HTML pages
Take one of the extractors from src/auth.rs as a handler argument:
| Extractor | Gives | Visitor without a session |
|---|---|---|
CurrentUser(user): CurrentUser | User | Redirected to /login with the alert “Please log in to continue.”; for GET requests the page is remembered and the user comes back to it after logging in |
ConfirmedUser(user): ConfirmedUser | User with a confirmed email | Like CurrentUser; an unconfirmed user is redirected to /account with “Please confirm your email address first.” |
OptionalUser(user): OptionalUser | Option<User> | None; the page renders |
They read user_id from the session and load the user with one D1 query (with --db-sessions, one query joining the session and the user). This module adds a dashboard for signed-in users and a greeting for everyone; register it with mod dashboard; under // ocre:modules and .merge(dashboard::routes()) under // ocre:routes in src/lib.rs:
// src/dashboard.rs
use askama::Template;
use axum::{Router, extract::State, response::Html, routing::get};
use ocre::{Ctx, Flash, Result, render};
use crate::{
auth::{CurrentUser, OptionalUser},
models::{
api_key::{self, ApiKey},
user::User,
},
};
pub fn routes() -> Router<Ctx> {
Router::new().route("/dashboard", get(show)).route("/hello", get(hello))
}
#[derive(Template)]
#[template(
source = r#"{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
<h1>Dashboard of {{ user.email }}</h1>
<ul>{% for key in keys %}<li>{{ key.name }}</li>{% endfor %}</ul>
<form method="post" action="/logout"><button>Log out</button></form>"#,
ext = "html"
)]
struct DashboardView {
flash: Flash,
user: User,
keys: Vec<ApiKey>,
}
/// Visitors are redirected to /login, then back here after logging in.
async fn show(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser, flash: Flash) -> Result<Html<String>> {
let keys = api_key::for_user(&ctx, user.id).await?;
render(&DashboardView { flash, user, keys })
}
/// Works for everyone; greets signed-in users by email.
async fn hello(OptionalUser(user): OptionalUser) -> String {
match user {
Some(user) => format!("Hello, {}", user.email),
None => "Hello, visitor".to_owned(),
}
}
Run against ocre dev, signed out then signed in:
curl -s -c jar.txt -b jar.txt http://localhost:8787/dashboard -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=Ada@Example.com&password=correct horse' -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/dashboard
curl -s -b jar.txt http://localhost:8787/hello; echo
curl -s http://localhost:8787/hello; echo
303 http://localhost:8787/login
303 http://localhost:8787/dashboard
<p class="notice">Signed in.</p>
<h1>Dashboard of ada@example.com</h1>
<ul></ul>
<form method="post" action="/logout"><button>Log out</button></form>
Hello, ada@example.com
Hello, visitor
To sign a user in from your own code (after an invitation, say), call auth::sign_in(&session, &user)?: it empties the session first, stores only the user id, and returns the path to redirect to. Sign out with auth::sign_out(&session)?. Never put more than the id in the session.
JSON clients: JWTs and API keys
JSON clients cannot rely on the session cookie; they send Authorization: Bearer <token>, where the token is a JWT or an API key. These routes come from src/auth_api.rs:
| Route | Body | Answer |
|---|---|---|
POST /api/auth/signup | {"email", "password"} | 201, the user |
POST /api/auth/token | {"email", "password"} | {"token", "token_type": "Bearer", "expires_in": 3600} |
GET /api/auth/me | The user of the token | |
GET /api/auth/keys | The user’s API keys (never the secrets) | |
POST /api/auth/keys | {"name"} | 201, {"key", "api_key"}: key is shown this once |
DELETE /api/auth/keys/{id} | 204, or 404 when the user has no such key |
Sign up and get a JWT:
curl -s -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' \
-d '{"email":"grace@example.com","password":"correct horse"}'
curl -s -X POST http://localhost:8787/api/auth/token -H 'content-type: application/json' \
-d '{"email":"grace@example.com","password":"correct horse"}'
{"id":2,"email":"grace@example.com","created_at":"2026-09-29 04:35:02","updated_at":"2026-09-29 04:35:02"}
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiaWF0IjoxNzkwNjU2NTAyLCJleHAiOjE3OTA2NjAxMDJ9.tucAZ1VrmMVeU7i0etJVIr568y13Iit2xJwJdsXs4Ag","token_type":"Bearer","expires_in":3600}
Wrong credentials, or a missing or invalid token, get a JSON 401 with WWW-Authenticate: Bearer:
curl -si -X POST http://localhost:8787/api/auth/token -H 'content-type: application/json' \
-d '{"email":"grace@example.com","password":"nope"}'
HTTP/1.1 401 Unauthorized
Transfer-Encoding: chunked
Content-Type: application/json
WWW-Authenticate: Bearer
...
{"error":{"message":"Unauthorized","status":401}}
Use the JWT, then create a long-lived API key with it:
TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
curl -s http://localhost:8787/api/auth/me -H "Authorization: Bearer $TOKEN"
curl -s -X POST http://localhost:8787/api/auth/keys -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"name":"CI deploys"}'
curl -s http://localhost:8787/api/auth/keys -H "Authorization: Bearer $TOKEN"
{"id":2,"email":"grace@example.com","created_at":"2026-09-29 04:35:02","updated_at":"2026-09-29 04:35:02"}
{"key":"5DNdPEYb0h2uCpRSUS4jFm-i05HI1DthdAgyL5tL_aY","api_key":{"id":1,"user_id":2,"name":"CI deploys","last_used_at":null,"created_at":"2026-09-29 04:35:11"}}
[{"id":1,"user_id":2,"name":"CI deploys","last_used_at":null,"created_at":"2026-09-29 04:35:11"}]
The key works like a JWT: Authorization: Bearer 5DNd.... It is used in the next sections as $KEY, and revoked at the end of Records that belong to a user.
JWTs versus API keys:
| JWT | API key | |
|---|---|---|
| Lifetime | 1 hour (TOKEN_TTL_SECONDS in src/auth_api.rs) | Until revoked |
| Revocable | No, it expires | Yes, DELETE /api/auth/keys/{id} |
| Check cost | One HMAC-SHA256, no D1 read to verify (plus the user lookup) | One D1 read by digest, plus a last_used_at write at most once an hour |
| Use for | Apps that log in with a password | Scripts, CI, integrations |
Bearer requests without an Origin or Sec-Fetch-Site header (curl, servers, mobile apps) pass the CSRF check; a separate browser frontend on another origin needs ALLOWED_ORIGINS (see Sessions, flash and security).
Protect JSON endpoints
Take BearerUser(user): BearerUser from src/auth_api.rs. It accepts a JWT (anything with dots) or an API key, loads the user, and otherwise answers the JSON 401 above; a deleted user’s tokens stop working. Put it before Json(..) in the arguments (axum runs the body extractor last).
This endpoint returns one of the caller’s API keys. The user_id condition in the query is the ownership check: another user’s key is “not found”, exactly like a missing one, so the answer reveals nothing. Register it like the dashboard module:
// src/my_keys_api.rs
use axum::{
Router,
extract::{Path, State},
routing::get,
};
use ocre::{ApiResult, Ctx, Json, OptionExt, Result, params};
use crate::{auth_api::BearerUser, models::api_key::ApiKey};
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/my/keys/{id}", get(show))
}
/// One of the user's API keys. The `user_id` condition is the ownership
/// check: another user's key is "not found", exactly like a missing one.
pub async fn find_owned(ctx: &Ctx, user_id: i64, id: i64) -> Result<Option<ApiKey>> {
ctx.db()?
.first(
"SELECT id, user_id, name, last_used_at, created_at FROM api_keys WHERE id = ?1 AND user_id = ?2",
params![id, user_id],
)
.await
}
/// 401 JSON without a valid `Authorization: Bearer <JWT or API key>`.
async fn show(State(ctx): State<Ctx>, BearerUser(user): BearerUser, Path(id): Path<i64>) -> ApiResult<Json<ApiKey>> {
Ok(Json(find_owned(&ctx, user.id, id).await?.or_404()?))
}
With Grace’s API key, then with a JWT of Ada (user 1, from POST /api/auth/token with her email and password):
KEY=5DNdPEYb0h2uCpRSUS4jFm-i05HI1DthdAgyL5tL_aY
ADA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzkwNjU2NTE3LCJleHAiOjE3OTA2NjAxMTd9.j6v-x42xurVULPJno1bh4lxP038jiRKVAFqac45QZoY
curl -s http://localhost:8787/api/my/keys/1 -H "Authorization: Bearer $KEY"
curl -s http://localhost:8787/api/my/keys/1 -H "Authorization: Bearer $ADA" -w ' %{http_code}\n'
{"id":1,"user_id":2,"name":"CI deploys","last_used_at":"2026-09-29 04:35:17","created_at":"2026-09-29 04:35:11"}
{"error":{"message":"Not found","status":404}} 404
last_used_at was set by the first request made with the key.
Records that belong to a user
Give a table a user_id column with references, then filter every query by the signed-in user’s id.
ocre g model Note title:string user:references
create migrations/0005_create_notes.sql
create src/models/note.rs
update src/models/user.rs
update src/models/mod.rs
Next:
ocre migrate
cargo check --target wasm32-unknown-unknown
The migration has user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE (a user’s notes are deleted with the user) and an index on user_id. src/models/note.rs gets note.user(&ctx), and src/models/user.rs gets the list query, already filtered by owner:
impl User {
// ocre:associations
/// Notes of this user, newest first.
pub async fn notes(&self, ctx: &Ctx, page: ocre::Page) -> Result<Vec<crate::models::note::Note>> {
ctx.db()?
.all(
"SELECT * FROM notes WHERE user_id = ?1 ORDER BY id DESC LIMIT ?2 OFFSET ?3",
params![self.id, page.limit, page.offset],
)
.await
}
}
A JSON API over the notes then takes the owner from the token, never from the request body, and answers 404 for other users’ notes:
// src/notes_api.rs
use axum::{
Router,
extract::{Path, State},
routing::get,
};
use ocre::{ApiResult, Created, Ctx, Error, Json, Page};
use serde::Deserialize;
use crate::{
auth_api::BearerUser,
models::note::{self, NewNote, Note},
};
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/notes", get(index).post(create)).route("/api/notes/{id}", get(show))
}
#[derive(Deserialize)]
struct NoteInput {
title: String,
}
async fn index(State(ctx): State<Ctx>, BearerUser(user): BearerUser, page: Page) -> ApiResult<Json<Vec<Note>>> {
Ok(Json(user.notes(&ctx, page).await?))
}
async fn create(State(ctx): State<Ctx>, BearerUser(user): BearerUser, Json(input): Json<NoteInput>) -> ApiResult<Created<Note>> {
// The owner comes from the token, never from the request body.
Ok(Created(note::create(&ctx, NewNote { title: input.title, user_id: user.id }).await?))
}
async fn show(State(ctx): State<Ctx>, BearerUser(user): BearerUser, Path(id): Path<i64>) -> ApiResult<Json<Note>> {
match note::find(&ctx, id).await? {
Some(note) if note.user_id == user.id => Ok(Json(note)),
_ => Err(Error::NotFound.into()),
}
}
Grace creates a note; Ada cannot see it:
curl -s -X POST http://localhost:8787/api/notes -H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' -d '{"title":"Buy flour"}'
curl -s http://localhost:8787/api/notes/1 -H "Authorization: Bearer $ADA" -w ' %{http_code}\n'
curl -s http://localhost:8787/api/notes -H "Authorization: Bearer $ADA"; echo
{"id":1,"title":"Buy flour","user_id":2,"created_at":"2026-09-29 04:35:17","updated_at":"2026-09-29 04:35:17"}
{"error":{"message":"Not found","status":404}} 404
[]
Finally, Grace revokes her API key; it stops working at once:
curl -s -X DELETE http://localhost:8787/api/auth/keys/1 -H "Authorization: Bearer $KEY" -w '%{http_code}\n'
curl -s http://localhost:8787/api/auth/me -H "Authorization: Bearer $KEY" -w ' %{http_code}\n'
204
{"error":{"message":"Unauthorized","status":401}} 401
Rules for owned records:
- Filter in SQL (
WHERE id = ?1 AND user_id = ?2) for updates and deletes, so one statement both checks and acts;api_key::revokeinsrc/models/api_key.rsdoes this. - Answer
Error::NotFoundfor other users’ records, so ids of other users’ data are not confirmed. UseError::Forbidden(403) when the user may know the record exists but may not change it (a shared document that only its owner edits). - The HTML scaffold pages of
ocre g scaffolddo not check ownership; addCurrentUserand theuser_idfilter to each handler that should be private.
Add a column to users
Add a migration, apply it, then update the model by hand (the generator does not edit user.rs):
ocre g migration add_name_to_users name:string?
ocre migrate
create migrations/0006_add_name_to_users.sql
Next:
ocre migrate
update the model in src/models/ to match the new columns
-- Migration: add_name_to_users
-- Applied once, in file-name order. Never edit a migration after it has been applied.
ALTER TABLE users ADD COLUMN name TEXT;
In src/models/user.rs: add pub name: Option<String>, to User (it is serialized in JSON answers; keep #[serde(skip_serializing)] for anything secret), pub name: String, to NewUser (missing in a form or JSON body means empty, thanks to #[serde(default)]), a rule in NewUser::validate (v.max_length("name", &self.name, 100);), and the column in the INSERT of create:
let name = Some(new.name.trim()).filter(|name| !name.is_empty());
db.first(
"INSERT INTO users (email, password_digest, name) VALUES (?1, ?2, ?3) RETURNING *",
params![email, password_digest, name],
)
For the HTML sign-up, add <label>Name <input name="name"></label> to templates/auth/signup.html. The JSON sign-up takes it right away:
curl -s -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' \
-d '{"email":"linus@example.com","password":"correct horse","name":"Linus"}'
curl -s -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' \
-d '{"email":"bad","password":"short"}'
{"id":3,"email":"linus@example.com","name":"Linus","created_at":"2026-09-29 04:42:46","updated_at":"2026-09-29 04:42:46"}
{"error":{"fields":{"email":["is invalid"],"password":["is too short (minimum is 8 characters)"]},"message":"Validation failed","status":422}}
Account deletion
POST /account/delete with confirmation (the password, or the email address for users without one) deletes the user and signs out; tokens, API keys, sessions and identities go with it (ON DELETE CASCADE). JSON clients send DELETE /api/auth/me with {"password": "..."}: 204, or 403 for a wrong password.
Rate limiting
Every route that checks a password or sends an email calls throttle(&ctx, &headers, "login") (in src/auth_api.rs), which counts one attempt per action and client IP address (ocre::remote_ip) with ocre::security::rate_limit against the AUTH_RATE_LIMITER Workers Rate Limiting binding. Over 10 a minute the answer is 429 Too Many Requests (JSON: {"error":{"status":429,"message":"Too many requests. Try again later."}}). The binding is on the free plan, uses no D1 or KV operation, and ocre dev simulates it; counters are per Cloudflare location and approximate. Change limit and period (10 or 60 seconds) in cloudflare.config.ts.
Sessions tracked in D1
With ocre g auth --db-sessions, each sign-in is a row of user_sessions (IP address, browser, last activity, expiry) and the cookie holds a random token whose SHA-256 digest finds the row (Rails 8’s Session model). /account/sessions lists the signed-in devices, “Sign out” ends one, “Sign out everywhere else” ends all others; deleting a row signs that device out on its next request. Cost: every signed-in request reads the session and its user (one query, 2 D1 rows); last_seen_at is written at most once an hour.
Continue with GitHub or Google
With ocre g auth --oauth github,google, the login page gets one button per provider. src/oauth.rs runs the OAuth 2.0 code flow with PKCE through ocre::oauth: POST /auth/{provider} stores a random state and PKCE verifier in the session and redirects to the provider; GET /auth/{provider}/callback checks the state, trades the code for a token, reads the profile, and signs in the user linked to that account (identities table), else the user with the same verified email (then linked), else a new confirmed user without a password. Register an OAuth app with the callback URL https://<your host>/auth/<provider>/callback (and one for http://localhost:8787), put GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET (or GOOGLE_...) in .dev.vars, and upload them with ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars (a git-ignored file of NAME=value lines). A sign-in costs 2 or 3 subrequests of the 50 a free-plan request may make.
Framework primitives
The generated code is built on these ocre items; use them for your own flows (invitations, email confirmation, one-time codes):
| Item | What it does | Cost |
|---|---|---|
ocre::password::hash(&password).await? | PBKDF2-HMAC-SHA256 digest, pbkdf2_sha256$100000$<salt>$<hash> (16-byte random salt) | About 5.5 ms of CPU (WebCrypto, outside WebAssembly): one per request at most |
ocre::password::verify(&password, &digest).await? | bool, constant-time comparison | Same as hash |
ocre::password::iterations(&digest) | The iteration count stored in a digest | None |
ocre::token::generate() | 256 random bits, URL-safe base64 (43 characters) | None |
ocre::token::digest(&token) | SHA-256 of a token, to store and look up instead of the token | None |
ocre::token::constant_time_eq(a, b) | Compares secrets without timing leaks | None |
ocre::jwt::encode(&ctx, &Claims::new(user.id.to_string(), ttl))? | HS256 JWT with sub, iat, exp; key derived from SECRET_KEY_BASE | One HMAC |
ocre::jwt::decode(&ctx, &token)? | Claims, or Error::Unauthorized when expired, badly signed, or not HS256 (including alg: none) | One HMAC |
ocre::now() | Unix seconds; SystemTime::now() panics on wasm32-unknown-unknown | None |
Error::Unauthorized | 401; JSON answers add WWW-Authenticate: Bearer | |
Error::Forbidden | 403, for “signed in but not allowed” |
Store only token::digest(&token) for secrets you email or show once, and look rows up by digest, as src/models/auth_token.rs and src/models/api_key.rs do. The full signatures are in the API index and the rustdoc of ocre::password, ocre::token and ocre::jwt.
Before going public
- Mail. Magic links and password resets call
ocre::mail::send. WithoutMAIL_ADAPTERin production,POST /magic_linkandPOST /passwordsanswer 500 and the log sayscannot send email: MAIL_ADAPTER is not set. Fix: .... For sign-up and reset mail to any address on the free plan, use Resend (100 emails a day, 3,000 a month, September 2026); see Email. - Rate limiting. Keep the
AUTH_RATE_LIMITERentry incloudflare.config.ts(see Rate limiting): each password check costs about 5.5 ms of CPU, and every emailed link costs a send from your mail quota. Its counters are per Cloudflare location and approximate; with a custom domain, a Cloudflare WAF rate limiting rule on/login,/signup,/magic_link,/passwordsand/api/auth/*adds a limit before the Worker runs (see Deployment). - OAuth. With
--oauth, put the provider’s..._CLIENT_IDand..._CLIENT_SECRETin.prod.varsand upload them withocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars, and register the production callback URL with the provider. - Secret.
ocre deploycreatesSECRET_KEY_BASEon the first deploy; never commit.dev.vars.
Security choices
In short (the reasoning is in Security model):
- Passwords: PBKDF2-HMAC-SHA256 with 100,000 iterations through WebCrypto, the most Workers accept (OWASP recommends 600,000; bcrypt or argon2 in WebAssembly would not fit in 10 ms of CPU). A login with an unknown email hashes too, so response times do not reveal accounts.
- Sessions: the encrypted cookie holds only
user_id(with--db-sessions, a random session token whose digest finds the D1 row) and its expiry; login empties the session first, logout clears it.CurrentUseronly returns to local paths (no open redirect). - Emailed tokens: 256 random bits, stored as SHA-256 digests, valid 15 minutes (email confirmation links: a day), used up by one
DELETE ... RETURNING(single use even under concurrent requests); a new request cancels the previous link; links open a page whose buttonPOSTs. Links use the request’s origin. - JWTs: HS256 only, key derived from
SECRET_KEY_BASEwith a fixed label (so it differs from the cookie key); no separateJWT_SECRET. RotatingSECRET_KEY_BASEsigns everyone out of sessions and tokens at once, unless the old value is kept inSECRET_KEY_BASE_PREVIOUSfor a while (see Rotating SECRET_KEY_BASE without signing everyone out). - API keys: 256 random bits shown once, stored as SHA-256 digests, revocable;
last_used_atwritten at most once an hour to save D1 writes.
What is not included
- Revoking cookie sessions before they expire, unless you use
--db-sessions. - Roles and permissions:
Error::Forbiddenis there for your own checks. - Revoking a JWT before it expires (use API keys for long-lived access).
See also
ocre g authin the generators reference.- Sessions, flash and security: sessions, CSRF,
ALLOWED_ORIGINS, headers. - Email: mail adapters for magic links and password resets.
- JSON APIs and GraphQL:
ApiResult, errors, pagination. - Models and migrations:
references, migrations, associations. - Security model.
This guide sends email from an Ocre app with ocre::mail (built directly, or by a mailer generated with ocre g mailer, with layouts, app-wide defaults and previews), adds several recipients, headers, attachments and inline images, picks a delivery adapter for development and production, sends from the background with deliver_later, inspects every email in ocre dev, and receives email through Cloudflare Email Routing with ocre g mailbox.
Before you start
- An app created with
ocre new. It already has what sending needs:MAIL_FROMincloudflare.config.tsandMAIL_ADAPTER=login.dev.vars, soocre devprints every email instead of sending it. - Mailers:
ocre g mailer. Receiving:ocre g mailbox. Sending later: oneocre g job(it wires the queuedeliver_lateruses). - For production delivery: a domain you control, verified with Resend or onboarded to Cloudflare Email Service (see Choose an adapter).
- The outputs below come from an app created with
ocre new shop --starter blog, served byocre devon the default port 8787.
Send an email
Build an ocre::mail::Email and hand it to ocre::mail::send:
use ocre::mail::Email;
let email = Email::new("ada@example.com", "Welcome", "Hello Ada,\n\nhttps://example.com/start\n")
.html("<p>Hello Ada,</p><p><a href=\"https://example.com/start\">Start</a></p>")
.reply_to("support@example.com");
ocre::mail::send(&ctx, email).await?;
Email | Meaning |
|---|---|
Email::new(to, subject, text) | One recipient, a one-line subject, a plain-text body (always sent) |
.also_to(address), .cc(address), .bcc(address) | More recipients; To and Cc see each other, Bcc recipients are hidden. 50 recipients at most (to, cc and bcc together) |
.html(html) | Adds an HTML body, shown by clients that render HTML. Sent as given: escape user input (askama templates do) |
.reply_to(address) | Replies go there instead of to the sender |
.from(address) | Sends from this address instead of MAIL_FROM (on a domain verified with the provider) |
.header(name, value) | An extra header: In-Reply-To and References to thread a reply, List-Unsubscribe, X-... |
.attach(filename, content_type, bytes) | Attaches a file (see Attachments and inline images) |
.inline(content_id, filename, content_type, bytes) | An image the HTML shows with <img src="cid:content_id"> |
Fields from, to, cc, bcc, subject, text, html, reply_to, headers, attachments | Public, for reading or changing an email a mailer built |
Every address may carry a display name, Ada Lovelace <ada@example.com>. ocre::mail::address_with_name("Acme, Inc.", "x@acme.test") builds one from a user-typed name, quoting it when needed ("Acme, Inc." <x@acme.test>) and removing characters that would break the header, like Rails’ email_address_with_name.
Nothing is checked while building. send checks, then delivers:
| Problem | Result |
|---|---|
A recipient (to, cc, bcc) or reply_to is not an email address | Error::BadRequest, 400 invalid email address: <address> |
MAIL_ADAPTER unset or unknown, the sender unset or not an address, no recipient or more than 50, subject empty or on several lines, a header Ocre sets itself (Subject, Cc…) or with a line break, an attachment without a name or a MIME type, RESEND_API_KEY or the EMAIL binding missing, the provider refused or could not be reached | Error::Internal, 500; the log line says what to fix |
The sender is the MAIL_FROM variable, noreply@yourdomain.com or Name <noreply@yourdomain.com>. ocre new puts a placeholder in cloudflare.config.ts:
// cloudflare.config.ts, in worker.env
// Sender for `ocre::mail::send`: "noreply@yourdomain.com" or "Name <noreply@yourdomain.com>".
MAIL_FROM: bindings.text("shop <noreply@example.com>"),
send waits for the provider (one HTTP subrequest with Resend). The request answers after delivery succeeded; use deliver_later to answer first and retry failures.
Choose an adapter
The MAIL_ADAPTER variable names how mail leaves the Worker. Nothing is guessed from which keys happen to be set, so a development machine holding a real API key never sends by accident.
MAIL_ADAPTER | Delivery | Configuration | Free-plan limits (September 2026) |
|---|---|---|---|
log | Prints the whole email (headers, text, HTML, one line per attachment) to the Worker console between [ocre mail] lines; sends nothing. In ocre dev it also keeps the last 20 for the development pages | none; ocre new writes MAIL_ADAPTER=log to .dev.vars, which overrides cloudflare.config.ts in ocre dev | none |
resend | POST https://api.resend.com/emails | RESEND_API_KEY secret; MAIL_FROM on a domain verified in Resend | Resend free plan: 100 emails a day, 3,000 a month, one domain; any recipient |
cloudflare | Cloudflare Email Service through the EMAIL send_email binding | EMAIL: bindings.sendEmail() in cloudflare.config.ts; MAIL_FROM on a domain onboarded to Email Service | Workers Free: only verified destination addresses of the account; any recipient needs Workers Paid (3,000 a month included, then $0.35 per 1,000) |
For sign-up, magic-link and password-reset mail to any address on the free plan, use Resend. The cloudflare adapter on the free plan suits mail to yourself (alerts, reports).
One email can use the other provider with .delivery_method("cloudflare") or .delivery_method("resend") (Rails’ delivery_method and delivery_method_options per message): for example customer mail through Resend and internal alerts through Cloudflare Email Service, each with its own configuration above. While MAIL_ADAPTER is log, such emails are still only logged; an unknown name is a 500 naming the fix.
With MAIL_ADAPTER unset, send fails rather than dropping mail silently. With MAIL_ADAPTER removed from .dev.vars, a handler that sends answers 500 and ocre dev logs:
✘ [ERROR] [ocre] cannot send email: MAIL_ADAPTER is not set. Fix: set MAIL_ADAPTER to "resend" (with the RESEND_API_KEY secret) or "cloudflare" (with the EMAIL: bindings.sendEmail() binding) in worker.env of cloudflare.config.ts, as MAIL_ADAPTER: bindings.text("resend"); `ocre new` puts MAIL_ADAPTER=log in .dev.vars so `ocre dev` only logs mail
log (development)
This is what ocre dev prints for an email with a text and an HTML body (a generated mailer’s, so the HTML comes wrapped in templates/mailers/layout.html):
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: bob@example.com
Subject: Welcome
Hello bob@example.com,
This is the welcome email. Edit templates/mailers/user/welcome.txt.
[ocre mail] HTML version:
<!DOCTYPE html>
<html>
<head>
...
<p>Hello bob@example.com,</p>
<p>This is the welcome email. Edit templates/mailers/user/welcome.html.</p>
</body>
</html>
[ocre mail] end
Cc, Bcc, Reply-To and extra headers are printed with the others, and each attachment as a line such as [ocre mail] attachment: digest.csv (text/csv, 4 bytes). Links in emails (magic links, password resets) can be opened from there, or from the development pages. In production, log writes to the Worker logs (Workers Logs in the dashboard); it never delivers.
resend (production, any recipient)
-
Create a Resend account, add and verify your domain at https://resend.com/domains, and create an API key at https://resend.com/api-keys.
-
Store the key as a Worker secret: put
RESEND_API_KEY=<key>in.prod.vars(git-ignored), thenocre secrets push RESEND_API_KEY --file .prod.vars(the Worker must exist: after the first
ocre deploy). -
In
cloudflare.config.ts, inworker.env, set the adapter and a sender on the verified domain:MAIL_FROM: bindings.text("Shop <noreply@yourdomain.com>"), MAIL_ADAPTER: bindings.text("resend"), -
ocre deploy..dev.varskeepsMAIL_ADAPTER=log, soocre devstill only prints. To send for real fromocre dev, putMAIL_ADAPTER=resendandRESEND_API_KEY=...in.dev.vars.
Resend errors are 500s whose log names the fix: a 401 or 403 from Resend says to check RESEND_API_KEY and the domain of MAIL_FROM; a 429 says the rate or daily quota was reached.
cloudflare (production, verified addresses on the free plan)
-
Onboard the sending domain to Cloudflare Email Service in the dashboard.
-
In
cloudflare.config.ts, inworker.env, uncomment the bindingocre newleft there, and set the adapter:MAIL_FROM: bindings.text("Shop <noreply@yourdomain.com>"), MAIL_ADAPTER: bindings.text("cloudflare"), EMAIL: bindings.sendEmail(), -
ocre deploy.
ocre dev simulates the binding: with MAIL_ADAPTER=cloudflare in .dev.vars and the binding uncommented, sending prints the local server’s summary (a wrangler line, since cf dev runs wrangler) and writes the bodies to files instead of sending:
[wrangler:info] send_email binding called with MessageBuilder:
From: "shop" <noreply@example.com>
To: bob@example.com
Subject: Welcome
Text: /private/tmp/webguides.5ujL/shop/.wrangler/tmp/email/miniflare-4210ceb107aa3f4fd67fad52fa59ebb0/email-text/MgiGstxS0hievJ5wjXK6kUAtkLUcdzErHDZS@example.com.txt
HTML: /private/tmp/webguides.5ujL/shop/.wrangler/tmp/email/miniflare-4210ceb107aa3f4fd67fad52fa59ebb0/email-html/MgiGstxS0hievJ5wjXK6kUAtkLUcdzErHDZS@example.com.html
In production a refused email is a 500 whose log explains that MAIL_FROM must use an onboarded domain and that the free plan only sends to verified destination addresses.
Mailers
A mailer is a module of functions that each build one Email from templates, like Rails’ Action Mailer. Generate one with the name and its actions:
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)
src/mailers/user.rs has one function per action. Each renders a .txt and an .html template with askama, and passes the email through the app’s defaults:
#[derive(Template)]
#[template(path = "mailers/user/welcome.txt")]
struct WelcomeText<'a> {
to: &'a str,
}
#[derive(Template)]
#[template(path = "mailers/user/welcome.html")]
struct WelcomeHtml<'a> {
to: &'a str,
}
/// "Welcome" email to `to`.
pub fn welcome(to: &str) -> Result<Email> {
let text = WelcomeText { to }.render()?;
let html = WelcomeHtml { to }.render()?;
Ok(super::defaults(Email::new(to, "Welcome", text).html(html)))
}
{% extends "mailers/layout.txt" %}
{% block content -%}
Hello {{ to }},
This is the welcome email. Edit templates/mailers/user/welcome.txt.
{%- endblock %}
The subject is the humanized action name (“Welcome”, “Password reset”); change it in the function, or translate it (see I18n: LOCALES.locale(locale).t("mailers.user.welcome.subject"), Rails’ default_i18n_subject). .txt templates are not HTML-escaped (they are plain text); .html templates are. The template of an action is the path of its struct: point it anywhere under templates/ (Rails’ template_path and template_name). In an API-only app (ocre new --api), the mailer has no templates: each function builds the text with format! and sends no HTML; add .html(...) yourself.
Layouts
Every generated template extends templates/mailers/layout.html or layout.txt (created by the first mailer), like Rails’ mailer.html.erb: the HTML layout holds the document, the inline styles mail clients need, and {% block content %}{% endblock %} where each email goes. Put a header, a footer or a signature there once. A mailer that needs another layout extends another file ({% extends "mailers/billing_layout.html" %}); shared pieces go in partials with {% include "mailers/_footer.html" %}.
Mailer view helpers
A mailer’s templates see only the fields of their struct, so what Rails’ mailer views get from helpers is data the function passes:
| Rails, in a mailer view | Ocre |
|---|---|
message.subject, message.to | pass them as fields (subject, to), the values the function gives Email::new |
attachments.inline["logo.png"].url | <img src="cid:logo"> with .inline("logo", ...) on the email (below) |
mailer.action_name, mailer_name | the template’s path: each action has its own templates |
format_paragraph(text, 72) | {{ text|word_wrap(72) }} with use ocre::filters; in the mailer module; all of Ocre’s view helpers work |
helper :formatting, add_template_helper | methods on the template struct, or the app’s filters module |
url_for, *_url, image_url | ocre::mail::url(ctx, path) in the caller (below) |
cache blocks (fragment caching) | the caller renders the costly part with ocre::cache::fragment(&ctx, &key, ttl, ...) and passes the cached HTML into the template (see Caching HTML fragments); a mailer function has no Ctx |
Defaults and callbacks: src/mailers/mod.rs
The first mailer creates src/mailers/mod.rs, which plays the part of Rails’ ApplicationMailer:
/// Applied to every email of the mailers, like Rails' `default from:` and
/// `after_action`: e.g. `email.from("Shop <hello@yourdomain.com>")`,
/// `.bcc("archive@yourdomain.com")` or `.header("List-Unsubscribe", ...)`.
/// Without `from`, emails come from the MAIL_FROM variable.
pub fn defaults(email: Email) -> Email {
email
}
/// Emails shown at /ocre/dev/mailers in `ocre dev` (debug builds only), built
/// with sample data: change the arguments to realistic values.
pub static PREVIEWS: &[Preview] = &[
// ocre:mailer-previews
Preview::new("user/welcome", || user::welcome("ada@example.com")),
Preview::new("user/password_reset", || user::password_reset("ada@example.com")),
];
Rails’ other mailer hooks are plain functions too:
-
before_actionand parameterized mailers (with(params)): the action’s arguments. A mailer function takes what it needs, typed. -
before_deliver,after_deliver, interceptors and observers: one app function that every handler calls instead ofocre::mail::send:// src/mailers/mod.rs /// Sends through the app's rules: staging mail goes to the team only (an /// interceptor), and every delivery is logged (an observer). pub async fn deliver(ctx: &ocre::Ctx, mut email: Email) -> ocre::Result<()> { if ctx.env().var("STAGING").is_ok() { email.subject = format!("[to {}] {}", email.to.join(", "), email.subject); email.to = vec!["team@example.com".to_owned()]; email.cc.clear(); email.bcc.clear(); } ocre::mail::send(ctx, email.clone()).await?; worker::console_log!("delivered {:?} to {:?}", email.subject, email.to); Ok(()) } -
rescue_from: a mailer function returnsResult; handle its error where you call it. Adeliver_lateremail that fails is retried by the queue.
Add data to an email
Everything an email shows goes through the function’s arguments into the template structs. To greet the user by name and link to their account, add the fields to both structs and to the function:
#[derive(Template)]
#[template(path = "mailers/user/welcome.txt")]
struct WelcomeText<'a> {
to: &'a str,
name: &'a str,
account_url: &'a str,
}
pub fn welcome(to: &str, name: &str, account_url: &str) -> Result<Email> {
let text = WelcomeText { to, name, account_url }.render()?;
let html = WelcomeHtml { to, name, account_url }.render()?;
Ok(Email::new(to, format!("Welcome, {name}"), text).html(html))
}
and use them in the templates (Hello {{ name }}, and {{ account_url }}). Pass values (strings, numbers), not a database handle: a mailer only builds the email, the caller loads the data. Links and images need absolute URLs, since the email is read outside the site. ocre::mail::url(&ctx, "/account") builds them from the APP_URL variable (Rails’ default_url_options[:host] and asset_host), in handlers and in jobs alike: set APP_URL: bindings.text("https://shop.example.com") in worker.env of cloudflare.config.ts and APP_URL=http://localhost:8787 in .dev.vars; unset, url is a 500 naming the fix. In a handler, crate::auth::origin(&uri) (after ocre g auth) gives the request’s own origin instead. Images can also be embedded with inline.
use ocre::{Ctx, Result};
/// `https://shop.example.com/account` with `APP_URL = "https://shop.example.com"`.
pub fn account_url(ctx: &Ctx) -> Result<String> {
ocre::mail::url(ctx, "/account")
}
Send a mailer’s email from a handler
Validate user-typed addresses first, so a typo is a form error (422) instead of a 400. This module sends the fixture’s welcome email as an invitation, now and from the queue; register it with mod invitations; under // ocre:modules and .merge(invitations::routes()) under // ocre:routes in src/lib.rs:
// src/invitations.rs
use axum::{Form, Router, extract::State, response::Redirect, routing::post};
use ocre::{Ctx, Result, Session, Validator};
use serde::Deserialize;
use crate::mailers;
pub fn routes() -> Router<Ctx> {
Router::new().route("/invitations", post(create)).route("/invitations/later", post(create_later))
}
#[derive(Deserialize)]
struct InvitationForm {
email: String,
}
/// Sends now: the request waits for the provider (or the console with `MAIL_ADAPTER=log`).
async fn create(State(ctx): State<Ctx>, session: Session, Form(form): Form<InvitationForm>) -> Result<Redirect> {
let mut v = Validator::new();
v.email("email", &form.email);
v.finish()?;
ocre::mail::send(&ctx, mailers::user::welcome(&form.email)?).await?;
session.flash("notice", "Invitation sent.")?;
Ok(Redirect::to("/"))
}
/// Sends from the jobs queue: answers at once, retries provider failures.
async fn create_later(State(ctx): State<Ctx>, session: Session, Form(form): Form<InvitationForm>) -> Result<Redirect> {
let mut v = Validator::new();
v.email("email", &form.email);
v.finish()?;
ocre::mail::deliver_later(&ctx, mailers::user::welcome(&form.email)?).await?;
session.flash("notice", "Invitation sent.")?;
Ok(Redirect::to("/"))
}
curl -s -X POST http://localhost:8787/invitations -d email=bob@example.com -o /dev/null -w '%{http_code}\n'
curl -s -X POST http://localhost:8787/invitations -d email=not-an-email -w '\n%{http_code}\n'
303
<h1>422</h1><p>Validation failed</p><ul><li>Email is invalid</li></ul>
422
The first request prints the “Welcome” email to bob@example.com shown in log (development).
Send from the background: deliver_later
ocre::mail::deliver_later(&ctx, email).await? is Rails’ deliver_later. It checks the email and the configuration right away like send (a bad address is still a 400, a missing MAIL_ADAPTER or MAIL_FROM a 500), puts the email on the jobs queue, and returns without waiting for the provider. The queue consumer sends it with send moments later; a failure (Resend down, quota reached) is retried with the jobs backoff (30 s, 1 min, 3 min, 9 min, 27 min), then moved to the dead-letter queue. The Resend key and the EMAIL binding are only looked up when the consumer sends.
It needs the JOBS queue that the first ocre g job adds to cloudflare.config.ts (see Background jobs and schedules); without it, deliver_later is a 500 whose log says to run ocre g job <Name> once. In ocre dev the queue runs locally, and the email appears within about 5 seconds, followed by the job line:
curl -s -X POST http://localhost:8787/invitations/later -d email=carol@example.com -o /dev/null -w '%{http_code}\n'
303
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: carol@example.com
Subject: Welcome
...
[ocre mail] end
[ocre jobs] mail done
ocre::mail::deliver_in(&ctx, email, delay).await? does the same with the message due after delay (24 hours at most), Rails’ deliver_later(wait:): a reminder an hour after sign-up.
Each deliver_later is one queue message: 3 of the 10,000 daily Queues operations on the free plan (September 2026), plus the provider’s own limits. Prefer it for mail sent while a user waits, once the app has a job queue; use send otherwise.
Attachments and inline images
.attach(filename, content_type, bytes) adds a file, like Rails’ attachments["name"] = ...; .inline(content_id, filename, content_type, bytes) adds an image that the HTML shows with cid:, like attachments.inline. Mail clients show inline images without loading anything remote, so they are the reliable way to put a logo in an email:
use ocre::{Ctx, Result, mail::Email};
/// A monthly report: a CSV attachment and the logo inline (`logo` holds the
/// PNG's bytes, e.g. `include_bytes!("../assets/logo.png").to_vec()`).
pub async fn send_report(ctx: &Ctx, to: &str, csv: String, logo: Vec<u8>) -> Result<()> {
let email = Email::new(to, "Your monthly report", "The report is attached.")
.html("<p><img src=\"cid:logo\" alt=\"Shop\"></p><p>The report is attached.</p>")
.inline("logo", "logo.png", "image/png", logo)
.attach("report.csv", "text/csv", csv.into_bytes());
ocre::mail::send(ctx, email).await
}
Both adapters send them (Resend as attachments with content_id, Cloudflare Email Service as attachments with an inline disposition), and log lists them. Sizes: Resend accepts 40 MB per email; with deliver_later the whole email must fit a 128 KB queue message, and files grow by a third in it (base64). For large files, store them in R2 (Files) and send a link.
Preview and inspect emails in development
While ocre dev runs, the app serves development pages, like Rails’ /rails/mailers and letter_opener. ocre g mailer and ocre g mailbox add them to routes() in src/lib.rs:
.merge(ocre::mail::dev_routes(mailers::PREVIEWS))
| Page | Shows |
|---|---|
/ocre/dev/mailers | The previews of PREVIEWS in src/mailers/mod.rs, and the last 20 emails sent with MAIL_ADAPTER=log (from requests, jobs and deliver_later) |
/ocre/dev/mailers/preview/<mailer>/<action> | One preview, rendered now with its sample data: headers, the HTML in a sandboxed frame (inline images shown), the text, the attachments |
/ocre/dev/mailers/sent/<id> | One email sent |
/ocre/dev/mailers/sent.json | The emails sent, as JSON ([{"id": 1, "from": "...", "email": {"to": [...], "subject": ..., "text": ...}}]), for end-to-end tests |
/ocre/dev/mailbox | A form that delivers a test email to the app’s mailbox (see Receive email) |
ocre g mailer adds a preview per action, called with "ada@example.com"; change the arguments to realistic data. The pages only exist in debug builds, which is what ocre dev builds; the release build of ocre deploy compiles them out, so they are 404s in production. They use no billed resource: previews render in the request, sent emails are kept in the Worker’s memory (lost when ocre dev restarts).
Test mailers
A mailer is a function that returns an Email, so unit tests call it natively, like Rails’ ActionMailer::TestCase:
#[cfg(test)]
mod tests {
#[test]
fn welcome_greets_the_user() {
let email = super::welcome("ada@example.com").unwrap();
assert_eq!(email.to, ["ada@example.com"]);
assert_eq!(email.subject, "Welcome");
assert!(email.text.contains("Hello ada@example.com"));
}
}
Deliveries (Rails’ assert_emails) are checked end to end against ocre dev: trigger the request, then read /ocre/dev/mailers/sent.json, for example to follow a magic link:
curl -s http://localhost:8787/ocre/dev/mailers/sent.json | jq -r '.[-1].email.text'
Receive email
Cloudflare Email Routing hands email sent to addresses of your domain to the Worker. Receiving is free and unlimited on every plan (September 2026). Each email is one run of the Worker’s email event, with the same CPU limit as a request (10 ms on the free plan): keep the mailbox to a few queries, and enqueue a job for slow work.
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
The generator adds this entry point to src/lib.rs (and the development pages to routes(), unless a mailer already did); ocre::mail::receive parses the message, logs [ocre mail] received from <from> to <to>: <subject>, and calls mailbox::receive(ctx, email) in src/mailbox.rs:
/// Incoming email from Cloudflare Email Routing, handled in src/mailbox.rs.
#[worker::event(email)]
async fn email(message: worker::ForwardableEmailMessage, env: worker::Env, _ctx: worker::Context) -> worker::Result<()> {
ocre::mail::receive(message, env, mailbox::receive).await
}
The handler gets an ocre::mail::InboundEmail:
| Method | Returns |
|---|---|
email.from() | Envelope sender (SMTP MAIL FROM), checked by Cloudflare; the From header may differ |
email.to() | Envelope recipient: which of your addresses received it |
email.subject() | Decoded Subject, "" when missing |
email.header(name) | First header with that name (case-insensitive), decoded |
email.headers() | Every (name, value) pair, in order |
email.text() / email.html() | First text/plain / text/html part, decoded to UTF-8 (multipart, quoted-printable, base64, RFC 2047 headers) |
email.attachments() | The files: every part that is not the first text or HTML body, decoded (filename, content_type, content, and content_id for inline images) |
email.raw() | The whole message as received (RFC 5322 bytes), e.g. to store in R2 |
email.reject(reason) | Bounces it: the sender gets a permanent SMTP error with reason |
email.forward(address).await? | Forwards it unchanged to a verified destination address |
Returning an Err logs [ocre mail] the mailbox failed: ... and bounces the email with “The message could not be processed”, so the sender knows it was not handled. email.html() is the sender’s HTML: never render it unescaped; attachments are the sender’s files: check their type and size before keeping them.
Routing is a match on email.to(), Rails’ mailbox routing: string patterns for exact addresses, guards for the rest (to if to.starts_with("reply+") => ... for plus-addressed replies, to if to.ends_with("@support.example.com") => ...), and _ for the catch-all. Rails’ before_processing and after_processing are code before and after the match; bounce_with is email.reject(reason). Cloudflare Email Routing is the only ingress: providers such as Mailgun or Postmark (Rails’ other ingresses) would post to an ordinary route instead.
This mailbox lets one editor post to the blog by email, forwards support mail to a person, and bounces the rest. It uses the blog starter’s Post model; replace the fixture’s src/mailbox.rs with it:
// src/mailbox.rs
use ocre::{Ctx, Result, mail::InboundEmail};
use crate::models::post::{self, NewPost};
/// Called once per email that Email Routing sends to the Worker.
pub async fn receive(ctx: Ctx, email: InboundEmail) -> Result<()> {
match email.to() {
// Post by email: the subject is the title, the text part the body.
"posts@example.com" => {
if email.from() != "ada@example.com" {
email.reject("Only the editor can post by email");
return Ok(());
}
let body = email.text().unwrap_or_default().trim().to_owned();
let new = NewPost { title: email.subject().to_owned(), body, published: false };
let created = post::create(&ctx, new).await?;
worker::console_log!("mailbox: created post {} from {}", created.id, email.from());
Ok(())
}
// Pass support mail on to a person (a verified destination address).
"support@example.com" => email.forward("team@example.com").await,
_ => {
email.reject("Unknown address");
Ok(())
}
}
}
email.from() is only as trustworthy as the sending server; do not grant access on the sender address alone. For anything sensitive, also use a hard-to-guess receiving address or a secret in the subject.
Test it locally
While ocre dev runs, the simplest way is the form at http://localhost:8787/ocre/dev/mailbox, Rails’ Action Mailbox conductor: fill in the envelope, the subject and the body, and it delivers the message through the dev server’s local email endpoint, showing the answer (200: Worker successfully processed email). The same endpoint works from a terminal: POST a raw message; from and to in the query string are the envelope. The message needs a Message-ID header:
curl 'http://localhost:8787/cdn-cgi/local/email?from=ada@example.com&to=posts@example.com' \
--data-binary $'From: Ada <ada@example.com>\r\nTo: posts@example.com\r\nSubject: Notes from the road\r\nMessage-ID: <1@example.com>\r\n\r\nWritten on a train.'
curl 'http://localhost:8787/cdn-cgi/local/email?from=mallory@example.com&to=posts@example.com' \
--data-binary $'From: mallory@example.com\r\nTo: posts@example.com\r\nSubject: Spam\r\nMessage-ID: <2@example.com>\r\n\r\nBuy now'
curl 'http://localhost:8787/cdn-cgi/local/email?from=ada@example.com&to=support@example.com' \
--data-binary $'From: ada@example.com\r\nTo: support@example.com\r\nSubject: Help\r\nMessage-ID: <3@example.com>\r\n\r\nHello'
curl 'http://localhost:8787/cdn-cgi/local/email?from=ada@example.com&to=posts@example.com' \
--data-binary $'From: ada@example.com\r\nTo: posts@example.com\r\nSubject: \r\nMessage-ID: <4@example.com>\r\n\r\nNo title'
curl 'http://localhost:8787/cdn-cgi/local/email?from=ada@example.com&to=posts@example.com' \
--data-binary $'From: ada@example.com\r\nTo: posts@example.com\r\nSubject: No id\r\n\r\nHi'
The answers (HTTP 200 for the processed ones, 400 otherwise):
Worker successfully processed email
Worker rejected email with the following reason: Only the editor can post by email
Worker successfully processed email
Worker rejected email with the following reason: The message could not be processed
Email could not be parsed: invalid or no message id provided
And the ocre dev output:
[ocre mail] received from ada@example.com to posts@example.com: Notes from the road
mailbox: created post 1 from ada@example.com
[ocre mail] received from mallory@example.com to posts@example.com: Spam
[wrangler:error] Email handler rejected message with the following reason: "Only the editor can post by email"
[ocre mail] received from ada@example.com to support@example.com: Help
[wrangler:info] Email handler forwarded message with
rcptTo: team@example.com
[ocre mail] received from ada@example.com to posts@example.com:
✘ [ERROR] [ocre mail] the mailbox failed: invalid: Title can't be blank
...
[wrangler:error] Email handler rejected message with the following reason: "The message could not be processed"
The email with an empty subject failed the Post validation; the Err bounced it. Locally, forward accepts any address; in production it fails unless the address is a verified destination (the log then says Fix: add team@example.com as a verified destination address in Cloudflare Email Routing). To test with a file, save a message as message.eml (CRLF line endings) and use --data-binary @message.eml.
In request tests (ocre test --e2e), ocre::testing::Client::receive_email(from, to, subject, body) builds such a message (with a Message-ID) and posts it to the test server, Rails’ receive_inbound_email_from_mail; assert on what the mailbox did (a row, a job, a reply in client.deliveries()):
let mut client = ocre::testing::Client::new();
client.receive_email("ada@example.com", "posts@example.com", "Notes from the road", "Written on a train.").assert_success();
Keep a record of inbound email
Rails stores every inbound email with a status (Action Mailbox’s InboundEmail) and deletes it after a while. In Ocre that is a table and a few lines of the mailbox, for apps that need it (an audit, a support inbox). A migration:
CREATE TABLE inbound_emails (
id INTEGER PRIMARY KEY,
message_id TEXT NOT NULL UNIQUE,
sender TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL, -- processing, delivered, bounced, failed
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
and the mailbox records each email before handling it and its outcome after:
pub async fn receive(ctx: Ctx, email: InboundEmail) -> Result<()> {
let db = ctx.db()?;
let id = email.header("Message-ID").unwrap_or_default().to_owned();
let fresh = db
.execute(
"INSERT INTO inbound_emails (message_id, sender, recipient, subject, status) \
VALUES (?1, ?2, ?3, ?4, 'processing') ON CONFLICT (message_id) DO NOTHING",
params![id.clone(), email.from(), email.to(), email.subject()],
)
.await?;
if fresh == 0 {
return Ok(()); // a redelivery of an email already handled
}
let result = route(&ctx, &email).await; // the `match email.to()` above, returning the status
let status = match &result {
Ok(status) => *status, // "delivered" or "bounced"
Err(_) => "failed",
};
db.execute("UPDATE inbound_emails SET status = ?1 WHERE message_id = ?2", params![status, id]).await?;
result.map(|_| ())
}
Each email costs 2 D1 rows written (100,000 a day on the free plan). Keep the raw message in R2 if you need it later (email.raw(); D1 rows hold 2 MB at most). Rails’ incineration is a schedule that deletes old rows: ocre g schedule incinerate_inbound_emails "every day at 4am" with DELETE FROM inbound_emails WHERE created_at < datetime('now', '-30 days').
Set up Email Routing
Receiving needs a domain on Cloudflare (its DNS managed by Cloudflare); *.workers.dev cannot receive email.
- Deploy the app with the mailbox:
ocre deploy. - In the Cloudflare dashboard, enable Email Routing for the domain; Cloudflare adds the MX and TXT records it needs.
- For forwarding, add each target as a destination address and confirm it from the email Cloudflare sends.
- Create a routing rule for an address (for example
posts@yourdomain.com) with the action “Send to a Worker”, and pick the app’s Worker. Each rule maps one address to one Worker; a catch-all rule can send every address to it, andemail.to()tells them apart.
Nothing is added to cloudflare.config.ts for receiving.
See also
- Background jobs and schedules: the queue behind
deliver_later, sending mail from jobs. - Authentication: magic links and password resets use
ocre::mail::send. - Configuration:
MAIL_ADAPTER,MAIL_FROM,RESEND_API_KEY, theEMAILbinding. ocre g mailerandocre g mailbox.- Free-plan limits.
- Rust API:
ocre::mail, or the API index.
Web push notifications
A push notification reaches a user whose page of the app is closed: “your video is ready”. The browser subscribes once, the app keeps its subscription, and when something happens the app posts an encrypted message to the browser’s push service (Google’s for Chrome, Apple’s for Safari, Mozilla’s for Firefox), which wakes the app’s service worker to show it. Sending is free: a subrequest from the Worker to the push service.
Set it up
ocre g pwa # the manifest and service worker that shows the messages
ocre g push
ocre migrate
create src/push.rs
create public/push.js
create tests/push.rs
create migrations/0002_create_push_subscriptions.sql
update templates/layout.html
update Cargo.toml
update .dev.vars
update src/lib.rs
src/push.rsanswersGET /push/key(the public VAPID key),POST /push/subscriptions(saves a browser’s subscription; the same browser again updates it) andPOST /push/subscriptions/delete, and sends withnotify_all(&ctx, &message), ornotify_user(&ctx, user_id, &message)when the app hasocre g auth(subscriptions then carry the signed-in user’s id).public/push.js, loaded by the layout, subscribes the browser when an element withdata-push-subscribeis clicked (browsers only ask for permission after a click):<button data-push-subscribe>Notify me</button>. It firespush:subscribed, orpush:error(whose default is an alert).- The table
push_subscriptionsholds each browser’s endpoint and keys. .dev.varsgets a VAPID key pair (VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY) andVAPID_SUBJECT, the contact push services may write to (mailto:orhttps:).Cargo.tomlturns on Ocre’spushfeature (P-256 for the encryption and the signatures).
Send
let message = ocre::push::message("Your video is ready", "Download it within 7 days.", "/videos/Xq3v9L");
let delivered = crate::push::notify_user(&ctx, user.id, &message).await?;
ocre::push::message(title, body, path) is the JSON the service worker of ocre g pwa shows; a click on the notification opens path. For each subscription, ocre::push::send encrypts the message for that browser (aes128gcm, RFC 8291), signs the request with the VAPID private key (RFC 8292, a 12-hour ES256 token) and posts it; the push service keeps it up to a day while the browser is offline. A subscription the push service answers 404 or 410 to is gone (the user revoked the permission or the browser dropped it): the generated code deletes it. One subrequest per browser, so notify_all sends to 40 at most per call; for more, send from a job, a batch per run. Messages are at most 4,079 bytes.
Browsers
Chrome, Edge and Firefox on desktop and Android accept subscriptions from any page served over HTTPS (or localhost). Safari on macOS does too; on iPhone and iPad (iOS 16.4 and later) only once the user has added the site to the Home Screen, which the manifest of ocre g pwa allows. The flow was checked in ocre dev against a local stand-in for a push service (the request’s headers and encrypted body, and a 410 deleting the subscription); the encryption matches the example of RFC 8291. Delivery through Google’s and Apple’s push services has not been run by Ocre’s tests (October 2026).
Production
ocre push-keys >> .prod.vars # a new key pair for production
ocre secrets push VAPID_PRIVATE_KEY --file .prod.vars
Then set VAPID_PUBLIC_KEY (the public half from .prod.vars) and VAPID_SUBJECT in worker.env of cloudflare.config.ts. Changing the key pair later invalidates every subscription: browsers must subscribe again.
See also
- Generators:
ocre g pushandocre g pwa. - Background jobs for sending to many subscribers.
Background jobs and schedules
This guide moves slow or retryable work out of requests with background jobs on Cloudflare Queues (ocre g job, perform_later, ocre::jobs::enqueue_all), explains retries, discarding, the dead-letter queue and idempotency, gives urgent jobs their own queue, and runs tasks on a timetable with Cron Triggers (ocre g schedule "every day at 3am", ocre schedules), all within the Workers free plan.
Before you start
- An app created with
ocre new. Jobs use Cloudflare Queues, which the Workers Free plan includes since February 2026; schedules use Cron Triggers. - Nothing to create by hand: the first
ocre g jobadds the queue tocloudflare.config.ts,ocre devruns it locally, andocre deploycreates it on Cloudflare. - The examples build on
ocre g auth(users) andocre g mailer User welcome(the welcome email); see Authentication and Email. - The outputs below come from an app created with
ocre new shop --starter blog, served byocre devon the default port 8787.
How jobs run
The app’s Worker is both the producer and the consumer of its queue, <app>-jobs, bound as JOBS (plus any named queue):
handler ── ocre::jobs::enqueue ──> <app>-jobs queue ──> queue event (src/lib.rs)
└─> ocre::jobs::consume ──> jobs::perform (src/jobs/mod.rs) ──> SendWelcome::perform
A handler enqueues a job and answers as soon as Cloudflare stored the message. Moments later Cloudflare calls the Worker’s queue event with a batch of messages; Ocre decodes each one into the app’s Job enum and calls perform. Dispatch is a plain match in app code, not a registry.
Generate a job
The arguments are fields, with the same types as models (integer is i64, string is String…):
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 shop-jobs and its dead-letter queue
The first job wires everything; later ones only create their file and add a variant to src/jobs/mod.rs.
| File | What the generator writes |
|---|---|
src/jobs/send_welcome.rs | pub struct SendWelcome { pub user_id: i64 } (serde), fn perform_later(self, ctx) (sends it to the queue) and async fn perform(self, ctx: &Ctx) -> Result<()>, which does nothing yet |
src/jobs/mod.rs | The Job enum (one variant per job) and perform, which matches on it; keep the // ocre:jobs, // ocre:job-variants and // ocre:job-dispatch markers |
src/lib.rs | mod jobs; and the queue entry point (first job only) |
cloudflare.config.ts | The JOBS producer binding and the consumer trigger (first job only) |
src/jobs/mod.rs after the first job:
// ocre:jobs
pub mod send_welcome;
pub use send_welcome::SendWelcome;
/// Every job of the app. A queue message holds one as JSON:
/// `{"send_welcome": {"user_id": 1}}`. Renaming a variant or changing its
/// fields makes messages already queued undecodable (they are logged and
/// dropped), so change jobs when the queue is empty or add a new variant.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Job {
// ocre:job-variants
SendWelcome(SendWelcome),
}
/// Runs one job; called by `ocre::jobs::consume` for each queue message.
/// `Ok` acknowledges it; `Err(Error::NotFound)` and other 4xx errors drop
/// it (logged); other errors retry it later. Code here runs around every
/// job, like Rails' `around_perform`.
pub async fn perform(ctx: Ctx, job: Job) -> Result<()> {
match job {
// ocre:job-dispatch
Job::SendWelcome(job) => job.perform(&ctx).await,
}
}
The entry point added to src/lib.rs:
/// Background jobs from the `JOBS` queue, run by `jobs::perform` (src/jobs/mod.rs).
#[worker::event(queue)]
async fn queue(batch: worker::MessageBatch<String>, env: worker::Env, _ctx: worker::Context) -> worker::Result<()> {
ocre::jobs::consume(batch, env, jobs::perform).await
}
And the queue in cloudflare.config.ts (for an app named shop), the producer after // ocre:env and the consumer after // ocre:triggers:
// in worker.env
// Background jobs (`ocre g job`): the Worker sends jobs to this queue and runs
// them (src/jobs/). ...
JOBS: bindings.queue({ name: "shop-jobs" }),
// in worker.triggers
// Runs the jobs of shop-jobs: up to 10 messages per run, waiting at most 5 s
// to fill a batch. Each run is one Worker request with 10 ms of CPU on the free
// plan: lower maxBatchSize for CPU-heavy jobs. ...
triggers.queue({ name: "shop-jobs", deadLetterQueue: "shop-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }),
Write perform
A job’s fields are its arguments, stored in the queue message as JSON (128 KB at most): pass ids and small values, and load records in perform. The record may have changed or disappeared since the job was enqueued, so decide what that means. This job sends the welcome email of the User mailer to a user:
// src/jobs/send_welcome.rs
use ocre::{Ctx, OptionExt, Result};
use serde::{Deserialize, Serialize};
use crate::{mailers, models::user};
/// The job's arguments: an id, not the user record.
#[derive(Debug, Serialize, Deserialize)]
pub struct SendWelcome {
pub user_id: i64,
}
impl SendWelcome {
/// `Err` retries the job later; `Ok` acknowledges it.
pub async fn perform(self, ctx: &Ctx) -> Result<()> {
// Deleted since the job was enqueued: `NotFound` drops the job (logged), no retry.
let user = user::find(ctx, self.user_id).await?.or_404()?;
ocre::mail::send(ctx, mailers::user::welcome(&user.email)?).await
}
}
Returning Ok(()) acknowledges the message. An error that another try cannot fix is dropped with a log line, like Rails’ discard_on: Error::NotFound (here, a user deleted since), BadRequest, Unauthorized, Forbidden, Invalid and PayloadTooLarge. Any other Err (a failed D1 query, a provider error from send: Error::Internal, or TooManyRequests) retries the job later. See Errors: retry or discard.
Jobs run outside any request: there is no session, no signed-in user and no request URL. Pass what perform needs (an id, a locale, an absolute base URL) as fields. Records travel as ids, like Rails’ GlobalID arguments; dates as strings or Unix times; any other type works if it implements serde’s Serialize and Deserialize (derive them, or use #[serde(with = "...")]), which replaces Rails’ custom argument serializers.
Enqueue a job
job.perform_later(&ctx).await? (generated in each job) sends the job now and returns as soon as Cloudflare stored it, like Rails’ perform_later. Under it are the functions of ocre::jobs, for any job of the Job enum:
| Call | Does |
|---|---|
ocre::jobs::enqueue(&ctx, &job) | Sends the job to the default queue, due now |
ocre::jobs::enqueue_in(&ctx, &job, delay) | Due after delay (whole seconds, 24 hours at most), like Rails’ set(wait:) |
ocre::jobs::enqueue_all(&ctx, &jobs) | Many jobs, in one call per 100 (see Enqueue many jobs) |
ocre::jobs::queue(&ctx, "urgent").enqueue(&job) | The same on a named queue (also enqueue_in, enqueue_all) |
job.perform(&ctx).await | Runs it now, in the request, like Rails’ perform_now |
This module lets a signed-in user ask for the welcome email again; register it with mod welcome; under // ocre:modules and .merge(welcome::routes()) under // ocre:routes in src/lib.rs:
// src/welcome.rs
use std::time::Duration;
use axum::{Router, extract::State, response::Redirect, routing::post};
use ocre::{Ctx, Result, Session};
use crate::{
auth::CurrentUser,
jobs::{Job, SendWelcome},
};
pub fn routes() -> Router<Ctx> {
Router::new().route("/welcome", post(resend)).route("/welcome/later", post(resend_later))
}
/// Answers as soon as Cloudflare stored the message; the email goes out moments later.
async fn resend(State(ctx): State<Ctx>, session: Session, CurrentUser(user): CurrentUser) -> Result<Redirect> {
SendWelcome { user_id: user.id }.perform_later(&ctx).await?;
session.flash("notice", "The welcome email is on its way.")?;
Ok(Redirect::to("/account"))
}
/// Same job, run in 10 minutes (24 hours at most).
async fn resend_later(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser) -> Result<Redirect> {
let job = Job::SendWelcome(SendWelcome { user_id: user.id });
ocre::jobs::enqueue_in(&ctx, &job, Duration::from_secs(600)).await?;
Ok(Redirect::to("/account"))
}
With a signed-in cookie jar:
curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/welcome -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/account
ocre dev runs the queue in-process; within about 5 seconds (max_batch_timeout) the job runs and its email is printed:
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Welcome
...
[ocre mail] end
[ocre jobs] send_welcome done
...
[wrangler:info] QUEUE shop-jobs 2/3 (13ms)
QUEUE shop-jobs 2/3 is the local server’s summary of one consumer run (a wrangler line: cf dev runs wrangler): 2 of the 3 messages of that batch were acknowledged (this job and a deliver_later email; the third was the failing job of Errors: retry or discard).
perform_later, enqueue, enqueue_in and enqueue_all fail with Error::Internal (500, the log names the fix) when the job does not serialize, the message is over 128 KB, the delay is over 24 hours, the queue’s binding (JOBS, JOBS_URGENT…) is missing from cloudflare.config.ts, or Queues refuses the message. For later work than 24 hours, enqueue from a scheduled task or store the due time in D1.
The message
Each job is one queue message, JSON text:
{"at": 1790656502, "job": {"send_welcome": {"user_id": 1}}}
at is the Unix time the job is due (now, or now plus the delay); job is the serde form of the Job enum, whose variant names are snake_case. An email from deliver_later is {"at": ..., "mail": {...}} on the same queue.
Enqueue many jobs
ocre::jobs::enqueue_all(&ctx, &jobs).await? is Rails’ perform_all_later: it sends a list of jobs (any variants of Job) with one sendBatch call per 100 messages (256 KB), instead of one call per job. A Worker invocation may only make a limited number of calls to bindings, so use it whenever a handler or a scheduled task enqueues more than a few jobs. Each call is atomic, the list as a whole is not: if the second call fails, the first 100 jobs are queued. Every job still costs 3 Queues operations.
use ocre::{Ctx, Result, params};
use serde::Deserialize;
use crate::jobs::{Job, SendWelcome};
#[derive(Deserialize)]
struct UserId {
id: i64,
}
/// One `sendBatch` call for up to 100 users.
pub async fn welcome_everyone(ctx: &Ctx) -> Result<()> {
let users: Vec<UserId> = ctx.db()?.all("SELECT id FROM users ORDER BY id LIMIT ?1", params![100]).await?;
let jobs: Vec<Job> = users.into_iter().map(|u| Job::SendWelcome(SendWelcome { user_id: u.id })).collect();
ocre::jobs::enqueue_all(ctx, &jobs).await
}
Urgent jobs: named queues
Cloudflare Queues has no priorities: every queue delivers its messages roughly in order, in batches. Ocre gives urgent work its own queue instead, like Rails’ queue_as and Loco’s named queues: a sign-in code must not wait behind a thousand digest emails.
ocre g job SendCode user_id:integer --queue urgent
create src/jobs/send_code.rs
update cloudflare.config.ts
update src/jobs/mod.rs
Next:
enqueue it from a handler: jobs::SendCode { user_id }.perform_later(&ctx).await?
ocre dev (jobs run locally; look for `[ocre jobs]` lines in the output)
ocre deploy creates the queue shop-jobs-urgent and its dead-letter queue
The generated perform_later sends to that queue (ocre::jobs::queue(ctx, "urgent").enqueue(...)), and cloudflare.config.ts gets the producer JOBS_URGENT and a consumer that waits at most 1 second to fill a batch:
// in worker.env
JOBS_URGENT: bindings.queue({ name: "shop-jobs-urgent" }),
// in worker.triggers
triggers.queue({ name: "shop-jobs-urgent", deadLetterQueue: "shop-jobs-urgent-failed", maxBatchSize: 10, maxBatchTimeout: 1, maxRetries: 5 }),
Every queue is consumed by the same queue event and the same perform: the queue changes when a job runs, not how. Queue names are lowercase letters, digits and -; default is the JOBS queue. Queues are free to create; a message costs the same 3 operations on any queue. Loco’s worker tags (a process that only runs some jobs) have no equivalent: there are no worker processes, and a queue per kind of work gives the same isolation.
Run code around jobs: callbacks
Rails’ before_perform, around_perform and after_perform are code around the match in perform of src/jobs/mod.rs, which runs every job; before_enqueue and after_enqueue are code in a job’s perform_later. Returning early halts, like throw :abort:
pub async fn perform(ctx: Ctx, job: Job) -> Result<()> {
let started = ocre::now();
let result = match job {
// ocre:job-dispatch
Job::SendWelcome(job) => job.perform(&ctx).await,
};
worker::console_log!("job took {} s", ocre::now() - started);
result
}
Handle an error of one job there too, like Rails’ rescue_from: Job::ImportFeed(job) => job.perform(&ctx).await.or_else(|err| ...).
Enqueue after the database commits
D1 has no transaction that stays open across awaits: a group of writes is one ctx.db()?.batch(statements).await?, committed when the call returns. Enqueue after it, and a job never sees data that was rolled back, which is what Rails’ enqueue_after_transaction_commit ensures. If enqueuing fails after the commit, the handler returns the error (500) with the data saved; make the job something a scheduled task can catch up on, or accept the rare miss.
Limit concurrency
Cloudflare runs several consumer invocations of a queue in parallel when messages pile up. Rails’ limits_concurrency has two Ocre forms:
-
For a whole queue, add
maxConcurrency: 1to itstriggers.queue({ ... })entry incloudflare.config.ts: one batch at a time. Combine it with a named queue for the jobs that must not overlap. -
For jobs sharing a key (one import per account, one GPU task per user), generate the job with
--lock:ocre g job ImportCsv account_id:integer --lock account_id. The job takes the lockimport_csv:<account_id>(a row injob_locks) before running and releases it after; a second run for the same account is enqueued again every 30 s until the lock is free, without using up its retries. A run that dies loses the lock after an hour. In code of your own,ocre::jobs::lock(&db, key, owner, ttl_seconds)returns whetherownergot the lock (the owner holding it gets it again, extended) andocre::jobs::unlock(&db, key, owner)releases it:let db = ctx.db()?; let key = format!("import:{}", self.account_id); if !ocre::jobs::lock(&db, &key, &self.run, 3600).await? { return Err(Error::internal("another import of this account is running")); // retried later } let result = self.import(ctx).await; ocre::jobs::unlock(&db, &key, &self.run).await?; resultEach lock and unlock is one D1 row written (100,000 a day on the free plan); the table is
ocre::jobs::LOCKS_TABLE_SQL.
Long jobs: continue in steps
Work made of distinct stages (download, split, process, notify) is a job with steps: ocre g job ProcessVideo video_id:integer --steps fetch,split,upscale,merge,notify --lock video_id. Each step is its own queue message and its own invocation, with its own 10 ms of CPU and 50 subrequests: perform runs the current step’s method, then enqueues the job again with the next step. A step that fails is retried on its own, from that step (the steps before it do not run again), and the run keeps its id (run) through retries, so with --lock no second run for the same video starts until the last step is done. Steps must be safe to repeat, like every job.
Within one step, or in a job of one piece, a loop over many rows uses a budget. A queue batch has the limits of a request: 10 ms of CPU on the free plan, and 50 D1 queries and 50 subrequests (fetch) per invocation; past those, calls fail. A job over many rows does a slice, then enqueues itself with a cursor for the rest, like Rails’ ActiveJob::Continuable. ocre::jobs::Budget counts the calls the job may still make, and run_steps runs steps while the budget covers them:
use ocre::jobs::{Budget, Step, run_steps};
use ocre::{Ctx, Query, Result, bulk};
impl Reindex {
pub async fn perform(self, ctx: &Ctx) -> Result<()> {
// 50 queries per invocation, 1 kept to enqueue the rest.
let budget = Budget::new(Budget::FREE_D1_QUERIES - 1);
// A step reads a page and writes it back: 2 queries.
let rest = run_steps(&budget, 2, self.after_id, |after_id| async move {
let db = ctx.db()?;
let page: Vec<Row> = Query::table("tracks").gt("id", after_id).order_asc("id").limit(200).all(&db).await?;
let Some(last) = page.last().map(|row| row.id) else { return Ok(Step::Done) };
let update = bulk::update("tracks", "id", &["slug"], &slugs(&page), true)?;
db.execute(&update.sql, update.params).await?;
Ok(Step::Next(last))
})
.await?;
if let Some(after_id) = rest {
ocre::jobs::enqueue(ctx, &Job::Reindex(Reindex { after_id })).await?; // the next invocation
}
Ok(())
}
}
run_steps returns Some(cursor) when the budget ran out before a step said Step::Done. budget.take(n) spends calls outside steps (a fetch to another service, a lookup before the loop). Ocre does not count the calls itself: give each step the cost it really has, and keep a margin. A retried job starts again from the cursor it was enqueued with, so steps must be safe to repeat. Writing many rows per query (ocre::bulk) keeps the number of steps down.
Errors: retry or discard
What perform returns decides what happens to the message:
perform returns | Ocre | Like Rails |
|---|---|---|
Ok(()) | Acknowledges it | |
Err(Error::NotFound), BadRequest, Unauthorized, Forbidden, Invalid, PayloadTooLarge | Logs discarded, not retried and acknowledges it: another try would fail the same way | discard_on, ActiveJob::DeserializationError |
Any other Err (Internal, TooManyRequests) | Retries it with a growing delay | retry_on |
A retried job comes back after twice the time since it was due, 30 seconds at least. For a job processed right away that gives 30 s, 1 min, 3 min, 9 min and 27 min. After maxRetries: 5 retries, Cloudflare moves the message to the dead-letter queue <app>-jobs-failed, where it stays 24 hours; inspect it in the dashboard (Queues > <app>-jobs-failed), which lists its messages. The last retry comes about 40 minutes after the job was due. The number of retries is per queue (maxRetries in cloudflare.config.ts): put jobs that need another policy on their own queue.
To change the policy of one job, map its errors in perform: Err(Error::internal(..)) to retry what Ocre would discard, Ok(()) (with a log line) to drop what it would retry.
A job that always fails (its perform returns Err(ocre::Error::internal(format!("{} answered 503", self.url)))), as logged by ocre dev:
✘ [ERROR] [ocre jobs] import_feed failed, retrying in 30 s: internal error: https://example.com/feed.xml answered 503
...
✘ [ERROR] [ocre jobs] import_feed failed, retrying in 80 s: internal error: https://example.com/feed.xml answered 503
The second delay is 80 s, not 60 s: the retry was processed 40 s after the job was due (30 s of delay plus the batch wait), and the next delay is twice that.
The log lines, all prefixed [ocre jobs]:
| Line | Meaning |
|---|---|
[ocre jobs] <job> done | perform returned Ok; the message is acknowledged |
[ocre jobs] <job> failed, retrying in <n> s: <error> | perform returned an error worth retrying; the message comes back after n seconds |
[ocre jobs] <job> discarded, not retried: <error> | perform returned a 4xx error; the message is acknowledged |
[ocre jobs] mail done | A deliver_later email was sent |
[ocre jobs] dropped message <id>: <reason> | The message did not decode; it is acknowledged and never retried |
Changing jobs safely
A message is dropped when it is not an Ocre message, or when its job no longer matches the Job enum: a renamed variant, a renamed field, a new field without a default, a field whose type changed. (Fields that old messages have and the struct no longer has are ignored.) Messages wait in the queue for up to 24 hours, so:
- Add a new variant (
SendWelcomeV2) instead of changing one while messages may be queued, and remove the old one a day later. - New fields can be added safely with
#[serde(default)]: old messages decode with the default. - Never rename a variant while its messages may be queued.
Make jobs safe to repeat
Queues deliver at least once: a job can run twice, for example when a Worker is evicted after perform succeeded but before the acknowledgement, or when a batch is retried. Write perform so a second run does no harm:
-
Prefer statements that are naturally idempotent:
UPDATE ... SET status = 'sent',INSERT ... ON CONFLICT DO NOTHING,DELETE. -
Record that the work was done, and check first. For example, with a
welcomed_atcolumn onusers:let claimed = ctx.db()? .execute("UPDATE users SET welcomed_at = datetime('now') WHERE id = ?1 AND welcomed_at IS NULL", params![self.user_id]) .await?; if claimed == 0 { return Ok(()); // already welcomed }Claiming before sending means an email whose sending fails after the claim is not retried; claiming after sending means a crash in between sends it twice. Pick the failure you prefer for each job.
-
Calls to other APIs: pass an idempotency key when the API accepts one (a value stored in the job’s fields, so every run sends the same one).
Schedules
A scheduled task runs on the deployed Worker at times given in plain English or as a cron expression, in UTC:
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)
| File | What the generator writes |
|---|---|
src/schedules/nightly_cleanup.rs | pub async fn run(ctx: &Ctx) -> Result<()>, which does nothing yet |
src/schedules/mod.rs | run(ctx, cron), which matches the expression that fired to a task; keep the // ocre:schedules and // ocre:schedule-dispatch markers |
cloudflare.config.ts | The expression, triggers.scheduled({ schedule: "0 3 * * *" }), after // ocre:triggers |
src/lib.rs | mod schedules; and the scheduled entry point (first schedule only) |
The entry point calls ocre::jobs::cron, which runs the task and logs the result:
/// Cron Triggers (`triggers.scheduled` in cloudflare.config.ts), run by `schedules::run` (src/schedules/mod.rs).
#[worker::event(scheduled)]
async fn scheduled(event: worker::ScheduledEvent, env: worker::Env, _ctx: worker::ScheduleContext) {
ocre::jobs::cron(event, env, schedules::run).await
}
When: English or cron
The generator turns a plain-English phrase into Cloudflare’s five cron fields (minute, hour, day of month, month, day of week) and keeps the phrase in the task’s comment, like Loco’s English schedules. Times are UTC, 24-hour (16:30) or with am/pm; midnight and noon work too.
| Phrase | Cron | Runs |
|---|---|---|
every minute | * * * * * | Every minute (the shortest interval Cron Triggers allow) |
every 15 minutes | */15 * * * * | Every 15 minutes |
every hour, hourly | 0 * * * * | At the start of every hour |
every 6 hours | 0 */6 * * * | At 00:00, 06:00, 12:00 and 18:00 |
every day at 3am, daily at 03:00, at 3am | 0 3 * * * | Every day at 03:00 |
midnight on tuesdays | 0 0 * * TUE | Tuesdays at midnight |
every monday and friday at 9:30 | 30 9 * * MON,FRI | Mondays and Fridays at 09:30 |
every weekday at 6pm | 0 18 * * MON-FRI | Monday to Friday at 18:00 |
every weekend at noon | 0 12 * * SAT,SUN | Saturdays and Sundays at 12:00 |
weekly, every week | 0 0 * * SUN | Sundays at midnight |
monthly, every month | 0 0 1 * * | The first day of each month, at midnight |
every month at 2am | 0 2 1 * * | The first day of each month, at 02:00 |
Anything else is refused with both forms in the hint; seconds (every 15 seconds) are refused because Cron Triggers run at most once a minute. A cron expression is accepted as is: letters, digits and * , - / #, with spaces normalized; Cloudflare validates the rest on deploy (see its supported expressions).
One task per expression: ocre g schedule refuses an expression already in [triggers] crons (run the new work from the existing task, or pick another minute). The free plan allows 5 Cron Triggers per account, across all Workers; past 5 in one app, the generator adds a warning to its Next: steps. Run several tasks from one cron when you need more.
List the schedules
ocre schedules reads [triggers] crons and the dispatch of src/schedules/mod.rs, without building the app:
ocre schedules
CRON (UTC) TASK
0 3 * * * src/schedules/nightly_cleanup.rs
0 9 * * MON src/schedules/weekly_digest.rs
A cron without a task shows (no task: fails when it fires) and a Next: step. With --json, the list is in schedules: [{"cron": "0 3 * * *", "task": "nightly_cleanup"}, ...].
Keep tasks short: enqueue jobs
A scheduled run has the same CPU limit as a request (10 ms on the free plan), and a failed run is only logged ([ocre cron] <cron> failed: <error>), never retried: the task runs again at its next time. So a task should do a few queries and enqueue one job per item; the jobs do the slow work, with retries. This task deletes expired sign-in tokens and enqueues a welcome email for each user who signed up in the last day:
// src/schedules/nightly_cleanup.rs
use ocre::{Ctx, Result, params};
use serde::Deserialize;
use crate::jobs::SendWelcome;
#[derive(Deserialize)]
struct UserId {
id: i64,
}
/// Runs at 03:00 UTC. Two queries and a few enqueues fit in 10 ms of CPU;
/// the per-user work (sending email) runs in the jobs.
pub async fn run(ctx: &Ctx) -> Result<()> {
let db = ctx.db()?;
let expired = db.execute("DELETE FROM auth_tokens WHERE expires_at <= datetime('now')", vec![]).await?;
worker::console_log!("nightly_cleanup: {expired} expired tokens deleted");
let new_users: Vec<UserId> = db
.all("SELECT id FROM users WHERE created_at >= datetime('now', '-1 day') ORDER BY id LIMIT ?1", params![20])
.await?;
for UserId { id } in new_users {
SendWelcome { user_id: id }.perform_later(ctx).await?;
}
Ok(())
}
The LIMIT bounds the work of one run; each perform_later is one Queues write. For more than a few jobs, send them with one enqueue_all.
Run a task locally
Cron Triggers do not fire in ocre dev. While it runs, fire one task from another terminal, like Loco’s scheduler --name:
ocre schedules run nightly_cleanup
fired nightly_cleanup (0 3 * * *); its `[ocre cron]` line is in the `ocre dev` output
It calls the dev server’s local endpoint with the task’s expression (--port if ocre dev is not on 8787); the same with curl, spaces as +:
curl 'http://localhost:8787/cdn-cgi/local/scheduled?cron=0+3+*+*+*'
The ocre dev output, with the task above and two users created that day (their jobs run a few seconds later):
nightly_cleanup: 0 expired tokens deleted
[ocre cron] 0 3 * * * done
...
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Welcome
...
[ocre mail] end
[ocre jobs] send_welcome done
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: grace@example.com
Subject: Welcome
...
[ocre mail] end
[ocre jobs] send_welcome done
[wrangler:info] QUEUE shop-jobs 2/2 (5ms)
An expression with no task in src/schedules/mod.rs fails, and the error names the fix:
curl 'http://localhost:8787/cdn-cgi/local/scheduled?cron=*/5+*+*+*+*'
✘ [ERROR] [ocre cron] */5 * * * * failed: internal error: no scheduled task for cron `*/5 * * * *`. Fix: add it to the match in src/schedules/mod.rs, or remove its `triggers.scheduled(...)` entry from cloudflare.config.ts
One-off tasks
Rails’ rake tasks and Loco’s cargo loco task run app code from a terminal. A Worker has no terminal: its code only runs inside workerd, for a request, a queue batch, a cron or an email. Ocre covers the uses of tasks without opening a remote-execution endpoint:
| Need | Ocre |
|---|---|
| Change data once (backfill a column, fix rows) | A data migration (ocre g migration, then SQL in the file), or ocre sql "UPDATE ..." --remote |
| Seed data | ocre db seed |
| Recurring work | ocre g schedule; ocre schedules run <task> runs it on demand in ocre dev |
| App code once in production (send a batch of emails, reindex) | A job enqueued from a handler that only an admin can call (ocre g auth’s CurrentUser, plus your own admin check); it runs with retries, in steps if long |
Test jobs
A job is a plain struct: build it in a unit test and check what it holds, or test the functions perform calls. perform itself needs a Ctx, which only exists inside workerd, so run it end to end in ocre dev: enqueue through a request, then read the [ocre jobs] lines, the database (ocre sql), or the emails it sent at http://localhost:8787/ocre/dev/mailers/sent.json (see Email). In a handler, job.perform(&ctx).await runs a job inline, Rails’ perform_now, with the same code as the queue.
Request tests (ocre test --e2e) check them with ocre::testing::Client::jobs(), Rails’ assert_enqueued_with and assert_performed_jobs: it reads GET /ocre/dev/jobs.json, which the first ocre g job merges into routes() (debug builds only, a 404 after ocre deploy), and lists the last 50 jobs enqueued (queue, job as JSON, name()) and the last 50 runs (job, outcome: done, discarded or retried):
let mut client = ocre::testing::Client::new();
client.post("/signups", &[("email", "ada@example.com")]);
assert_eq!(client.jobs().enqueued.last().unwrap().name(), Some("send_welcome"));
ocre::testing::eventually(|| client.jobs().performed.iter().any(|run| run.job == "send_welcome" && run.outcome == "done").then_some(()));
Deploy
ocre deploy handles the queues and crons of cloudflare.config.ts:
- Before deploying, it lists the account’s queues (
cf queues list) and creates (cf queues create) every queue named there that is missing (producers, consumers and dead-letter queues), because a consumer of a missing queue fails the deploy. For each queue it creates, it printsCreated queue <name> on Cloudflare(for exampleCreated queue shop-jobs on CloudflareandCreated queue shop-jobs-failed on Cloudflare); with--jsonthey are listed inprovisionedas"queue shop-jobs". cf deploythen registers the consumer and thetriggers.scheduledcrons. Crons fire only on the deployed Worker.
Follow the deployed Worker’s job and cron lines in Workers Logs (dashboard: your Worker > Logs; search [ocre jobs] or [ocre cron]). See Deployment for the rest of the deploy.
Free-plan budget
Limits of the Workers Free plan (September 2026) and what Ocre does about them:
| Limit | Value | What Ocre does |
|---|---|---|
| Queues operations | 10,000 a day; a message costs 3 (write, read, delete), each retry 1 more read, a dead-lettered message 1 more write | One message per job; about 3,300 jobs a day, whatever the queue; discarded jobs are not retried |
| Retention | 24 hours on Free (not configurable) | Retries stop long before: the last one comes after about 40 minutes |
| Message size | 128 KB; 100 messages and 256 KB per sendBatch | enqueue refuses larger jobs with an error naming the fix (pass ids); enqueue_all splits lists into batches |
| Queues | Named queues cost nothing to create | ocre g job --queue <name> adds one, with its own consumer |
| Delay | 24 hours, on send and on retry | enqueue_in refuses longer delays |
| Batches | Up to 100 messages, 60 s wait | max_batch_size = 10, max_batch_timeout = 5: one consumer run (one Worker invocation) per 10 jobs |
| CPU | 10 ms per invocation, for requests, cron runs and consumer batches | Jobs should be I/O (D1, mail, fetch); the jobs of a batch share one invocation, so lower max_batch_size for CPU-heavy jobs |
| Cron Triggers | 5 per account | ocre g schedule warns past 5 in the app; run several tasks from one cron |
See also
ocre g jobandocre g schedulein the generators reference.- Email:
deliver_latersends email through this queue. - Free-plan limits and Cost model.
- Deployment and
ocre deploy. - Rust API:
ocre::jobs, or the API index.
File storage
Ocre stores uploaded files in Cloudflare R2 and describes each one with four columns of the record that owns it, like Active Storage without its extra tables. This page covers attachment fields, the code the generators write for them, the ocre::storage API for custom upload and download handlers, direct browser-to-R2 uploads and downloads through presigned URLs, file analysis, image variants, and what all of it costs on the free plan.
Before you start
- An Ocre app created with
ocre new(see Installation). - R2 enabled once on the Cloudflare account before the first deploy (see Deploying: enable R2 once). Local development needs nothing:
ocre devsimulates R2. - The generator that adds the first
attachmentfield also adds theSTORAGER2 binding tocloudflare.config.ts; nothing else to configure. - The protected download example uses
crate::auth::CurrentUser, created byocre g auth(see Authentication).
How a file is stored
A file lives in the R2 bucket bound as STORAGE, under a random key such as photos/image/2u1Vd0zJ8sQqS6rJq0rVmA. The record that owns it keeps four columns: an attachment named image is stored as image_key, image_filename, image_content_type and image_size. The framework reads them back as an ocre::storage::Attachment:
pub struct Attachment {
pub key: String, // `<prefix>/<22 random characters>` in the bucket
pub filename: String, // the uploader's file name, cleaned up
pub content_type: String, // lowercase, without parameters: `image/png`
pub size: i64, // bytes
}
Keys are never reused: replacing a file stores a new object under a new key and deletes the old one once the row points to the new one.
Adding an attachment field
attachment is a field type of every model generator. Suffix it with ? to make it optional; ^ (unique) is refused because every file already gets its own key.
ocre g scaffold Photo title:string image:attachment notes:attachment?
create migrations/0002_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
What it generates, piece by piece:
| Piece | What it does |
|---|---|
| Migration | image_key, image_filename, image_content_type TEXT NOT NULL and image_size INTEGER NOT NULL; the same four columns without NOT NULL for notes |
pub const IMAGE: Rules / NOTES in the model | Largest file (10 MB) and the allowed content types: PNG, JPEG, GIF, WebP, PDF, plain text |
photo.image() / photo.notes() | The Attachment (Option<Attachment> for the optional one) |
NewPhoto / PhotoChanges | image: Option<Upload> and notes: Option<Upload> (Option<Option<Upload>> in PhotoChanges: Some(None) removes the file); #[serde(skip)], since JSON cannot carry a file |
validate() | image can’t be blank on create; every upload is checked against its Rules with v.file(..) before anything is stored |
create / update / delete | Store new files under photos/image/<random> and photos/notes/<random>, then write the row. Files are deleted again when the write fails; replaced and removed files are deleted after it succeeds; a deleted record’s files are deleted with it |
| HTML forms | enctype="multipart/form-data", a file input per attachment with an accept list, and a “Remove notes” checkbox on the edit page for the optional file |
FORM_LIMIT in src/photos.rs | Largest request the forms accept: the sum of the files’ limits plus 1 MB for the text fields. A larger request gets a 413 page |
GET /photos/{id}/image, GET /photos/{id}/notes | Streams the file with storage::serve (404 when the optional file is absent) |
cloudflare.config.ts | STORAGE: bindings.r2({ name: "<app>-storage" }), added by the first generator that needs it |
The cloudflare.config.ts entry, after the // ocre:env marker:
// Files (`ocre::storage`, `attachment` fields): an R2 bucket. `ocre dev` keeps a
// local copy under .wrangler/state; `ocre deploy` creates the bucket if needed.
// Free plan: 10 GB stored, 1M writes and 10M reads a month; deletes are free.
STORAGE: bindings.r2({ name: "platapp-storage" }),
The rules are plain constants in src/models/photo.rs; change them there:
/// Files `image` accepts; `validate()` checks each upload before anything is
/// stored. The whole request is held in the Worker's memory (128 MB): keep
/// `max_bytes` modest, and forms add it to their request limit.
pub const IMAGE: Rules = Rules {
max_bytes: 10 * 1024 * 1024,
content_types: &["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"],
};
Content types are exact and lowercase. There is no wildcard: image/* would admit SVG, which can carry scripts.
create in the generated model shows the order of operations (validate, store, insert, clean up on failure):
pub async fn create(ctx: &Ctx, new: NewPhoto) -> Result<Photo> {
let db = ctx.db()?;
new.validate().finish()?;
// Files go to R2 once the values are valid; they are deleted again if the INSERT fails.
let image = match new.image {
Some(upload) => Some(storage::store(ctx, "photos/image", upload).await?),
None => None,
};
let notes = match new.notes {
Some(upload) => Some(storage::store(ctx, "photos/notes", upload).await?),
None => None,
};
let mut params = params![new.title];
params.extend(storage::columns(image.as_ref()));
params.extend(storage::columns(notes.as_ref()));
let created = db.first("INSERT INTO photos (title, image_key, image_filename, image_content_type, image_size, notes_key, notes_filename, notes_content_type, notes_size) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) RETURNING *", params).await;
if !matches!(created, Ok(Some(_))) {
storage::delete_attachments(ctx, &[image, notes]).await?;
}
created?.ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))
}
Set files only through the model (NewPhoto { image: Some(upload), .. }, PhotoChanges { notes: Some(None), .. }); never write the *_key columns by hand, or R2 objects leak or rows point to deleted objects.
Trying the HTML scaffold
With ocre dev running, open http://localhost:8787/photos/new, or post the form with curl:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-F title=Beach -F image=@beach.png http://localhost:8787/photos
303 http://localhost:8787/photos/1
Without a file, the form comes back with a 422 and the error list (Image can't be blank). File inputs cannot be refilled by the server, so after an error the visitor chooses the file again.
Attachments in a JSON API
ocre g api accepts attachment fields, but they must be optional: a JSON body cannot carry a file, so create cannot require one.
ocre g api Report file:attachment
error: attachment `file` must be optional in a JSON API
hint: JSON cannot carry a file, so create cannot require one: use `file:attachment?`, then upload with `curl -X PUT -F file=@file http://localhost:8787/api/<plural>/1/file`
ocre g api Document name:string file:attachment?
Besides the five REST routes (see JSON APIs and GraphQL), each attachment gets three routes in src/documents_api.rs:
| Route | Effect |
|---|---|
GET /api/documents/{id}/file | The file, streamed like the HTML scaffold’s (404 when there is none) |
PUT /api/documents/{id}/file | Multipart body with the file as file; stores it and deletes the one it replaces; 422 when the file is missing or not allowed |
DELETE /api/documents/{id}/file | Removes the file; answers the updated record |
The request limit of PUT is document::FILE.max_bytes as usize + 64 * 1024 (the file plus room for the multipart framing). GraphQL (--graphql) exposes the four columns; files go through these REST routes.
A session with ocre dev running (output captured from a real run):
curl -s -X POST http://localhost:8787/api/documents -H 'Content-Type: application/json' -d '{"name":"Spec"}'
{"id":1,"name":"Spec","file_key":null,"file_filename":null,"file_content_type":null,"file_size":null,"created_at":"2026-09-29 04:34:44","updated_at":"2026-09-29 04:34:44"}
curl -s -X PUT -F file=@spec.pdf http://localhost:8787/api/documents/1/file
{"id":1,"name":"Spec","file_key":"documents/file/JZJAQ032E8H_AiY5YnpcYA","file_filename":"spec.pdf","file_content_type":"application/pdf","file_size":19,"created_at":"2026-09-29 04:34:44","updated_at":"2026-09-29 04:34:44"}
A type outside the rules, and a request without the file:
curl -s -X PUT -F file=@logo.svg http://localhost:8787/api/documents/1/file
curl -s -X PUT -F name=x http://localhost:8787/api/documents/1/file
{"error":{"fields":{"file":["has an unsupported type (allowed: image/png, image/jpeg, image/gif, image/webp, application/pdf, text/plain)"]},"message":"Validation failed","status":422}}
{"error":{"fields":{"file":["can't be blank"]},"message":"Validation failed","status":422}}
Removing it:
curl -s -X DELETE http://localhost:8787/api/documents/1/file
{"id":1,"name":"Spec","file_key":null,"file_filename":null,"file_content_type":null,"file_size":null,"created_at":"2026-09-29 04:34:44","updated_at":"2026-09-29 04:34:44"}
The content type is the one the client sends. curl guesses it from a few extensions (.pdf, .png, .svg…) and sends application/octet-stream for others such as .csv; name the type explicitly then: -F 'file=@contacts.csv;type=text/csv'.
Many files per record
name:attachments (plural) gives a record any number of files, Rails’ has_many_attached. It works with ocre g model, scaffold and api:
ocre g scaffold Album title:string photos:attachments
Each file is a row of a child model, AlbumPhoto (table album_photos: album_id with ON DELETE CASCADE, and a required file attachment checked against album_photo::FILE). The parent gets:
| Method | Does |
|---|---|
album.attach_photos(&ctx, uploads) | stores new files (Rails’ photos.attach); every file is validated before any is stored |
album.replace_photos(&ctx, uploads) | deletes the current files, then attaches (Rails’ photos =) |
album.purge_photos(&ctx) | deletes the rows and their objects in R2 |
album.album_photos(&ctx, page) | the rows, newest first; album_photo::query().eq("album_id", id) for anything else |
Deleting an album deletes its files first, then the rows go with it. The scaffold’s show page lists the files (links open them), deletes one, and adds several at once from <input type="file" name="photos" multiple> (POST /albums/{id}/photos, GET /albums/{id}/photos/{file_id}, POST .../{file_id}/delete). ocre g api adds the JSON routes:
curl -F photos=@a.png -F photos=@b.png http://localhost:8787/api/albums/1/photos # add, answers the rows
curl http://localhost:8787/api/albums/1/photos # list
curl http://localhost:8787/api/albums/1/photos/2 # one file
curl -X DELETE http://localhost:8787/api/albums/1/photos/2 # 204
A request adding files may carry up to ten files at their limit; a refused file answers 422 and stores none. In a handler of your own, MultipartForm::files("photos") takes every file of a multiple input (also sent as photos[]).
Downloads: ETag, 304 and Range
Every generated file route calls storage::serve, which answers with the headers a browser needs to cache, seek and save the file:
curl -si http://localhost:8787/api/documents/1/file
HTTP/1.1 200 OK
Content-Length: 19
Content-Type: application/pdf
Accept-Ranges: bytes
Cache-Control: private, no-cache
Content-Disposition: inline; filename="spec.pdf"
ETag: "b6ab5f279cbcf9a1b96b3ab5b207cf94"
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
%PDF-1.4 fake spec
Sending the ETag back answers 304 Not Modified without a body:
curl -si -H 'If-None-Match: "b6ab5f279cbcf9a1b96b3ab5b207cf94"' http://localhost:8787/api/documents/1/file
HTTP/1.1 304 Not Modified
Cache-Control: private, no-cache
ETag: "b6ab5f279cbcf9a1b96b3ab5b207cf94"
...
A single Range (video seeking, resumed downloads) answers 206; a range past the end answers 416 without calling R2:
curl -si -H 'Range: bytes=0-7' http://localhost:8787/api/documents/1/file
curl -si -H 'Range: bytes=100-' http://localhost:8787/api/documents/1/file
HTTP/1.1 206 Partial Content
Content-Length: 8
Content-Type: application/pdf
Content-Range: bytes 0-7/19
Accept-Ranges: bytes
Cache-Control: private, no-cache
Content-Disposition: inline; filename="spec.pdf"
ETag: "b6ab5f279cbcf9a1b96b3ab5b207cf94"
...
%PDF-1.4
HTTP/1.1 416 Range Not Satisfiable
Content-Length: 0
Content-Range: bytes */19
...
Several ranges, other units and malformed values send the whole file, as RFC 9110 allows. Cache-Control: private, no-cache (storage::CACHE_CONTROL) lets browsers keep the file but revalidate it each time; override the header on the returned response for files that may be cached longer.
The ocre::storage API
Everything the generated code uses is public, for handlers the generators do not write. Full signatures are in the rustdoc of ocre::storage.
| Item | Use |
|---|---|
Multipart(mut form): Multipart<LIMIT> | Extractor for a multipart/form-data body of at most LIMIT bytes |
form.form::<T>() | The text fields, deserialized like axum’s Form (file fields ignored); 400 Invalid form: ... on a missing or unparsable field |
form.text("name") | One text field, Option<&str> |
form.file("name") | Takes the first file sent as name: Option<Upload>, None when no file was chosen |
Upload { filename, content_type, bytes }, upload.size() | A received file, before it is stored |
Rules { max_bytes, content_types }, rules.allows(ct) | What a file field accepts |
Validator::file(field, &upload, &rules) | Adds “is too large (maximum is 10 MB)” and/or “has an unsupported type (allowed: …)” |
storage::store(&ctx, prefix, upload) | Stores an upload under <prefix>/<random>; returns the Attachment |
storage::store_bytes(&ctx, prefix, filename, content_type, bytes) | Stores bytes the app made (an export, a report) |
storage::store_body(&ctx, prefix, filename, content_type, size, body) | Streams a raw request body of known length into R2 without holding it in memory |
storage::read(&ctx, key) | The whole object as Option<Vec<u8>>, for files the Worker processes itself |
storage::delete(&ctx, key) | Deletes one object; a missing key is not an error |
storage::delete_attachments(&ctx, &[Option<Attachment>]) | Deletes the objects of every Some attachment in one R2 call |
storage::serve(&ctx, &attachment, &headers, Disposition::Inline) | Streams a file with ETag/304, Range and a safe Content-Disposition; 404 when the object is missing |
Disposition::Inline / Disposition::Download | Show safe types in the browser / always download |
storage::columns(Some(&attachment)), storage::column_changes(..) | Query parameters for the four columns in an INSERT / UPDATE |
storage::human_size(bytes), attachment.human_size() | 512 bytes, 2 KB, 1.5 MB |
Upload::new(filename, content_type, bytes) | An upload made by the app (Active Storage’s attach(io:)), cleaned up like a browser’s |
storage::head(&ctx, key), storage::exists(&ctx, key) | A StoredObject (size, type, ETag, upload time) or None; one class B operation |
storage::list(&ctx, prefix, cursor, limit) | One page (up to 1,000) of StoredObjects plus the next cursor; one class A operation |
storage::read_first(&ctx, key, length) | The first bytes of an object, for analyze |
storage::presign_get(&ctx, &attachment, disposition, expires_in), storage::serve_redirect(..) | A download URL straight from R2 / a 302 to it |
storage::direct_upload(..), storage::attach_direct_upload(..) | Start and finish a direct browser-to-R2 upload |
storage::purge_unattached(&ctx, prefix, table, column, max_age, cursor) | Delete direct uploads no row adopted |
storage::analyze(bytes), Validator::file_content(field, &upload) | Real type and image size from the bytes; refuse files whose bytes do not match their type |
Variant::new().width(300).fit(Fit::Cover).path(src) | A Cloudflare Image Transformations URL |
storage::public_url(&ctx, key) | The permanent URL of a file in a public bucket |
S3Endpoint::r2(..), endpoint.presign(..) | SigV4 presigning for R2’s S3 API or another S3-compatible store |
The Multipart extractor rejects bad requests before the handler runs: 400 when the body is not multipart/form-data with a boundary or is malformed, 413 “The request is too large (maximum is …)” when Content-Length announces more than LIMIT (before anything is read) or as soon as the body passes it. Browsers (Accept: text/html) get an HTML error page, other clients JSON.
Every storage function fails with a 500 whose log names the missing STORAGE: bindings.r2(...) entry when the STORAGE binding is absent.
A custom upload handler
This endpoint accepts a CSV or text file plus a source text field, validates both, stores the file and answers its Attachment. Add the module to src/lib.rs (mod imports; under // ocre:modules, .merge(imports::routes()) under // ocre:routes).
// src/imports.rs
use axum::{Router, extract::State, routing::put};
use ocre::{
ApiResult, Created, Ctx, Validator,
storage::{self, Attachment, Multipart, Rules},
};
/// What an import accepts: CSV or plain text, 2 MB at most (no wildcards: list exact types).
const IMPORT: Rules = Rules { max_bytes: 2 * 1024 * 1024, content_types: &["text/csv", "text/plain"] };
/// Largest request: the file at its limit plus room for the text fields and the multipart framing.
const LIMIT: usize = IMPORT.max_bytes as usize + 64 * 1024;
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/imports", put(create))
}
/// `curl -X PUT -F source=crm -F 'file=@contacts.csv;type=text/csv' http://localhost:8787/api/imports`
async fn create(State(ctx): State<Ctx>, Multipart(mut form): Multipart<LIMIT>) -> ApiResult<Created<Attachment>> {
let source = form.text("source").unwrap_or_default().to_owned();
let file = form.file("file"); // None when no file was sent
let mut v = Validator::new();
v.required("source", &source);
v.check("file", file.is_none(), "can't be blank");
if let Some(file) = &file {
v.file("file", file, &IMPORT);
}
v.finish()?; // 422 with every message; nothing is stored
let file = file.expect("the validator refused a missing file");
// One R2 class A operation; the key is `imports/<source>/<22 random characters>`.
let attachment = storage::store(&ctx, &format!("imports/{source}"), file).await?;
Ok(Created(attachment))
}
Real runs against ocre dev:
curl -s -w "\n%{http_code}\n" -X PUT -F source=crm -F 'file=@contacts.csv;type=text/csv' http://localhost:8787/api/imports
{"key":"imports/crm/O3B4U81uD8w9O8AJO5Torw","filename":"contacts.csv","content_type":"text/csv","size":55}
201
Every problem is reported at once:
curl -s -X PUT -F file=@logo.svg http://localhost:8787/api/imports
{"error":{"fields":{"file":["has an unsupported type (allowed: text/csv, text/plain)"],"source":["can't be blank"]},"message":"Validation failed","status":422}}
A 3 MB file is refused by the extractor from its Content-Length, before the body is read:
curl -s -H 'Expect:' -X PUT -F source=crm -F 'file=@big.csv;type=text/csv' http://localhost:8787/api/imports
{"error":{"message":"The request is too large (maximum is 2.1 MB)","status":413}}
curl adds Expect: 100-continue to bodies over 1 MB; in our run against ocre dev, curl then printed the 413 but kept waiting until its timeout. -H 'Expect:' turns that off.
To keep the file, save the returned Attachment in a row with storage::columns(Some(&attachment)), and storage::delete(&ctx, &attachment.key) if that write fails, as generated create functions do. For an existing model, prefer adding an attachment field with a migration and going through the model.
Serving a file
A route that only signed-in users may use and that always downloads the file under its original name. Any check you put before serve is the only protection of the file: R2 objects are never public unless you turn on public access.
// src/downloads.rs
use axum::{
Router,
extract::{Path, State},
http::HeaderMap,
response::Response,
routing::get,
};
use ocre::{
Ctx, OptionExt, Result,
storage::{self, Disposition},
};
use crate::{auth::CurrentUser, models::photo};
pub fn routes() -> Router<Ctx> {
Router::new().route("/photos/{id}/image/download", get(download))
}
/// Signed-in users only (visitors are sent to /login), and always as a download with the original name.
async fn download(
CurrentUser(_user): CurrentUser,
State(ctx): State<Ctx>,
Path(id): Path<i64>,
headers: HeaderMap,
) -> Result<Response> {
let record = photo::find(&ctx, id).await?.or_404()?;
storage::serve(&ctx, &record.image(), &headers, Disposition::Download).await
}
A visitor is redirected; a signed-in user (session cookie in jar) gets the file with attachment:
curl -si http://localhost:8787/photos/1/image/download
curl -si -b jar http://localhost:8787/photos/1/image/download
HTTP/1.1 303 See Other
Location: /login
...
HTTP/1.1 200 OK
Content-Length: 15
Content-Type: image/png
Accept-Ranges: bytes
Cache-Control: private, no-cache
Content-Disposition: attachment; filename="beach.png"
ETag: "87942cd740b20e26ee606ffa3d9cb033"
...
The generated GET /photos/{id}/image answers the same file with Content-Disposition: inline; filename="beach.png".
To restrict files to their owner, load the record filtered by user.id (WHERE id = ?1 AND user_id = ?2) and answer 404 otherwise, as for any other record (see Authentication).
Inspecting the bucket: head, exists, list
storage::head(&ctx, key) describes an object without reading it (one class B operation): a StoredObject with size, content_type, etag, uploaded_at (Unix seconds) and filename (recorded by store, None for direct uploads), or None when the key does not exist. storage::exists(&ctx, key) is the same call as a bool.
storage::list(&ctx, prefix, cursor, limit) returns one page of the objects under a prefix, in key order, with the cursor of the next page (None on the last one). Each call is a class A operation, like an upload, and decoding 1,000 entries takes a few ms of the 10 ms CPU budget: list from scheduled tasks and admin pages, never on every request.
// src/storage_report.rs: total size of the photo images, 1,000 keys per class A operation.
use ocre::{Ctx, Result, storage};
pub async fn images_size(ctx: &Ctx) -> Result<u64> {
let (mut total, mut cursor) = (0, None);
loop {
let page = storage::list(ctx, "photos/image/", cursor.as_deref(), 1000).await?;
total += page.objects.iter().map(|object| object.size).sum::<u64>();
cursor = page.cursor;
if cursor.is_none() {
return Ok(total);
}
}
}
Presigned URLs and redirect serving
R2 also speaks the S3 API at https://<account_id>.r2.cloudflarestorage.com. A presigned URL is an S3 request signed in advance (AWS Signature Version 4, region auto, path style /<bucket>/<key>): whoever holds it can perform that one request until it expires, without credentials and without the Worker. Ocre signs locally with HMAC-SHA256 (microseconds of CPU, no R2 operation); the browser’s download is then a class B operation and an upload a class A one, as through the Worker.
Settings
Create an R2 API token once in the dashboard (R2 > Manage API tokens > Create API token, permission “Object Read & Write”, limited to the <app>-storage bucket). It shows an access key ID and a secret access key; keep them as secrets, and the account ID and bucket name as plain variables:
| Name | Kind | Value |
|---|---|---|
R2_ACCESS_KEY_ID | secret | The token’s access key ID |
R2_SECRET_ACCESS_KEY | secret | The token’s secret access key (also signs the keys of direct uploads) |
R2_ACCOUNT_ID | variable | The account ID shown on the R2 overview page |
R2_BUCKET | variable | <app>-storage, the name of the STORAGE binding |
// cloudflare.config.ts, in worker.env
R2_ACCOUNT_ID: bindings.text("0123456789abcdef0123456789abcdef"),
R2_BUCKET: bindings.text("docs-app-storage"),
# .prod.vars (git-ignored): R2_ACCESS_KEY_ID=... and R2_SECRET_ACCESS_KEY=...
ocre secrets push R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY --file .prod.vars
For ocre dev, put the four values in .dev.vars. A missing one makes every presigning call fail with a 500 whose log names the missing settings and these steps. Presigned URLs always point to the real bucket: ocre dev’s local R2 simulation has no S3 API, so a file uploaded through a presigned URL is not visible to storage::head or storage::serve in ocre dev. Try direct uploads on a deployed Worker.
Download URLs and redirects
storage::presign_get(&ctx, &attachment, disposition, expires_in) returns a URL valid expires_in seconds (1 second to 7 days). It carries response-content-type and response-content-disposition, so R2 answers with the same safe headers as storage::serve: the original file name, inline only for safe types, HTML and SVG as application/octet-stream. The file is then served from R2’s host, not the app’s origin.
storage::serve_redirect(&ctx, &attachment, disposition, expires_in) answers 302 Found to such a URL (Active Storage’s redirect mode), with Cache-Control: private, max-age=<expires_in / 2>. Use it for large or popular files: the Worker only signs a URL, and R2 serves Range requests itself.
// src/photo_redirects.rs
use axum::{
Router,
extract::{Path, State},
response::Response,
routing::get,
};
use ocre::{
Ctx, OptionExt, Result,
storage::{self, Disposition},
};
use crate::{auth::CurrentUser, models::photo};
pub fn routes() -> Router<Ctx> {
Router::new().route("/photos/{id}/image/direct", get(image))
}
/// Signed-in users get a 302 to R2, valid 5 minutes.
async fn image(CurrentUser(_user): CurrentUser, State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<Response> {
let record = photo::find(&ctx, id).await?.or_404()?;
storage::serve_redirect(&ctx, &record.image(), Disposition::Inline, 300)
}
Authorize before signing: anyone with the URL can use it until it expires, even after signing out, so keep lifetimes short.
Direct uploads
A direct upload sends the file from the browser to R2 without passing through the Worker (Active Storage’s direct uploads): no 100 MB request limit, no Worker memory, no CPU spent on the body. It takes three requests:
- The page asks the app to start an upload, with the file’s name, type and size.
storage::direct_uploadchecks them against the field’sRules(422 otherwise), picks a new key under a prefix of its own, and answers a presignedPUTURL, the headers to send with it and asigned_key. - The browser
PUTs the file to that URL. The URL signsContent-TypeandContent-Length, so R2 refuses a file of another type or size than declared. - The form is submitted with the
signed_key(and the file name) instead of the file.storage::attach_direct_uploadchecks the signature (only keys this app issued are accepted, so nobody can claim another record’s file), runsheadon the object, checks its size and type against theRulesagain (deleting a refused object) and returns itsAttachment.
The server side, for the Photo scaffold (ocre g scaffold Photo title:string image:attachment notes:attachment?):
// src/photo_uploads.rs
use axum::{Router, extract::State, routing::post};
use ocre::{
ApiResult, Created, Ctx, Error, Json, Validator, params,
storage::{self, DirectUpload, DirectUploadRequest},
};
use serde::Deserialize;
use crate::models::photo::{self, Photo};
/// Direct uploads of `image` live under their own prefix, so `purge_unattached` can find abandoned ones.
pub const IMAGE_UPLOADS: &str = "uploads/photos/image";
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/photos/uploads", post(start)).route("/api/photos/direct", post(create))
}
/// Step 1: `{"filename": "beach.png", "content_type": "image/png", "size": 48213}`.
async fn start(State(ctx): State<Ctx>, Json(request): Json<DirectUploadRequest>) -> ApiResult<Json<DirectUpload>> {
Ok(Json(storage::direct_upload(&ctx, IMAGE_UPLOADS, "image", &request, &photo::IMAGE)?))
}
#[derive(Deserialize)]
struct NewDirectPhoto {
title: String,
image_signed_key: String,
image_filename: String,
}
/// Step 3: the form, with the signed key in place of the file.
async fn create(State(ctx): State<Ctx>, Json(form): Json<NewDirectPhoto>) -> ApiResult<Created<Photo>> {
Validator::new().required("title", &form.title).finish()?;
// One class B operation (`head`); 422 when the key is forged, the file missing, or breaks the rules.
let image =
storage::attach_direct_upload(&ctx, "image", &form.image_signed_key, &form.image_filename, &photo::IMAGE)
.await?;
let mut values = params![form.title];
values.extend(storage::columns(Some(&image)));
let sql = "INSERT INTO photos (title, image_key, image_filename, image_content_type, image_size) \
VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *";
let created: Result<Option<Photo>, Error> = ctx.db()?.first(sql, values).await;
if !matches!(created, Ok(Some(_))) {
storage::delete(&ctx, &image.key).await?; // no row points to it
}
Ok(Created(created?.ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))?))
}
The browser side, plain JavaScript without a build step. XMLHttpRequest stands in for Rails’ direct-upload:* events: upload.onprogress is direct-upload:progress, onload is direct-upload:end, onerror is direct-upload:error:
<form id="photo-form">
<input name="title" required>
<input type="file" name="image" accept="image/png,image/jpeg,image/gif,image/webp" required>
<progress value="0" max="100" hidden></progress>
<button>Save</button>
<p class="error" hidden></p>
</form>
<script type="module">
const form = document.getElementById("photo-form");
const progress = form.querySelector("progress");
const error = form.querySelector(".error");
const postJson = (url, body) =>
fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
// PUT the file to R2 with the signed headers, reporting progress.
const put = (upload, file) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", upload.url);
for (const [name, value] of Object.entries(upload.headers)) xhr.setRequestHeader(name, value);
xhr.upload.onprogress = (e) => e.lengthComputable && (progress.value = (100 * e.loaded) / e.total);
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`R2 answered ${xhr.status}`)));
xhr.onerror = () => reject(new Error("upload failed (network, or the bucket's CORS rule)"));
xhr.send(file);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = form.image.files[0];
try {
// 1. Start: the app checks the declared type and size, and signs a PUT.
const started = await postJson("/api/photos/uploads", {
filename: file.name,
content_type: file.type || "application/octet-stream",
size: file.size,
});
if (!started.ok) throw new Error((await started.json()).error.message);
const upload = await started.json();
// 2. Upload straight to R2.
progress.hidden = false;
await put(upload, file);
// 3. Submit the form with the signed key.
const created = await postJson("/api/photos/direct", {
title: form.title.value,
image_signed_key: upload.signed_key,
image_filename: file.name,
});
if (!created.ok) throw new Error((await created.json()).error.message);
window.location = `/photos/${(await created.json()).id}`;
} catch (e) {
error.textContent = e.message;
error.hidden = false;
}
});
</script>
A 422 from step 1 or 3 carries the field messages ({"error":{"fields":{"image":["is too large (maximum is 10 MB)"]},...}}). The URL of step 1 must be used within 10 minutes; a PUT started in time may take longer.
The bundled script
The page above is written by hand to show the steps. Ocre ships the same logic as a script, Active Storage’s activestorage.js: merge its route once and mark the input.
// src/lib.rs, in routes()
.merge(ocre::storage::direct_upload_script()) // GET /ocre/direct-upload.js
<script src="/ocre/direct-upload.js" defer></script>
<form action="/photos/direct" method="post">
<input type="file" name="image" data-direct-upload-url="/api/photos/uploads">
<button>Save</button>
</form>
When the form is submitted, each chosen file is signed (a JSON POST to the input’s URL, step 1) and PUT to R2 (step 2); the form is then submitted without the file, with image_key (the signed key) and image_filename, which the handler passes to storage::attach_direct_upload (step 3). With multiple, the two fields repeat. The script dispatches Active Storage’s events, which bubble: direct-uploads:start / direct-uploads:end on the form, and per file direct-upload:start, direct-upload:progress (event.detail.progress, 0 to 100), direct-upload:error (call preventDefault() to replace the default alert) and direct-upload:end.
document.addEventListener("direct-upload:progress", (event) => {
document.querySelector("progress").value = event.detail.progress;
});
Bucket CORS rule
The browser PUTs to R2’s host, a different origin, so the bucket needs a CORS rule allowing it. In the dashboard: R2 > <app>-storage > Settings > CORS policy > Add, with:
[
{
"AllowedOrigins": ["https://docs-app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"MaxAgeSeconds": 3600
}
]
Ocre does not set it for you and this guide’s authors have not run this step against a live bucket: check the dashboard’s current wording, and that the preflight (OPTIONS) answers before debugging anything else when onerror fires. Multipart uploads also need "ExposeHeaders": ["ETag"], so the script can read each part’s ETag.
Large files: multipart uploads that resume
One PUT is fine for photos. For videos of several gigabytes, a dropped connection would restart the whole file, and R2 takes at most 5 GB in one PUT. storage::multipart_uploads sends a file in parts (S3 multipart uploads), four at a time, each retried; the parts already sent are remembered in the browser, so choosing the same file again after a lost connection or a closed tab sends only the missing ones.
use ocre::storage::{self, Rules};
// `u64`: files over 4 GB.
pub static VIDEO: Rules = Rules { max_bytes: 20 * 1024 * 1024 * 1024, content_types: &["video/mp4", "video/quicktime"] };
// in routes():
.merge(storage::multipart_uploads("/videos/uploads", "uploads/videos", "video", &VIDEO))
.merge(storage::direct_upload_script())
<script src="/ocre/direct-upload.js" defer></script>
<form action="/videos" method="post">
<input type="file" name="video" data-multipart-upload-url="/videos/uploads">
<button>Upload</button>
</form>
The form is then submitted with video_key and video_filename, exactly as after a direct upload: the handler calls storage::attach_direct_upload(&ctx, "video", &form.video_key, &form.video_filename, &VIDEO), which checks the assembled object’s size and type and returns the Attachment to save. The script dispatches the same events (direct-upload:progress covers the whole file).
What happens, with the routes under /videos/uploads:
POST /videos/uploadswith the file’s name, type and size: checked againstVIDEO, then the upload is created in R2 (one class A operation). The answer is the signed key, R2’supload_id, the part size (10 MiB, larger for files that would need over 10,000 parts) and the number of parts.POST /videos/uploads/partswith the part numbers still to send: where toPUTeach one.- Each part is
PUTthere (one class A operation each: a 5 GB video is 512 parts). The script keeps every finished part’s number and ETag inlocalStorage, under the URL, file name, size and modification date. POST /videos/uploads/completewith every part: R2 assembles the object (one class A operation).POST /videos/uploads/abortdrops an upload; R2 also deletes the parts of an upload left unfinished, after a while set by the bucket’s lifecycle rules.
Where the parts go:
- Straight to R2 in a release build with the
R2_*settings: presignedPUTURLs (valid 24 hours), so the file never passes through the Worker, whatever its size. The bucket’s CORS rule must allowPUTand exposeETag. - Through the Worker otherwise: in
ocre dev(whose local R2 has no S3 API) and without theR2_*settings. Each part is one request toPUT /videos/uploads/parts/<n>, streamed into R2 (parts of at most 95 MB, under the 100 MB request limit; files up to about 950 GB). It works on the free plan, but every part costs a Worker request and the CPU of copying it through WebAssembly: prefer the direct mode in production.
The upload’s key is signed with R2_SECRET_ACCESS_KEY, or SECRET_KEY_BASE without it. The through-the-Worker mode was checked in ocre dev with a browser (a 25 MB file in three parts, the third failing until the form was sent again, then only that part resent); the direct mode has not been run against a live bucket (October 2026).
Purging unattached uploads
A direct upload whose form is never submitted (a closed tab, a failed validation) leaves an object no row points to. storage::purge_unattached(&ctx, prefix, table, column, max_age, cursor) lists one page (up to 1,000 keys) under the prefix, keeps the objects uploaded more than max_age seconds ago, looks them up in table.column (SELECT column FROM table WHERE column IN (...), 100 keys per query) and deletes the ones no row references. It returns the deleted keys and the cursor of the next page.
Run it from a scheduled task (ocre g schedule purge_uploads "every day at 4am", see Background jobs and schedules):
// src/schedules/purge_uploads.rs
use ocre::{Ctx, Result, storage};
/// Direct uploads of photo images left unattached for a day. At most 5 pages (5 class A operations) per run.
pub async fn run(ctx: &Ctx) -> Result<()> {
let mut cursor = None;
for _ in 0..5 {
let purged =
storage::purge_unattached(ctx, "uploads/photos/image", "photos", "image_key", 86_400, cursor.as_deref())
.await?;
println!("purge_uploads: {} unattached uploads deleted", purged.deleted.len());
cursor = purged.cursor;
if cursor.is_none() {
break;
}
}
Ok(())
}
Costs and limits:
- Each page is one class A operation (1M free per month), whether or not anything is deleted, and attached uploads are listed again on every run. A daily run over 5,000 keys is about 150 class A operations a month.
- Each lookup is a D1 query; index the column (
CREATE UNIQUE INDEX photos_image_key ON photos(image_key);in a migration) so it reads only the matching rows instead of the whole table. - Deletes are free. Decoding a page of 1,000 keys takes a few ms of the 10 ms of CPU a run gets: keep the page cap low, and give each attachment its own prefix so listings stay short.
- Keep
max_agewell above the 10 minutes an upload URL lasts plus the time a form stays open; a day is safe.
File analysis
The content type of an upload is whatever the browser claims. storage::analyze(&bytes) reads the file’s signature instead (Active Storage’s analyzers, without reading pixels): PNG, JPEG, GIF, WebP, AVIF, PDF, ZIP (and the Office and OpenDocument formats built on it), MP4, M4A and QuickTime. For PNG, GIF, WebP and JPEG it also reads the width and height from the header. Text formats (plain text, CSV, HTML, SVG) have no signature and give None. It reads the first 32 bytes, plus a JPEG’s segment headers: microseconds of CPU whatever the file size.
Validator::file_content(field, &upload) uses it to refuse “has content that does not match image/png”: a declared type that analyze recognizes but the bytes do not carry, or bytes of a recognized type sent under another one. Chain it after v.file(..):
// src/avatars.rs
use axum::{Router, extract::State, routing::put};
use ocre::{
ApiResult, Created, Ctx, Error, Validator,
storage::{self, Attachment, Multipart, Rules},
};
const AVATAR: Rules = Rules { max_bytes: 2 * 1024 * 1024, content_types: &["image/png", "image/jpeg", "image/webp"] };
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/avatars", put(create))
}
/// `curl -X PUT -F avatar=@me.png http://localhost:8787/api/avatars`
async fn create(State(ctx): State<Ctx>, Multipart(mut form): Multipart<{ 3 * 1024 * 1024 }>) -> ApiResult<Created<Attachment>> {
let avatar = form.file("avatar").ok_or_else(|| Error::bad_request("Send the file as `avatar`"))?;
let analysis = storage::analyze(&avatar.bytes);
let mut v = Validator::new();
v.file("avatar", &avatar, &AVATAR).file_content("avatar", &avatar);
v.check("avatar", analysis.width.is_some_and(|width| width < 64), "must be at least 64 pixels wide");
v.finish()?;
Ok(Created(storage::store(&ctx, "avatars", avatar).await?))
}
For a direct upload the bytes are in R2, not in the request: storage::read_first(&ctx, key, 64 * 1024) reads the start of the object (one class B operation; use 256 KB for JPEGs with large EXIF blocks) for analyze.
Image variants
Resized versions of images come from Cloudflare Image Transformations, not from the Worker: a URL /cdn-cgi/image/<options>/<source> on the app’s own domain makes Cloudflare’s edge fetch the source image, resize it and cache the result. ocre::storage::Variant builds that URL:
// src/photo_variants.rs
use ocre::storage::{Fit, Variant};
/// Square thumbnails for the photo index; `format=auto` sends AVIF or WebP to browsers that accept them.
pub const THUMB: Variant = Variant::new().width(300).height(300).fit(Fit::Cover).quality(80);
/// `/cdn-cgi/image/width=300,height=300,fit=cover,quality=80,format=auto/photos/1/image`
pub fn thumb_path(photo_id: i64) -> String {
THUMB.path(&format!("/photos/{photo_id}/image"))
}
In a template: <img src="{{ crate::photo_variants::thumb_path(photo.id) }}" alt="">. Fit maps Active Storage’s resize options: ScaleDown (resize_to_limit), Contain (resize_to_fit), Cover (resize_to_fill), Crop, Pad (resize_and_pad).
- Lazy by design: a variant is made on its first request and then served from Cloudflare’s cache (Rails’ lazy variant loading); nothing is precomputed or stored in R2, and the Worker’s CPU is not used. The first request of each variant fetches the source, which invokes the Worker once (one class B operation for
storage::serve). - Needs a custom domain: the app must be served from a zone on Cloudflare with Transformations enabled for it (dashboard: Images > Transformations > enable for the zone).
*.workers.devhosts cannot use/cdn-cgi/image/; there the URL answers an error. - Free quota: the Images Free plan includes 5,000 unique transformations a month (unverified assumption at the time of writing; check Images pricing). Each distinct source and option set counts once a month; beyond it, new variants fail rather than being billed on the Free plan (also unverified).
- Source caching:
storage::servesendsCache-Control: private, no-cache; for public images, serve the source with a public cache header (see theCACHE_CONTROLexample in the rustdoc) so Cloudflare can cache it. Keep variants for images anyone may see: the transformation fetches the source on its own.
Public files
A bucket can be made public (dashboard: R2 > <app>-storage > Settings > Public access): through an r2.dev subdomain (rate-limited, meant for development) or a custom domain connected to the bucket. Set its base URL as a variable, and storage::public_url(&ctx, key) returns <base>/<key> (each segment percent-encoded):
// cloudflare.config.ts, in worker.env
STORAGE_PUBLIC_URL: bindings.text("https://files.docs-app.example.com"),
// src/photo_links.rs
use ocre::{Ctx, Result, storage};
use crate::models::photo::Photo;
/// The permanent link of a photo's image; no Worker, no signing, cached by Cloudflare.
pub fn image_url(ctx: &Ctx, photo: &Photo) -> Result<String> {
storage::public_url(ctx, &photo.image().key)
}
The trade-off is total: every object of the bucket is then readable forever by whoever has its key, with no authorization and no expiry, and the Content-Type is the stored one (the safe-type rules of serve do not apply; do not make a bucket public if it holds user-uploaded HTML or SVG). Use a separate public bucket for avatars and product images, never for private documents. A missing STORAGE_PUBLIC_URL is a 500 whose log says how to set it.
Images in rich text
A rich_text field’s scaffold form lets writers drop images into the Trix editor (Action Text attachments). The editor posts each file to POST /<plural>/embeds (a multipart file, images up to 10 MB, the EMBED rules in the controller), the controller stores it in R2 under <plural>/embeds/ and answers its URL, GET /<plural>/embeds/<name>, which the editor puts in the text as <figure><img src="..."></figure>. The model’s sanitize keeps figure, figcaption and relative img sources. The upload runs in /ocre/direct-upload.js (the scaffold merges ocre::storage::direct_upload_script() into routes() once), for any <trix-editor data-embeds-url="...">.
An image removed from the text stays in R2; storage::purge_unattached cannot tell (the keys are in HTML), so list <plural>/embeds/ and delete what no text contains if storage matters.
Safety choices
- Keys are random (128 bits), never derived from file names, so a name cannot overwrite or guess another file.
- File names lose their directories (old Windows browsers send
C:\...) and control characters, and are cut to 200 characters with the extension kept;filestands in when nothing is left.Content-Dispositioncarries them as ASCIIfilename=plus UTF-8filename*=when needed (RFC 6266). - Inline display is limited to types that cannot run scripts: raster images (PNG, JPEG, GIF, WebP, AVIF, BMP, TIFF, icons), PDF, plain text, audio and video. HTML, SVG, XML and JavaScript are sent as
application/octet-streamdownloads even withDisposition::Inline, so an uploaded file never runs as part of the app (Rails’content_types_allowed_inline/content_types_to_serve_as_binary). - Content types come from the browser: the
Rulesallowlist limits them, andv.file_content(..)checks that the bytes match (see File analysis).X-Content-Type-Options: nosniff(added to every response) stops browsers from guessing. - Direct uploads sign both the upload URL (type and size) and the key handed back to the form, and are checked again with
headbefore they are attached. - Validation before storage:
v.file(..)runs beforestore, so a refused file costs no R2 operation. - Authorization: file routes are as protected as the handler around them.
Free-plan costs and limits
R2 is free every month within these amounts (R2 pricing, September 2026):
| Resource | Free every month | Ocre use |
|---|---|---|
| Storage | 10 GB-month | Every stored file; replaced and deleted files are removed by the generated models |
| Class A operations | 1,000,000 | Each upload (store, store_bytes, store_body, a direct upload’s PUT) and each list page is one |
| Class B operations | 10,000,000 | Each serve (downloads and 304s alike), read, read_first, head/exists and presigned download is one |
| Deletes | free | delete, delete_attachments |
| Egress | free | Downloads cost no bandwidth fee |
Past the free tier, R2 Standard storage costs $0.015 per GB-month and Class A operations $4.50 per million (October 2026): see Free-plan limits: R2 for video-sized examples.
Worker limits that shape uploads (Workers limits, September 2026):
- Memory: a Worker has 128 MB, and the
Multipartextractor holds the whole request in memory while R2 gets a copy. Keepmax_bytesin the tens of MB. - Request size: Cloudflare refuses request bodies over 100 MB on the Free plan before they reach the Worker.
- CPU: uploads are split with a substring search, about 1.2 ms per 10 MB in WebAssembly (memchr, measured in V8), plus about 0.15 ms per 10 MB to copy them to R2. Downloads never pass through WebAssembly:
storage::serveattaches R2’s stream to the response andocre::serveanswers with it directly (this is why the Worker’sfetchreturnsworker::web_sys::Response), so a download costs almost no CPU whatever its size. - For a single large file,
store_bodystreams a raw body (curl -T big.zip) of knownContent-Lengthinto R2 with flat memory; it checks noRules, so check the size and type yourself first.
Local development
ocre dev runs the local R2 simulation of cf dev: objects are kept under .wrangler/state (git-ignored) and survive restarts. ocre db reset only deletes the local database, not the stored objects. The bindings table printed at startup shows the bucket:
env.STORAGE (platapp-storage) R2 Bucket local
Deploying: enable R2 once
ocre deploy runs cf r2 buckets get <bucket> for every bindings.r2(...) entry and cf r2 buckets create for the missing ones, before deploying. R2 has to be enabled once per account in the Cloudflare dashboard (Storage & databases > R2), which asks for a payment method even for the free tier. When it is not, the check fails with Cloudflare API code 10042 and the CLI prints this hint (from crates/ocre-cli/src/cloudflare.rs):
hint: enable R2 once in the Cloudflare dashboard (Storage & databases > R2; the free plan asks for a payment method but charges nothing within 10 GB, 1M writes and 10M reads a month), then run `ocre deploy` again
Not included
- Storage services other than R2:
store,serveand the other runtime functions use theSTORAGEbinding.S3Endpointpresigns URLs for any S3-compatible store (AWS S3, MinIO), but there is no S3 client in the Worker and no mirroring. - Image processing inside the Worker: variants come from Cloudflare Image Transformations.
- Cleanup of files whose rows are removed by
ON DELETE CASCADE: the database deletes the child rows without calling the child model’sdelete, so their files stay in R2. Delete them in the parent’sdeleteif that matters;photos:attachmentschildren are deleted this way by the generated code.
See also
- Field types:
attachmentand the other types. - Generators:
ocre g scaffoldandocre g api. - Configuration: the
STORAGEentry. - Validations:
Validatorand 422 responses. - Authentication:
CurrentUserand ownership checks. - Free-plan limits and Cost model.
ocre::storagerustdoc.
Webhooks and external services
A payment provider confirms a payment, a GPU service reports that a job finished, a mail service reports a bounce: each calls the app back with an HTTP POST, a webhook. Ocre checks the signature of the call, keeps a log of the events received, and runs each event’s effect once, however many times the sender delivers it. ocre::webhooks also signs the calls the app makes, so a service can check them the same way.
Receive a webhook
ocre g webhook payments
ocre migrate
create src/payments_webhook.rs
create tests/payments_webhook.rs
create migrations/0001_create_webhook_events.sql
update .dev.vars
update src/lib.rs
src/payments_webhook.rs answers POST /webhooks/payments:
- It checks the signature: the HMAC-SHA256 of the raw body with the secret
PAYMENTS_WEBHOOK_SECRET, sent in theX-Signatureheader as hex,sha256=<hex>(GitHub’s form) or base64. A missing or wrong signature is a 401. - It parses the body as JSON and reads the event’s
id(a string or a number); without one, 400. - It runs
handle(&ctx, &event)throughwebhooks::once, which records the event inwebhook_eventsand skips it if it was already processed. - It answers
{"status": "processed"}, or{"status": "duplicate"}for an event already processed.
Write the effect in handle:
async fn handle(ctx: &Ctx, event: &Value) -> Result<()> {
if event["type"] == "payment.paid" {
let order_id = event["data"]["order_id"].as_i64().ok_or_else(|| Error::bad_request("no order_id"))?;
ctx.db()?
.execute("UPDATE orders SET status = 'paid' WHERE id = ?1 AND status = 'pending'", params![order_id])
.await?;
}
Ok(())
}
The generator puts a random secret in .dev.vars. Give the sender the URL (https://<your host>/webhooks/payments) and the production secret (the provider often generates it: use theirs), and upload it:
ocre secrets push PAYMENTS_WEBHOOK_SECRET --file .prod.vars
Webhook requests come from servers, not browsers: they carry no Origin or Sec-Fetch-Site header, so Ocre’s cross-site request check lets them through, and the signature is what authenticates them.
Standard Webhooks
Providers that follow Standard Webhooks (Svix and the services built on it, Resend…) send webhook-id, webhook-timestamp and webhook-signature headers and give a whsec_... secret:
ocre g webhook mail_events --standard
The handler calls webhooks::verify_standard(&secret, &headers, &body, 300, ocre::now()): a signature of {id}.{timestamp}.{body} must match (several space-separated v1,<base64> signatures are accepted, for secret rotation), and the timestamp must be within 5 minutes, so a captured delivery cannot be replayed later. The webhook-id is the event id.
Other signature schemes
Providers sign in many ways. webhooks::verify(secret, message, signature) checks an HMAC-SHA256 of any message, so the handler builds what the provider signs:
| Provider style | Header | Message to verify |
|---|---|---|
| Plain HMAC (GitHub, many APIs) | X-Hub-Signature-256: sha256=<hex> | the raw body |
| Timestamped (Stripe) | Stripe-Signature: t=<ts>,v1=<hex> | format!("{t}.{body}"); then check t against ocre::now() |
| Standard Webhooks | webhook-signature: v1,<base64> | use verify_standard |
Always verify the raw body (body: Bytes), not JSON parsed and serialized again: a reordered key or a different space changes the signature. A provider without signatures (some send a token in the URL or a header) is checked by comparing that token with ocre::token::constant_time_eq; prefer a provider option that signs.
Each event once
Senders retry until they get a 2xx answer, and may deliver an event twice even after a success. webhooks::once(&db, source, event_id, payload, effect):
- inserts
(source, event_id)intowebhook_eventswith the raw payload and statusprocessing; the pair is unique, so a second delivery of the same event inserts nothing and getsDelivery::Duplicatewithout running the effect; - runs the effect; on success marks the event
processed; on failure marks itfailedwith the error message and returns the error, so the handler answers 500 and the sender’s retry runs the effect again; - takes over an event left
processingfor more than 5 minutes (webhooks::STALE_AFTER): the invocation that claimed it died.
The table doubles as a log of what was received:
ocre sql "SELECT source, event_id, status, attempts, error FROM webhook_events ORDER BY id DESC LIMIT 20"
Recording and effect are separate D1 statements. Write effects that are safe to run again (UPDATE ... WHERE status = 'pending', INSERT ... ON CONFLICT DO NOTHING), so an invocation that dies between the effect and the processed mark does no harm when the event runs again. Each delivery costs two or three D1 writes.
Test a webhook
tests/payments_webhook.rs has two request tests, run by ocre test --e2e: an event signed with the wrong secret gets 401, and the same signed event delivered twice is processed, then duplicate. They sign with the secret of .dev.vars, read with ocre::testing::var:
let body = format!(r#"{{"id":"{id}","type":"payment.paid","data":{{"order_id":{order_id}}}}}"#);
let mut client = Client::new().header("X-Signature", &webhooks::sign(secret().as_bytes(), body.as_bytes()));
client.request("POST", "/webhooks/payments", Some(("application/json", body.into_bytes()))).assert_status(200);
In ocre dev, a provider’s dashboard cannot reach localhost: send test events with curl and a signature computed the same way, or expose the dev server with a tunnel (cloudflared tunnel --url http://localhost:8787).
Run work on another service
A Worker cannot start programs (no ffmpeg, no Python) and has 10 ms of CPU per invocation on the free plan. Video encoding, AI inference and other heavy work run elsewhere, and the app tracks them: it submits a job, the service reports back, and a schedule catches jobs that went silent.
ocre g external_job upscale video_id:integer scale:float
ocre migrate
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
Each job is a row of upscale_jobs with the inputs (video_id, scale), a status, the service’s external_id, progress (0-100), result (the service’s output as JSON text), error and attempts:
stateDiagram-v2
[*] --> queued: start()
queued --> submitted: service accepted the POST
queued --> queued: POST failed, sweep retries
queued --> failed: MAX_ATTEMPTS failed POSTs
submitted --> running: event
running --> running: event (progress)
submitted --> done: event
running --> done: event
submitted --> failed: event, or no news for STALE_AFTER
running --> failed: event, or no news for STALE_AFTER
- Submit.
upscale_jobs::start(&ctx, video_id, scale).await?inserts the job and POSTsrequest_body(&job, webhook)toUPSCALE_URL, signed withUPSCALE_SECRET(X-Signature), plusAuthorization: Bearer <UPSCALE_TOKEN>when that secret is set (RunPod, Modal and most APIs take an API key that way). The body names the address to report to,<APP_URL>/webhooks/upscale/<id>?token=<the job's token>. A 2xx answer makes the jobsubmittedand keeps theidof the answer asexternal_id; anything else keeps itqueuedwith the error. - Receive events. The service POSTs JSON to that address: a
status(running,done,failed, or RunPod’sIN_QUEUE,IN_PROGRESS,COMPLETED,FAILED,CANCELLED,TIMED_OUT), and optionallyprogress,output(orresult) anderror. An event must be signed with the shared secret or carry the job’s token: RunPod and many GPU services cannot sign their callbacks, and a random token per job means a leaked URL exposes one job only. Events only move a job forward, so a repeated or late event changes nothing (arunningevent afterdoneis ignored), and no event log is needed. - Sweep.
src/schedules/upscale_jobs_sweep.rsruns every 5 minutes (--sweep "every 10 minutes"to change it; it uses one of the 5 Cron Triggers of the free plan) and callsupscale_jobs::sweep: jobssubmittedorrunningwithout news forSTALE_AFTER(an hour) becomefailed, andqueuedjobs whose submission failed more thanRETRY_AFTERseconds ago are submitted again, 10 per run, untilMAX_ATTEMPTS. - React.
changed(&ctx, &job)runs after every change: send the result by email, enqueue the next job, or broadcast the progress to the pages showing it. With--realtime, it does the last one: a progress bar per job that moves in every open tab (Progress of a long task).
Adapt two functions to the service: request_body (what it expects; the generated one is RunPod’s {"input": {...}, "webhook": "..."} with the job id added) and handle_event (how it reports). Services that only answer polling (no callback) can be polled from sweep: fetch the status of the submitted and running jobs and pass each answer to handle_event.
Settings: UPSCALE_URL (the service’s endpoint) and APP_URL (this app’s public address, for the callback) go in worker.env of cloudflare.config.ts; UPSCALE_SECRET and UPSCALE_TOKEN are secrets (ocre secrets push UPSCALE_SECRET UPSCALE_TOKEN --file .prod.vars). The generator adds UPSCALE_URL, UPSCALE_SECRET and APP_URL to .dev.vars.
Costs: a submission is one subrequest and two D1 queries; an event two D1 queries. The service itself is billed by its provider: RunPod and Modal charge GPU time per second; Cloudflare Containers need the Workers Paid plan, and are called through a Durable Object binding rather than a URL, so submit calls the container’s binding instead of post_signed. A container (or any service) can run ffmpeg/ffprobe: probe a video’s duration and resolution there and report them in an event’s output.
Sign the calls you send
When the app calls a service that checks signatures (or another app of yours), sign the body:
let body = serde_json::to_vec(&job)?;
let signature = ocre::webhooks::sign(secret.as_bytes(), &body); // lowercase hex HMAC-SHA256
let response = reqwest::Client::new()
.post(url)
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.body(body)
.send()
.await;
ocre::webhooks::post_signed(url, secret, bearer, &json) does it in one call (one subrequest) and returns the status and body; ocre g external_job uses it. webhooks::sign_standard(secret, id, timestamp, body) gives the webhook-signature value of a Standard Webhooks delivery. Outgoing HTTP is covered in Calling other services.
See also
- Background jobs and schedules: work that continues after the webhook answered.
- CLI: secrets for the production secret.
Realtime
Ocre pushes HTML to open pages over WebSockets, like Rails’ Action Cable and Turbo Streams: handlers and jobs broadcast fragments to a named channel, and htmx swaps them into every page subscribed to it. This page covers the --realtime scaffold, how channels run on a hibernating Durable Object, the broadcast helpers, authorization of channels, and what it costs on the free plan.
Before you start
- An Ocre app created with
ocre new, full-stack (the default). API-only apps can use the same pieces by hand; see Without the scaffold. - Nothing to set up on Cloudflare: the first
--realtimescaffold adds everything toCargo.tomlandcloudflare.config.ts, andocre deploycreates the Durable Object namespace. - The private-channel example uses
crate::auth::OptionalUser, created byocre g auth(see Authentication); the job example needs a job fromocre g job(see Background jobs and schedules).
How it works
browser ──WebSocket──> GET /realtime/messages ──> src/realtime.rs connect (who may listen)
└─> OcreChannel "messages" (Durable Object, holds the sockets)
POST /messages ──> create ──> ocre::realtime::broadcast(&ctx, "messages", html) ──> every socket
- One Durable Object per channel. Ocre ships a Durable Object class,
OcreChannel, bound asCHANNELS. Each channel name (messages,post:12) gets its own instance, which accepts the channel’s WebSockets. - Hibernation. The object accepts sockets with the WebSocket Hibernation API: between broadcasts it is evicted from memory while browsers stay connected, and hibernated sockets cost no duration. It stores nothing.
- Broadcast.
ocre::realtime::broadcast(&ctx, channel, message)sends one request to the channel’s object, which sends the text to every socket. It returns once the object has sent it. - Clients listen. What a browser sends over the socket is ignored, unless its
connectenabled relaying (see Identifying subscribers and relaying client messages); the object completes the closing handshakes browsers start. Use forms and htmx requests to send data. - Client side: htmx’s WebSocket extension connects, reconnects with backoff, and swaps each message into the element with the same
id(hx-swap-oob). No custom JavaScript.
Generating a live resource
ocre g scaffold Message body:text --realtime
create migrations/0004_create_messages.sql
create src/models/message.rs
create src/messages.rs
create templates/messages/index.html
create templates/messages/show.html
create templates/messages/new.html
create templates/messages/edit.html
create templates/messages/_form.html
create templates/messages/_row.html
create src/realtime.rs
update src/models/mod.rs
update src/lib.rs
update Cargo.toml
update cloudflare.config.ts
update templates/layout.html
Next:
ocre migrate
ocre dev
open http://localhost:8787/messages
open http://localhost:8787/messages in a second window, then create a message
/messages now shows the messages other visitors create, edit and delete without reloading. Compared with a plain scaffold, --realtime adds:
| Piece | What it does |
|---|---|
templates/messages/_row.html | One table row with id="message_<id>", included by the index and rendered for broadcasts |
templates/messages/index.html | Wraps the table in <div hx-ext="ws" ws-connect="/realtime/messages">; the <tbody> has id="messages" |
src/messages.rs | After a successful create, broadcasts prepend("messages", row); after update, the new row (same id, so it replaces the old one); after delete, remove("message_<id>") |
src/realtime.rs (first use) | GET /realtime/{channel} routed to connect, which lists the channels anyone may open; later --realtime scaffolds add their channel under // ocre:channels |
Cargo.toml (first use) | Ocre’s realtime feature |
cloudflare.config.ts (first use) | The CHANNELS Durable Object binding and the OcreChannel export |
templates/layout.html (first use) | <script src="https://unpkg.com/htmx-ext-ws@2.0.4/dist/ws.js" crossorigin="anonymous"></script> after htmx’s script tag, so every page can connect. A page that loaded the extension itself would not connect when reached through an hx-boost link: htmx processes the swapped page before the script arrives |
The cloudflare.config.ts entries (for an app named chat):
// in worker.env, after // ocre:env
// The realtime channels' Durable Objects (`ocre::realtime`).
CHANNELS: bindings.durableObject({ worker: "chat", exportName: "OcreChannel" }),
// in worker.exports, after // ocre:exports
// Realtime channels (`ocre::realtime`): one Durable Object per channel holds the
// browsers' WebSockets, hibernated between broadcasts so idle connections cost
// no duration. The free plan only accepts SQLite-backed classes.
OcreChannel: exports.durableObject({ storage: "sqlite" }),
The generated index page (templates/messages/index.html):
{# Live updates: htmx's WebSocket extension (loaded by layout.html) swaps in the rows other visitors create, edit and delete (src/realtime.rs). #}
<div hx-ext="ws" ws-connect="/realtime/messages">
<table>
<thead>
<tr><th>Body</th><th></th></tr>
</thead>
<tbody id="messages">
{% for message in messages %}
{% include "messages/_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
The broadcasting part of the generated controller (src/messages.rs, shortened):
/// One table row; the index page and live updates share it.
#[derive(Template)]
#[template(path = "messages/_row.html")]
struct RowView<'a> {
message: &'a Message,
}
fn row(record: &Message) -> Result<String> {
Ok(render(&RowView { message: record })?.0)
}
/// Sends `html` to every open index page (channel `messages`, see
/// src/realtime.rs). Best effort: a failed broadcast is logged by Ocre and
/// never fails the request.
async fn broadcast(ctx: &Ctx, html: &str) {
realtime::broadcast(ctx, "messages", html).await.ok();
}
// in create, after message::create succeeded:
broadcast(&ctx, &realtime::prepend("messages", &row(&record)?)).await;
// in update:
broadcast(&ctx, &row(&record)?).await;
// in delete:
broadcast(&ctx, &realtime::remove(&format!("message_{id}"))).await;
ocre dev runs the Durable Object locally (workerd supports Durable Objects and WebSocket Hibernation); the startup bindings table lists it:
env.CHANNELS (OcreChannel) Durable Object local
Watching the broadcasts
A browser is the usual client, but any WebSocket client shows the raw messages. This Node.js (22 or later) script prints what a channel receives:
// listen.mjs: node listen.mjs ws://localhost:8787/realtime/messages
const ws = new WebSocket(process.argv[2]);
ws.onopen = () => console.log("open");
ws.onmessage = (event) => console.log("message:", event.data);
ws.onclose = (event) => console.log("close", event.code);
With ocre dev running, start it in one terminal, then create, edit and delete a message in another:
node listen.mjs ws://localhost:8787/realtime/messages
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/messages -d 'body=First'
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/messages/3 -d 'body=First, edited'
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/messages/3/delete
The listener prints (real run):
open
message: <tbody hx-swap-oob="afterbegin:#messages"><tr id="message_3"><td>First</td><td><a href="/messages/3">Show</a> <a href="/messages/3/edit">Edit</a></td></tr></tbody>
message: <tr id="message_3"><td>First, edited</td><td><a href="/messages/3">Show</a> <a href="/messages/3/edit">Edit</a></td></tr>
message: <div id="message_3" hx-swap-oob="delete"></div>
Messages and helpers
A message is text: usually HTML, JSON for non-htmx clients. For htmx, each top-level element of a message is swapped into the page element with the same id:
| Build it with | Message | Effect in the page |
|---|---|---|
an element with an id | <tr id="message_3">...</tr> | Replaces the element with that id |
realtime::prepend(target, html) | <tbody hx-swap-oob="afterbegin:#messages">...</tbody> | Inserts html at the start of #target |
realtime::append(target, html) | <ul hx-swap-oob="beforeend:#comments">...</ul> | Inserts html at the end of #target |
realtime::update(target, html) | <div hx-swap-oob="innerHTML:#post_count">3 posts</div> | Replaces the contents of #target |
realtime::remove(id) | <div id="post_3" hx-swap-oob="delete"></div> | Removes #id |
prepend, append and update wrap the fragment in an element the browser can parse it in (a <tr> in a <tbody>, an <li> in a <ul>, likewise for other table parts, options and <dt>/<dd>, anything else in a <div>); htmx drops the wrapper. target and id are escaped; the HTML is sent as is, so render it with askama templates, which escape user content. Several swaps can go in one message: concatenate them.
The helpers are pure functions; nothing is sent until broadcast.
Broadcasting from a handler
This page has a form that posts with htmx and a list subscribed to the announcements channel. The handler stores nothing: it renders the item and broadcasts it, and every open page, the sender’s included, appends it. Add "announcements" => {} to connect in src/realtime.rs (see the next section) and register the module in src/lib.rs.
// src/announcements.rs
use askama::Template;
use axum::{
Form, Router,
extract::State,
http::StatusCode,
response::Html,
routing::get,
};
use ocre::{Ctx, Result, Validator, realtime, render};
use serde::Deserialize;
#[derive(Template)]
#[template(
source = r#"<!doctype html>
<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.4/dist/ws.js" crossorigin="anonymous"></script>
<h1>Announcements</h1>
<form hx-post="/announcements" hx-swap="none" hx-on::after-request="this.reset()">
<input name="text" required> <button>Announce</button>
</form>
<div hx-ext="ws" ws-connect="/realtime/announcements">
<ul id="announcements"></ul>
</div>"#,
ext = "html"
)]
struct IndexView;
/// One announcement, escaped by askama like any page.
#[derive(Template)]
#[template(source = r#"<li id="announcement_{{ at }}">{{ text }}</li>"#, ext = "html")]
struct ItemView<'a> {
at: i64,
text: &'a str,
}
#[derive(Deserialize)]
struct AnnouncementForm {
text: String,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/announcements", get(index).post(create))
}
async fn index() -> Result<Html<String>> {
render(&IndexView)
}
/// Sends the announcement to every open page, the sender's included. Nothing is stored.
async fn create(State(ctx): State<Ctx>, Form(form): Form<AnnouncementForm>) -> Result<StatusCode> {
Validator::new().required("text", &form.text).finish()?;
let item = render(&ItemView { at: ocre::now(), text: &form.text })?.0;
// One Durable Object request, whatever the number of subscribers.
realtime::broadcast(&ctx, "announcements", &realtime::append("announcements", &item)).await?;
Ok(StatusCode::NO_CONTENT)
}
A listener on ws://localhost:8787/realtime/announcements receives, for curl -X POST http://localhost:8787/announcements -d 'text=Deploy at 5 pm <b>sharp</b>' (answered 204):
message: <ul hx-swap-oob="beforeend:#announcements"><li id="announcement_1790656892">Deploy at 5 pm <b>sharp</b></li></ul>
Here the broadcast is the whole point of the request, so its error is returned (?). The generated controllers treat broadcasts as best effort instead: they .ok() the result, so a failure is only logged and the database write still succeeds. Either way, broadcast after the database write succeeded, and once per change, not once per row in a loop.
Every failure is logged as [ocre realtime] broadcast to <channel> failed: ... and returned as a 500 (Error::Internal): an invalid channel name, a missing CHANNELS binding (the message names the cloudflare.config.ts entries to add), or a channel object that cannot be reached, for example past the daily free-plan quota.
Authorizing channels
src/realtime.rs is app code: connect decides who may open which channel before upgrade.connect(&ctx, &channel) hands the socket to the channel’s object. The generated version lets everyone listen to the listed channels and answers 404 for the others. Browsers send the session cookie with the WebSocket handshake, so the session and the auth extractors work there.
This version keeps messages and announcements public and adds private per-user channels, user:<id>, that only that user may open:
// src/realtime.rs
use axum::{
Router,
extract::{Path, State},
response::Response,
routing::get,
};
use ocre::{Ctx, Error, Result, realtime::WebSocketUpgrade};
use crate::auth::OptionalUser;
pub fn routes() -> Router<Ctx> {
Router::new().route("/realtime/{channel}", get(connect)).merge(ocre::realtime::dev_routes())
}
/// Opens a WebSocket on `channel` for whoever may listen to it.
/// Public channels are listed by name; `user:<id>` is only for that user.
async fn connect(
State(ctx): State<Ctx>,
Path(channel): Path<String>,
OptionalUser(user): OptionalUser,
upgrade: WebSocketUpgrade,
) -> Result<Response> {
match channel.as_str() {
// ocre:channels
"messages" => {}
"announcements" => {}
private => {
let Some(id) = private.strip_prefix("user:") else { return Err(Error::NotFound) };
let user = user.ok_or(Error::Unauthorized)?;
if id != user.id.to_string() {
return Err(Error::Forbidden);
}
}
}
upgrade.connect(&ctx, &channel).await
}
OptionalUser keeps public channels open to visitors; taking CurrentUser instead would redirect every anonymous handshake to /login. Keep the // ocre:channels marker: later --realtime scaffolds add their channel under it.
Handshakes sent with curl (-H Connection:Upgrade -H Upgrade:websocket -H Sec-WebSocket-Version:13 -H Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==), with and without the session cookie of user 1, real run:
| Request | Status |
|---|---|
/realtime/announcements, anonymous | 101 Switching Protocols |
/realtime/nope, anonymous | 404 Not Found |
/realtime/user:1, anonymous | 401 Unauthorized |
/realtime/user:1, signed in as user 1 | 101 Switching Protocols |
/realtime/user:2, signed in as user 1 | 403 Forbidden |
/realtime/announcements with Sec-Fetch-Site: cross-site | 403 Forbidden |
/realtime/announcements without Upgrade: websocket | 400 |
The cross-site refusal comes from ocre::serve, which checks WebSocket handshakes like unsafe form posts (see Sessions, flash and security). A request without the upgrade headers gets a 400 page: “Expected a WebSocket connection (Upgrade: websocket). Connect with htmx’s ws extension or new WebSocket(url).”
Rules for channels:
- Names are 1 to 128 ASCII letters, digits,
_,-,.or:(posts,post:12,user:7).upgrade.connectanswers 400 for other names (aconnectthat lists its channels answers 404 first) andbroadcastfails with a 500. - Private data goes on a channel per user or per record, checked in
connect; never on a shared channel. Anyone who may open a channel receives everything broadcast to it. - A page subscribes to its private channel with the id from the session, e.g.
<div hx-ext="ws" ws-connect="/realtime/user:{{ user.id }}">. - Channel parameters (Rails’
paramsof a subscription) are the route’s: the channel name in the path, plus any query string read with axum’sQueryextractor inconnect.
Identifying subscribers and relaying client messages
Two builder methods on WebSocketUpgrade, called in connect before connect(..):
upgrade.identified_by(id)names the subscriber (Rails’identified_by :current_user), usually the user’s id. The identity stays with the socket in the channel’s object, even while it hibernates. At most 256 bytes.upgrade.rebroadcast()lets this client publish: every text message it sends (up to 16 KB) goes to the channel’s other sockets as JSON,{"from": "<identity>", "data": <message>}.fromis set by the server (nullwithoutidentified_by), so a client cannot pretend to be someone else;datais the message parsed as JSON, or a string. No app code runs for relayed messages and they are never HTML swaps, so a client cannot inject markup into other pages. Use it for typing indicators, cursors or ephemeral chat between JavaScript clients; send anything that must be validated or stored to an ordinary route, which saves it and callsbroadcast.
// src/chat.rs
use axum::{
Router,
extract::{Path, State},
response::Response,
routing::get,
};
use ocre::{Ctx, Error, Result, realtime::WebSocketUpgrade};
use crate::auth::OptionalUser;
pub fn routes() -> Router<Ctx> {
Router::new().route("/chat/{room}", get(join))
}
/// Signed-in users join `chat:<room>`; what each sends reaches the others as
/// `{"from": "<user id>", "data": ...}`.
async fn join(
State(ctx): State<Ctx>,
Path(room): Path<String>,
OptionalUser(user): OptionalUser,
upgrade: WebSocketUpgrade,
) -> Result<Response> {
let user = user.ok_or(Error::Unauthorized)?;
upgrade.identified_by(user.id.to_string()).rebroadcast().connect(&ctx, &format!("chat:{room}")).await
}
In the page, const ws = new WebSocket("/chat/lobby"); ws.send(JSON.stringify({ typing: true })) reaches every other member as {"from":"1","data":{"typing":true}}. Broadcasts sent with ocre::realtime::broadcast go to every socket, publishers included.
Free plan: Cloudflare bills incoming WebSocket messages to a Durable Object at 20 messages per request; relaying them to the other sockets is free.
Testing broadcasts
In ocre dev, GET /ocre/dev/realtime/sent.json lists the last 50 successful broadcasts of the Worker, oldest first, the way /ocre/dev/mailers/sent.json lists emails (Rails’ assert_broadcasts and assert_broadcast_on):
[{"id":1,"channel":"messages","message":"<tbody hx-swap-oob=\"afterbegin:#messages\">...</tbody>"}]
An end-to-end test creates a record, then checks that the list grew with the expected channel and HTML, without opening a WebSocket (see Testing). The generated src/realtime.rs merges ocre::realtime::dev_routes(); deployed (release) builds answer 404 there. The message builders (prepend, update…) are pure functions, testable with plain unit tests.
Broadcasting from a job
broadcast works anywhere a Ctx is available, including jobs, which is how long work reports back to the page that started it. After ocre g job ImportFinished user_id:integer rows:integer, edit the generated perform:
// src/jobs/import_finished.rs
use ocre::{Ctx, Result, realtime};
use serde::{Deserialize, Serialize};
/// The job's arguments, stored in the queue message as JSON.
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportFinished {
pub user_id: i64,
pub rows: i64,
}
impl ImportFinished {
/// Tells the user's open pages that the import is done: the element
/// `id="import_status"` gets the new text. `Err` retries the job.
pub async fn perform(self, ctx: &Ctx) -> Result<()> {
let html = realtime::update("import_status", &format!("Import finished: {} rows", self.rows));
realtime::broadcast(ctx, &format!("user:{}", self.user_id), &html).await
}
}
The page shows <p id="import_status">Importing...</p> inside <div hx-ext="ws" ws-connect="/realtime/user:{{ user.id }}">. Returning the broadcast’s error makes the queue retry the job; use .ok() when an update that nobody sees is not worth a retry. A job may run twice, so a repeated broadcast must be harmless (replacing contents, as here, is).
Progress of a long task
A task that runs elsewhere (a GPU job, a transcoding) reports its progress to the app, and every page showing it should follow. Give each task its own channel, named by an unguessable id, and broadcast a progress bar with a fixed element id: the WebSocket extension replaces the bar in every open tab.
ocre::helpers::progress_bar(id, percent, label) is that bar: <div id="<id>" class="progress-bar"><progress max="100" value="40">40%</progress> <span>Encoding</span></div> (the label escaped, the percent kept within 0-100).
ocre g external_job upscale video_id:integer --realtime wires it all for jobs run by another service (see Run work on another service):
- each job has a
public_id, and its channel isupscale_jobs:<public_id>;src/realtime.rsaccepts the channels starting withupscale_jobs:(channel if channel.starts_with("upscale_jobs:") => {}), so nobody can list or guess them; changed, run after every change of a job (a webhook event, the sweep), broadcastsprogress_html(&job), the bar with the idupscale_jobs_<public_id>;GET /upscale_jobs/<public_id>/progressanswers the bar wrapped in its subscription,<div hx-ext="ws" ws-connect="/realtime/upscale_jobs:<public_id>">...</div>. A page that has htmx and thewsextension loads it with:
<div hx-get="/upscale_jobs/{{ job.public_id }}/progress" hx-trigger="load"></div>
An event from the service then moves the bar in every tab showing that job, typically within a few hundred milliseconds (checked in ocre dev with two tabs and a 1-second limit). Each event costs one Durable Object request for the broadcast; open tabs cost nothing between messages.
For your own tasks, do the same by hand: a channel per record named by its public_id (accepted in connect by prefix), and ocre::realtime::broadcast(&ctx, &channel, &ocre::helpers::progress_bar(&id, percent, label)) from the handler, job or webhook that learns the progress.
Free-plan costs
Limits of the Workers Free plan (September 2026):
| Resource | Free plan | Realtime use |
|---|---|---|
| Durable Object requests | 100,000 a day | 1 per connection (and reconnection), 1 per broadcast (even with no subscriber); incoming WebSocket messages count 1/20 (only clients connected with rebroadcast() have a reason to send any); messages to browsers are free |
| Durable Object duration | 13,000 GB-s a day (128 MB objects: about 28 hours awake) | Only while handling a connection or a broadcast, a few milliseconds; hibernated sockets cost nothing |
| Worker requests | 100,000 a day | 1 per connection; a broadcast is a subrequest of the request that sends it |
| Durable Object limits | SQLite-backed classes only; 32,768 WebSockets per object | new_sqlite_classes = ["OcreChannel"]; one object per channel |
So the daily count is roughly: connections and reconnections of every open page, plus one per broadcast. Broadcasting per row in a loop, or many pages reconnecting often, is what exhausts the quota. The Durable Object request quota is shared with any other Durable Object use of the account.
Deploying
ocre deploy needs no extra step: cf deploy creates the Durable Object namespace from the OcreChannel export. Keep the export as generated: removing or renaming it deletes the class and its objects.
Apps first deployed with an earlier Ocre declared the class with a wrangler [[migrations]] entry (tag ocre-realtime-v1). How cf maps the export onto such a Worker is not verified yet: see Upgrading from wrangler.toml and deploy to a preview first.
Without the scaffold, and in API-only apps
The scaffold only writes app code around three framework pieces, so any app, API-only included, can set them up by hand:
- Turn on the feature in
Cargo.toml:ocre = { ..., features = ["realtime"] }(see Configuration). - Add the
CHANNELSbinding and theOcreChannelexport shown above tocloudflare.config.ts. - Add a
connectroute likesrc/realtime.rsabove (any path;WebSocketUpgradeis the extractor) and merge it inroutes(). - For htmx pages, load the WebSocket extension in the
<head>oftemplates/layout.html, as shown in the table above. - Broadcast from handlers or jobs. Non-htmx clients usually want JSON:
realtime::broadcast(&ctx, "orders", &ocre::serde_json::json!({"id": 12, "status": "paid"}).to_string()).WebSocketUpgraderejects a request withoutUpgrade: websocketwith a 400 rendered as JSON in API-only apps.
A browser client without htmx is a plain new WebSocket("wss://<host>/realtime/orders") with an onmessage handler; clients do not need to send anything.
Coming from Rails
| Action Cable | Ocre |
|---|---|
ApplicationCable::Connection, identified_by, reject_unauthorized_connection | The connect handler: extractors, identified_by, Err(Error::Forbidden) |
Connection and channel callbacks, rescue_from | Code before upgrade.connect(..) and its Result; the channel object runs no app code |
Channel classes, subscribed, stream_from, stream_for | One channel name per stream (post:12), checked in connect |
Channel params | The route’s path and query string |
Client actions (perform) | Ordinary routes (htmx hx-post) that broadcast |
| Rebroadcasting client data | upgrade.rebroadcast() |
ActionCable.server.broadcast, broadcast_to | ocre::realtime::broadcast(&ctx, channel, message) |
| Subscription adapters (async, Redis, PostgreSQL, Solid Cable) | One: the OcreChannel Durable Object, no pub/sub server |
Mount path, action_cable_meta_tag, allowed origins | The connect route’s path, the ws-connect URL, the same-site check of ocre::serve |
| Standalone cable server, worker pool | Each channel’s own Durable Object, apart from the request Worker; nothing to size |
assert_broadcasts, assert_broadcast_on | /ocre/dev/realtime/sent.json in ocre dev |
createConsumer, subscriptions.create | htmx’s ws extension, or new WebSocket(url) |
See also
- Generators:
ocre g scaffold --realtime. - Configuration: the Durable Object entries.
- Background jobs and schedules: enqueueing jobs that broadcast.
- Authentication:
CurrentUserandOptionalUser. - Sessions, flash and security: the cross-site check on handshakes.
- Free-plan limits and Cost model.
ocre::realtimerustdoc.
Caching
Ocre has opt-in caching tools chosen for the Workers free plan: ocre::cache keeps slow or costly results (JSON values or rendered HTML fragments) in Workers KV, every request remembers its own SELECT results and KV reads, and CacheControl, ETag and Conditional let browsers reuse pages with 304 Not Modified answers. This page shows them, the KV write budget that limits the first, and why Ocre does not wrap Cloudflare’s own page caches.
Before you start
- An Ocre app created with
ocre new. - For values in KV: run
ocre g cacheonce; it adds theCACHEbinding (see Setting up the KV cache). HTTP caching needs no setup. - The examples use the
Postmodel of the blog starter (ocre new <name> --starter blog) and theI18nextractor, which needsocre g locale(see Translations); drop the locale from the ETag in an app without translations.
Which tool
KV values (ocre::cache::fetch) | HTTP (CacheControl, ETag, Conditional) | |
|---|---|---|
| Saves | Slow or costly work: D1 aggregates over many rows, third-party API calls | Rendering and bandwidth: 304 Not Modified without a body |
| Where the copy lives | Workers KV, global, eventually consistent (up to 60 s) | The browser (and Cloudflare with Workers Cache, below) |
| Free plan (September 2026) | 100,000 reads and 1,000 writes a day, 1 GB stored (KV limits) | Free: no KV or D1 operation |
| Setup | ocre g cache | None |
Setting up the KV cache
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)
It adds this to cloudflare.config.ts, after the // ocre:env marker:
// Cached values for `ocre::cache` (Workers KV), added by `ocre g cache`. The
// first `ocre deploy` creates the namespace and writes its id here; `ocre dev`
// uses a local one. Free plan: 100,000 reads and 1,000 writes a day, 1 GB.
CACHE: bindings.kv(),
Running it again fails without changing anything:
error: cloudflare.config.ts already has the `CACHE` binding
hint: nothing to generate: call `ocre::cache::fetch(&ctx, key, ttl, || async { ... })` in a handler
ocre devuses a local namespace (under.wrangler/state); its startup bindings table listsenv.CACHEas a localKV Namespace.ocre deploygives eachbindings.kv()entry without anidthe namespace titled<worker>-<binding>(blog-cache): it links an existing namespace with that title, or runscf kv namespaces create, then rewrites the entry toCACHE: bindings.kv({ id: "..." }),. Commit that change so every later deploy uses the same namespace.- The binding is not in
ocre newapps because KV writes are the scarcest free resource (see The write budget).
Without the binding, every ocre::cache function fails with a 500 whose log line names the fix:
KV binding `CACHE` is missing (...). Fix: run `ocre g cache`, which adds `CACHE: bindings.kv(),` to worker.env in cloudflare.config.ts
Caching a value with fetch
ocre::cache::fetch(&ctx, key, ttl, compute) is Rails’ Rails.cache.fetch: it returns the value stored under key, or runs compute, stores its result for ttl and returns it. Values are the JSON of any Serialize + Deserialize type.
This endpoint returns post counts, computed at most once an hour per location, and has a second route that forgets them:
// src/stats.rs
use std::time::Duration;
use axum::{Json, Router, extract::State, http::StatusCode, routing::{get, post}};
use ocre::{Ctx, Result, params};
use serde::{Deserialize, Serialize};
/// Cache key of the stats. Bump `v1` whenever `Stats` changes shape.
const KEY: &str = "stats:v1";
/// What `GET /stats` returns (and what KV stores, as JSON).
#[derive(Serialize, Deserialize)]
pub struct Stats {
pub posts: i64,
pub published: i64,
/// Unix time of the computation: equal across responses while the value is cached.
pub computed_at: i64,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/stats", get(show)).route("/stats/refresh", post(refresh))
}
/// One KV read per request; the query runs only on a miss (then one KV write).
async fn show(State(ctx): State<Ctx>) -> Result<Json<Stats>> {
let stats = ocre::cache::fetch(&ctx, KEY, Duration::from_secs(3600), || compute(&ctx)).await?;
Ok(Json(stats))
}
/// Forgets the cached value, so the next `GET /stats` recomputes it (one KV write).
async fn refresh(State(ctx): State<Ctx>) -> Result<StatusCode> {
ocre::cache::delete(&ctx, KEY).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn compute(ctx: &Ctx) -> Result<Stats> {
#[derive(Deserialize)]
struct Counts {
posts: i64,
published: i64,
}
let sql = "SELECT COUNT(*) AS posts, COALESCE(SUM(published), 0) AS published FROM posts";
let counts: Option<Counts> = ctx.db()?.first(sql, params![]).await?;
let (posts, published) = counts.map_or((0, 0), |c| (c.posts, c.published));
Ok(Stats { posts, published, computed_at: ocre::now() })
}
Register it in src/lib.rs (mod stats; under // ocre:modules, .merge(stats::routes()) under // ocre:routes). With ocre dev running and two posts, one published (real run):
curl -s http://localhost:8787/stats
curl -s http://localhost:8787/stats
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/stats/refresh
curl -s http://localhost:8787/stats
{"posts":2,"published":1,"computed_at":1790656352}
{"posts":2,"published":1,"computed_at":1790656352}
204
{"posts":2,"published":1,"computed_at":1790656360}
The second call was a hit (same computed_at); after the delete, the value was computed again.
Rules for keys and values
- Version the key. Stored values are the JSON of the type. Put a version in the key (
stats:v1) and change it when the type changes. A stored value that no longer decodes is logged ([ocre cache] decode `stats:v1` failed: ...; recomputing it (change the key to avoid this)) and treated as a miss. - Never share per-user data. A key is global to the app: put the user id in keys of per-user values (
dashboard:v1:user:42). - TTL of 60 seconds or more. KV refuses shorter TTLs;
fetchandwritereturn a 500 naming the fix (cache TTL ... is below KV's minimum of 60 seconds). Durations are truncated to whole seconds. Prefer an hour or more (see the budget below). - Keys are 1 to 512 bytes.
- Eventual consistency. Other locations may read the old value for up to 60 seconds after a
writeordelete. Do not cache values that must be exact right after a change (a balance, a stock count used to accept an order). - Invalidate after the write: call
ocre::cache::delete(&ctx, key)after the database change behind the value succeeded, for example in the handler that updates a post.
Failure behavior
fetch never fails because of KV itself. When a KV read or write fails (past a daily limit, for example), it logs [ocre cache] read `stats:v1` failed: ... (or write) and computes the value, so the page keeps working, only slower. It fails only for a missing binding, an invalid key or TTL, a value that does not serialize to JSON, or an error returned by compute (returned unchanged; nothing is stored).
read, write and delete
The explicit forms, for when a closure does not fit (a value refreshed by a scheduled task, for example):
| Function | Returns | KV cost | On KV failure |
|---|---|---|---|
ocre::cache::fetch(&ctx, key, ttl, compute) | Result<T> | 1 read; a miss adds 1 write | Logged; computes the value |
ocre::cache::read::<T>(&ctx, key) | Result<Option<T>>: None when absent, expired or undecodable | 1 read | Logged; None |
ocre::cache::write(&ctx, key, &value, ttl) | Result<()> | 1 write | Error (500) |
ocre::cache::delete(&ctx, key) | Result<()>; deleting an absent key succeeds | 1 write | Error (500) |
ocre::cache::clear(&ctx, prefix, limit) | Result<Cleared>: how many were deleted, and more when keys with the prefix remain | 1 list + 1 write per key | Error (500) |
// in a scheduled task: refresh the value before visitors ask for it
let rates = fetch_rates(ctx).await?;
ocre::cache::write(ctx, "rates:v1", &rates, Duration::from_secs(6 * 3600)).await?;
// in a handler: use it if present
let rates: Option<Rates> = ocre::cache::read(&ctx, "rates:v1").await?;
clear is Rails’ Rails.cache.clear, bounded: it deletes at most limit values whose key starts with prefix ("" for all, "views/" for fragments), since each delete spends one of the free plan’s 1,000 daily KV writes. Call it again, for example from a scheduled task, while more is true:
let cleared = ocre::cache::clear(&ctx, "views/", 200).await?;
ctx.log().info(format_args!("cleared {} fragments, more: {}", cleared.deleted, cleared.more));
Bumping the version in the keys (v1 to v2) needs no delete at all: old values expire with their TTL.
Caching HTML fragments
askama templates are compiled Rust and cannot wait for KV, so Rails’ <% cache post do %> becomes a call in the handler: ocre::cache::fragment(&ctx, &key, ttl, || template) returns the fragment stored under key, or renders the template, stores the HTML and returns it. The result is an ocre::cache::Fragment, which the page template writes with {{ card }}: it was escaped when it was rendered, so it needs no |safe. ocre::cache::fragments does the same for a list, with one KV bulk read for all its items (Rails’ render collection:, cached: true); only the missing items are rendered and written.
// src/cached_posts.rs
use std::time::Duration;
use askama::Template;
use axum::{Router, extract::State, response::Html, routing::get};
use ocre::{Ctx, Page, Result, cache::{self, Fragment}, render};
use crate::models::post::{self, Post};
/// One row; its HTML is cached per post and version.
#[derive(Template)]
#[template(source = r#"<li id="post_{{ post.id }}"><strong>{{ post.title }}</strong> {{ post.body }}</li>"#, ext = "html")]
struct Row<'a> {
post: &'a Post,
}
#[derive(Template)]
#[template(source = "<ul>{% for row in rows %}{{ row }}{% endfor %}</ul>", ext = "html")]
struct Index {
rows: Vec<Fragment>,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/cached-posts", get(index))
}
/// One KV bulk read for the page; each post changed since its last view costs one render and one KV write.
async fn index(State(ctx): State<Ctx>, page: Page) -> Result<Html<String>> {
let posts = post::all(&ctx, page).await?;
let rows = cache::fragments(
&ctx,
&posts,
Duration::from_secs(7 * 86_400),
// Bump `row-v1` when the Row template changes.
|post| cache::key(&[&"posts", &post.id, &post.updated_at, &"row-v1"]),
|post| Row { post },
)
.await?;
render(&Index { rows })
}
Keys
ocre::cache::key(&[&"posts", &post.id, &post.updated_at, &"row-v1"]) joins its parts with / (posts/1/2026-09-29 14:05:00/row-v1), like Rails’ cache_key_with_version; keys longer than 256 bytes become sha256/<hex>. Fragments are stored under views/<key>.
- Records: the id and
updated_atof every record the fragment shows. Editing a record changes its key, so the old fragment is never read again and expires with its TTL: nodelete, no extra write. - Template version: Rails adds a digest of the template and its partials to the key. Ocre keeps a version you write (
row-v1) and bump when the template changes, so a deploy does not invalidate every fragment at once (each rewrite is a KV write out of 1,000 a day). - Everything else the HTML depends on: the locale (
i18n.locale()) when the fragment is translated, the user’s id for per-user HTML. Never cache HTML with a CSRF token or a CSP nonce in it. - Nested fragments (Russian doll caching): the outer key includes the newest
updated_atof the records inside, e.g.cache::key(&[&"posts", &post.id, &post.updated_at, &newest_comment_at, &"card-v1"])withnewest_comment_atfromSELECT MAX(updated_at) FROM comments WHERE post_id = ?1. Rails’touch: truedoes the same by updating the parent’supdated_atwhen a child changes; do it in the child’s save when the parent’s key only usespost.updated_at. - Formats: a fragment is plain HTML under a free-form key, so the same fragment serves a page, an htmx swap, a realtime broadcast (
fragment.into_string()) or an email; add the format to the key only when the HTML differs.
When to cache fragments
Only when rendering costs noticeable CPU (long lists, Markdown, heavy formatting) and the records are read far more often than they change: a miss costs a KV read plus a write, a hit a KV read. To cache only sometimes (Rails’ cache_if), put an if around the call and render the template directly otherwise. Failures behave like fetch: a KV error is logged and the template is rendered.
Per-request caches
Two caches live in the Ctx of one request (or one queued job, or one cron run) and cost nothing:
- Query cache: a
SELECTrun twice with the same parameters throughctx.db()(orctx.db_named(..)) is answered from memory the second time, without a D1 round trip or rows read, like Rails’ query cache. Any other statement (execute,batch,INSERT ... RETURNINGthroughfirst) empties it, so a request always reads its own writes. It keeps up to 100 results.ctx.db()?.uncached()bypasses it for a query that must see changes made by other requests during this one. - Local cache:
fetch,readandfragmentread each KV key at most once per request, and remember whatwriteanddeletedid (Rails’ local cache).
Nothing is shared between requests: Worker instances have no memory you can rely on.
Turning caching off
The Worker variable CACHE_STORE selects the store without code changes (Rails’ config.cache_store):
CACHE_STORE | Store |
|---|---|
absent or kv | Workers KV, the CACHE namespace |
null | None: fetch and fragment always compute, read finds nothing, write and delete do nothing, no KV operation |
To develop without the cache (Rails’ bin/rails dev:cache), add CACHE_STORE=null to .dev.vars and restart ocre dev; remove the line to turn it back on. Any other value is a 500 naming the two valid ones. To empty the cache in production, bump the version in your keys: old entries expire with their TTL, which costs no write (deleting keys one by one would cost one write each).
The write budget
The free plan allows 1,000 KV writes a day (writes and deletes, to different keys; one write per second per key) and 100,000 reads (KV limits, September 2026). Every fetch is a read; every miss, write and delete is a write.
A key refreshed every ttl seconds costs up to 86,400 / ttl writes a day, per key:
| TTL | Writes a day, per key | Keys that fit in 1,000 writes |
|---|---|---|
| 60 s (minimum) | 1,440 | none: one key alone is over the budget |
| 5 minutes | 288 | 3 |
| 1 hour | 24 | about 40 |
| 1 day | 1 | about 1,000 |
Use TTLs of an hour or more and few keys. Keys that embed an id (post:42:v1) multiply writes by the number of ids requested: keep them for values read far more often than they change. Each delete after an update is one more write. Past the daily write limit, fetch keeps answering by computing every time.
HTTP caching: 304 without rendering
Conditional is an extractor for the request’s If-None-Match; conditional.fresh_when(etag, cache_control, render) answers 304 Not Modified without calling render when the browser already has this version, like Rails’ fresh_when. Otherwise it renders and adds the ETag and Cache-Control headers. The database query that builds the ETag still runs; the template does not. No KV or D1 operation is added.
// src/articles.rs
use askama::Template;
use axum::{
Router,
extract::{Path, State},
response::Response,
routing::get,
};
use ocre::{
Ctx, OptionExt, Result,
cache::{CacheControl, Conditional, ETag},
i18n::I18n,
render,
};
use crate::models::post::{self, Post};
#[derive(Template)]
#[template(
source = r#"<!doctype html>
<html lang="{{ i18n.locale() }}">
<title>{{ post.title }}</title>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</html>"#,
ext = "html"
)]
struct ArticleView {
post: Post,
i18n: I18n,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/articles/{id}", get(show))
}
/// 304 without rendering when the browser already has this version of the page.
async fn show(State(ctx): State<Ctx>, Path(id): Path<i64>, i18n: I18n, conditional: Conditional) -> Result<Response> {
let post = post::find(&ctx, id).await?.or_404()?;
// Everything the page shows: the post (its `updated_at` changes on every edit) and the locale.
let etag = ETag::of(&(&post, i18n.locale()))?;
conditional.fresh_when(etag, CacheControl::no_cache(), || render(&ArticleView { post, i18n }))
}
A real run against ocre dev. The first request renders the page:
curl -si http://localhost:8787/articles/1
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html; charset=utf-8
Cache-Control: private, no-cache
ETag: W/"fc878d99b9decd186a69ff08a7b0f4d6"
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
<!doctype html>
<html lang="en">
<title>Hello</title>
<h1>Hello</h1>
<p>First post</p>
</html>
Sending the ETag back, as a browser does, gets a 304 without a body:
curl -si -H 'If-None-Match: W/"fc878d99b9decd186a69ff08a7b0f4d6"' http://localhost:8787/articles/1
HTTP/1.1 304 Not Modified
Cache-Control: private, no-cache
ETag: W/"fc878d99b9decd186a69ff08a7b0f4d6"
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
The same If-None-Match with Accept-Language: fr gets 200 OK (the locale is part of the tag), and so does the English page after the post was edited (updated_at changed; the new tag was W/"db7674b7d119cab19e03b8c1a8fa5a0d").
Building the ETag
ETag::of(&data)?hashes the JSON of any serializable data:ETag::of(&(&posts, i18n.locale()))?.ETag::new(version)hashes a version string you build, cheaper for large data:ETag::new(format!("{}-{}", post.id, post.updated_at)). Both give a weak tag,W/"<32 hex characters>"(the first 128 bits of a SHA-256).ETag::strong(version)gives a strong one,"<32 hex characters>"(Rails’strong_etag:), which promises byte-identical bodies: use it for exact files or exports, not for pages with nonces or CSRF tokens.- Put everything the page shows in it: the records, the locale, the signed-in user’s id, the flash messages (
flash.notice(),flash.alert()). A page that shows something the tag does not cover can be served stale from the browser’s copy. - Only GET and HEAD requests are considered; for other methods the client is never fresh. Comparison is weak (RFC 9110):
W/"x"matches"x", and*matches any tag.
Cache-Control policies
CacheControl is a response part: return it in a tuple, (CacheControl::public(Duration::from_secs(300)), Html(page)), or pass it to fresh_when.
| Constructor | Header | Use for |
|---|---|---|
CacheControl::no_store() | no-store | Secrets, one-time tokens |
CacheControl::no_cache() | private, no-cache | Per-visitor pages, with an ETag: the browser keeps a copy and asks every time |
CacheControl::private(ttl) | private, max-age=N | Per-visitor data that may be stale for N seconds without asking |
CacheControl::public(ttl) | public, max-age=N | Responses identical for every visitor |
.stale_while_revalidate(window) | adds stale-while-revalidate=N | After max-age, serve the old copy while fetching a new one (ignored by no_store and no_cache) |
Use public only for responses that are the same for everyone: no session data, no flash, and the locale in the path rather than taken from a cookie or Accept-Language.
Serving pages without running the Worker
Two Cloudflare caches can answer requests before the Worker runs. Ocre wraps neither; here is why, as of September 2026:
- The Cache API (
caches.default) only works on custom domains: on*.workers.dev, where Ocre apps deploy by default,putdoes nothing. It is also local to one data center, and the Worker still runs (and counts) for every request. - Workers Cache (
cache: { enabled: true }inworkerofcloudflare.config.ts) works onworkers.devtoo and servesCacheControl::public(..)responses from Cloudflare’s tiered cache: hits use no CPU. But on the free plan every hit still counts toward the 100,000 requests a day, and turning it on also counts requests for static assets inpublic/, which are otherwise free (pricing). Its cache key ignores cookies andAccept-Language, so only mark responsespublicwhen they are the same for every visitor; responses withSet-Cookieare never stored.
For most free-plan apps, no_cache pages with an ETag (cheap 304s) and KV values for the expensive parts are the better trade.
Coming from Rails
| Rails | Ocre |
|---|---|
Rails.cache.fetch/read/write/delete | ocre::cache::fetch/read/write/delete |
cache view helper, cache_key_with_version | ocre::cache::fragment in the handler, ocre::cache::key |
render collection:, cached: true | ocre::cache::fragments (KV bulk reads) |
| Template digests | A version in the key, bumped by hand |
touch: true (Russian doll) | Children’s newest updated_at in the parent’s key |
| Solid Cache, Redis, Memcached, file and memory stores | Workers KV, the one store; CACHE_STORE=null for none |
| Query cache, local cache | Per-request caches in Ctx |
fresh_when, stale?, http_cache_forever | Conditional::fresh_when, CacheControl::public(..) |
bin/rails dev:cache | CACHE_STORE=null in .dev.vars |
See also
- Generators:
ocre g cache. - Configuration: the
CACHEentry. - CLI commands: what
ocre deployprovisions. - Translations: the locale in ETags.
- File storage: files have their own ETag/304 handling in
storage::serve. - Free-plan limits and Cost model.
ocre::cacherustdoc.
Translations
Ocre translates an app Rails-style: strings live in locales/<code>.yml files compiled into the Worker, handlers take the I18n extractor for the request’s locale, and templates call i18n.t("key") with %{name} values and CLDR plural forms. This page covers ocre g locale, the locale file format, how the locale of a request is chosen, missing keys and defaults, scoped keys, HTML in translations, dates, numbers, model and attribute names, validation messages, the built-in translations, ocre i18n missing, and translations outside requests.
Before you start
- An Ocre app created with
ocre new. Translations work in full-stack and API-only apps; the template examples need a full-stack app. - Nothing on Cloudflare: translations use no binding, no D1, KV or other billed resource.
- The examples use the
Postmodel of the blog starter (ocre new <name> --starter blog) for a count.
Setting up translations
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
The first code is the default locale. The first run adds two things to src/lib.rs: the LOCALES static at the end of the file and the layer as the last call of routes():
fn routes() -> Router<Ctx> {
Router::new()
.route("/", get(home))
.route("/up", get(up))
// ocre:routes
.merge(posts::routes())
.layer(ocre::i18n::layer(&LOCALES))
}
/// Translations in locales/<code>.yml, compiled into the Worker; the first code
/// is the default locale. `ocre g locale <code>` adds one.
static LOCALES: ocre::i18n::Locales = ocre::locales!("en", "fr");
ocre::locales!("en", "fr")includeslocales/en.ymlandlocales/fr.yml(paths from the app root) withinclude_str!; a missing file is a compile error. The files are parsed once per Worker instance, on first use.ocre::i18n::layer(&LOCALES)makes the catalog available to theI18nextractor. Keep it last inroutes(), after the// ocre:routesmarker, so it covers every route; generators insert new routes above it. Without it,I18nanswers a 500 whose log names this fix.
Later runs add a locale (ocre g locale de creates locales/de.yml and appends "de" to the macro). Codes are a language, optionally with a region or script: en, fr, pt-BR, zh-Hant. Declaring a code twice fails:
error: locale `fr` is already declared in src/lib.rs
hint: edit locales/fr.yml; `ocre i18n missing` lists keys to translate
The generated files:
# Default locale (en). Nested keys, used as i18n.t("app.welcome") in handlers
# and {{ i18n.t("app.welcome") }} in templates. Quote values that start with
# `%` or contain `: `, e.g. "%{count} posts"; `%{name}` takes .arg("name", value).
# Plurals: a key with one/other children (plus few/many... for some languages),
# picked by .count(n). `ocre i18n missing` checks the other locales against this file.
en:
app:
welcome: "Welcome"
# Locale fr. Translate every key of locales/en.yml at the same path;
# `ocre i18n missing` lists the keys still to add.
fr:
Locale files
A locale file is a subset of YAML: nested keys and strings, the part Rails locale files use. Every file Ocre accepts is valid YAML with the same meaning. The examples on this page use these two files:
# locales/en.yml
en:
app:
welcome: "Welcome"
hello:
title: "Hello"
greeting: "Hello %{name}!"
posts:
one: "%{count} post"
other: "%{count} posts"
# locales/fr.yml
fr:
app:
welcome: "Bienvenue"
hello:
title: "Bonjour"
greeting: "Bonjour %{name} !"
posts:
one: "%{count} article"
other: "%{count} articles"
Keys are addressed with dots, without the locale: hello.greeting. The rules the parser enforces:
| Rule | Detail |
|---|---|
| One root key | The file starts with its code on its own line (fr:), at column 0; everything else is indented under it |
| Indentation | Spaces, not tabs; siblings use the same indentation (2 spaces by convention) |
| Keys | ASCII letters, digits, _ and -, followed by : or by : at the end of the line; no duplicate keys |
| Values | Strings on the same line: double-quoted (escapes \n, \t, \", \\, \/, \uXXXX), single-quoted ('' for a quote), or plain |
| Plain values | Must be quoted when they start with one of [, {, &, *, !, %, @, ,, ?, - or a backtick, contain : , end with :, or read as another YAML type (~, null, true, false, yes, no, on, off) |
| Comments | Lines starting with #, and # ... after a value |
| Not supported | Lists (- item), block scalars (|, >), flow collections, anchors; a key with neither a value nor children |
When in doubt, double-quote every value. Values containing %{...} start with % often enough that the generated comment asks to quote them.
ocre dev and ocre deploy read the files with the same parser before building and refuse a file the Worker could not load, naming the line and the fix. With posts: %{count} articles on line 4 of locales/fr.yml:
ocre dev
error: invalid locale files:
locales/fr.yml:4: quote values starting with `%`, e.g. "%{count} articles"
hint: fix each line named above (quote values with "..." when in doubt); `ocre i18n missing` checks them again
Translating in handlers and templates
Take i18n: ocre::i18n::I18n in a handler and pass it to the template struct (it is Copy). In templates, {{ i18n.t("key") }} writes the translation straight into the page, escaped by askama like any value; never mark it |safe.
i18n.t("hello.greeting").arg("name", value)fills%{name}(valueis anythingDisplay). A placeholder without a value stays as written.i18n.t("hello.posts").count(n)picks the plural form fornand fills%{count}.i18n.locale()is the code, for<html lang="{{ i18n.locale() }}">;i18n.codes()lists every code, default first, for a language switcher.- In Rust code,
i18n.t("posts.created").to_string()gives aString(flash messages, JSON, emails). i18n.path("/posts")is/fr/postsfor French visitors: links that keep the locale (see Links that keep the locale).
// src/hello.rs
use askama::Template;
use axum::{Router, extract::State, response::Html, routing::get};
use ocre::{Ctx, Result, i18n::I18n, render};
use crate::models::post;
#[derive(Template)]
#[template(
source = r#"<!doctype html>
<html lang="{{ i18n.locale() }}">
<title>{{ i18n.t("hello.title") }}</title>
<h1>{{ i18n.t("hello.greeting").arg("name", name) }}</h1>
<p>{{ i18n.t("hello.posts").count(posts) }}</p>
<nav>{% for code in i18n.codes() %}
<form method="post" action="/locale/{{ code }}"><button>{{ code }}</button></form>
{%- endfor %}</nav>
</html>"#,
ext = "html"
)]
struct HelloView {
i18n: I18n,
name: &'static str,
posts: i64,
}
/// `/hello` picks the locale from the cookie or `Accept-Language`;
/// `/en/hello` and `/fr/hello` take it from the path.
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/hello", get(hello))
.nest("/{locale}", Router::new().route("/hello", get(hello)))
}
async fn hello(State(ctx): State<Ctx>, i18n: I18n) -> Result<Html<String>> {
let posts = post::count(&ctx).await?;
render(&HelloView { i18n, name: "Ada", posts })
}
Register it in src/lib.rs (mod hello; under // ocre:modules, .merge(hello::routes()) under // ocre:routes, above the layer). With ocre dev running and two posts:
curl -s http://localhost:8787/hello
<!doctype html>
<html lang="en">
<title>Hello</title>
<h1>Hello Ada!</h1>
<p>2 posts</p>
<nav>
<form method="post" action="/locale/en"><button>en</button></form>
<form method="post" action="/locale/fr"><button>fr</button></form></nav>
</html>
How the locale of a request is chosen
The I18n extractor takes the first of:
- The
{locale}path segment, for routes nested with.nest("/{locale}", routes). An unknown code is a 404. - The
localecookie, when it names a known locale. Accept-Language, by quality order: an exact code first, then the same language (fr-CHmatchesfr,ptmatchespt-BR).- The default locale, the first code in
ocre::locales!.
Reading the locale only reads headers. Real runs against the handler above (showing lines 2 to 4 of each page):
curl -s -H 'Accept-Language: fr-CH,fr;q=0.9,en;q=0.8' http://localhost:8787/hello
<html lang="fr">
<title>Bonjour</title>
<h1>Bonjour Ada !</h1>
The path wins over Accept-Language:
curl -s -H 'Accept-Language: fr' http://localhost:8787/en/hello
<html lang="en">
<title>Hello</title>
<h1>Hello Ada!</h1>
The cookie wins over Accept-Language:
curl -s -H 'Cookie: locale=fr' -H 'Accept-Language: en' http://localhost:8787/hello
<html lang="fr">
<title>Bonjour</title>
<h1>Bonjour Ada !</h1>
An unknown code in the path:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8787/de/hello
404
Without any of them, the page is in the default locale (en above). In API-only apps, the extractor’s errors are JSON.
Letting visitors choose: the locale cookie
i18n.cookie() (or LOCALES.locale(code).cookie()) is the Set-Cookie value locale=<code>; Path=/; Max-Age=31536000; SameSite=Lax, remembered for a year. Send it when the visitor picks a language:
// src/locale.rs
use axum::{
Router,
extract::Path,
http::header,
response::{IntoResponse, Redirect},
routing::post,
};
use ocre::{Ctx, Error, Result};
pub fn routes() -> Router<Ctx> {
Router::new().route("/locale/{code}", post(switch))
}
/// Remembers the visitor's language for a year (`locale` cookie), then goes to /hello.
async fn switch(Path(code): Path<String>) -> Result<impl IntoResponse> {
if !crate::LOCALES.codes().any(|known| known == code) {
return Err(Error::NotFound);
}
let cookie = crate::LOCALES.locale(&code).cookie();
Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/hello")))
}
It is a POST, like any request that changes state (the forms of the /hello page above post to it; Ocre’s CSRF check covers them). Real runs:
curl -si -X POST http://localhost:8787/locale/fr
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/locale/de
HTTP/1.1 303 See Other
Location: /hello
Set-Cookie: locale=fr; Path=/; Max-Age=31536000; SameSite=Lax
...
404
LOCALES.locale(code) alone never fails: an unknown code gives the default locale, which is why the handler checks codes() first.
Plurals
A key is plural when every child is a CLDR category name: zero, one, two, few, many, other. .count(n) picks the form with the locale’s rule and fills %{count}; a zero form, when present, wins for 0; a missing form falls back to other. Negative numbers use their absolute value.
| Languages (code) | Forms ocre i18n missing requires | Rule |
|---|---|---|
| English and every language not listed below | one, other | one for 1 |
French (fr), Portuguese (pt), Hindi (hi), Persian (fa), Bengali (bn) | one, other | one for 0 and 1 |
Russian (ru), Ukrainian (uk), Belarusian (be) | one, few, many, other | one for 1, 21, 31…; few for 2-4, 22-24…; many otherwise |
Polish (pl) | one, few, many, other | one for 1 only; few for 2-4, 22-24…; many otherwise |
Czech (cs), Slovak (sk) | one, few, other | one for 1, few for 2-4 |
Arabic (ar) | zero, one, two, few, many, other | 0, 1, 2; few for 3-10 (mod 100); many for 11-99 (mod 100) |
Hebrew (he, iw) | one, two, other | 1, 2 |
Japanese (ja), Chinese (zh), Korean (ko), Vietnamese (vi), Thai (th), Indonesian (id), Malay (ms), Lao (lo), Burmese (my) | other | always other |
The rule comes from the language part of the code (pt-BR uses Portuguese). So .count(0) gives “0 posts” in English and “0 article” in French; add a zero: form (“Aucun article”) to say it differently.
Missing keys
A key missing from the request’s locale behaves differently by build:
-
Debug builds (
ocre dev, tests) showtranslation missing: fr.hello.postsin its place, so gaps are visible. Real output of/hellowithAccept-Language: frbeforehello.postswas added tofr.yml:<p>translation missing: fr.hello.posts</p> -
Release builds (
ocre deploy) fall back to the default locale’s text. A key missing from the default locale too showstranslation missing: <code>.<key>in both builds.
ocre i18n missing
ocre i18n missing checks every declared locale against the default one: keys absent, plural forms the language needs, locale files not declared in src/lib.rs, declared files that do not exist, and syntax errors. It exits with 1 when there is any problem. With hello.posts missing from fr.yml:
ocre i18n missing
error: 2 locale problems:
locales/fr.yml: missing hello.posts.one
locales/fr.yml: missing hello.posts.other
hint: add each missing key at the same path as in locales/en.yml, translated (plural keys need the forms listed); then run `ocre i18n missing` again
After adding them:
every locale (en, fr) has every key of locales/en.yml
With --json:
{"command":"i18n missing","ok":true,"ran":["every locale (en, fr) has every key of locales/en.yml"]}
Run it after adding keys to the default locale, and in CI. A plural key written as plain text in another locale counts as present. See CLI commands.
Defaults and alternative keys
Rails’ default: option is two methods of the translation:
.or_key("actions.save")tries another key when the first has no translation; chain several, tried in order..or("Save")is the text used when neither the key nor its alternatives have one. It replacestranslation missing: ...in every build (in debug builds, a key missing from the request’s locale shows this text rather than the default locale’s).%{name}placeholders in it are filled like in a translation.
i18n.exists("posts.title") tells whether a key has a translation (in the locale, the default locale or the built-in translations), for example to show an optional help text.
// src/buttons.rs
use ocre::i18n::I18n;
/// The label of a form's submit button: the form's own, else the shared one, else English.
pub fn submit_label(i18n: I18n) -> String {
i18n.t("posts.form.submit").or_key("actions.save").or("Save").to_string()
}
Scoped keys (lazy lookup)
Rails resolves t(".title") from the view’s path. In Ocre, the handler says which scope its template uses: i18n.scope("posts.index") returns the same I18n (still Copy) where keys starting with a dot are relative to that scope. Keys without a leading dot stay absolute.
// src/scoped.rs
use askama::Template;
use axum::response::Html;
use ocre::{Result, i18n::I18n, render};
#[derive(Template)]
#[template(
source = r#"<h1>{{ i18n.t(".title") }}</h1>
<a href="{{ i18n.path("/posts/new") }}">{{ i18n.t(".new") }}</a>
<footer>{{ i18n.t("app.welcome") }}</footer>"#,
ext = "html"
)]
struct IndexView {
i18n: I18n,
}
/// `.title` is `posts.index.title`, `.new` is `posts.index.new`.
pub async fn index(i18n: I18n) -> Result<Html<String>> {
render(&IndexView { i18n: i18n.scope("posts.index") })
}
A new scope(..) replaces the previous one. .or_key(".other"), exists(".key") and namespace(".group") resolve the dot the same way.
Namespaces
i18n.namespace("editor") returns every translation under a prefix as a BTreeMap from the relative key (bold, link.title) to the text, for example to hand a group of strings to JavaScript with Json(i18n.namespace("editor")). Plural keys give their other form. Only the app’s files are read; release builds add the default locale’s keys missing from the request’s locale, as t falls back to them.
HTML in translations
Rails marks keys ending in _html as safe. In Ocre, end the translation with .html(): the text of the translation is written as is, and every .arg(..) value is HTML-escaped, so user input stays text. askama writes the result without escaping it again.
en:
signup:
terms: "I accept the <a href=\"/terms\">terms of service</a>, %{name}."
<label>{{ i18n.t("signup.terms").arg("name", user.name).html() }}</label>
With user.name set to <b>Ada</b>, the page gets I accept the <a href="/terms">terms of service</a>, <b>Ada</b>.. Use .html() only for keys whose text you wrote; plain {{ i18n.t(..) }} stays escaped, and never needs |safe.
Dates, times and numbers
I18n has the localized versions of the view helpers. They take what the helpers take: Unix seconds, or the text D1 stores (2026-09-29, 2026-09-29 14:05:00), read as UTC.
| Call | English | French |
|---|---|---|
i18n.l("2026-09-29", "long") | September 29, 2026 | 29 septembre 2026 |
i18n.l("2026-09-29 14:05:00", "short") | 29 Sep 14:05 | 29 sept. 14h05 |
i18n.l(at, "%A %-d %B") | Tuesday 29 September | mardi 29 septembre |
i18n.number(1234567.5) | 1,234,567.5 | 1 234 567,5 |
i18n.number_with_precision(3.14159, 2) | 3.14 | 3,14 |
i18n.currency(1234.5, "€") | €1,234.50 | 1 234,50 € |
i18n.time_ago_in_words(post.created_at) | about 3 hours | environ 3 heures |
i18n.distance_of_time_in_words(from, to) | 2 days | 2 jours |
l(value, format): a format name is looked up indate.formats.<name>for a date without time and intime.formats.<name>otherwise (built in:default,short,long); a format containing%is a pattern with the directives ofstrftime. Month and day names come fromdate.month_names,date.abbr_month_names,date.day_names(Sunday first) anddate.abbr_day_names, written as comma-separated lists since locale files have no YAML lists;%pusestime.am/time.pm. An unknown format name showstranslation missing: fr.date.formats.<name>; a value that is not a time is returned unchanged.- Numbers use
number.format.delimiterandnumber.format.separator(French: a no-break space and,);currencylays out the unit withnumber.currency.format.format(%uthe unit,%nthe number:%u%nin English,%n %uin French). For amounts in cents, divide first:i18n.currency(cents as f64 / 100.0, "€"). time_ago_in_wordsuses Rails’datetime.distance_in_words.*keys (less_than_x_minutes,x_minutes,about_x_hours,x_days,about_x_months,x_months,about_x_years,over_x_years,almost_x_years, each with plural forms). Add “ago” with your own key:i18n.t("posts.ago").arg("time", i18n.time_ago_in_words(post.created_at)).
Any of these keys in a locale file overrides the built-in value:
fr:
date:
formats:
short: "%-d %b"
abbr_month_names: "janv,févr,mars,avr,mai,juin,juil,août,sept,oct,nov,déc"
number:
currency:
format:
format: "%n %u"
Model and attribute names
Rails’ Post.model_name.human and human_attribute_name are i18n.model_name("post", count) and i18n.attribute("post", "title"), for page titles, form labels and table headers:
fr:
models:
post:
one: "Article"
other: "Articles"
attributes:
created_at: "Créé le" # every model
post:
title: "Titre" # this model only
model_name("post", n)readsmodels.post, a text or plural forms. Without it, the humanized name (blog_postgivesBlog post) for every count: Ocre has no runtime inflector, so plurals of other languages are translation keys.attribute("post", "title")readsattributes.post.title, thenattributes.title, then humanizes the field likeFieldError::full_message(author_idgivesAuthor).
Rails nests these under activerecord.; Ocre’s models are not Active Record classes, so the keys start at models. and attributes..
Validation messages
Every Validator check records Rails’ key of its message (FieldError::key): blank, too_long, too_short, wrong_length, greater_than, greater_than_or_equal_to, less_than, less_than_or_equal_to, other_than, inclusion, exclusion, confirmation, accepted, present (absence), invalid (format, email), not_a_number, plus Ocre’s not_json, not_a_date, not_a_datetime, not_a_time, not_a_uuid and not_a_decimal. i18n.error_message("post", &error) translates the message; i18n.full_message("post", &error) adds the translated field name with errors.format (default %{attribute} %{message}). The first key found wins:
errors.models.post.attributes.title.blankerrors.models.post.blankerrors.attributes.title.blankerrors.messages.blank
looked up in the request’s locale file, then in the built-in translations of its language, then in the default locale. %{count} is the check’s bound (plural forms work: too_long: {one: ..., other: ...}), %{attribute} the translated field (for confirmation, the confirmed one) and %{model} the model name. An error added with check(..) or reworded with .message(..) keeps its text, except the English messages above (has already been taken, the uniqueness message of generated models, is taken).
fr:
errors:
models:
post:
attributes:
title:
blank: "donnez un titre à l'article"
Generated forms show error.full_message(), in English. To translate them, give the form template i18n: I18n and write the errors with the model name:
// src/form_errors.rs
use askama::Template;
use ocre::{FieldError, i18n::I18n};
/// The errors of the post form, translated.
#[derive(Template)]
#[template(
source = r#"<ul>{% for error in errors %}
<li>{{ i18n.full_message("post", error) }}</li>
{%- endfor %}</ul>
<label for="title">{{ i18n.attribute("post", "title") }}</label>"#,
ext = "html"
)]
pub struct PostFormErrors {
pub i18n: I18n,
pub errors: Vec<FieldError>,
}
For a JSON API, map the errors the same way before answering: errors.iter().map(|e| i18n.error_message("post", e)).
Built-in translations
Ocre compiles rails-i18n’s framework texts for English (en), French (fr), German (de), Spanish (es), Italian (it), Portuguese (pt, used by pt-BR) and Dutch (nl): the validation messages and errors.format, date.* and time.* names and formats, number.format.* and number.currency.format.format, and datetime.distance_in_words.*. They are Rust tables, not YAML: nothing is parsed, and they add no binding call. Every lookup (t included) goes through, in order: the locale’s file, the built-in translations of its language, the default locale’s file (release builds only for t), its built-in translations, then built-in English. So t("errors.messages.blank") works in French without a line of YAML, and any key in a locale file wins. Other languages get English until their files define the keys; ocre i18n missing does not require them.
Mailer subjects, form labels and buttons are generated code in the app, not framework strings: translate them with t as in Translating generated scaffolds.
Links that keep the locale
Rails’ default_url_options and scope "(:locale)" keep the locale in every link. In Ocre, nest the localized routes under the {locale} segment and build links with i18n.path(..):
Router::new().nest("/{locale}", posts::routes()) // /en/posts, /fr/posts
<a href="{{ i18n.path("/posts") }}">{{ i18n.t("posts.index.title") }}</a> <!-- /fr/posts -->
i18n.path("/") gives /fr. The I18n extractor of those routes reads the locale from the path (an unknown code is a 404), so the link and the page agree.
Search engines: canonical and hreflang
Each translated page tells search engines its canonical URL and where its other languages are. i18n.alternate_links(base_url, path) writes those <link> elements for the visitor’s locale: canonical (the page in this locale), one alternate per locale with its hreflang (the locale code: fr, zh-Hans), and x-default (the default locale). base_url is the app’s public origin (APP_URL, see ocre::mail::url); path is the page without its locale prefix.
<!-- templates/layout.html, in <head> -->
{{ i18n.alternate_links(base_url, page_path)|safe }}
<link rel="canonical" href="https://example.com/fr/pricing">
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing">
<link rel="alternate" hreflang="x-default" href="https://example.com/en/pricing">
i18n.alternates(base_url, path) gives the same URLs as (code, url) pairs, for a sitemap: ocre g seo writes src/seo.rs, whose /sitemap.xml lists every page of its PAGES list in every locale with these alternates, and whose /llms.txt lists them for language models (see Views: structured data, sitemap and llms.txt). Old URLs that carried the locale in the query (/pricing?locale=fr) move with a permanent redirect: a route on the old path answering Redirect::permanent(&LOCALES.locale(&query.locale).path("/pricing")) (a 308, which search engines treat like a 301).
Other locales, views and sources
- Another locale than the request’s:
i18n.in_locale("de")(Rails’locale:option) returns the sameI18nin German, keeping the scope;LOCALES.locale(code)does it without a request. The default locale is the first code ofocre::locales!; the available locales are the codes it lists (i18n.codes()), and every locale falls back to the default one, then to the built-in translations. - Localized views: Rails picks
index.fr.html.erbby file name. In Ocre, a page whose layout differs by language (not just its strings) chooses the template struct in the handler:match i18n.locale() { "fr" => render(&IndexFr { .. }), _ => render(&Index { .. }) }. - Other sources:
Catalog::loadtakes any(code, text)pairs of&'static str, so translations can come from generated Rust constants or otherinclude_str!files as well aslocales/*.yml. There is no swappable backend: translations stored in KV or D1 would cost a billed read per request (KV allows 100,000 reads a day on the free plan) and could not be checked byocre i18n missingbefore deploying.
Translating generated scaffolds
Generated scaffolds, auth pages and mailers contain English strings. To translate one, for example the posts index:
-
Move each string to
locales/en.yml(and translate it in the other files):en: posts: index: title: "Posts" new: "New post" created: "Post was successfully created." -
Add
i18n: I18nto the handler’s arguments and to its template struct (use ocre::i18n::I18n;):struct IndexView { flash: Flash, posts: Vec<Post>, i18n: I18n, } async fn index(State(ctx): State<Ctx>, flash: Flash, page: Page, i18n: I18n) -> Result<Html<String>> { render(&IndexView { flash, posts: post::all(&ctx, page).await?, i18n }) } -
Replace the text in the template:
<h1>{{ i18n.t("posts.index.title") }}</h1>,<a href="/posts/new">{{ i18n.t("posts.index.new") }}</a>. -
Flash messages are set in Rust:
session.flash("notice", i18n.t("posts.created").to_string())?;(takei18n: I18nincreatetoo). -
The generated
templates/layout.htmlhas<html lang="en">. To make it follow the request, every view that extends the layout needs ani18nfield; then write<html lang="{{ i18n.locale() }}">. -
Run
ocre i18n missing.
Validation messages come from Validator in English; i18n.full_message("post", &error) translates them (see Validation messages). Pages whose ETag must change with the language put i18n.locale() in it (see Caching).
Outside requests: mailers and jobs
Mailers, jobs and scheduled tasks have no request, so no I18n extractor. LOCALES.locale(code) gives the same I18n for a stored code, for example a locale column of the user. The match is exact and case-insensitive (fr-CH does not match fr here, unlike Accept-Language); an unknown code gives the default locale.
// src/mailers/welcome_localized.rs
use ocre::mail::Email;
/// The welcome email in the user's language; `locale` is their saved code ("fr").
pub fn welcome(to: &str, locale: &str) -> Email {
let i18n = crate::LOCALES.locale(locale);
let subject = i18n.t("mailers.welcome.subject").to_string();
let text = i18n.t("mailers.welcome.body").arg("email", to).to_string();
Email::new(to, subject, text)
}
LOCALES is the private static in src/lib.rs; child modules reach it as crate::LOCALES. In a job, store the code in the job’s arguments (or load it with the user) and call crate::LOCALES.locale(&code) in perform. See Email and Background jobs and schedules.
Cost
Translations cost no billed resource. The files are compiled into the binary; the first translation in a Worker instance parses them once with a small parser written for this subset (the README reports that the toml crate alone, parsing into a table, compiled to 205 KB of release WebAssembly, against about 420 KB for the whole blog app). Lookups are a BTreeMap search, and t(..) is written straight into the askama output without an intermediate String.
See also
- Generators:
ocre g locale. - CLI commands:
ocre i18n missing, andocre dev, which checks the files first. - Controllers and routing and Views, helpers and forms: handlers, nested routes and templates.
- Caching: the locale in ETags and
CacheControl::public. - Email and Background jobs and schedules.
ocre::i18nrustdoc.
Errors, logging and debugging
This page shows how an Ocre app logs (levels, request-scoped fields, JSON lines for Workers Logs), how errors become HTTP responses, how to report them to a service such as Sentry, what the development error page and the Server-Timing header show, how to read a deployed Worker’s logs with ocre logs, and how to debug a Worker, which has no interactive debugger.
Before you start
- An Ocre app created with
ocre new. The examples are plain handlers; add them toroutes()insrc/lib.rs. ocre devruns a debug build: the development error page,Server-Timinganddebuglog lines only exist there.ocre deploybuilds in release mode.- Reading production logs with
ocre logsneeds a deployed Worker and a wrangler login (see Live logs). - Free-plan numbers are dated September 2026; see Free-plan limits.
Logging
Every Ctx carries a logger, ctx.log(). In a request, each of its lines carries the request’s request_id, method and path, so the lines of one request can be found together; Logger::with adds fields (Rails’ tagged logging):
// src/payments.rs
use axum::extract::{Path, State};
use ocre::{Ctx, Result};
pub async fn pay(State(ctx): State<Ctx>, Path(order_id): Path<i64>) -> Result<&'static str> {
let log = ctx.log().with("order_id", order_id);
log.info("payment started");
// Formatted only when `debug` lines are on: no cost in production.
log.debug(format_args!("cart: {:?}", [3, 5]));
if order_id > 1_000_000 {
log.warn("unusually large order id");
}
Ok("OK")
}
In ocre dev the terminal shows:
INFO payment started method=POST order_id=7 path=/orders/7/pay request_id=950f7d1fe51ecfdb
DEBUG cart: [3, 5] method=POST order_id=7 path=/orders/7/pay request_id=950f7d1fe51ecfdb
After ocre deploy, the same call writes a JavaScript object, whose fields Workers Logs indexes (filter on request_id, order_id… in the dashboard):
{"level": "info", "message": "payment started", "method": "POST", "order_id": 7, "path": "/orders/7/pay", "request_id": "8c2f1a0b9d3e4f5a-CDG"}
Levels and formats
Two Worker variables configure logging. Set them in .dev.vars for ocre dev, and as bindings.text(...) entries of worker.env in cloudflare.config.ts for production:
| Variable | Values | Default in ocre dev | Default after ocre deploy |
|---|---|---|---|
LOG_LEVEL | debug, info, warn, error, off (warning, fatal accepted) | debug | info |
LOG_FORMAT | json, text | text | json |
// cloudflare.config.ts, inside worker.env
LOG_LEVEL: bindings.text("warn"),
Lines go to console.debug, console.info, console.warn or console.error, so the level also shows in the dashboard. Rails’ :fatal and :unknown are error. Code without a Ctx (a helper function, a unit test) uses ocre::log::Logger::new().
Sensitive fields
Fields whose name contains a fragment of ocre::security::FILTERED_PARAMETERS (passw, email, secret, token, _key…) are written as [FILTERED], at any depth, like Rails’ filter_parameters. The message itself is not filtered: put values in fields, not in the message.
What Ocre logs
| Line | Level | When |
|---|---|---|
GET /posts 200 in 40 ms (db: 3 queries, 12 ms), with -> /posts/7 for a redirect | debug | every request |
event order.placed {"order_id":42} with the event’s tags | info | ctx.events().notify(..) |
SQL (1.2 ms) SELECT ... with duration_ms (and failed on errors) | debug | every D1 statement through ctx.db() |
[ocre] <message> with error_class, handled, source | error | a handler returned Error::Internal (a 500) |
panicked at src/posts.rs:12:5: <message> | error | a panic, which ends the request |
[ocre jobs] ..., [ocre cron] ..., [ocre mail] ... | info / error | jobs, cron runs, mail |
Durations come from the Workers clock, which only advances while the Worker waits for I/O: they measure time spent in D1 and other bindings, not CPU time. Workers Logs shows each request’s CPU time.
Free plan
Workers Logs (observability: { enabled: true } in cloudflare.config.ts) keeps 200,000 events a day for 3 days; each request is one event, plus one per line it logs. Above that, events are sampled, never billed. Ocre’s own per-request and SQL lines are debug, so they cost nothing at the default production level info.
Structured events
Events are named facts about what the app did, for analytics or an audit trail (Rails 8.1’s Rails.event). ctx.events().notify(name, payload) logs an info line with the request’s fields (so Workers Logs can query it) and hands the event to the subscribers registered with ocre::events::subscribe:
ctx.events().set_context("tenant", "acme"); // on every event of this request
ctx.events().notify("order.placed", ocre::serde_json::json!({ "order_id": 42 }));
ctx.events().tagged("step", "payment").notify("payment.captured", ocre::serde_json::json!({ "order_id": 42 }));
A subscriber (registered in the start event of src/lib.rs) implements ocre::events::Subscriber: emit(&Event, vars) returns the HTTP request to send (Delivery), or None to skip the event. Ocre sends deliveries with fetch after the handler, like error reports; each is a subrequest (50 per request on the free plan). See ocre::events.
Errors as responses
Handlers return ocre::Result<T> (HTML pages) or ocre::ApiResult<T> (JSON). Each ocre::Error variant knows its status, and only client errors show their message:
| Error | Status | The client sees |
|---|---|---|
Error::NotFound (option.or_404()?) | 404 | Not found |
Error::bad_request("...") | 400 | its message |
Error::Unauthorized | 401 | Unauthorized |
Error::Forbidden | 403 | Forbidden |
Error::Conflict("...") | 409 | its message |
Error::PayloadTooLarge("...") | 413 | its message |
Error::Invalid(fields) (validations) | 422 | Validation failed and the field errors |
Error::TooManyRequests | 429 | Too many requests. Try again later. |
Error::internal("..."), and ? on a worker::Error | 500 | Internal server error; the message is logged and reported |
JSON errors are {"error": {"status": 404, "message": "Not found"}} (plus "fields" for a 422). For any other status, return a plain axum response: (StatusCode::IM_A_TEAPOT, Json(...)). Wrap a foreign error with a message that names the fix: .map_err(|err| Error::internal(format!("price feed unreadable ({err}). Fix: ...")))?.
Reporting errors
ctx.errors() is Rails’ Rails.error: a report is logged at once, with the request’s fields and its context, then handed to every subscriber the app registered.
// src/rates.rs
use axum::extract::State;
use ocre::{Ctx, Result, errors::{Options, Severity}};
pub async fn refresh(State(ctx): State<Ctx>) -> Result<String> {
// Added to every report of this request.
ctx.errors().set_context("feed", "ecb");
// Rails.error.handle: report the error as handled and go on with a fallback.
let rates: Vec<f64> = ctx.errors().handle(fetch_rates().await).unwrap_or_default();
// Rails.error.record: report it as unhandled, then fail the request.
ctx.errors().record(save(&rates).await)?;
// Rails.error.report, with options.
if rates.is_empty() {
let options = Options::new().severity(Severity::Info).context("day", ocre::now() / 86_400).source("rates");
ctx.errors().report(&"no exchange rates today", options);
}
Ok(format!("{} rates", rates.len()))
}
async fn fetch_rates() -> Result<Vec<f64>> {
Ok(vec![1.08, 0.86])
}
async fn save(_rates: &[f64]) -> Result<()> {
Ok(())
}
| Call | Rails | Handled | Severity |
|---|---|---|---|
ctx.errors().report(&err, Options::new()) | Rails.error.report | true (option) | warning (option) |
ctx.errors().handle(result) returns Option<T> | Rails.error.handle; .unwrap_or(x) is fallback: | true | warning |
ctx.errors().record(result) returns result | Rails.error.record | false | error |
ctx.errors().unexpected("...") | Rails.error.unexpected: panics in debug builds, reports in release builds | true | error |
ctx.errors().set_context(key, value) | Rails.error.set_context, reset for each request, job batch and cron run | ||
Options::new().except("sentry") | Rails.error.disable(subscriber) for one report |
handle and record only see errors of their Result‘s type; other errors propagate with ? first, which is Rails’ filter by exception class.
Ocre reports on its own, with handled: false: a handler’s Error::Internal (source ocre.request, context request_id, method, path), a failed job (ocre.job, with job and retried), a failed cron run (ocre.cron) and a failed inbound email handler (ocre.mailbox).
Sending reports to Sentry
ocre::errors::Sentry sends each report to Sentry, or to any service that speaks Sentry’s envelope protocol (GlitchTip, Bugsink…). Register it once per Worker instance, in the start function ocre new writes in src/lib.rs (Ocre’s initializers):
#[event(start)]
fn start() {
ocre::errors::subscribe(ocre::errors::Sentry);
}
Then give the Worker the project’s DSN as a secret:
echo "SENTRY_DSN=https://<key>@o123.ingest.sentry.io/456" >> .prod.vars
ocre secrets push SENTRY_DSN --file .prod.vars
Without SENTRY_DSN (in ocre dev, for instance) nothing is sent. The optional SENTRY_RELEASE variable sets the release. Each event carries the error (type, message, source, handled), the severity, the environment (development or production), request_id as a tag and the context, filtered like log fields, as extra data.
Deliveries happen after the handler, before the response goes out: an error response waits for them. Each report is one subrequest (the free plan allows 50 per request); a request without errors sends nothing. A failed delivery is logged as a warn line and never retried.
Other services
A subscriber turns a report into the HTTP POST to send; Ocre sends it with fetch:
// src/error_webhook.rs
use ocre::errors::{Delivery, Report, Subscriber};
/// Posts each error to a chat webhook (URL in the ERRORS_WEBHOOK_URL secret).
pub struct Webhook;
impl Subscriber for Webhook {
fn name(&self) -> &'static str {
"webhook"
}
fn deliver(&self, report: &Report, vars: &dyn Fn(&str) -> Option<String>) -> Option<Delivery> {
let url = vars("ERRORS_WEBHOOK_URL")?;
let text = format!("[{}] {}: {}", report.severity.as_str(), report.source, report.message);
let body = ocre::serde_json::json!({ "text": text }).to_string();
Some(Delivery { url, headers: vec![("content-type".into(), "application/json".into())], body })
}
}
Register it in start() with ocre::errors::subscribe(error_webhook::Webhook);.
The development error page
In ocre dev, a 500 shows the development error page instead of templates/error.html: the internal message, the request (id, method, path, query and headers, with cookies, Authorization and sensitive names filtered) and the D1 statements it ran, with their durations. A JSON error gets the message as error.detail:
{"error": {"status": 500, "message": "Internal server error", "detail": "D1 query failed: no such table: posts. SQL: SELECT ..."}}
Release builds never show internal details. Two headers help too:
X-Request-Idon every response: the id of the request’s log lines and error reports (Cloudflare’sCF-Rayid when present). Handlers get it with theocre::RequestIdextractor.Server-Timinginocre dev:db;dur=12;desc="3 queries", total;dur=40, shown by the browser’s developer tools (Network, then Timing).
Live logs: ocre logs
ocre logs streams the deployed Worker’s logs as they happen: each request, its console lines and uncaught exceptions. Ctrl-C stops it.
ocre logs # everything, readable
ocre logs --status error # failed invocations only
ocre logs --search checkout --format json # one JSON object per event
It runs the app’s wrangler tail (cf 1.0.0-beta.5 has no tail command), which uses wrangler’s own login, separate from ocre login: run npx wrangler login in the app once, or set CLOUDFLARE_API_TOKEN. Logs of the last 3 days are in the dashboard: Workers & Pages, your Worker, Logs.
Debugging a Worker
A Worker has no process to attach to, so Rails’ debug gem, binding.break and web-console have no equivalent. What works:
- Log.
ctx.log().debug(format_args!("{value:?}"))shows anyDebugvalue in theocre devterminal; nothing is written in production at the default level. - Panics are logged with their location (
panicked at src/posts.rs:12:5: ...) before the request fails. - In a template,
{{ value|json }}prints a serializable value and{{ "{:?}"|format(value) }}itsDebugform (Rails’debugandinspecthelpers). - Unit tests run natively:
cargo test(orocre test) can use a debugger such asrust-lldbor an IDE on the app’s model and helper code. - Chrome DevTools. The local dev server started by
ocre devexposes the V8 inspector (wrangler’s--inspector-port, 9229 by default): openchrome://inspectto see console output and record CPU profiles of the WebAssembly module. Breakpoints in Rust source are not practical: the module has no source maps. - Memory. Each Worker isolate has 128 MB; memory is freed when the isolate is recycled. Keep request data bounded (
LIMITqueries, streamed files) rather than hunting leaks with Valgrind-style tools.
See also
- Configuration:
LOG_LEVEL,LOG_FORMAT,SENTRY_DSN, typed settings withctx.config(). - Deployment: Workers Logs in the dashboard.
- Controllers and routing: handlers and their errors.
- API:
ocre::log,ocre::errors,ocre::Errorin the API index.
Testing an Ocre app
An Ocre app is tested in four layers: native cargo test for code that touches no Cloudflare binding, cargo check --target wasm32-unknown-unknown for the real build, request tests that talk HTTP to the app running in workerd, and browser tests (Playwright) for pages with JavaScript. Generators write the tests for what they generate; ocre test runs the first two layers, ocre test --e2e all four, against a fresh test database.
Before you start
- An Ocre app created with
ocre new(the examples useocre new blog --starter blog, whosePostmodel hastitle:string,body:textandpublished:boolean). - Rust installed with rustup; the app’s
rust-toolchain.tomladds thewasm32-unknown-unknowntarget. - Node.js 22 or newer and the app’s npm packages (
npm install, run byocre new).
What you can test, and where
| Code | Native cargo test | Request tests (ocre test --e2e) |
|---|---|---|
Pure functions, a model’s validate(), mailer functions (the Email they build), askama templates | yes | yes |
ocre::password, ocre::token, ocre::jwt::{encode_with, decode_with} | yes | yes |
| Model queries, uniqueness and reference checks, callbacks | no | yes |
Handlers with State(ctx), Session, Flash, Cookies, CurrentUser; CSRF and security headers | no | yes |
| Sending mail, jobs, crons, R2 files, KV cache, realtime broadcasts, mailboxes | no | yes |
| Pages whose behavior needs JavaScript (htmx, Trix, uploads) | no | browser tests |
Everything that reaches D1, KV, R2, Queues, Durable Objects or email goes through ocre::Ctx, which only exists inside workerd, so request tests reach it over HTTP.
The tests generators write
| File | Written by | What it holds |
|---|---|---|
tests/app.rs | ocre new | /up and the home page answer |
tests/<plural>.rs | ocre g scaffold | list, show, create, update, delete and a rejected invalid record |
tests/api_<plural>.rs | ocre g api | the same through the JSON API |
tests/factories/<model>.rs | ocre g model (and scaffold, api) | valid, unique attributes: post(), .insert(), .form(), .json() |
tests/fixtures/<table>.yml | you, or ocre db dump --dir tests/fixtures | named rows loaded into the test database before each run |
tests/system/<name>.spec.ts | ocre g system_test <name> | a browser test |
A generated request test:
// tests/posts.rs
mod factories;
use factories::post::post;
use ocre::testing::Client;
#[test]
#[ignore = "request test: run with `ocre test --e2e`"]
fn creates_a_post() {
let mut client = Client::new();
let created = client.post("/posts", &post().form());
let location = created.assert_status(303).location().unwrap_or_default().to_owned();
assert!(location.starts_with("/posts/"), "redirects to the new post: {location}");
assert_eq!(client.flash("notice").as_deref(), Some("Post was successfully created."));
client.follow_redirect(&created).assert_status(200).assert_contains("Post was successfully created.");
}
#[ignore] marks the tests that need the running app: plain cargo test (and ocre test) skips them, ocre test --e2e runs them.
Unit tests with cargo test
cargo test builds the app for your machine (not WebAssembly) and runs the #[test] functions. The worker crate and Ocre compile natively, so the whole app builds; only code that calls the Workers runtime cannot run.
Test a pure function
Keep logic that needs no binding in plain functions: they are the cheapest code to test.
// src/slug.rs (declare it with `mod slug;` under `// ocre:modules` in src/lib.rs)
/// `"Hello, Edge World!"` -> `"hello-edge-world"`: lowercase ASCII letters and
/// digits, words joined by `-`.
pub fn slugify(title: &str) -> String {
title
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::slugify;
#[test]
fn joins_lowercase_words_with_dashes() {
assert_eq!(slugify("Hello, Edge World!"), "hello-edge-world");
}
#[test]
fn drops_punctuation_and_non_ascii_letters() {
assert_eq!(slugify(" -- "), "");
assert_eq!(slugify("Crème brûlée"), "cr-me-br-l-e");
}
}
Test a model’s validations
A generated model’s validate() needs no database: it returns an ocre::Validator, and finish() gives Error::Invalid with every message. Add a test module at the end of src/models/post.rs:
// at the end of src/models/post.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_post_needs_a_title_and_a_body() {
let new = NewPost { title: " ".into(), body: String::new(), published: false };
let err = new.validate().finish().unwrap_err();
assert_eq!(err.to_string(), "invalid: Title can't be blank, Body can't be blank");
}
#[test]
fn changes_only_check_the_fields_they_set() {
assert!(PostChanges::default().validate().finish().is_ok());
}
}
The uniqueness (^) and foreign-key (references) checks run in create and update, against D1: test them end to end.
cargo test
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.16s
Running unittests src/lib.rs (target/debug/deps/blog-e620362bcd833d70)
running 4 tests
test models::post::tests::changes_only_check_the_fields_they_set ... ok
test slug::tests::drops_punctuation_and_non_ascii_letters ... ok
test models::post::tests::a_post_needs_a_title_and_a_body ... ok
test slug::tests::joins_lowercase_words_with_dashes ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The first cargo test compiles every dependency natively (19 seconds on the machine these docs were written on); later runs take a second or two.
Test async code
Handlers and Ocre’s password functions are async. The app has no async runtime outside workerd; ocre::testing::block_on (the testing feature a generated app enables in [dev-dependencies]) runs a future on the test thread. This module, at the end of src/lib.rs, tests the starter’s home and up handlers and the functions ocre g auth builds on:
// at the end of src/lib.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_page_renders() {
let Html(page) = ocre::testing::block_on(home()).unwrap();
assert!(page.contains("<h1>blog</h1>"), "{page}");
}
#[test]
fn up_answers_ok() {
assert_eq!(ocre::testing::block_on(up()), "OK");
}
#[test]
fn passwords_hash_natively() {
let digest = ocre::testing::block_on(ocre::password::hash("correct horse")).unwrap();
assert!(ocre::testing::block_on(ocre::password::verify("correct horse", &digest)).unwrap());
}
#[test]
fn jwt_round_trip() {
use ocre::jwt::{Claims, Key, decode_with, encode_with};
let key = Key::from_secret_key_base(&"x".repeat(64));
let token = encode_with(&key, &Claims::new("42", 60));
assert_eq!(decode_with(&key, &token, ocre::now()).unwrap().sub, "42");
}
}
cargo test
running 8 tests
test tests::up_answers_ok ... ok
test models::post::tests::changes_only_check_the_fields_they_set ... ok
test slug::tests::drops_punctuation_and_non_ascii_letters ... ok
test slug::tests::joins_lowercase_words_with_dashes ... ok
test tests::home_page_renders ... ok
test models::post::tests::a_post_needs_a_title_and_a_body ... ok
test tests::jwt_round_trip ... ok
test tests::passwords_hash_natively ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.99s
Natively, ocre::password computes the same PBKDF2-HMAC-SHA256 digest in pure Rust instead of WebCrypto, with 100,000 iterations. In an unoptimized test build, passwords_hash_natively (one hash, one verify) alone took 5.39 seconds; with cargo test --release passwords it took 0.11 seconds. Keep such tests few, or run them in release mode. ocre::jwt::encode and decode read SECRET_KEY_BASE from a Ctx; tests use encode_with and decode_with with a Key instead.
Type-check the WebAssembly build
cargo test compiles for your machine; the Worker is built for wasm32-unknown-unknown, where some code differs (#[cfg(target_arch = "wasm32")] in Ocre and the worker crate). Check the real target after every change:
cargo check --target wasm32-unknown-unknown
Finished `dev` profile [unoptimized + debuginfo] target(s) in 22.52s
This check does not compile #[cfg(test)] code, and cargo test does not compile for WebAssembly: run both. A function used only by tests shows up here as a dead_code warning (“function slugify is never used”) until app code calls it.
Request tests: ocre test –e2e
ocre test --e2e runs, in order and stopping at the first failure:
cargo testand the wasm32 check;- a fresh test database in
.wrangler/test-state(the development data in.wrangler/stateis untouched): migrations, thentests/fixtures/*.yml; - one server for the whole run (the app’s wrangler, as
cf devruns it) on port 8788 (--portto change it), logging to.wrangler/test-state/dev.log; cargo test -- --ignored --test-threads=1, withOCRE_TEST_URL,OCRE_TEST_STATEandOCRE_TEST_LOGset forocre::testing;tests/e2e.shwithBASE_URL, when the app has one;- the browser tests of
tests/system/, when there are some.
ocre test --e2e
ocre test --e2e -- posts # only request tests whose name contains "posts"
ocre test --e2e --json # one JSON report on stdout, tool output on stderr
{"command":"test","ok":true,"ran":["cargo test: ok","cargo check --target wasm32-unknown-unknown: ok","test database .wrangler/test-state: migrated, tests/fixtures loaded","cargo test -- --ignored against the test server on port 9555: ok","playwright test (tests/system) against the test server on port 9555: ok"]}
Tests share one database and run one at a time: create your own records (factories give unique values) and assert on them, not on global counts. There are no per-test transactions: the database belongs to the server process.
The client
ocre::testing::Client sends requests to the test server and keeps cookies like a browser (the session, its flash messages, your own cookies):
| Method | Does |
|---|---|
get, post(path, &form), post_json, patch_json, put_json, delete, request | send a request; forms are URL-encoded |
follow_redirect(&response) | GET the Location |
header(name, value), cross_site(), htmx() | extra headers (a cross-site form, an htmx request) |
session(), flash(kind), cookie(name), set_cookie(name, value) | read the decrypted session, the pending flash, a cookie |
deliveries() | emails the app sent (MAIL_ADAPTER=log), from /ocre/dev/mailers/sent.json |
broadcasts() | realtime messages sent (/ocre/dev/realtime/sent.json) |
jobs() | jobs enqueued and those the local queue ran, with done, discarded or retried (/ocre/dev/jobs.json, merged by the first ocre g job) |
receive_email(from, to, subject, body) | delivers a message to the app’s mailbox (ocre g mailbox) through the local Email Routing endpoint |
A Response has status, headers, body and the assertions assert_status, assert_success, assert_redirect_to, assert_contains, assert_not_contains and assert_header, each returning the response for chaining.
use ocre::testing::{Client, eventually};
#[test]
#[ignore = "request test: run with `ocre test --e2e`"]
fn signing_up_sends_a_welcome_email_from_a_job() {
let mut client = Client::new();
client.post("/signups", &[("email", "ada@example.com")]).assert_status(303);
assert_eq!(client.jobs().enqueued.last().unwrap().name(), Some("send_welcome"));
// Local queues deliver within a few seconds.
eventually(|| client.jobs().performed.iter().any(|run| run.job == "send_welcome" && run.outcome == "done").then_some(()));
assert_eq!(client.deliveries().last().unwrap().to, ["ada@example.com"]);
}
Test data: factories and fixtures
A factory builds valid attributes, unique per call, and writes them with one call to the test database:
let id = post().insert(); // a row
let draft = factories::post::Post { published: false, ..post() }.insert(); // with changes
client.post("/posts", &post().form()); // as a form
Factories of models with references create the parent first (with_parents). Fixtures are named rows for data every test can rely on, in Rails’ format:
# tests/fixtures/posts.yml
DEFAULTS: &defaults
published: true
hello:
<<: *defaults
title: Hello $LABEL # $LABEL is the row's label
author: ada # author_id = id of the fixture labelled `ada` in users.yml
Read them with ocre::testing::fixture_id("hello") and fixture("posts", "hello"). ocre::testing::sql(query), count(table) and insert(table, values) reach the test database directly.
Assertions, the server log and time
assert_difference(|| count("posts"), 1, || { ... }),assert_no_difference,assert_changes,assert_no_changes: Rails’ names.Log::mark()thenlog.wait_for("[ocre jobs] send_welcome done"): lines the server printed since the mark.eventually(|| ...): retries a check for a few seconds (queues, broadcasts).travel_to(unix),travel(seconds),freeze_time(),travel_back():ocre::now()in the test process (native code under test, such as token expiry).redact(text): replaces ids, dates and tokens, for stable snapshots.
A shell script: tests/e2e.sh
Checks that are easier with curl go in tests/e2e.sh, run with BASE_URL set:
#!/bin/sh
set -eu
status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
[ "$(status "$BASE_URL/up")" = 200 ]
[ "$(status -X POST "$BASE_URL/posts" -H 'Sec-Fetch-Site: cross-site' -d 'title=x&body=y')" = 403 ]
Browser tests: ocre g system_test
Pages that need JavaScript (htmx swaps, the rich text editor, direct uploads) are tested in a real browser with Playwright, Rails’ system tests:
ocre g system_test creating_a_post
npm install # @playwright/test, added to package.json
npx playwright install chromium # once per machine
The first run writes playwright.config.ts: tests in tests/system/, the base URL from BASE_URL, one worker (the database is shared), each test on a desktop and a phone screen, and a screenshot and trace of each failure in test-results/ (git-ignored). Edit the generated test:
// tests/system/creating_a_post.spec.ts
import { expect, test } from "@playwright/test";
test("Creating a post", async ({ page }) => {
await page.goto("/posts/new");
await page.getByLabel("Title").fill("Hello from Playwright");
await page.getByRole("button", { name: /create|save/i }).click();
await expect(page.getByText("Post was successfully created.")).toBeVisible();
});
ocre test --e2e runs them after the request tests, with node_modules/.bin/playwright test. Assertions wait for the page, so no sleeps are needed. Other browsers are more projects in the config (devices["Desktop Firefox"], then npx playwright install firefox). Against ocre dev instead: BASE_URL=http://localhost:8787 npx playwright test --ui.
Errors: tests/system has tests but Playwright is not installed (run npm install), and playwright test failed (exit status: 1) with a hint pointing to test-results/.
Manual checks against ocre dev
ocre dev runs the app with the development data. ocre sql "SELECT ..." reads the same database while it runs, ocre db reset (with ocre dev stopped) recreates it from the migrations and db/seeds.sql, and the development pages show what has no HTTP answer: /ocre/dev/mailers (emails), /ocre/dev/mailbox (deliver an email), /ocre/dev/jobs.json (jobs), /ocre/dev/realtime/sent.json (broadcasts).
CI
ocre ci runs cargo fmt --check, clippy with -D warnings, cargo test, the wasm32 check and ocre i18n missing; ocre g ci writes the same steps as a GitHub Actions workflow. Generators format the Rust they write with the app’s rustfmt.toml, so a freshly generated app passes. Add ocre test --e2e to the workflow to run the request and browser tests too (the runner needs Node.js and, for browser tests, npx playwright install --with-deps chromium).
Reference
- CLI commands:
ocre test,ocre ci,ocre db dump,ocre sql - Generators: factories,
ocre g system_test - Email, Background jobs and schedules, Realtime: what the captures list
- The
ocre::testingrustdoc
Deployment
The ocre deploy command builds the app in release mode, creates the Cloudflare resources it is missing, applies the D1 migrations and publishes the Worker on workers.dev. This page explains each step, how to set production variables and secrets, run commands on the production database, roll back, add a custom domain, read logs and deploy from CI.
Before you start
- An Ocre app created with
ocre new, with its npm packages installed (ocre newrunsnpm install; after--no-installor in a fresh clone, runnpm installin the app). - Rust installed with rustup (the app’s
rust-toolchain.tomladds thewasm32-unknown-unknowntarget) and Node.js 22 or newer (Cloudflare’scfCLI needs it). - A Cloudflare account; the free plan is enough. Sign up at https://dash.cloudflare.com/sign-up.
- If the app has
attachmentfields (aSTORAGE: bindings.r2(...)entry incloudflare.config.ts): R2 enabled once in the dashboard (Storage & databases > R2). Cloudflare asks for a payment method even for the free tier (R2 pricing, September 2026). - Either a browser login (
ocre login, below) or an API token inCLOUDFLARE_API_TOKEN(see Deploy from CI).
Log in to Cloudflare
ocre login
ocre login runs cf auth whoami; when you are not logged in, it runs cf auth login, which opens the browser to approve access (OAuth), then checks again. It prints Logged in to Cloudflare as <your email> (with --json: {"command":"login","email":"<your email>","ok":true}). cf keeps the login, so this is needed once per machine.
cf keeps its own login, separate from wrangler’s. If you deployed with an earlier Ocre (which ran wrangler), log in once again after upgrading; ocre doctor reminds you when it finds a wrangler login but no cf one.
Several Cloudflare accounts
When your login has access to several accounts, cf needs to know which one to deploy to:
- New app:
ocre new blog --account-id <id> --login(or--deploy) checks that the login can use that account and writesaccountId: "<id>",at the top ofcloudflare.config.ts, beforeworker:. Without--account-id,ocre new --loginfails withthis Cloudflare login has several accountsand a hint listing them asid (name). - Existing app: add
accountId: "<id>",insidedefineConfig({ ... })yourself, or setCLOUDFLARE_ACCOUNT_IDin the environment.npx cf auth whoamilists the accounts of your login. - Several logins on one machine (a personal and a work account): cf has named profiles.
npx cf auth create <profile>adds one, andnpx cf auth activate <profile> .in the app directory binds it to that app, so everyocrecommand run there uses it.
Deploy
ocre deploy
Run it from the app directory (or any directory below it). It takes the same steps on every deploy, stops at the first failure with an error and a hint naming the fix, and never deploys half-provisioned. Ocre creates every resource itself before cf deploy runs:
flowchart TD
A[Check locale files, the wasm32 target and node_modules] --> B[Create the D1 database if missing]
B --> C[Create missing queues]
C --> D[Create missing R2 buckets]
D --> E[Create or link KV namespaces without an id]
E --> F{Worker has SECRET_KEY_BASE?}
F -- no --> G[Take the one of .prod.vars, or generate one]
F -- yes --> H[Apply remote migrations]
G --> H
H --> I["cf deploy --secrets-file (the new secret, or {})"]
- Checks. Locale files the Worker could not load (
locales/*.yml) stop the deploy with the file, the line and the fix (see Translations). Then it checks thatrustchas thewasm32-unknown-unknowntarget (if not:the wasm32-unknown-unknown target is not installed for rustc at <sysroot>, with the hint to use a rustup toolchain), and thatnode_moduleshas the app’scfandwrangler(if not:the app's npm packages are not installed ..., with the hint to runnpm install). - Database. It looks for the
DBdatabase (DB: bindings.d1({ name: "blog" })) withcf d1 list --name blogand creates it withcf d1 create --name blogwhen missing. The config needs no database id: Ocre resolves the name to the id each time. - Queues. For every queue
cloudflare.config.tsnames (bindings.queueandtriggers.queuenames, and eachdeadLetterQueue, written by the firstocre g job), it checkscf queues listand runscf queues create --queue-name <name>for the missing ones. A consumer of a missing queue would fail the deploy. - R2 buckets. Each
bindings.r2({ name })(<app>-storage, written by the firstattachmentfield) is checked withcf r2 buckets getand created withcf r2 buckets createwhen missing. An account without R2 gets Cloudflare’s API error 10042, and the deploy stops with this hint: “enable R2 once in the Cloudflare dashboard (Storage & databases > R2; the free plan asks for a payment method but charges nothing within 10 GB, 1M writes and 10M reads a month), then runocre deployagain”. - KV namespaces. Each
bindings.kv()entry without anid(theCACHEbinding ofocre g cache) gets the namespace titled<worker name>-<binding>, lowercased with_as-(blog-cache). Ifcf kv namespaces listalready has it, it is linked; otherwisecf kv namespaces createmakes it. Either wayocre deployrewrites the entry toCACHE: bindings.kv({ id: "<id>" }),incloudflare.config.ts: commit that change. SECRET_KEY_BASE. It runscf workers secrets list --worker <name>. When the Worker has noSECRET_KEY_BASE(or does not exist yet), it takes the one of.prod.vars, or else generates a random one (128 hex characters, likeocre secret) and, once the deploy succeeded, appends it to.prod.vars(git-ignored, readable by you only) with a comment: Cloudflare never gives a secret back, and losing it signs everyone out and makes encrypted columns unreadable, so back that file up. Every deploy, with or without a new secret, writes.wrangler/ocre-secrets.json(readable by you only, git-ignored), holding{"SECRET_KEY_BASE": ...}or{}, passes it tocf deploy --secrets-file, and deletes it afterwards, even when the deploy fails: cf keeps a Worker’s existing secrets (including those ofocre secrets push) only when--secrets-fileis passed; deployed without it, the new version has none (cf 1.0.0-beta.5; cf rejects an empty.envfile, hence JSON). An existing secret is never replaced: that would sign every user out. If the secrets list fails for any other reason, the deploy stops rather than risk overwriting it.- Migrations.
cf d1 migrations apply <id>on the production database, before the new code goes live, so it never runs against an old schema. On the first deploy the database is new and gets every migration. - Build and upload.
cf deploy, withOCRE_BUILD=--release. cf delegates the build to the app’s wrangler, which runs thebuild.commandofwrangler.config.ts,worker-build --release(installingworker-build0.8 withcargo installif needed): an optimized WebAssembly build passed throughwasm-opt. Durable Objects (theCHANNELSnamespace of realtime apps) are created by this step from theexportsofcloudflare.config.ts; nothing else to provision. - URL. The report shows the first
https://...workers.devaddress cf printed.
ocre deploy does not load db/seeds.sql into production; run ocre db seed --remote if you want it there.
Why wrangler still appears
Ocre drives Cloudflare’s cf CLI, yet each app also has wrangler in its package.json, and you will see wrangler in some output. Two reasons, both on purpose:
- cf delegates builds to the app’s wrangler.
cf dev,cf buildand the build step ofcf deployhand the build (and, forcf dev, the local server) to the app’s wrangler (node_modules/wrangler, 4.136 or newer), which readswrangler.config.ts. The local server’s lines ([wrangler:info] Ready on http://localhost:8787,[custom build] ...) come from it. - Local database commands run the app’s wrangler. cf 1.0.0-beta.5’s local D1 addresses databases only by UUID (while the dev server keys the local database by its binding name), keeps its state outside the app, and its local writes do not exit. So
ocre migrate,ocre migrate --status,ocre db create/seed/reset/prepare/truncate/version/schema,ocre sql,ocre test --e2eandocre doctor’s migration check runnode_modules/.bin/wrangler d1 ... DB --localwith a config Ocre derives fromcloudflare.config.ts(.wrangler/ocre-d1.json) and the same.wrangler/stateascf dev. Their--remoteforms use cf.
You never run wrangler yourself, and it needs no login of its own. Ocre will drop the local fallback once cf’s local D1 can replace it (see the roadmap).
What it prints
cf’s output streams as it runs, then ocre deploy summarizes what it created and the URL. With a new app that has a job, a cache and an attachment, the end looks like this (the lines come from the CLI’s code and integration tests; these docs did not run a real deploy):
...
Created the SECRET_KEY_BASE secret on Cloudflare
Saved it in .prod.vars (git-ignored): back it up, Cloudflare never gives it back
Created D1 database blog on Cloudflare
Created queue blog-jobs on Cloudflare
Created queue blog-jobs-failed on Cloudflare
Created R2 bucket blog-storage on Cloudflare
Created KV namespace blog-cache (id written to cloudflare.config.ts) on Cloudflare
https://blog.<your-subdomain>.workers.dev
Later deploys print only the URL after cf’s output: everything exists.
JSON output
With --json, the output of cf and wrangler goes to stderr and stdout carries one JSON object; the command never prompts and exits with 0 on success, 1 on failure:
{"command":"deploy","ok":true,"provisioned":["D1 database blog","queue blog-jobs","queue blog-jobs-failed","R2 bucket blog-storage","KV namespace blog-cache (id written to cloudflare.config.ts)"],"secret_created":true,"url":"https://blog.<your-subdomain>.workers.dev"}
| Key | Present | Meaning |
|---|---|---|
ok, command | always | true, "deploy" |
url | when cf printed a workers.dev URL | The deployed URL. Absent when the Worker is only on a custom domain (workersDev: false) |
secret_created | only when true | A SECRET_KEY_BASE was uploaded with this deploy. The secret itself is never printed |
secret_saved | only when set | ".prod.vars": the generated secret was written there |
provisioned | only when not empty | Resources created because they were missing: D1 database <name>, queue <name>, R2 bucket <name>, KV namespace <title> (id written to cloudflare.config.ts) |
A failure is {"ok": false, "error": "...", "hint": "..."}, for example when not logged in:
{"error":"`cf d1 list --name blog` failed: ┌ Error ...","hint":"log in with `ocre login`, or set CLOUDFLARE_API_TOKEN (and CLOUDFLARE_ACCOUNT_ID when the token sees several accounts)","ok":false}
When a cf command itself fails, error is `cf <args>` failed (exit status: 1) and the cause is in cf’s output on stderr.
Check the build size
The Worker is one WebAssembly module plus a small JavaScript shim. To see its size without uploading anything, build it in release mode like ocre deploy does (no login needed), then look at build/index_bg.wasm:
npx cf build
ls -l build/index_bg.wasm
npx cf deploy --dry-run also builds and validates the whole deploy without uploading. For the blog starter (ocre new blog --starter blog), in September 2026 (a wrangler dry run of the time):
[custom build] Finished `release` profile [optimized] target(s) in 46.90s
[custom build] [INFO]: Optimizing wasm binaries with `wasm-opt`...
...
[custom build] index.js 25.4kb
...
Total Upload: 790.66 KiB / gzip: 238.60 KiB
build/index_bg.wasm was 766,215 bytes. The limit that applies is the uncompressed size, 64 MiB on both the Free and Paid plans, and a Worker must start within 1 second (Workers limits, September 2026). Size matters more for start-up CPU than for the limit: the graphql feature (ocre g api ... --graphql) adds about 1.1 MB and 20-60 ms of CPU when a new Worker instance starts.
Production variables and secrets
.dev.vars is for ocre dev only; it is never uploaded. Production reads plain variables from the bindings.text(...) entries of cloudflare.config.ts (deployed with the code) and secrets from Cloudflare (uploaded with ocre secrets push from a git-ignored file, never committed):
| Name | Kind | Set it with | Needed for |
|---|---|---|---|
SECRET_KEY_BASE | secret | ocre deploy (first deploy) | Sessions, flash, CSRF-protected forms, JWTs |
MAIL_FROM | var | MAIL_FROM: bindings.text(...), written by ocre new with a placeholder | Sending email: an address on your domain |
MAIL_ADAPTER | var | MAIL_ADAPTER: bindings.text("resend") or "cloudflare" | Sending email at all: unset, ocre::mail::send fails with a 500 whose log names the fix |
RESEND_API_KEY | secret | ocre secrets push RESEND_API_KEY --file .prod.vars | MAIL_ADAPTER = "resend" |
ALLOWED_ORIGINS | var | ALLOWED_ORIGINS: bindings.text("https://app.example.com") | A frontend on another origin calling the app from the browser (CORS, CSRF) |
ALLOWED_HOSTS | var | ALLOWED_HOSTS: bindings.text("example.com, .example.com") | Answering only on your own host names (other hosts get 403) |
SECRET_KEY_BASE_PREVIOUS | secret | ocre secrets push SECRET_KEY_BASE_PREVIOUS --file .prod.vars | Rotating SECRET_KEY_BASE without signing anyone out |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET (or GOOGLE_...) | secret | ocre secrets push <NAME>... --file .prod.vars | ocre g auth --oauth github (or google) |
// cloudflare.config.ts, in worker.env
MAIL_FROM: bindings.text("Blog <noreply@example.com>"),
MAIL_ADAPTER: bindings.text("resend"),
ALLOWED_ORIGINS: bindings.text("https://app.example.com, https://admin.example.com"),
Secret values go in .prod.vars, one NAME=value per line; the app’s .gitignore lists it, so it is never committed:
# .prod.vars
RESEND_API_KEY=re_...
ocre secrets push RESEND_API_KEY --file .prod.vars # one upload; the Worker gets it at once
ocre secrets list # names locally and on the Worker, never values
ocre deploy # deploys the variable changes
The value is read from the file, so it never appears in your shell history or process list. ocre secrets push uploads several names in one call (cf workers secrets bulk, a new Worker version without a rebuild); the Worker must exist, so push after the first ocre deploy.
Without MAIL_ADAPTER, everything that sends email fails in production, including the sign-up, magic-link and password-reset pages of ocre g auth. Details and free-plan limits of each adapter: Email; every variable: Configuration.
ocre doctor checks this setup before a deploy (Loco’s production safety check): its production config check fails when a plain-text variable of worker.env is named like a secret (..._TOKEN, ..._API_KEY, ..._PASSWORD…) or when .gitignore lacks .dev.vars, and warns on development values left in worker.env (MAIL_ADAPTER = "log", CACHE_STORE = "null", LOG_LEVEL = "debug"). A missing secret is not a boot failure on Workers: the request that needs it answers 500 and Workers Logs names the secret ([ocre] line), and ocre doctor’s production secrets check lists the missing ones ahead of time. Your own checks go in .ocre/doctor/ as executables (see ocre doctor).
Rotating SECRET_KEY_BASE
ocre deploy never changes an existing SECRET_KEY_BASE. To rotate it without signing anyone out, upload the current value as SECRET_KEY_BASE_PREVIOUS together with a new one (Worker secrets cannot be read back, so use the value you saved). In .prod.vars:
SECRET_KEY_BASE_PREVIOUS=<the current SECRET_KEY_BASE>
SECRET_KEY_BASE=<a new value from `ocre secret`>
ocre secrets push SECRET_KEY_BASE_PREVIOUS SECRET_KEY_BASE --file .prod.vars
Cookies encrypted with the old value are read and re-encrypted with the new one, and JWTs signed with it verify until they expire. Delete SECRET_KEY_BASE_PREVIOUS (dashboard: Workers & Pages > your Worker > Settings > Variables and Secrets, or npx cf workers secrets delete SECRET_KEY_BASE_PREVIOUS --worker <app> --force; without --force cf prints “Aborted.” and deletes nothing) after the longest session lifetime (two weeks with ocre g auth). Rotating without it signs every user out and stops every JWT at once, which is what you want after a leak. API keys and emailed links are stored as digests in D1 and keep working either way. See Configuration and Security model.
Commands on the production database
The database commands target the local database unless you pass --remote:
ocre migrate --status --remote # pending migrations in production
ocre migrate --remote # apply them without deploying code
ocre db seed --remote # run db/seeds.sql in production
ocre sql "SELECT COUNT(*) AS n FROM posts" --remote
ocre migrate --status --remote, ocre db seed --remote and ocre sql --remote print Target: remote D1 database on Cloudflare in human mode, and their --json report has "remote": true. ocre db reset has no --remote flag: it only deletes the local database. Every remote query counts against D1’s daily free-plan quotas (Free-plan limits).
Migrations, deploys and compatibility
For an existing database, ocre deploy applies migrations before the new code goes live, so for a moment the old code runs against the new schema. Write migrations the running code survives:
- Add columns as optional (
field:type?) or with a default, then use them in the same deploy. - Remove or rename a column in two deploys: first deploy code that no longer uses it, then the migration that drops it.
- If a migration fails, D1 rolls that migration back and
ocre deploystops beforecf deploy: the previous code keeps running on the previous schema.
Never edit an applied migration; add a new one with ocre g migration (see Models and migrations).
Roll back
Each deploy creates a new version of the Worker. cf lists them and deploys an older one again (a rollback is a deployment of a previous version at 100%):
npx cf workers deployments list --worker blog # recent deployments
npx cf workers versions list --worker-id blog # recent versions, with their ids
npx cf workers deployments create --worker blog --strategy percentage \
--versions '[{"version_id":"<version-id>","percentage":100}]'
The dashboard does the same (Workers & Pages > your Worker > Deployments > Rollback). npx cf <command> --help and npx cf schema <command> show every option.
A rollback changes the code only (Rollbacks, September 2026):
- Bound resources are not changed: the D1 schema stays migrated. The older code must work with the newer schema, which the rules of the previous section give you.
- You can roll back to the 100 most recent versions only.
- Cloudflare refuses a rollback across a Durable Object class migration, such as the one that created the
OcreChannelclass of the firstocre g scaffold ... --realtime, and to a version bound to an R2 bucket, KV namespace or queue that no longer exists.
D1 has no down migrations. To undo a schema change, write a new migration. To undo data changes (a bad backfill, a DELETE without WHERE), D1 Time Travel restores the whole database to a minute in the past, up to 7 days back on the Free plan (Time Travel, September 2026). It overwrites everything written since:
npx cf d1 list --name blog # the database's uuid
npx cf d1 time-travel get-bookmark <uuid> # current bookmark
npx cf d1 time-travel restore <uuid> --timestamp 2026-09-29T10:00:00Z
To delete a production database on purpose, use the dashboard or npx cf d1 delete <uuid> --force (without --force cf prints “Aborted.” and deletes nothing); Ocre itself never deletes production data.
Custom domains
The first deploy publishes the app on https://<app>.<your-subdomain>.workers.dev. To serve it on your own domain, the domain must be an active zone in the same Cloudflare account; then add it to worker in cloudflare.config.ts:
worker: {
name: "blog",
domains: ["blog.example.com"],
// ...
},
and run ocre deploy. ocre domains add blog.example.com writes that entry (and ocre domains remove takes one out; see ocre domains). Cloudflare creates the DNS record and the certificate (Custom Domains). The workers.dev address keeps working unless you add workersDev: false; ocre deploy then reports no url. (domains and workersDev are keys of cf’s config types, and cf’s loader accepts the entry ocre domains writes; this page did not deploy one.)
A custom domain changes a few things in an Ocre app:
- Links in emails from
ocre g authuse the host of the request, so they follow whichever domain the user came from. - Cloudflare WAF rate limiting rules (next section) apply to zones, so they need a custom domain.
ALLOWED_HOSTS(see Configuration) can then keep visitors off theworkers.devaddress: requests for unlisted hosts get 403.- The Cache API (
caches.default) only stores responses on custom domains (see Caching).
Before going public: rate limiting
ocre g auth limits its login, sign-up, magic-link, password-reset, confirmation, token and account-deletion routes to 10 attempts a minute per client IP address and action: the generated throttle calls ocre::security::rate_limit against the AUTH_RATE_LIMITER Workers Rate Limiting binding it adds to cloudflare.config.ts, and over the limit the answer is 429 Too Many Requests. The binding works on workers.dev and on custom domains, is on the free plan, and uses no D1 or KV operation. Its counters are per Cloudflare location and approximate, so treat it as a brake, not an exact quota. Before going public, check that:
- the
AUTH_RATE_LIMITER: bindings.rateLimit(...)entry is still incloudflare.config.ts(without it, those routes answer 500 and the log names the entry to add), andlimitandperiod(10 or 60 seconds) suit you; - your own routes that are expensive or send email call
ocre::security::rate_limitwith a binding of their own (see Sessions, flash and security).
With a custom domain, a Cloudflare WAF rate limiting rule can add a limit before the Worker runs, so blocked requests cost no Worker request. On the Free plan (September 2026): one rule, matching on the URI path, counting requests per IP address over 10 seconds, blocking for 10 seconds.
Logs
Apps made by ocre new have observability: { enabled: true } in cloudflare.config.ts, which keeps every request and log line in Workers Logs, searchable in the dashboard (Workers & Pages > your Worker > Logs, with a live view); search for [ocre to see Ocre’s own lines. The free plan keeps 200,000 events a day for 3 days (see Configuration). From a terminal, npx cf observability telemetry query --help queries the same logs (npx cf cli search "query worker logs" finds related commands).
ocre logs streams the deployed Worker’s logs live (--status error, --search <text>, --format json), and ctx.log() writes structured lines whose fields Workers Logs indexes; to send errors to Sentry, see Errors, logging and debugging.
Ocre never shows internal errors to users: a 500 page or JSON error says Internal server error, and the details go to the log with a prefix:
| Prefix | Written by |
|---|---|
[ocre] | Any Error::Internal: missing binding or secret, failed D1 query (with its SQL), invalid digest… |
[ocre jobs] | Background jobs: <job> done, failures and retries, dropped message |
[ocre cron] | Scheduled tasks, including failed runs (not retried) |
[ocre mail] | The log mail adapter (development) |
[ocre cache] | KV failures, after which ocre::cache::fetch computes the value |
[ocre realtime] | Failed broadcasts |
CI
ocre deploy never needs ocre login in CI: cf authenticates with the CLOUDFLARE_API_TOKEN environment variable instead of a browser login, and takes the account from CLOUDFLARE_ACCOUNT_ID when the token can see several accounts (cf checks the token before any login or profile). ocre deploy runs cf with the environment it was given, and the CLI’s hints name both variables when a Cloudflare call fails.
ocre g ci
writes .github/workflows/ci.yml (see ocre g ci): a check job on every push and pull request runs the steps of ocre ci (cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test, the wasm32 check, and ocre i18n missing when the app has locales), then a deploy job runs ocre deploy --json on pushes to main. ocre ci runs the same checks on your machine first (Rails 8.1’s local CI; ocre ci --signoff then marks the commit green with gh signoff). To set it up:
- Create a token in the dashboard (My Profile > API Tokens) that can edit what
ocre deploytouches: Account permissions “Workers Scripts: Edit” and “D1: Edit”, plus “Queues: Edit”, “Workers KV Storage: Edit” and “Workers R2 Storage: Edit” when the app uses them, and the zone permission “Workers Routes: Edit” for custom domains. - Store it as the repository secret
CLOUDFLARE_API_TOKEN, and your account id asCLOUDFLARE_ACCOUNT_ID(needed when the token sees several accounts) in Settings > Secrets and variables > Actions. - Run the first deploy locally (or commit afterwards) so the KV
idthatocre deploywrites intocloudflare.config.tsis committed. A CI deploy without it finds the namespace by title and links it again, in its own checkout. - Commit
package.jsonandpackage-lock.json; the deploy job installs the pinnedcfandwranglerwithnpm ci.
The workflow installs the CLI with cargo install --git https://github.com/tgeselle/ocre.rs ocre-cli and caches Rust builds with Swatinem/rust-cache. It is yours to edit; --force rewrites it from the generator.
On a developer machine with several Cloudflare logins, a cf profile does the same job as the token: npx cf auth activate <profile> . binds one to the app directory.
See also
- CLI commands:
ocre deploy,ocre login,ocre migrate,ocre secret - Configuration:
SECRET_KEY_BASE,MAIL_ADAPTER,ALLOWED_ORIGINS - Free-plan limits and Cost model
- Security model: what
SECRET_KEY_BASEprotects, and what Ocre leaves to rate limiting - Testing an Ocre app: checks to run before deploying
Upgrading from wrangler.toml
Earlier versions of Ocre generated apps configured by a wrangler.toml and ran npx wrangler for everything. Ocre now drives Cloudflare’s cf CLI and reads cloudflare.config.ts. This page converts an existing app, step by step: Cloudflare’s own converter (cf migrate) does most of it, then a few Ocre-specific fixes finish the job.
Until an app is converted, every ocre command run in it stops with:
error: /path/to/blog uses wrangler.toml; Ocre now reads cloudflare.config.ts
hint: convert it: `npm install --save-dev --save-exact cf@1.0.0-beta.5 wrangler@4.144.0`, then `npx cf migrate --no-install`, then apply the Ocre fixes of the upgrading guide (<docs>/guides/upgrading.html)
Before you start
- Node.js 22 or newer (
node --version): cf needs it. - The new
ocreCLI installed (cargo install --git https://github.com/tgeselle/ocre.rs ocre-cli). - The app in git with a clean worktree:
cf migraterefuses to run otherwise (or pass--force). Commit or stash first, so every change below shows up in one diff. - About 15 minutes. Nothing here touches Cloudflare until the last step.
1. Install the pinned packages
In the app directory:
npm install --save-dev --save-exact cf@1.0.0-beta.5 wrangler@4.144.0 typescript@5.9.3
This creates package.json (if the app had none), package-lock.json and node_modules/. Then edit package.json so it reads like the one ocre new writes, with "type": "module" (without it, every cf call warns while loading cloudflare.config.ts):
{
"name": "blog",
"private": true,
"type": "module",
"devDependencies": {
"cf": "1.0.0-beta.5",
"typescript": "5.9.3",
"wrangler": "4.144.0"
}
}
The versions must stay exact (no ^): Ocre is tested with these, and ocre doctor warns when the installed ones differ.
2. Run cf migrate
npx cf migrate --no-install
It reads wrangler.toml and writes two files next to it, cloudflare.config.ts (bindings, triggers, variables) and wrangler.config.ts (the build command and the public/ assets directory), and leaves wrangler.toml in place. On an Ocre app it exits with code 1 and says the migration “requires follow-up work”: that is expected, the next section is the follow-up.
wrangler.config.ts needs no change. It should read:
import { defineWranglerConfig } from "wrangler/experimental-config";
export default defineWranglerConfig({
build: { command: 'cargo install -q "worker-build@^0.8" && worker-build ${OCRE_BUILD:---release}' },
types: { generate: false },
assetsDirectory: "public",
});
3. Finish cloudflare.config.ts
cf converts most entries: crons become triggers.scheduled(...), the queue consumer becomes triggers.queue({ name, deadLetterQueue, maxBatchSize, maxBatchTimeout, maxRetries }), [vars] become bindings.text(...), and the D1, KV, R2, queue, rate-limiting and email bindings get their bindings.* form. Then fix these:
-
Remove the stop sign. Delete the
throw new Error("Migration incomplete…")line cf put in the file, and its TODO comments once each is dealt with below. -
migrations_dir. cf flags it as “requires manual migration”. Drop it: there is no such key, and cf’s default directory,./migrations, is where Ocre keeps migrations. -
Durable Object binding (realtime apps). cf flags it for review. Keep what it generated,
CHANNELS: bindings.durableObject({ worker: "blog", exportName: "OcreChannel" }),(with your app name asworker; the key is required). -
[[migrations]](realtime apps). cf reports it as unsupported. Replace it with the export of the class, insideworker:exports: { // ocre:exports OcreChannel: exports.durableObject({ storage: "sqlite" }), }, -
Markers. Ocre’s generators add entries after three marker comments. Put each on its own line:
// ocre:envinsideworker.env,// ocre:triggersinsideworker.triggers(addtriggers: [ ... ],if the app has none yet) and// ocre:exportsinsideworker.exports(addexports: { ... },if needed). The import line must bring all four names:import { bindings, defineConfig, exports, triggers } from "cf/config";. -
Literals. Ocre reads the file without running it, so the values it needs (the worker
name,DB’sname, queue, bucket and cron values, KV ids) must be plain string or number literals, as cf writes them. See Configuration.
A converted blog with a job, a cache and realtime looks like this (comments and your own variables may differ):
import { bindings, defineConfig, exports, triggers } from "cf/config";
export default defineConfig({
worker: {
name: "blog",
compatibilityDate: "2026-09-01",
entrypoint: "build/index.js",
observability: { enabled: true },
env: {
DB: bindings.d1({ name: "blog" }),
MAIL_FROM: bindings.text("blog <noreply@example.com>"),
// ocre:env
JOBS: bindings.queue({ name: "blog-jobs" }),
CACHE: bindings.kv({ id: "0f2a..." }),
CHANNELS: bindings.durableObject({ worker: "blog", exportName: "OcreChannel" }),
},
triggers: [
// ocre:triggers
triggers.queue({ name: "blog-jobs", deadLetterQueue: "blog-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }),
triggers.scheduled({ schedule: "0 3 * * *" }),
],
exports: {
// ocre:exports
OcreChannel: exports.durableObject({ storage: "sqlite" }),
},
},
});
Compare with the file a new app gets (Configuration) if something looks off; ocre new scratch --no-install in a temporary directory writes a fresh one to copy from, along with an AGENTS.md that no longer mentions wrangler.
4. Add tsconfig.json and update .gitignore
Create tsconfig.json, so editors and tsc resolve cf/config:
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["cloudflare.config.ts", "wrangler.config.ts"]
}
Make sure .gitignore lists:
node_modules/
.wrangler/
.cloudflare/
.dev.vars
.dev.vars.*
.prod.vars
.env
.env.*
.cloudflare/ is cf’s build output; .env* files may hold cf credentials (CLOUDFLARE_API_TOKEN). Commit package.json and package-lock.json.
Then check the config:
npx tsc -p .
It prints nothing when the file is right, and names the line of any wrong value (period: 30 on a rate limiter: Type '30' is not assignable to type '10 | 60').
5. Delete wrangler.toml
git rm wrangler.toml
From now on cloudflare.config.ts is the only Cloudflare configuration. .dev.vars needs no change: ocre dev still reads it. The local database under .wrangler/state is the one ocre dev and ocre migrate keep using; ocre migrate --status shows whether anything is pending.
6. Log in again and check
cf keeps its own login, separate from wrangler’s, so log in once more (CI keeps using CLOUDFLARE_API_TOKEN, see Deployment):
ocre login
ocre doctor
ocre doctor checks Node.js, the installed cf and wrangler against the pins, the login, cloudflare.config.ts with cf’s own loader and tsc, the bindings your code uses, pending migrations and secrets. Fix what it reports, then run ocre dev and try the app locally before deploying.
cf sends anonymous usage telemetry by default; npx cf cli telemetry disable (or CF_SEND_TELEMETRY=false) turns it off.
Realtime apps: deploy to a preview first
How cf deploy maps exports: { OcreChannel: exports.durableObject({ storage: "sqlite" }) } onto a Worker first deployed by wrangler with the [[migrations]] tag ocre-realtime-v1 has not been verified yet. Durable Object migrations cannot be undone, and Cloudflare refuses rollbacks across them.
So before deploying a realtime app to production, deploy it to a preview first: a second Worker that was deployed once with your previous Ocre (the same app under another name, deployed with the old CLI), converted as above, then deployed with the new ocre deploy. Check that the deploy succeeds and that realtime pages still connect and receive broadcasts. If it fails, keep production on the previous Ocre and report it; this page will document the extra step once it is known.
Apps without realtime have no Durable Objects, and deploy with ocre deploy as before.
See also
- Configuration: every entry of
cloudflare.config.ts, the markers,wrangler.config.ts,package.jsonandtsconfig.json. - Deployment: what
ocre deploydoes now, and why wrangler still appears. - CLI commands: every command and its errors.
Migrating from Postgres
D1 is SQLite. An app moving from Postgres (Rails, Phoenix, Django) brings its tables and rows over with one command, then adapts what SQLite does differently.
Convert the dump
pg_dump --no-owner --no-acl myapp_prod > dump.sql # plain SQL, schema and data
ocre db import-postgres dump.sql
The command writes two files and loads nothing:
migrations/NNNN_import_from_postgres.sql: oneCREATE TABLEper table, with the primary keys, unique and foreign keys thatpg_dumpadds afterwards (ALTER TABLE ... ADD CONSTRAINT) folded in, as SQLite requires, and the plain indexes;db/import_from_postgres.sql: the rows of eachCOPYblock asINSERTs, 100 rows (and at most 90 KB) per statement, under D1’s statement size limit.
It prints the rows per table and a note for every difference to check. Read them, edit the migration if needed, then load the data locally and try the app:
ocre migrate
ocre db load db/import_from_postgres.sql
ocre sql "SELECT count(*) FROM videos"
On Cloudflare: ocre migrate --remote, then ocre db load db/import_from_postgres.sql --remote (the file goes in one request: for dumps over a few MB, split it, or use npx wrangler d1 execute <database> --remote --file db/import_from_postgres.sql). The free plan’s D1 database holds 500 MB.
How types change
| Postgres | SQLite (D1) | Values | Note |
|---|---|---|---|
smallint, integer, bigint, serial, bigserial | INTEGER | as is | a serial primary key becomes INTEGER PRIMARY KEY AUTOINCREMENT |
real, double precision | REAL | as is | |
numeric, decimal, money | TEXT | the exact digits | Ocre’s decimal field type; a REAL would round them |
boolean | INTEGER | t/f become 1/0 | models read them with ocre::bool_from_sql |
uuid | TEXT | as is | new rows need an id from the app: gen_random_uuid() defaults are dropped |
timestamp with time zone | TEXT | converted to UTC, YYYY-MM-DD HH:MM:SS (fractions kept) | the format of datetime('now'), which Ocre’s created_at uses |
timestamp, date, time | TEXT | as is | |
json, jsonb | TEXT + CHECK (json_valid(...)) | as is | query with json_extract(col, '$.key') or col ->> '$.key'; Ocre’s json field type |
an ENUM type | TEXT + CHECK (col IN (...)) | as is | Ocre’s enum field type |
arrays (text[]…) | TEXT | a JSON array of strings | read them with json_each |
bytea | BLOB | hex literals | prefer R2 for files (File storage) |
citext | TEXT COLLATE NOCASE | as is | case-insensitive for ASCII letters only |
interval, tsvector, ranges, geometry… | TEXT | as is | noted: rework them in the app |
Defaults: now() and CURRENT_TIMESTAMP become (datetime('now')), true/false become 1/0, literal defaults stay (their ::type casts dropped); other function calls are dropped with a note.
Skipped, with a note: functions, triggers, views, row security policies, indexes on expressions or with a method SQLite lacks (gin, gist). Their logic moves into the app: a trigger’s work goes in the model’s callbacks (after_create…), a view becomes a query function, full-text search uses a LIKE or a SQLite FTS table you create in a migration. Sequences, extensions, ownership and grants have no D1 equivalent and are dropped silently.
Then
- Generate models for the tables:
ocre g modelwrites the model and acreate_migration; when the table exists already (from the import), keep the model file and delete the new migration, or write the model by hand on the imported columns (Models and migrations). - Public ids: tables keyed by
uuidkeep their ids as text. For new rows, the app sets one (ocre::token::public_id()gives a random, URL-safe id); or move toid INTEGER PRIMARY KEYwith apublic_id:tokencolumn (Field types). - Times: Ecto’s
utc_datetimeand Rails’datetimecolumns aretimestamp without time zoneholding UTC already; they are copied as they are.
The converter reads pg_dump’s plain format (the default); a custom-format dump (-Fc) converts with pg_restore -f dump.sql dump.custom first. Dumps made with --inserts keep their INSERT statements as they are, public. removed. Checked on a pg_dump of PostgreSQL 16’s layout (enums, uuid, jsonb, arrays, bytea, timestamptz, a trigger and its function, foreign keys added by ALTER TABLE), loaded into ocre dev’s D1.
CLI commands
This page documents every ocre command and flag, what each one does step by step, its human and --json output, and the errors it reports with their hints. Code generators (ocre g ...) have their own page, Generators.
Before you start
- The
ocreCLI, installed withcargo install --git https://github.com/tgeselle/ocre.rs ocre-cli(see Installation). - Node.js 22 or newer, and the app’s npm packages (
npm install, whichocre newruns): commands that touch Cloudflare or the dev server run the app’s CloudflarecfCLI (node_modules/.bin/cf, pinned inpackage.json; outside an app,npx --yes cf@1.0.0-beta.5), and local database commands run the app’s own wrangler (see Why wrangler still appears). - A rustup toolchain with the
wasm32-unknown-unknowntarget forocre devandocre deploy. - Except
ocre new,ocre login,ocre secret,ocre push-keys,ocre version,ocre doctorandocre help, commands run inside an Ocre app: the CLI walks up from the current directory to the nearestcloudflare.config.ts, and reads thenameof itsDB: bindings.d1({ name })entry (see Configuration). A directory with only awrangler.toml(an app made by an older Ocre) is refused with a hint pointing to Upgrading from wrangler.toml. - Commands with
--remote,ocre loginandocre deployneed a Cloudflare account (free) and a login (ocre login) or theCLOUDFLARE_API_TOKENenvironment variable that cf reads (plusCLOUDFLARE_ACCOUNT_IDwhen the token sees several accounts).
The examples below come from ocre 0.1.0 on apps created by ocre new ... --starter blog; the lines printed by wrangler come from wrangler 4.143.0 (local database commands, and cf dev, which delegates to it). Commands that need Cloudflare (login, deploy, --remote, new --login/--deploy) are shown with the fake cf of the CLI’s integration tests, so the lines cf prints there are the fake’s, while the lines and JSON printed by ocre itself are Ocre’s.
Summary
| Command | What it does |
|---|---|
ocre new [NAME] | Creates an app in ./NAME; in a terminal, asks for anything flags did not answer; --template applies an application template |
ocre login | Logs in to Cloudflare in the browser, unless already logged in |
ocre generate / ocre g | Generates code: see Generators; --pretend, --force, --skip |
ocre destroy / ocre d | Undoes a recorded generator run |
ocre template SOURCE | Applies an application template (a file or URL of ocre commands) to the app |
ocre migrate | Applies D1 migrations (local unless --remote); --status lists pending ones |
ocre db create | Creates the local database, or with --remote the D1 database on Cloudflare |
ocre db prepare | Local, safe to repeat: applies pending migrations, seeds a new database |
ocre db seed | Loads db/fixtures, then runs db/seeds.sql (local unless --remote); --replant empties the tables first |
ocre db reset | Local only: deletes the local database, applies every migration, loads fixtures and seeds |
ocre db drop | Local only: deletes the local database |
ocre db truncate | Local only: deletes every row, keeps tables and migrations |
ocre db version | Prints the last applied migration |
ocre db load | Runs the statements of a SQL file (local unless --remote) |
ocre db import-postgres | Converts a Postgres dump into a migration and a data file for D1 |
ocre db schema | Writes the database’s CREATE statements to db/schema.sql |
ocre db dump | Writes table rows to fixture files db/fixtures/<table>.yml |
ocre sql QUERY | Runs SQL on D1 and prints the rows |
ocre dev | Applies local migrations, then runs the app with cf dev |
ocre test | Runs cargo test, the wasm32 check and, with --e2e, the request tests, tests/e2e.sh and the browser tests against a local server |
ocre deploy | Creates missing Cloudflare resources, applies remote migrations, then runs cf deploy |
ocre logs | Streams the deployed Worker’s live logs (wrangler tail) |
ocre push-keys | Prints a new VAPID key pair for web push |
ocre secret | Prints a new random value for SECRET_KEY_BASE |
ocre secrets list / push / fetch | Lists secret names locally and on the Worker; uploads values from a git-ignored file; prints one local value |
ocre domains [add|remove HOST] | The Worker’s custom domains in cloudflare.config.ts |
ocre routes [FILTER] | Lists the app’s HTTP routes, read from its source |
ocre schedules [run TASK] | Lists the Cron Triggers and their tasks; run fires one on ocre dev |
ocre i18n missing | Checks the locale files; fails on missing keys or invalid files |
ocre doctor | Checks the tools and the app’s setup (production settings, .ocre/doctor/ checks); fails when a check fails |
ocre ci | Runs the CI steps locally (fmt, clippy, tests, wasm32 check, translations); --signoff runs gh signoff |
ocre about, ocre version | Versions and the app’s configuration |
ocre stats [DIRS] | Lines of code per part of the app |
ocre notes | Lists TODO, FIXME and OPTIMIZE comments |
ocre time-zones | Prints the IANA time zone names |
ocre help | Help text |
Global flag: –json
--json is accepted by every command, before or after the subcommand (ocre --json routes and ocre routes --json are the same). It is the contract for scripts and AI agents:
- stdout carries exactly one JSON object, on one line, printed when the command ends.
- The command never prompts (
ocre newskips its wizard). - Output of the tools the CLI runs (cf, wrangler, npm, and cargo through the build) goes to stderr instead of stdout.
On success the object has "ok": true, command, and only the keys that apply (empty lists, false flags and absent values are omitted):
| Key | Type | Set by | Meaning |
|---|---|---|---|
ok | boolean | every command | true |
command | string | every command | The command’s name, e.g. new, migrate, db seed, secrets list, schedules run, destroy, doctor, or generate <generator> (generate scaffold; generate custom for app generators) |
created | string[] | new, generators, db dump | Files created, relative to the app root (to the current directory for ocre new, so they start with the app name) |
updated | string[] | generators, destroy, template, db schema, db dump --force | Existing files changed |
skipped | string[] | generators (--skip), destroy | Existing files kept: by --skip, or left changed by destroy (Cargo.toml, cloudflare.config.ts, package.json, changed lines) |
removed | string[] | destroy | Files deleted, the generation record last |
pretend | true | generators, destroy | --pretend: nothing was written |
templates | object[] | generate override | {"path", "overridden"} per generator template |
about | object | version, about | cli, app, app_version, ocre, and for about also mode, rust_toolchain, compatibility_date, bindings, vars, features |
checks | object[] | doctor | {"name", "status", "detail", "hint"}, status being ok, warn or fail |
stats | object | stats | rows (name, files, lines, loc, functions), code_loc, test_loc |
notes | object[] | notes | {"path", "line", "tag", "text"} |
version | string or null | db version | Last applied migration file, null when none is |
secrets | object[] | secrets list | {"name", "local", "deployed"} |
schedules | object[] | schedules | {"cron", "task"} |
url | string | dev, deploy, new --deploy | http://localhost:<port>, or the https://....workers.dev URL found in cf deploy’s output |
email | string | login, new --login, new --deploy | Email of the Cloudflare login |
pending | string[] | migrate --status | Migration files not applied yet |
ran | string[] | new, db seed, db reset, db dump, i18n missing | Steps performed, in order |
rows | array | sql | D1’s JSON: one object per statement, with results (the rows), success and meta |
routes | object[] | routes | {"method", "path", "handler"}, sorted by path then method |
remote | true | migrate --status --remote, db seed --remote, db dump --remote, sql --remote | The command used the production database |
secret | string | secret | 128 lowercase hex characters |
secret_created | true | deploy, new --deploy | The deploy uploaded a new SECRET_KEY_BASE because the Worker had none |
provisioned | string[] | deploy | Cloudflare resources created because they were missing, e.g. D1 database blog, queue blog-jobs |
next | string[] | new, migrate --status, generators, destroy, db tasks, secrets list | Commands or actions to run next, in order |
On failure the object is {"ok": false, "error": "...", "hint": "..."}. hint names the fix; it is null for the few errors without one (for example I/O errors). ocre doctor and ocre test report failed checks the same way, with the report’s keys (checks, ran) alongside.
{"error":"no cloudflare.config.ts found in this directory or its parents","hint":"run this command inside an Ocre app, or create one with `ocre new <name>`","ok":false}
Without --json, the same result is printed for humans: create <path> and update <path> lines, the steps, the route table, the URL, then a Next: list. Failures print error: <message> and hint: <hint> on stderr.
Exit codes
| Code | When | stdout with --json |
|---|---|---|
0 | Success | {"ok": true, ...} |
1 | The command failed | {"ok": false, "error", "hint"} |
2 | Invalid arguments (unknown flag, missing required argument), detected by the argument parser before the command runs | Nothing: the usage error is plain text on stderr, even with --json |
ocre g model Thing --json
error: the following required arguments were not provided:
<FIELDS>...
Usage: ocre generate model --json <NAME> <FIELDS>...
For more information, try '--help'.
(exit code 2, printed on stderr).
Errors shared by several commands
| Error | Hint | Cause |
|---|---|---|
no cloudflare.config.ts found in this directory or its parents | run this command inside an Ocre app, or create one with `ocre new <name>` | Run outside an app |
<dir> uses wrangler.toml; Ocre now reads cloudflare.config.ts | convert it: `npm install --save-dev --save-exact cf@1.0.0-beta.5 wrangler@4.144.0`, then `npx cf migrate --no-install`, then apply the Ocre fixes of the upgrading guide (...) | An app made by an older Ocre; see Upgrading from wrangler.toml |
cloudflare.config.ts has no D1 database bound to `DB` | add `DB: bindings.d1({ name: "<app>" }),` inside `worker.env` | The DB binding was removed or renamed |
cloudflare.config.ts defines `<KEY>` in a form Ocre cannot read | the canonical entry to write, e.g. write `STORAGE: bindings.r2({ name: "<bucket>" }),` | A value Ocre needs is not a literal (see Configuration) |
the app's npm packages are not installed (node_modules/.bin/cf, node_modules/.bin/wrangler) | run `npm install` in <app> (needs Node.js 22 or newer) | ocre new --no-install, or a fresh clone |
could not run cf: ... (or wrangler) | install Node.js 22 or newer, then run `npm install` in the app | No Node.js on PATH |
`cf <arguments>` failed (exit status: N) (or `wrangler <arguments>`) | read the cf output above; the first error line names the cause | The tool failed; its own error is on stderr just above |
`cf <arguments>` failed: ┌ APIError ... | log in with `ocre login`, or set CLOUDFLARE_API_TOKEN (and CLOUDFLARE_ACCOUNT_ID when the token sees several accounts) | A Cloudflare API call made by Ocre (lists, creations) failed; cf’s error box, with the API code, is in the message |
the wasm32-unknown-unknown target is not installed for rustc at <sysroot> | use a rustup toolchain (Homebrew's `rust` has no wasm target) and run `rustup target add wasm32-unknown-unknown` | ocre dev, ocre deploy, ocre new --deploy with a toolchain that cannot build WebAssembly |
rustc not found | install Rust with rustup: https://rustup.rs | No Rust on PATH |
invalid locale files: followed by one line per problem | fix each line named above (quote values with "..." when in doubt); `ocre i18n missing` checks them again | ocre dev and ocre deploy refuse locale files the Worker could not load (see ocre i18n missing) |
ocre new
ocre new [OPTIONS] [NAME]
Creates a new app in ./NAME. When stdin and stdout are both terminals and neither --json nor --yes is given, a wizard asks for everything the flags did not answer. Otherwise the flags alone decide, and anything not given takes its default: this is the mode for scripts and agents.
| Argument or flag | Default (flag mode) | Effect |
|---|---|---|
NAME | required in flag mode | App name, also the Worker and D1 database name: lowercase letters, digits and dashes, starting with a letter, not ending with a dash, at most 63 characters |
--api | off | API-only app: JSON endpoints, no HTML templates, no askama; Ocre’s default features (html) are off |
--full-stack | on | HTML pages with askama and htmx, plus JSON APIs when generated. --api and --full-stack override each other; the last one wins |
--starter <STARTER> | empty | empty: home page (status endpoint in API mode) only. qa: the live Q&A app of the tutorial (accounts, events, live questions and votes); full-stack only. blog: adds a Post resource (title:string body:text published:boolean) with CRUD pages, or a JSON API in an API-only app |
--login / --no-login | no login | Log in to Cloudflare (cf auth login, opens a browser) if not logged in yet |
--account-id <ACCOUNT_ID> | none | Cloudflare account to deploy to, written as accountId: "<id>", at the top of cloudflare.config.ts. Required with --login/--deploy when the login has several accounts |
--git / --no-git | no git | Run git init in the new app |
--deploy / --no-deploy | no deploy | Deploy right after creating the app; implies --login |
--no-install | install | Skip npm install in the new app (for offline use); run npm install in it before ocre dev. Cannot be combined with --deploy |
-y, --yes | off | Never prompt, even in a terminal |
--ocre-path <OCRE_PATH> | git dependency | Use a local checkout of the ocre crate (crates/ocre of the Ocre repository) instead of git = "https://github.com/tgeselle/ocre.rs" |
-m, --template <TEMPLATE> | none | Application template to apply once the app exists: a file or https:// URL of ocre commands, one per line (see ocre template). Every line is checked before the app is written |
What it does, in flag mode:
- Checks the name and that
./NAMEdoes not exist, resolves--ocre-path, and checks thatgitruns when--gitis given;--apiwith--starter qastops withthe qa starter has HTML pages; it cannot be API-only(hint:drop --api, or pick `--starter blog` or `--starter empty` for an API-only app). Nothing is written if any check fails. - With
--loginor--deploy: runscf auth whoami, runscf auth loginif not logged in, and picks the account (--account-idmust be one of the login’s accounts; with one account none is needed). - Writes the app:
Cargo.toml,cloudflare.config.ts(withaccountIdwhen an account was picked),wrangler.config.ts,package.json,tsconfig.json,rust-toolchain.toml,.gitignore,AGENTS.md,migrations/.gitkeep,public/robots.txt,src/lib.rs, and in full-stack appstemplates/layout.html,templates/home.htmlandtemplates/error.html. An API-only app’sCargo.tomlhasocre = { ..., default-features = false }, no askama, and[package.metadata.ocre] mode = "api", which generators read. - Writes
.dev.vars(git-ignored) with a new randomSECRET_KEY_BASEandMAIL_ADAPTER=log, used byocre devonly. - With
--starter blog: runs the equivalent ofocre g scaffold Post title:string body:text published:boolean(ocre g apiin an API-only app). With--starter qa: runs the equivalents ofocre g auth,ocre g scaffold Event name:string public_id:token user:referencesandocre g scaffold Question event:references body:text votes:integer answered:boolean --realtime(recorded forocre destroy), writes the room’s controllers, templates and request tests over the generated files, and removes the generated question pages;createdlists the files that remain. - With
--template: runs the template’s lines in the new app, likeocre template; the files they create are added tocreatedand the lines toran. - Unless
--no-install: runsnpm installin the app, which installs the pinnedcf,wranglerandtypescriptintonode_modules/and writespackage-lock.json(commit it).rangetsnpm install (cf 1.0.0-beta.5, wrangler 4.144.0). - With
--git: runsgit init --quiet. - With
--deploy: runs the same steps asocre deploy, without the locale check.
The wizard asks, in order: the app name, “What are you building?” (full-stack or API only), “Pick a starter”, then checks the Cloudflare login and offers it (“Log in now” or “Later”), asks which account when the login has several, “Initialize a git repository?” (default yes), and “Deploy it now?” (default yes, only when logged in). The output of cf and npm is hidden behind a spinner and included in error messages; with --no-install, the wizard does not offer to deploy. Each question is skipped when its flag was given. When the user chose not to log in, ocre login is added to the next steps. Esc or Ctrl-C cancels with the error cancelled.
Example, flag mode:
ocre new blog --starter blog --yes
create blog/Cargo.toml
create blog/cloudflare.config.ts
create blog/wrangler.config.ts
create blog/package.json
create blog/tsconfig.json
create blog/rust-toolchain.toml
create blog/.gitignore
create blog/AGENTS.md
create blog/migrations/.gitkeep
create blog/public/robots.txt
create blog/src/lib.rs
create blog/templates/layout.html
create blog/templates/home.html
create blog/templates/error.html
create blog/.dev.vars
create blog/src/models/mod.rs
create blog/migrations/0001_create_posts.sql
create blog/src/models/post.rs
create blog/src/posts.rs
create blog/templates/posts/index.html
create blog/templates/posts/show.html
create blog/templates/posts/new.html
create blog/templates/posts/edit.html
create blog/templates/posts/_form.html
npm install (cf 1.0.0-beta.5, wrangler 4.144.0)
Next:
cd blog
ocre dev
ocre deploy
(With --ocre-path pointing at a local checkout, which only changes the ocre line of Cargo.toml; npm install’s own output is not shown.)
An API-only app, with --json and without the npm install (run npm install in it before ocre dev; ocre doctor reminds you):
ocre new shop --api --no-install --json
{"command":"new","created":["shop/Cargo.toml","shop/cloudflare.config.ts","shop/wrangler.config.ts","shop/package.json","shop/tsconfig.json","shop/rust-toolchain.toml","shop/.gitignore","shop/AGENTS.md","shop/migrations/.gitkeep","shop/public/robots.txt","shop/src/lib.rs","shop/.dev.vars"],"next":["cd shop","ocre dev","ocre deploy"],"ok":true}
Creating and deploying in one command (fake cf; the account has two Cloudflare accounts, hence --account-id):
ocre new two --deploy --account-id def456 --git
Uploaded app
https://app.example.workers.dev
Migrations applied to two (--remote)
create two/Cargo.toml
...
create two/.dev.vars
Logged in to Cloudflare as ada@example.com
Created the SECRET_KEY_BASE secret on Cloudflare
Saved it in .prod.vars (git-ignored): back it up, Cloudflare never gives it back
https://app.example.workers.dev
Next:
cd two
ocre dev
The same with --json prints cf’s lines on stderr and this on stdout:
{"command":"new","created":["three/Cargo.toml","three/cloudflare.config.ts","three/wrangler.config.ts","three/package.json","three/tsconfig.json","three/rust-toolchain.toml","three/.gitignore","three/AGENTS.md","three/migrations/.gitkeep","three/public/robots.txt","three/src/lib.rs","three/templates/layout.html","three/templates/home.html","three/templates/error.html","three/.dev.vars"],"email":"ada@example.com","next":["cd three","ocre dev"],"ok":true,"ran":["npm install (cf 1.0.0-beta.5, wrangler 4.144.0)"],"secret_created":true,"url":"https://app.example.workers.dev"}
Errors:
| Error | Hint |
|---|---|
missing app name | run `ocre new <name>`, or run `ocre new` in a terminal for the guided setup |
invalid app name `Bad_Name` | use lowercase letters, digits and dashes, starting with a letter (max 63), e.g. `my-blog` |
`<path>` already exists | choose another name or remove the directory |
--ocre-path <path>: <OS error> | pass the directory of the `ocre` crate (crates/ocre in the Ocre repository) |
git is not installed | install git, or create the app without `--git` |
`git init` failed (<status>) | none |
Cloudflare login did not complete | run `ocre login` and approve access in the browser, or set CLOUDFLARE_API_TOKEN |
this Cloudflare login has several accounts | pass --account-id with one of: abc123 (Ada), def456 (Work) (the login’s accounts) |
account `zzz` is not available to this Cloudflare login | use one of: abc123 (Ada), def456 (Work) |
npm not found | install Node.js 22 or newer (it provides npm), or pass --no-install and run `npm install` in the app later |
`npm install` failed in <dir> (<status>): ... | the app is created: fix the cause above (network, Node.js 22+), then run `npm install` in it |
--deploy needs the app's npm packages | drop --no-install, or deploy later with `npm install && ocre deploy` |
this Cloudflare login has no accounts | create an account at https://dash.cloudflare.com/sign-up, then run `ocre login` again |
cancelled (wizard only) | run `ocre new <name>` with flags (see `ocre new --help`) to skip the questions |
With --deploy, the errors of ocre deploy apply too. The app directory is already written when a deploy fails; fix the cause, cd into it and run ocre deploy.
ocre login
ocre login [--json]
Logs in to Cloudflare. It runs cf auth whoami; when not logged in, it runs cf auth login, which opens a browser to approve access (OAuth), then checks cf auth whoami again. Already logged in, it only prints the login. It takes no flags besides --json. Inside an app it runs the app’s cf; elsewhere npx --yes cf@1.0.0-beta.5.
cf keeps its own login, separate from wrangler’s: after upgrading from an Ocre that used wrangler, run ocre login once again. On a machine with several Cloudflare accounts, npx cf auth activate <profile> . binds a cf profile to the app directory. CI uses CLOUDFLARE_API_TOKEN instead (see Deployment).
Example (fake cf, not logged in yet; Successfully logged in. is the fake’s line):
ocre login
Successfully logged in.
Logged in to Cloudflare as ada@example.com
ocre login --json
{"command":"login","email":"ada@example.com","ok":true}
email is absent when the session has none (an API token). Errors: Cloudflare login did not complete (hint: run `ocre login` and approve access in the browser, or set CLOUDFLARE_API_TOKEN), unexpected `cf auth whoami` output: ..., and the shared errors.
ocre generate
ocre generate <GENERATOR> [ARGS]... [--pretend] [--force | --skip]
ocre g <GENERATOR> [ARGS]...
g is an alias. The built-in generators are model, scaffold, api, resource, controller, auth, migration, mailer, mailbox, job, schedule, cache, data, system_test, ci, pwa, locale, override and generator; any other name runs the app’s own generator in .ocre/generators/<name>/. Each one is documented with its arguments, files, output and errors in Generators.
| Flag | Effect |
|---|---|
--pretend | Report the files that would be created or updated; write nothing |
--force | Overwrite files that already exist |
--skip | Keep files that already exist and generate the rest (conflicts with --force) |
Generated Rust files go through rustfmt with the app’s rustfmt.toml before they are written (unchanged when rustfmt is missing), so cargo fmt --check and ocre ci pass on generated code. Every run that writes files is recorded in .ocre/generated/ for ocre destroy (see Generation records).
ocre destroy
ocre destroy <GENERATOR> [NAME] [--force] [--pretend] [--json]
ocre d <GENERATOR> [NAME]
Undoes a generator run, like rails destroy, from its record in .ocre/generated/: deletes the files it created (and the directories left empty), takes the lines it added out of existing files and puts back the lines it replaced, then deletes the record. Changes to Cargo.toml, cloudflare.config.ts and package.json stay (features and bindings later code may use); they are reported as skipped. No cf, no network.
| Argument or flag | Default | Effect |
|---|---|---|
GENERATOR | required | The generator as typed after ocre g (scaffold, controller, or an app generator’s name) |
NAME | latest run | The name given to the generator (Post; BlogPost, blog_post and blog-post match each other). Without it, the latest run of that generator |
--force | off | Delete the generated files even if they changed since; leave in place the added lines that changed |
--pretend | off | Show what would be removed; change nothing |
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 ...`)
The latest ocre g controller run, whose src/help.rs was edited since, deleted anyway:
ocre destroy controller --force --json
{"command":"destroy","ok":true,"removed":["src/help.rs","templates/help/faq.html",".ocre/generated/0008_controller_help.json"],"updated":["src/lib.rs"]}
A migration file is deleted, but a migration already applied to a database stays applied: the next step says so. Destroy later runs first when they build on an earlier one (a scaffold whose model a later references field extended).
Errors:
| Error | Hint |
|---|---|
no recorded `ocre g scaffold Temp` run to destroy | recorded runs: ocre g scaffold Post title:string ..., ... (the recorded commands), or, without records, `ocre destroy` undoes runs recorded in .ocre/generated/; this app has none, so remove the files by hand |
cannot destroy `ocre g controller Help faq`: src/help.rs changed since it was generated (also <file>: the generated `<line>` changed and <file> no longer exists) | destroy later generator runs first (newest first), undo your edits, or pass --force to delete the generated files anyway and leave changed lines in place |
.ocre/generated/<file> is not a valid generation record: ... | restore it from version control, or delete it if you no longer need `ocre destroy` for it |
ocre template
ocre template <SOURCE> [--json]
Applies an application template to the current app, like rails app:template (ocre new --template does the same for a new app). SOURCE is a file path or an http(s):// URL of a text file with one ocre command per line; # starts a comment and the leading ocre is optional:
# Blog comments
g scaffold Comment body:text post:references
cargo add slug
migrate
Only commands that change the app locally are allowed: g/generate, d/destroy, migrate, db, sql, i18n, routes, and cargo add / cargo remove; nothing with --remote, no deploy, login or shell commands. Every line is checked before the first one runs; lines then run in order, each as ocre <line> --json in the app (or cargo ...), and the first failing line stops the run. A template runs generators and cargo add on your machine: apply only templates you trust.
ocre template blog.ocre
create migrations/0004_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 Cargo.toml
update src/lib.rs
update src/models/mod.rs
update src/models/post.rs
ocre g scaffold Comment body:text post:references
cargo add slug
ocre migrate
With --json: {"command": "template", "created": [...], "updated": [...], "ran": [...], "ok": true}.
Errors:
| Error | Hint |
|---|---|
blog.ocre line 1: `deploy` is not allowed in a template (also targets production (--remote), has an unclosed quote) | template lines are ocre commands that change the app locally: g/generate, destroy, migrate, db, sql, i18n, routes, or `cargo add`/`cargo remove`, without --remote |
could not read the template missing.ocre: ... | pass the path of a text file of ocre commands, or an https:// URL |
could not download the template <url>: ... | check the URL (it must serve the template as plain text), or download it and pass its path |
template line `ocre g ...` failed: <its error> | the line’s own hint |
template line `cargo add ...` failed (<status>) | the cargo output above names the cause |
ocre migrate
ocre migrate [--remote] [--status] [--json]
Applies the pending SQL files of migrations/ to the app’s D1 database, in file-name order. Locally it runs the app’s wrangler, wrangler d1 migrations apply DB --local -c .wrangler/ocre-d1.json --persist-to .wrangler/state (a config Ocre derives from cloudflare.config.ts; see Why wrangler still appears); with --remote it looks the database id up with cf d1 list --name <database> and runs cf d1 migrations apply <id>. Applied migrations are recorded in the database’s d1_migrations table, so each file runs once.
| Flag | Default | Effect |
|---|---|---|
--remote | local | Use the production database on Cloudflare instead of the local one in .wrangler/state/v3/d1 |
--status | off | List pending migrations instead of applying them (see below) |
Example, local:
ocre migrate
...
Migrations to be applied:
┌───────────────────────┐
│ name │
├───────────────────────┤
│ 0001_create_posts.sql │
└───────────────────────┘
? About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue?
...
All of it is wrangler’s output (wrangler answers its own question with its non-interactive default, yes). With --json, that output goes to stderr and stdout gets:
{"command":"migrate","ok":true}
The object is the same with --remote: the success report of ocre migrate has no remote key.
ocre migrate –status
Locally, runs wrangler d1 migrations list DB --local (same derived config), shows its table and reads the pending file names from it. With --remote, runs cf d1 migrations list <id> and reads the names from its JSON.
ocre migrate --status
...
Migrations to be applied:
┌───────────────────────┐
│ Name │
├───────────────────────┤
│ 0001_create_posts.sql │
└───────────────────────┘
Next:
ocre migrate
ocre migrate --status --json
{"command":"migrate","next":["ocre migrate"],"ok":true,"pending":["0001_create_posts.sql"]}
With nothing pending, wrangler prints No migrations to apply! and the report is {"command":"migrate","ok":true}: no pending, no next.
ocre migrate –remote
Applies (or with --status, lists) migrations on the production D1 database. It needs a Cloudflare login and a database that exists: the first ocre deploy creates it, and every deploy applies remote migrations itself, so ocre migrate --remote is only needed to migrate without deploying. With --status, the human output ends with Target: remote D1 database on Cloudflare and the JSON has "remote": true (fake cf):
{"command":"migrate","next":["ocre migrate --remote"],"ok":true,"pending":["0002_create_comments.sql"],"remote":true}
Errors: the shared ones, and the D1 database blog does not exist on Cloudflare yet (hint: run `ocre deploy` (or `ocre db create --remote`) first) with --remote. A failing migration is reported as `wrangler d1 migrations apply DB --local` failed (exit status: 1) (or `cf d1 migrations apply <id>` failed) with the tool’s error above it.
ocre db seed
ocre db seed [--remote] [--replant] [--from <DIR>] [--json]
Loads the fixtures of db/fixtures/ (see Fixtures), then runs db/seeds.sql; either may be missing, not both. The fixtures become one SQL file, .wrangler/ocre-fixtures.sql (deleted afterwards), run with wrangler d1 execute DB --local --file .wrangler/ocre-fixtures.sql --yes: foreign keys deferred, each fixture table emptied, then one INSERT per row, so loading twice gives the same rows. Fixtures load into the local database only.
db/seeds.sql runs locally with wrangler d1 execute DB --file db/seeds.sql --local --yes (the app’s wrangler; --yes answers its “database unavailable during import” question), with --remote through cf d1 query <id> --batch @.wrangler/ocre-batch.json (a temporary file holding the SQL). The file is plain SQL, typically INSERT statements; it is not tracked, so running it twice inserts the rows twice.
| Flag | Default | Effect |
|---|---|---|
--remote | local | Seed the production database on Cloudflare (db/seeds.sql only; refused when there are fixtures to load) |
--replant | off | Local only: first empty every app table like ocre db truncate, then load the fixtures and seeds, so the data matches the files exactly. Loco’s seed --reset |
--from <DIR> | db/fixtures | Fixture directory, relative to the app root |
-- db/seeds.sql
INSERT INTO posts (title, body, published) VALUES ('Hello', 'First post', 1);
INSERT INTO posts (title, body, published) VALUES ('Draft', 'Not yet', 0);
ocre db seed
...
[
{
"results": [],
"success": true,
"meta": {
"duration": 0
}
},
...
]
loaded db/seeds.sql (--local)
ocre db seed --json
{"command":"db seed","ok":true,"ran":["loaded db/seeds.sql (--local)"]}
With --remote, ran is ["loaded db/seeds.sql (--remote)"], the JSON has "remote": true and the human output ends with Target: remote D1 database on Cloudflare.
ocre db seed --replant --json reports both steps: {"command":"db seed","ok":true,"ran":["emptied posts (--local)","loaded db/seeds.sql (--local)"]}. Fixtures add a step before the seeds: "loaded db/fixtures: authors, posts (--local)".
Errors: db/seeds.sql not found in the app when there is neither a fixture directory nor seeds (hint: create db/seeds.sql with INSERT statements or fixture files in db/fixtures/, then run `ocre db seed` ), <DIR> not found in the app for a missing --from directory (hint: pass the directory of the fixture files, relative to the app root), the fixtures of db/fixtures only load into the local database with --remote (hint: fixtures replace table rows: local only; use db/seeds.sql for remote data), fixture file errors naming the file and label (e.g. db/fixtures/posts.yml: `hello`: no fixture labelled `ada` for `author` , hint: add a `ada:` row to a fixture file, or set `author_id` to an id), `ocre db seed --replant` only runs on the local database for --replant --remote (hint: Ocre never deletes production data; use the Cloudflare dashboard or `cf d1 ...` for that on purpose), and the shared ones.
ocre db reset
ocre db reset [--json]
Local only. Deletes .wrangler/state/v3/d1 (the local D1 databases) when it exists, applies every migration like ocre migrate, then loads the fixtures of db/fixtures/ and db/seeds.sql when they exist. It takes no --remote: production data is never reset.
ocre db reset
...
deleted .wrangler/state/v3/d1
applied migrations (--local)
loaded db/seeds.sql (--local)
ocre db reset --json
{"command":"db reset","ok":true,"ran":["deleted .wrangler/state/v3/d1","applied migrations (--local)","loaded db/seeds.sql (--local)"]}
ran lists only the steps that happened: no deleted ... without a local database, no loaded ... without fixtures or seeds.
ocre db create
ocre db create [--remote] [--json]
Creates the app’s database. Locally, it runs SELECT 1 through the app’s wrangler (wrangler d1 execute DB --local), which creates the database file under .wrangler/state/v3/d1; the next step is ocre migrate. With --remote, it looks for the D1 database with cf d1 list --name <database> and runs cf d1 create --name <database> when it is missing (the first ocre deploy does the same on its own).
{"command":"db create","next":["ocre migrate"],"ok":true,"ran":["created local database shop"]}
ran says local database shop already exists when it did, and D1 database shop already exists with --remote; a remote creation is listed in provisioned (D1 database shop) with "remote": true.
ocre db prepare
ocre db prepare [--json]
Local, safe to run any time (Rails’ db:prepare): applies pending migrations to the local database and, when that database did not exist yet, loads the fixtures of db/fixtures/ and db/seeds.sql if present. A good first command after cloning an app.
{"command":"db prepare","ok":true,"ran":["applied migrations (--local)","loaded db/seeds.sql (--local)"]}
On an existing database, ran is only ["applied migrations (--local)"].
ocre db drop
ocre db drop [--json]
Local only: deletes .wrangler/state/v3/d1, the local D1 databases. The next step is ocre db prepare.
deleted .wrangler/state/v3/d1
Next:
ocre db prepare
Without a local database, ran is ["no local database to delete"]. ocre db drop --remote is refused: `ocre db drop` only runs on the local database (hint: Ocre never deletes production data; use the Cloudflare dashboard or `cf d1 ...` for that on purpose).
ocre db truncate
ocre db truncate [--json]
Local only: deletes every row of every app table (all tables but SQLite’s, D1’s and d1_migrations) in one batch with deferred foreign keys, and resets the AUTOINCREMENT counters. Tables and applied migrations stay.
{"command":"db truncate","ok":true,"ran":["emptied posts (--local)"]}
With no table, ran is ["no tables to empty"]. --remote is refused with `ocre db truncate` only runs on the local database and the same hint as ocre db drop.
ocre db version
ocre db version [--remote] [--json]
Prints the last migration applied (local database unless --remote), read from the d1_migrations table.
ocre db version
0001_create_posts.sql
{"command":"db version","ok":true,"version":"0001_create_posts.sql"}
Before any migration it prints no migration applied ("version": null). With --remote, the human output ends with Target: remote D1 database on Cloudflare and the JSON has "remote": true.
ocre db load
ocre db load <FILE> [--remote] [--json]
Runs the statements of a SQL file (relative to the app root) on the local database, or with --remote on the D1 database on Cloudflare; prints loaded <file> (--local). For data files such as the one of ocre db import-postgres. Errors: <file> not found; the tool’s error when a statement fails.
ocre db import-postgres
ocre db import-postgres <DUMP> [--name <NAME>] [--json]
Converts a Postgres dump in plain SQL (pg_dump --no-owner --no-acl <database> > dump.sql) for D1, and loads nothing: migrations/NNNN_<name>.sql holds the tables in SQLite’s types (constraints and indexes folded in), db/<name>.sql the rows of the dump’s COPY blocks as INSERTs (100 rows, at most 90 KB, per statement). --name defaults to import_from_postgres. It prints the rows per table and a note for each type it changes, default it drops and statement it skips (functions, triggers, views, expression indexes). See Migrating from Postgres.
create migrations/0001_import_from_postgres.sql
create db/import_from_postgres.sql
users: 2 rows
videos: 2 rows
note: videos.id: uuid -> TEXT; new rows need an id from the app (`ocre::token::public_id()`, or a UUID crate)
note: videos.inserted_at: timestamp with time zone -> TEXT `YYYY-MM-DD HH:MM:SS`, converted to UTC (`datetime('now')`'s format)
note: 1 triggers skipped: rewrite their logic in Rust (models' callbacks, jobs)
Next:
read the notes, and edit the migration if needed
ocre migrate
ocre db load db/import_from_postgres.sql
production: ocre migrate --remote, then ocre db load db/import_from_postgres.sql --remote
Errors: no CREATE TABLE in the dump (hint: dump the schema too, in plain SQL); <table>: row <n> has <a> values for <b> columns; <path> already exists (pass another --name).
ocre db schema
ocre db schema [--remote] [--json]
Writes the database’s current CREATE TABLE, CREATE INDEX, view and trigger statements (from sqlite_master, without SQLite’s and D1’s own tables) to db/schema.sql, like Rails’ structure.sql. It is a snapshot to read and the input of ocre g migration rebuild_<table>; migrations stay the source of truth. Run it again after ocre migrate.
ocre db schema
create db/schema.sql
-- Schema of the local D1 database, written by `ocre db schema` from sqlite_master.
-- A snapshot for reading: migrations/ are the source of truth. Run `ocre db schema` again after `ocre migrate`.
CREATE TABLE posts (
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'))
);
Later runs report update db/schema.sql ("updated": ["db/schema.sql"]). Errors: the shared errors, and unexpected D1 query output: ....
ocre db dump
ocre db dump [--tables <TABLES>] [--dir <DIR>] [--force] [--remote] [--json]
Writes the rows of every app table (the tables of ocre db schema), or of --tables posts,authors, to fixture files <DIR>/<table>.yml that ocre db seed loads back. It reads the table names from sqlite_master, then runs one SELECT * FROM "<table>"; per table in a single query (locally through the app’s wrangler, with --remote through cf d1 query; each row returned is a D1 row read). Each row gets the label <table>_<id> and keeps its id; strings are double-quoted YAML.
| Flag | Default | Effect |
|---|---|---|
--tables <TABLES> | every app table | Comma-separated table names |
--dir <DIR> | db/fixtures | Directory of the fixture files, relative to the app root |
--force | off | Overwrite existing fixture files |
--remote | local | Read the production database on Cloudflare (read-only) |
ocre db dump --tables posts
# db/fixtures/posts.yml
# Rows of the local D1 database, written by `ocre db dump`; `ocre db seed` loads them.
posts_1:
id: 1
title: "Hello"
published: 1
{"command":"db dump","created":["db/fixtures/posts.yml"],"ok":true}
With --force, rewritten files are in updated; with no tables, ran is ["no tables to dump"]. Errors: db/fixtures/posts.yml already exist when a file exists (hint: pass --force to overwrite them, or --dir <dir> to dump elsewhere), unexpected D1 query output: ..., and the shared ones.
ocre sql
ocre sql [--remote] [--json] <QUERY>
Runs one or more SQL statements separated by ; locally with wrangler d1 execute DB --command <QUERY> --local --json (the app’s wrangler), with --remote with cf d1 query <id> --batch @.wrangler/ocre-batch.json, and prints each statement’s rows. Quote the query for the shell.
| Argument or flag | Default | Effect |
|---|---|---|
QUERY | required | SQL statements separated by ; |
--remote | local | Run on the production database on Cloudflare |
The tool’s output is captured, not printed, in both modes: the human output is one table per statement, each followed by its row count, with NULL for null values.
ocre sql "SELECT id, title, published FROM posts"
id | title | published
---+-------+----------
1 | Hello | 1
2 | Draft | 0
(2 rows)
ocre sql "SELECT COUNT(*) AS n FROM posts; SELECT * FROM posts WHERE id = 99"
n
-
2
(1 row)
(0 rows)
With --json, rows is D1’s JSON unchanged, one object per statement:
ocre sql "SELECT id, title FROM posts WHERE published = 1; SELECT COUNT(*) AS n FROM posts" --json
{"command":"sql","ok":true,"rows":[{"meta":{"duration":1},"results":[{"id":1,"title":"Hello"},{"id":3,"title":"Hello"}],"success":true},{"meta":{"duration":0},"results":[{"n":4}],"success":true}]}
With --remote, the JSON has "remote": true and the human output ends with Target: remote D1 database on Cloudflare.
Errors: a failing statement is reported with the tool’s captured output in the message (here the local database, through the app’s wrangler):
ocre sql "SELECT * FROM nope" --json
{"error":"`wrangler d1 execute DB --local --command SELECT * FROM nope --json` failed (exit status: 1):\n\n{\n \"error\": {\n \"text\": \"no such table: nope: SQLITE_ERROR\"\n }\n}","hint":"the wrangler output above names the cause","ok":false}
Also unexpected D1 query output: ... when the tool’s output is not the expected JSON, and the shared errors (with --remote, a missing database too).
ocre dev
ocre dev [--port <PORT>] [--json]
Runs the app locally, at http://localhost:<PORT>, until stopped (Ctrl-C).
| Flag | Default | Effect |
|---|---|---|
--port <PORT> | 8787 | Port of the local server |
--no-cache / --cache | neither | Turn ocre::cache off (CACHE_STORE=null in .dev.vars) or back on; kept for later runs |
Steps:
- Checks the locale files (see ocre i18n missing); a file the Worker could not load stops here.
- Checks that rustc has the
wasm32-unknown-unknowntarget. - Checks that the app’s npm packages are installed (
node_modules/.bin/cfandwrangler). - Applies local migrations, like
ocre migrate(through the app’s wrangler). - Runs
cf dev --port <PORT>. cf delegates the build and the local server to the app’s wrangler, which runs thebuild.commandofwrangler.config.ts;ocre devsetsOCRE_BUILD=--dev, soworker-buildmakes an unoptimized build, much faster to compile than the--releasebuild of deploys. The first build compiles every dependency (about a minute); later ones are incremental. Local D1, R2, KV, Queues and Durable Objects are simulated under.wrangler/state, the same state the local database commands use, and.dev.varsprovides the local secrets.
Abridged output of a run on a new blog app (the lines come from wrangler, which cf runs):
ocre dev --port 8943
...
[custom build] Running: cargo install -q "worker-build@^0.8" && worker-build ${OCRE_BUILD:---release}
...
[custom build] Finished `dev` profile [unoptimized + debuginfo] target(s) in 37.28s
...
Using secrets defined in .dev.vars
Your Worker has access to the following bindings:
Binding Resource Mode
env.DB (demo) D1 Database local
env.MAIL_FROM ("demo <noreply@example.com>") Environment Variable local
env.SECRET_KEY_BASE ("(hidden)") Environment Variable local
env.MAIL_ADAPTER ("(hidden)") Environment Variable local
...
[wrangler:info] Ready on http://localhost:8943
The report is printed after cf dev exits successfully; with --json it is:
{"command":"dev","ok":true,"url":"http://localhost:8787"}
and every line above goes to stderr. Errors: the shared ones (locale files, wasm target, npm packages, cf). `cf dev --port 8787` failed usually means the build failed: the compiler errors are in the output above it.
ocre dev --no-cache turns ocre::cache off in development (Rails’ dev:cache): it writes CACHE_STORE=null into .dev.vars, so every ocre::cache::fetch computes its value, and the choice stays for later runs; ocre dev --cache removes the line. The report’s ran says which (caching off: CACHE_STORE=null in .dev.vars). ocre secrets push refuses to upload that development value.
ocre test
ocre test [--e2e] [--port <PORT>] [--json] [-- <CARGO_TEST_ARGS>...]
Runs the app’s checks in one command, like bin/rails test:all, and stops at the first failure:
-
cargo test, the native unit tests, with the arguments after--passed on (ocre test -- modelsruns the tests whose name containsmodels); request tests are#[ignore]d here; -
cargo check --target wasm32-unknown-unknown, the type check of the real build; -
with
--e2e:- a fresh test database in
.wrangler/test-state(migrations, thentests/fixtures/*.yml); the development data is untouched, - one server on
<PORT>for the whole run, logging to.wrangler/test-state/dev.log, cargo test -- --ignored --test-threads=1: the request tests, withOCRE_TEST_URL,OCRE_TEST_STATEandOCRE_TEST_LOGset forocre::testing,sh tests/e2e.shwithBASE_URL, when the app has it,node_modules/.bin/playwright testwithBASE_URL, whentests/system/has*.spec.tsfiles (ocre g system_test),
then stops the server.
- a fresh test database in
| Flag | Default | Effect |
|---|---|---|
--e2e | off | Also run the request tests, tests/e2e.sh and the browser tests against a local server (see Testing) |
--port <PORT> | 8788 | Port of the server started for --e2e, next to ocre dev’s 8787 |
ocre test --e2e --json
{"command":"test","ok":true,"ran":["cargo test: ok","cargo check --target wasm32-unknown-unknown: ok","test database .wrangler/test-state: migrated, tests/fixtures loaded","cargo test -- --ignored against the test server on port 8788: ok","playwright test (tests/system) against the test server on port 8788: ok"]}
With --json, the output of cargo, wrangler and Playwright goes to stderr. A failing step ends with, for example, error: cargo test -- --ignored failed (exit status: 101) and a hint naming how to rerun it; the steps that passed are still listed in ran. Other errors: tests/e2e.sh failed (<status>), tests/system has tests but Playwright is not installed (hint: npm install, then npx playwright install chromium), playwright test failed (<status>) (hint: screenshots and traces in test-results/), the test server stopped or did not get ready (hint: its output is in .wrangler/test-state/dev.log), a fixture file that does not parse, the missing wasm target, and could not run cargo: ....
ocre deploy
ocre deploy [--json]
Builds the app in release mode and deploys it to Cloudflare as the Worker named in cloudflare.config.ts, creating what it needs first. It needs a Cloudflare login (ocre login) or CLOUDFLARE_API_TOKEN (with CLOUDFLARE_ACCOUNT_ID when the token reaches several accounts), and the app’s npm packages. Ocre provisions every resource itself before cf deploy runs. Steps, in order:
- Checks the locale files, the wasm target and that
node_moduleshas the app’scfandwrangler(hint:npm install). - Database: looks for the
DBdatabase withcf d1 list --name <database>and creates it withcf d1 create --name <database>when missing. - Queues: lists the account’s queues (
cf queues list) and creates, withcf queues create --queue-name <name>, each queue the config names (bindings.queuenames,triggers.queuenames and theirdeadLetterQueue) that is missing. A consumer of a missing queue would fail the deploy. - R2 buckets: for each
bindings.r2({ name })(theSTORAGEbucket added by the firstattachmentfield), runscf r2 buckets get <name>and creates the bucket (cf r2 buckets create) when Cloudflare answers that it does not exist (API code 10006). - KV namespaces: for each
bindings.kv()entry without anid(theCACHEbinding added byocre g cache), finds the namespace titled<worker>-<binding>(lowercase,_as-, e.g.blog-cache) incf kv namespaces list, creates it when missing, and rewrites the entry toCACHE: bindings.kv({ id: "<id>" }),incloudflare.config.ts. Commit that change: later deploys reuse the namespace. SECRET_KEY_BASE: runscf workers secrets list --worker <name>. When the Worker does not exist yet or has noSECRET_KEY_BASE, a new random value (or the one of.prod.vars) is uploaded with the deploy. Every deploy passescf deploy --secrets-file .wrangler/ocre-secrets.json(mode 0600, deleted afterwards, even on failure), holding{"SECRET_KEY_BASE": ...}or{}: cf keeps a Worker’s existing secrets (including those ofocre secrets push) only when--secrets-fileis passed; deployed without it, the new version has none (cf 1.0.0-beta.5). An existing secret is never replaced (that would sign every user out); any other failure of the secrets list stops the deploy rather than risk it.- Migrations:
cf d1 migrations apply <id>on the production database, always before the new code goes live, so it never runs on the old schema. - Deploy:
cf deploy, withOCRE_BUILD=--release; cf delegates the release build to the app’s wrangler (build.commandofwrangler.config.ts). - Reports the
https://...workers.devURL found in cf’s output.
Durable Object namespaces (realtime channels) need no step: cf deploy creates them from the exports of cloudflare.config.ts.
First deploy of an app with a job, a cache, attachments (fake cf; the lines before Created the SECRET_KEY_BASE secret are cf’s):
ocre deploy
Migrations applied to the remote database
Uploaded app
https://app.example.workers.dev
Created the SECRET_KEY_BASE secret on Cloudflare
Saved it in .prod.vars (git-ignored): back it up, Cloudflare never gives it back
Created D1 database blog on Cloudflare
Created queue blog-jobs on Cloudflare
Created queue blog-jobs-failed on Cloudflare
Created R2 bucket blog-storage on Cloudflare
Created KV namespace blog-cache (id written to cloudflare.config.ts) on Cloudflare
https://app.example.workers.dev
The same first deploy with --json:
{"command":"deploy","ok":true,"provisioned":["D1 database blog","queue blog-jobs","queue blog-jobs-failed","R2 bucket blog-storage","KV namespace blog-cache (id written to cloudflare.config.ts)"],"secret_created":true,"url":"https://app.example.workers.dev"}
A later deploy, with everything in place:
{"command":"deploy","ok":true,"url":"https://app.example.workers.dev"}
Free-plan note (September 2026): R2 must be enabled once in the Cloudflare dashboard, which asks for a payment method even for use within the free tier of 10 GB stored, 1M writes and 10M reads a month (R2 pricing). ocre deploy detects an account without R2 (API code 10042) and says so in its hint.
Errors (besides the shared ones):
| Error | Hint |
|---|---|
`cf d1 list --name <database>` failed: ... (also cf queues list, cf queues create ..., cf kv namespaces list ..., cf d1 create ...) | log in with `ocre login`, or set CLOUDFLARE_API_TOKEN (and CLOUDFLARE_ACCOUNT_ID when the token sees several accounts) |
`cf d1 create --name <database>` returned no uuid: ... | run `ocre deploy` again: it finds the database by name once Cloudflare lists it |
`cf kv namespaces create --title <title>` returned no id: ... | run `ocre deploy` again: it links the namespace once Cloudflare lists it |
`cf r2 buckets get <bucket>` failed: ... | R2 not enabled (code 10042): enable R2 once in the Cloudflare dashboard (Storage & databases > R2; the free plan asks for a payment method but charges nothing within 10 GB, 1M writes and 10M reads a month), then run `ocre deploy` again; otherwise the login hint above |
`cf workers secrets list --worker <name>` failed: ... | the login hint above, then ; Ocre only creates SECRET_KEY_BASE when sure the Worker has none |
`cf d1 migrations apply <id>` failed (exit status: 1) | read the cf output above; the first error line names the cause; the previous code keeps running |
`cf deploy` failed (exit status: 1) | same |
unexpected `cf ...` output: ... | none |
Resources created before a failure stay created; running ocre deploy again skips them.
ocre logs
Streams the deployed Worker’s live logs until you stop it (Ctrl-C): each request with its outcome, its console lines (ctx.log() lines, Ocre’s [ocre] errors) and uncaught exceptions. It runs the app’s wrangler tail, because cf 1.0.0-beta.5 has no tail command, so it uses wrangler’s own login (npx wrangler login in the app, or CLOUDFLARE_API_TOKEN). Stored logs are in the dashboard (Workers Logs).
ocre logs # pretty, every request
ocre logs --status error # failed invocations only
ocre logs --search checkout --format json
| Flag | Effect |
|---|---|
--format pretty|json | pretty (default), or one JSON object per event |
--status ok|error|canceled | Keep invocations with that outcome (repeatable) |
--search <text> | Keep events whose console lines contain the text |
Errors: outside an app, the usual “no cloudflare.config.ts found” error; without npm install, the hint names it; when wrangler fails (not logged in, Worker never deployed), the error carries wrangler’s output and the hint wrangler tail uses wrangler's own login: run npx wrangler login ... the Worker must be deployed (ocre deploy). See Errors, logging and debugging.
ocre push-keys
ocre push-keys [--json]
Prints a new VAPID key pair for web push (ocre g push): VAPID_PUBLIC_KEY=<65-byte P-256 point> and VAPID_PRIVATE_KEY=<32 bytes>, URL-safe base64, one per line, ready to append to .prod.vars. With --json: {"command": "push-keys", "ok": true, "vapid": {"public_key": "...", "private_key": "..."}}. It works anywhere and writes nothing. See Web push notifications.
ocre secret
ocre secret [--json]
Prints a new random value for SECRET_KEY_BASE, like rails secret: 64 bytes from the operating system’s random number generator, as 128 lowercase hex characters. It works anywhere (no app needed) and writes nothing. ocre new already puts one in .dev.vars and the first ocre deploy uploads one; use ocre secret to replace a leaked secret (which signs every user out): put the value in .prod.vars (git-ignored) as SECRET_KEY_BASE=<value>, then
ocre secrets push SECRET_KEY_BASE --file .prod.vars
ocre secret
951f4f0c57883adb8f82c2f6f1b63b21001acb6427c56a6e8ac3b471545d79da61252e7027db0b7a72b13c64bdaa7e949d2ceb9ccf85cbf0a4f5a9be35fc08d7
ocre secret --json
{"command":"secret","ok":true,"secret":"f40f8e050bae938dc4b2475e7ac01e92be3bb8f6bf3d08b9d674efea068ec69e75e915ad156b3d61a6596d6953c81ec73313167965fde14b17720434301625ba"}
See SECRET_KEY_BASE.
ocre secrets
ocre secrets list [--json]
ocre secrets push <NAMES>... [--file <FILE>] [--store [--store-id <ID>]] [--json]
Worker secrets are Ocre’s credentials (Rails’ credentials.yml.enc): Cloudflare stores them encrypted, the Worker reads them with ctx.secret(NAME).await, and nothing secret is committed. Values can be written but never read back.
ocre secrets list shows each secret name of .dev.vars (used by ocre dev) and of the deployed Worker (cf workers secrets list --worker <name>), side by side. Names set locally but not deployed get a next step; SECRET_KEY_BASE and MAIL_ADAPTER are left out of it, their .dev.vars values being for development only.
GITHUB_CLIENT_ID .dev.vars
MAIL_ADAPTER .dev.vars
SECRET_KEY_BASE .dev.vars
Next:
ocre secrets push GITHUB_CLIENT_ID --file <production values>
{"command":"secrets list","next":["ocre secrets push GITHUB_CLIENT_ID --file <production values>"],"ok":true,"secrets":[{"deployed":false,"local":true,"name":"GITHUB_CLIENT_ID"},{"deployed":false,"local":true,"name":"MAIL_ADAPTER"},{"deployed":false,"local":true,"name":"SECRET_KEY_BASE"}]}
ocre secrets push uploads the named secrets to the deployed Worker, with their values read from a NAME=value file (--file, default .dev.vars; keep production values in .prod.vars, which the app’s .gitignore lists). It writes them to a temporary JSON file under .wrangler/ (readable only by you), runs one cf workers secrets bulk --worker <name> --file <it> (a new Worker version, no rebuild), and deletes the file whatever happened. Values never appear on a command line. The report’s ran is ["uploaded GITHUB_CLIENT_ID", ...].
ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars
Errors:
| Error | Hint |
|---|---|
name the secrets to upload | e.g. `ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET`; `ocre secrets list` shows them |
SECRET_KEY_BASE in .dev.vars is a development value (also MAIL_ADAPTER) | production needs its own: `ocre deploy` creates SECRET_KEY_BASE; for others put the production value in another git-ignored file (e.g. .prod.vars) and pass `--file <it>` |
NOPE is not set in .prod.vars | add `NOPE=<value>` to .prod.vars (a git-ignored file), then run this again |
cannot read <file>: ... | none |
With --store, the secrets go to the account’s Secrets Store instead, where every Worker that binds them reads them: a name already bound in cloudflare.config.ts keeps its store and secret name; a new one goes to --store-id, else to the account’s first store (cf secrets-store stores list), and gets its binding, NAME: bindings.secretsStoreSecret({ storeId: "<id>", secretName: "NAME" }),, after // ocre:env. Each value is written to a temporary JSON file under .wrangler/ (readable only by you) for one cf secrets-store secrets create <store> --body @<file> (or secrets edit <id> --store-id <store> when the secret exists), then the file is deleted. The report’s ran is ["stored NAME in the Secrets Store <id>", ...]; next asks for ocre deploy when a binding was added, and flags a Worker secret of the same name, which must be deleted for the binding to take it. ocre secrets list reads the stores the Worker binds, and marks those names in the Secrets Store (JSON: "store": "<id>").
ocre secrets push RESEND_API_KEY --store --file .prod.vars
More errors with --store: <NAME> cannot live in the Secrets Store for SECRET_KEY_BASE and R2_* (Ocre reads them without waiting; keep them Worker secrets); <NAME> is already a `<kind>` binding in cloudflare.config.ts; the account has no Secrets Store (hint: open Secrets Store in the dashboard once, or npx wrangler secrets-store store create default --remote). The store commands’ answers follow the API’s documented shapes; Ocre’s tests have not run them against a live account (October 2026).
Both commands need a Cloudflare login for the deployed side, and the shared errors apply.
ocre secrets fetch NAME [--file FILE] prints one value of a local NAME=value file (default .dev.vars), like Rails’ credentials:fetch, for scripts: export STRIPE_KEY=$(ocre secrets fetch STRIPE_KEY --file .prod.vars). With --json the value is in secret. It never calls Cloudflare, which does not return deployed values. Error: STRIPE_KEY is not set in .dev.vars (hint: add `STRIPE_KEY=<value>` to .dev.vars; deployed values cannot be read back (Cloudflare only stores them)).
ocre domains
ocre domains [--json]
ocre domains add <HOST> [--json]
ocre domains remove <HOST> [--json]
The Worker’s custom domains: the domains list of worker in cloudflare.config.ts (cf’s config schema). add writes the list (after name: the first time) and remove takes a host out; the next ocre deploy publishes the Worker on them, and Cloudflare creates the DNS record and the certificate. The host’s zone must be on the Worker’s Cloudflare account (free plan zones work); the token of a CI deploy then also needs the Workers Routes permission of that zone. Without a subcommand, lists them.
ocre domains add www.example.com
{"command":"domains add","domains":["www.example.com"],"next":["ocre deploy (creates the DNS record and certificate; the zone must be on this Cloudflare account)"],"ok":true,"updated":["cloudflare.config.ts"]}
Errors: `https://x` is not a hostname Ocre can add as a custom domain (hint: a lowercase hostname without scheme, path or wildcard), www.example.com is already a custom domain of the Worker, www.example.com is not a custom domain of the Worker, and cloudflare.config.ts has a `domains` entry Ocre cannot read (hint: write it as a list of string literals). Routes with wildcards (example.com/api/*) stay hand-written triggers.fetch({ pattern, zone }) entries.
ocre routes
ocre routes [FILTER] [--json]
Lists the app’s HTTP routes without building it, like rails routes. It reads src/lib.rs, finds .route("<path>", get(handler).post(handler)...) calls, follows each .merge(<module>::routes()) into src/<module>.rs (or src/<module>/mod.rs), and knows that ocre::graphql::routes(...) serves GET and POST /graphql. It is a tolerant scanner, not a Rust parser: routes built another way (closures as handlers, routers from functions not named routes, macros) are skipped. Routes are sorted by path, then by method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).
| Argument | Default | Effect |
|---|---|---|
FILTER | none | Keep routes whose method, path or handler contains this text, case-insensitive |
ocre routes comments
METHOD PATH HANDLER
GET /comments comments::index
POST /comments comments::create
GET /comments/new comments::new
GET /comments/{id} comments::show
POST /comments/{id} comments::update
POST /comments/{id}/delete comments::delete
GET /comments/{id}/edit comments::edit
ocre routes comments --json
{"command":"routes","ok":true,"routes":[{"handler":"comments::index","method":"GET","path":"/comments"},{"handler":"comments::create","method":"POST","path":"/comments"},{"handler":"comments::new","method":"GET","path":"/comments/new"},{"handler":"comments::show","method":"GET","path":"/comments/{id}"},{"handler":"comments::update","method":"POST","path":"/comments/{id}"},{"handler":"comments::delete","method":"POST","path":"/comments/{id}/delete"},{"handler":"comments::edit","method":"GET","path":"/comments/{id}/edit"}]}
The filter is a substring match on all three columns: ocre routes up also returns /signup and every ::update handler. With no match, the human output is No routes. and the JSON has "routes": []. Error: cannot read src/lib.rs: ... (hint: `ocre routes` reads the router built in src/lib.rs; run it inside an Ocre app).
ocre schedules
ocre schedules [--json]
ocre schedules run <TASK> [--port <PORT>] [--json]
ocre schedules lists the triggers.scheduled expressions of cloudflare.config.ts with the task that handles each, read from the "<cron>" => <task>::run(...) arms of src/schedules/mod.rs (see ocre g schedule), without building the app. A cron no task handles fails when it fires; it is listed as (no task: fails when it fires), with a next step.
CRON (UTC) TASK
0 3 * * * src/schedules/nightly_cleanup.rs
0 9 * * MON src/schedules/weekly_digest.rs
{"command":"schedules","ok":true,"schedules":[{"cron":"0 3 * * *","task":"nightly_cleanup"},{"cron":"0 9 * * MON","task":"weekly_digest"}]}
ocre schedules run <TASK> fires the task’s cron on the running ocre dev, through the dev server’s local /cdn-cgi/local/scheduled endpoint, and returns once it answered; the task’s [ocre cron] <cron> done (or failed) line is in the ocre dev output. Cron Triggers never fire in ocre dev by themselves.
| Argument | Default | Effect |
|---|---|---|
TASK | required | The task, as in src/schedules/<task>.rs |
--port | 8787 | The port ocre dev listens on |
{"command":"schedules run","ok":true,"ran":["fired nightly_cleanup (0 3 * * *); its `[ocre cron]` line is in the `ocre dev` output"]}
Errors: no scheduled task `<task>` (hint: the tasks of the app, or how to create one); cannot reach `ocre dev` on port 8787: ... (hint: start ocre dev first, or pass --port); the scheduled task answered HTTP 500 (hint: read the [ocre cron] line in the ocre dev output).
ocre i18n missing
ocre i18n missing [--json]
Checks the translation files set up by ocre g locale. It reads the codes declared in ocre::locales!(...) in src/lib.rs (the first is the default locale) and parses locales/<code>.yml with the same parser the Worker uses. It fails when any of these is found:
- a declared locale without its file (a compile error in the app);
- a file in
locales/thatsrc/lib.rsdoes not declare (never loaded); - a file the Worker could not load, with the line number;
- a key of the default locale missing from another locale, including the plural forms that language needs (
one/other, plusfew/many… for some languages).
ocre i18n missing
every locale (en, fr) has every key of locales/en.yml
ocre i18n missing --json
{"command":"i18n missing","ok":true,"ran":["every locale (en, fr) has every key of locales/en.yml"]}
A failing check (exit code 1):
error: 3 locale problems:
locales/de.yml: not declared; add "de" to ocre::locales!(...) in src/lib.rs
locales/fr.yml: missing app.posts.one
locales/fr.yml: missing app.posts.other
hint: add each missing key at the same path as in locales/en.yml, translated (plural keys need the forms listed); then run `ocre i18n missing` again
An invalid file is reported with its line, e.g. locales/fr.yml:6: quote values starting with `%`, e.g. "%oops". In an app without translations the error is src/lib.rs declares no locales (ocre::locales!(...) not found) with the hint run `ocre g locale en` to set up translations, then `ocre g locale fr` for each other language.
ocre dev and ocre deploy run the loading part of this check (missing files, invalid files) but not the missing-keys part: at runtime, a missing key falls back to the default locale in release builds (see Translations).
ocre ci
ocre ci [--signoff] [--json]
Runs the app’s CI steps on your machine, in order, and stops at the first failure (Rails 8.1’s bin/ci). They are the steps of the GitHub workflow that ocre g ci writes, so a green ocre ci means a green CI run:
cargo fmt --check(hint on failure:run `cargo fmt`, then `ocre ci` again);cargo clippy --all-targets -- -D warnings(hint:fix the warnings above (`rustup component add clippy` if cargo has no clippy command));cargo test;cargo check --target wasm32-unknown-unknown(after checking that rustc has the target);ocre i18n missing, whensrc/lib.rsdeclares locales.
| Flag | Effect |
|---|---|
--signoff | After a green run, runs gh signoff, the basecamp/gh-signoff extension of the GitHub CLI, which sets a passing status on the pushed commit (install it with gh extension install basecamp/gh-signoff; a branch protection rule can require it) |
cargo fmt --check: ok
cargo clippy --all-targets -- -D warnings: ok
cargo test: ok
cargo check --target wasm32-unknown-unknown: ok
ocre i18n missing: ok
{"command":"ci","ok":true,"ran":["cargo fmt --check: ok","cargo clippy --all-targets -- -D warnings: ok","cargo test: ok","cargo check --target wasm32-unknown-unknown: ok"]}
With --json, cargo’s output goes to stderr. A failing step ends with, for example, error: cargo test failed (exit status: 101) ("ok": false, the steps that passed still in ran). Other errors: the missing wasm target, could not run cargo: ..., the ocre i18n missing problems, gh not found (hint: install the GitHub CLI (https://cli.github.com), then `gh extension install basecamp/gh-signoff` ) and gh signoff failed (<status>).
ocre doctor
ocre doctor [--json]
Checks the tools and, inside an app, its setup, like cargo loco doctor. Each check is ok, warn or fail; any fail makes the command exit with code 1, so it can gate CI. Warnings (not logged in, pending migrations) do not. Outside an app, only the tool checks run.
| Check | Fails or warns when |
|---|---|
rust | Fails: rustc has no wasm32-unknown-unknown target |
node | Fails: node --version does not run or is older than 22 (cf’s requirement) |
cloudflare login | Warns: not logged in (cf auth whoami); the hint says to log in once more when a wrangler login exists, since cf keeps its own. Fails when cf errors |
npm packages | In an app. Fails: node_modules has no cf or wrangler (run npm install), or wrangler is older than 4.136. Warns: the installed versions differ from the ones this CLI is tested with (cf 1.0.0-beta.5, wrangler 4.144.0) |
config | In an app, with the packages installed. Fails: cf’s own loader (@cloudflare/config) rejects cloudflare.config.ts, or tsc -p . reports type errors in it or in wrangler.config.ts |
cache binding, storage binding, jobs queue, cron triggers, realtime binding | Only when the code uses them (ocre::cache::, ocre::storage::, the queue and scheduled events, realtime channels). Fails: cloudflare.config.ts lacks the CACHE KV binding, the STORAGE R2 binding, a queue binding and its triggers.queue, a triggers.scheduled entry, or the CHANNELS binding and OcreChannel export |
migrations | Warns: local migrations are pending (wrangler d1 migrations list DB --local, through the app’s wrangler) |
local secrets | Fails: .dev.vars has no SECRET_KEY_BASE |
production secrets | When logged in. Warns: the deployed Worker lacks SECRET_KEY_BASE (or RESEND_API_KEY with MAIL_ADAPTER = "resend"); ok when the Worker is not deployed yet |
production config | Settings unsafe in production (Loco’s production safety check). Fails: a plain-text variable of worker.env is named like a secret (SECRET, TOKEN, PASSWORD, API_KEY, PRIVATE_KEY: committed and readable; push it with ocre secrets push), or the app is a git repository whose .gitignore lacks .dev.vars. Warns: MAIL_ADAPTER = "log", CACHE_STORE = "null", LOG_LEVEL = "debug" or "trace" in worker.env (development values belong in .dev.vars) |
Each file of .ocre/doctor/ | The app’s own checks (Loco’s initializer check()), run in name order from the app root with no input: exit 0 is ok, exit 2 warn, anything else fail; the first line of stdout is the detail, the first line of stderr the hint. A file that cannot run (not executable, no #! line) fails |
An app check is any executable, for example .ocre/doctor/stripe (chmod +x it):
#!/bin/sh
# Fails unless .prod.vars has the Stripe key the production Worker needs.
if grep -q '^STRIPE_KEY=' .prod.vars 2>/dev/null; then
echo "STRIPE_KEY ready in .prod.vars"
else
echo "STRIPE_KEY missing from .prod.vars"
echo "add STRIPE_KEY=... to .prod.vars, then ocre secrets push STRIPE_KEY --file .prod.vars" >&2
exit 1
fi
ok rust wasm32-unknown-unknown target installed
ok node node v22.23.2
ok cloudflare login logged in as ada@example.com
ok npm packages cf 1.0.0-beta.5, wrangler 4.144.0
ok config cloudflare.config.ts is valid
ok storage binding configured in cloudflare.config.ts
warn migrations pending locally: 0001_create_posts.sql
run `ocre migrate`
ok local secrets SECRET_KEY_BASE set in .dev.vars
ok production config no secret in plain-text variables, no development-only setting
ok production secrets not deployed yet
With --json, checks holds one {"name", "status", "detail", "hint"} object per line. A failed check ends the output with error: 1 check(s) failed: local secrets and hint: fix each failed check as its hint says, then run ocre doctor again ("ok": false, error and hint with --json).
ocre about
ocre about [--json]
Versions and the app’s configuration, read from Cargo.toml, cloudflare.config.ts, wrangler.config.ts and rust-toolchain.toml without building anything (Rails’ about, Loco’s doctor --config). Variable values are never printed, only their names.
Ocre CLI 0.1.0
App ci-app
App version 0.1.0
Ocre crate git https://github.com/tgeselle/ocre.rs
Rust toolchain stable
Compatibility date 2026-09-01
Mode full-stack (HTML and JSON)
Bindings D1 DB (ci-app), KV CACHE, R2 STORAGE (ci-app-storage), Queue JOBS (ci-app-jobs), Queue consumer (ci-app-jobs), Durable Object CHANNELS (OcreChannel), cron 0 3 * * *, Assets (public)
Variables MAIL_FROM
Ocre features realtime, graphql
With --json, the same values are in about: cli, app, app_version, ocre, mode, rust_toolchain, compatibility_date, bindings, vars and features.
ocre version
ocre version [--json]
ocre --version
ocre version prints the CLI’s version and, inside an app, the app’s name and version and its ocre dependency (version, git source or path):
Ocre CLI 0.1.0
App ci-app
App version 0.1.0
Ocre crate git https://github.com/tgeselle/ocre.rs
{"about":{"app":"ci-app","app_version":"0.1.0","cli":"0.1.0","ocre":"git https://github.com/tgeselle/ocre.rs"},"command":"version","ok":true}
ocre --version (or -V) prints only the CLI’s version, e.g. ocre 0.2.0.
ocre stats
ocre stats [DIRS]... [--json]
Lines of code per part of the app, like rails stats: files, lines, LOC (lines neither blank nor only a comment) and Rust functions for src/models, src/jobs, src/mailers, src/schedules, the rest of src/, templates/, migrations/ and tests/, then the Rust code-to-test ratio. Each extra directory (relative to the app root) gets its own row.
Name Files Lines LOC Functions
Models 19 3282 2358 273
Jobs 4 144 64 7
Mailers 3 85 44 3
Schedules 3 53 21 3
Controllers and app 27 2921 2233 211
Templates 50 787 641 0
Migrations 26 217 186 0
Tests 0 0 0 0
Code LOC: 4720 Test LOC: 0 Code to test ratio: 1:0.0
With --json: "stats": {"rows": [{"name", "files", "lines", "loc", "functions"}, ...], "code_loc", "test_loc"}. Error: lib is not a directory of the app (hint: pass directories relative to the app root, e.g. `ocre stats lib` ).
Directories to count on every run (Rails’ CodeStatistics.register_directory) go in Cargo.toml; each gets its own row, once, before those given on the command line:
[package.metadata.ocre]
stats = ["lib", "benches"]
ocre notes
ocre notes [--annotations TAG,TAG...] [--json]
Lists the TODO, FIXME and OPTIMIZE comments of src/, templates/, migrations/, tests/, db/ and locales/, like rails notes. A tag counts inside a comment (//, /*, {#, <!--, -- or #) as a whole word; --annotations replaces the tags.
src/pages.rs:42: [TODO] tidy this
{"command":"notes","notes":[{"line":42,"path":"src/pages.rs","tag":"TODO","text":"tidy this"}],"ok":true}
ocre time-zones
ocre time-zones [--json]
Prints the 418 IANA time zone names that ocre::helpers::time_zone_options offers, one per line (Rails’ bin/rails time:zones:all); --json returns them as time_zones.
ocre time-zones | grep Europe/P
Europe/Paris
Europe/Podgorica
Europe/Prague
ocre help
ocre help [COMMAND]...
ocre <COMMAND> --help
Prints the help of ocre or of a command (ocre help db seed, ocre g model --help). -h prints a shorter summary where the long help has examples. The help of ocre g model lists the field types.
See also
- Generators: every
ocre ggenerator. - Configuration:
cloudflare.config.ts,wrangler.config.ts,package.json,.dev.vars, secrets and variables. - Deployment: deploying, custom domains, production data.
- Models and migrations: writing migrations and seeds.
- Free-plan limits.
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 nearestcloudflare.config.ts). - Generators only write files: they need no network, no cf and no Cloudflare account. Run
ocre migrateafter the ones that add migrations, andcargo check --target wasm32-unknown-unknown(orocre test) to type-check the result. - Keep the
// ocre:...marker comments of generated files (// ocre:modulesand// ocre:routesinsrc/lib.rs,// ocre:modelsinsrc/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:triggersand// ocre:exports; see Configuration). When a file it would create exists, it stops with<path> already existsand the hintgenerators 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 withocre g migration. - Report. The human output lists
create <path>,update <path>andskip <path>lines, thenNext:steps. With--jsonit is one object withcommand(generate <generator>),created,updated,skipped,pretendandnext(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 --apihas[package.metadata.ocre] mode = "api"inCargo.toml. There,ocre g scaffoldgenerates a JSON API (likeocre g api), andauthandmailergenerate no HTML. - Migrations are numbered after the highest existing number:
migrations/0002_create_comments.sql, then0003_.... - Marker errors. When a marker a generator needs is missing, it fails with
<file> is missing the `<marker>` markerand a hint saying where to put it back.
| Generator | Creates |
|---|---|
ocre g model | Migration and src/models/<model>.rs |
ocre g scaffold | Model (unless it exists) plus HTML CRUD pages; --realtime for live updates |
ocre g api | Model (unless it exists) plus a JSON REST resource; --graphql for GraphQL |
ocre g resource | Model (unless it exists) plus index and show actions to fill in |
ocre g controller | A module of GET actions, with a page each (or JSON) |
ocre g auth | Users, sessions, password reset, magic links, email confirmation, JWT and API keys; --db-sessions, --oauth (once per app) |
ocre g migration | One numbered SQL migration, its SQL inferred from the name |
ocre g mailer | Functions building emails, with templates |
ocre g mailbox | The handler of incoming email (once per app) |
ocre g job | A background job on Cloudflare Queues |
ocre g schedule | A task run by a Cron Trigger |
ocre g cache | The CACHE Workers KV binding |
ocre g ci | The GitHub Actions workflow: ocre ci’s checks, then ocre deploy on main |
ocre g pwa | Web app manifest, service worker and icon, linked from the layout |
ocre g locale | Translation files |
ocre g override | Copies of generator templates in .ocre/templates/, which then replace the built-in ones |
ocre g generator | An 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:
| Flag | Effect |
|---|---|
--pretend | Computes and reports the changes (create, update and skip lines, then (--pretend: nothing was written); "pretend": true in JSON), writes nothing and records nothing |
--force | Overwrites files the generator creates when they already exist (they are reported as update) |
--skip | Keeps 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:
| Type | SQL column | Rust type | Notes |
|---|---|---|---|
string | TEXT | String | One-line text; required unless ? |
text | TEXT | String | Multi-line text (a textarea in forms); required unless ? |
integer (int, small_int, big_int) | INTEGER | i64 | Validated within ±(2^53 - 1), the integers D1 returns exactly |
float (double) | REAL | f64 | |
decimal | TEXT | String | Exact number such as 19.99 (money); validated as a decimal |
boolean (bool) | INTEGER NOT NULL DEFAULT 0 | bool | A checkbox; cannot be ? |
date | TEXT | String | Validated as YYYY-MM-DD |
time | TEXT | String | Validated as HH:MM[:SS] |
datetime (date_time) | TEXT | String | Validated as a date and time |
uuid | TEXT | String | Validated as a hyphenated UUID |
references | <name>_id INTEGER REFERENCES <plural>(id) ON DELETE CASCADE (SET NULL when ?), indexed | i64 | author:references adds author_id, author:references:writer_id names the column; src/models/author.rs must exist; validated as “must exist” |
attachment | four columns: <name>_key, <name>_filename, <name>_content_type (TEXT), <name>_size (INTEGER) | ocre::storage::Upload when received, Attachment when stored | A 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::Value | Any 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_text | TEXT | String | Formatted 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 records | commentable: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 attachment | the child model, and attach_<name> / replace_<name> / purge_<name> on the parent | photos: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):
| Error | Hint |
|---|---|
field `title` has no type | write 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 optional | booleans are true or false (a checkbox); drop the `?` |
attachment `a` cannot be unique | every stored file gets its own random key already; drop the `^` |
json field `v` cannot be unique | a unique index compares JSON text, where key order and spacing differ; drop the `^` |
enum `s` has no values | list 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 unique | a 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 twice | names 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:
| Input | Struct | Module and file | Table, plural module, URL segment | Human |
|---|---|---|---|---|
Post | Post | post, src/models/post.rs | posts | Post, Posts |
BlogPost | BlogPost | blog_post | blog_posts | Blog post, Blog posts |
category | Category | category | categories | Category, Categories |
Box | Box | box | boxes | Box, Boxes |
Person | Person | person | people | Person, 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>...
| Argument | Required | Meaning |
|---|---|---|
NAME | yes | Singular model name (see Model names) |
FIELDS | yes, at least one | name: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 TABLEwithid INTEGER PRIMARY KEY AUTOINCREMENT, the field columns,created_atandupdated_at(TEXT NOT NULL DEFAULT (datetime('now'))), then aCREATE UNIQUE INDEXper^field and aCREATE INDEXper reference. Skipped when a*_create_<plural>.sqlmigration already exists.src/models/<model>.rs: the row struct (Author, derivingDeserializeandSerialize),NewAuthor(values for a new row),AuthorChanges(every field anOption; for optional fieldsSome(None)clears the value),validate()on both (presence of required text, dates, safe integers, files), and the functionsall(ctx, page)(newest first),count,find,find_many(100 ids per query),create,updateanddelete.createandupdateadd 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 addsmod models;tosrc/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 flag | Default | Meaning |
|---|---|---|
NAME | required | Singular model name |
FIELDS | required, at least one | name:type fields |
--realtime | off | Live 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:
| Route | Handler | Effect |
|---|---|---|
GET /comments | index | List, newest first, paginated |
GET /comments/new | new | New form |
POST /comments | create | Create; redirects with a flash notice, or shows the form with errors (422) |
GET /comments/{id} | show | One record |
GET /comments/{id}/edit | edit | Edit form |
POST /comments/{id} | update | Update |
POST /comments/{id}/delete | delete | Delete, then redirect |
GET /comments/{id}/<attachment> | <attachment>_file | For 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 Realtime in the Ocre README. See Controllers and routing, Views, helpers and forms, File storage and Realtime.
ocre g api
ocre g api <NAME> <FIELDS>... [--graphql]
| Argument or flag | Default | Meaning |
|---|---|---|
NAME | required | Singular model name |
FIELDS | required, at least one | name:type fields; attachments must be optional (file:attachment?) |
--graphql | off | Also 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:
| Route | Effect |
|---|---|
GET /api/products?limit=&offset= | List, newest first |
GET /api/products/{id} | One record |
POST /api/products | Create (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 flag | Default | Meaning |
|---|---|---|
NAME | required | Singular model name |
FIELDS | required, at least one | name:type fields |
--api | off | JSON 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 flag | Default | Meaning |
|---|---|---|
NAME | required | Controller name, PascalCase or snake_case; a Controller suffix is dropped (PagesController gives pages). Not a Rust keyword, not ending in api |
ACTIONS | index | Action names in snake_case, one GET route each; index answers at the controller’s root path |
--api | off | JSON actions under /api/<name> in a full-stack app (always JSON in an API-only app) |
--auth | off | Signed-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:
| Error | Hint |
|---|---|
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 twice | list 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 exists | the 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.
| Option | Effect |
|---|---|
--db-sessions | Tracks 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.
| File | Contents |
|---|---|
src/models/user.rs, api_key.rs, auth_token.rs | Users (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.rs | The CurrentUser, ConfirmedUser and OptionalUser extractors, sign_in/sign_out, SESSION_SECONDS (two weeks) and OAUTH_PROVIDERS (full-stack only) |
src/registrations.rs | GET/POST /signup, GET /account, POST /account/delete |
src/sessions.rs | GET/POST /login (“remember me”), POST /logout, magic-link login (/magic_link, /magic_link/{token}) |
src/passwords.rs | Password reset: /passwords/new, POST /passwords, /passwords/{token} |
src/confirmations.rs | Email confirmation: POST /confirmations (send a new link), /confirmations/{token} |
src/auth_api.rs | In 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/*.html | The pages (full-stack only) |
cloudflare.config.ts | The 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 tablewhensrc/models/user.rsor a*_create_users.sqlmigration 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 hintrun `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]...
| Argument | Required | Meaning |
|---|---|---|
NAME | yes | snake_case migration name |
FIELDS | no | Columns 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:
| Name | SQL |
|---|---|
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 fields | An 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:
| Error | Hint |
|---|---|
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 add | list 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 table | SQLite 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 names | list them in index order, without types, e.g. `ocre g migration add_index_to_posts author_id created_at` |
`drop_tags` takes no fields | the name says it all, e.g. `rename_title_to_headline_in_posts`, `drop_tags`, `rebuild_posts` |
cannot tell what `rename_title` renames | name 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.sql | run `ocre migrate` then `ocre db schema` to refresh it, and check the table name |
`comments` references `posts`: rebuilding it would delete or clear their rows | D1 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>...
| Argument | Required | Meaning |
|---|---|---|
NAME | yes | Mailer name, PascalCase or snake_case; a Mailer suffix is dropped (UserMailer, user_mailer and User all give user) |
ACTIONS | yes, at least one | Email 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>Jobrow (status:queued,submitted,running,doneorfailed;external_id,progress,result,error,attempts),start(&ctx, <fields>)(insert and submit with a signed POST to<NAME>_URL),submit,find,sweep, the routePOST /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>_jobsmigration; 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>_SECRETandAPP_URLin.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>]
| Argument | Required | Meaning |
|---|---|---|
NAME | yes | Job name, PascalCase or snake_case, a verb phrase; a Job suffix is dropped (ImportCsvJob gives import_csv and ImportCsv) |
FIELDS | no | The job’s arguments as name:type; no attachment, no ^ |
--queue <QUEUE> | no | The 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,...> | no | Run 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> | no | One 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:
| Error | Hint |
|---|---|
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 attachment | files 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 exists | the 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>
| Argument | Required | Meaning |
|---|---|---|
NAME | yes | Task name in snake_case (HourlyPing is accepted and becomes hourly_ping); not a reserved word |
WHEN | yes | When, 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:
| Error | Hint |
|---|---|
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] crons | one 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.ts | one 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` marker | put `// 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
checkjob on every push and pull request: checkout,rustup component add rustfmt clippy(rust-toolchain.tomladds the wasm32 target), a Rust cache, then the steps ofocre ciin the same order (cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test,cargo check --target wasm32-unknown-unknown), andocre i18n missingwhenlocales/*.ymlexist (it installs the CLI first); - a
deployjob after it, on pushes tomainonly, 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, thenocre deploy --jsonwithCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDfrom 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):
| File | Contents |
|---|---|
public/manifest.webmanifest | Name (the Worker’s), start_url, display: standalone, colors and the icon, so browsers offer to install the app |
public/service-worker.js | Caches 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.js | Registers the service worker (a file, so the default script-src 'self' Content-Security-Policy allows it) |
public/icon.svg | A 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>...
| Argument | Required | Meaning |
|---|---|---|
CODES | yes, at least one | Locale 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:
| Error | Hint |
|---|---|
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.rs | edit locales/fr.yml; `ocre i18n missing` lists keys to translate |
ocre::locales!() in src/lib.rs lists no locale | put 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:
| Templates | Variables |
|---|---|
controller/html.rs, controller/api.rs | command, module (pages), file (pages or pages_api), human (Pages), auth, actions (each with name, pascal, human, path) |
controller/view.html | module, 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/*.html | model, 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:
| Variable | Value for ocre g service BlogPost amount:integer total:decimal? --api --queue=urgent |
|---|---|
model, singular, plural | BlogPost, blog_post, blog_posts |
human_singular, human_plural | Blog post, Blog posts |
args | the arguments after the name: ["amount:integer", "total:decimal?"] |
fields | when 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:
| Error | Hint |
|---|---|
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 name | run `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>` marker | put `<marker>` on its own line where the generated lines go |
.ocre/generators/<name>/<file> failed to render: ... | fix .ocre/generators/<name>/<file> |
See also
- CLI commands:
ocre migrate,ocre dev,ocre deploy,ocre routesand the--jsoncontract. - Field types: each field type in SQL, Rust, forms and JSON.
- Models and migrations, Validations.
- Why generated code.
Field types
This page lists every field type the ocre g model, ocre g scaffold, ocre g api, ocre g resource and ocre g migration generators accept, with the SQL column, Rust types, serde attributes, form input, JSON and GraphQL representation and validations each one produces, plus the ? (optional) and ^ (unique) modifiers and the names that are refused.
Before you start
- An Ocre app created with
ocre new(see CLI commands). - Fields are written after the model name of a generator:
ocre g model Post title:string^ body:text published:boolean(see Generators). referencesfields need the referenced model to exist first (ocre g model Author name:stringbeforeauthor:references).- Everything below was checked by running the generators of the current
ocreCLI in a new app and quoting the files they wrote.
Syntax
A field is name:type, optionally followed by modifiers:
| Written | Meaning |
|---|---|
title:string | Required: NOT NULL column, a value is needed to create a record |
summary:string? | Optional: the column accepts NULL, Rust uses Option<T> |
slug:string^ | Unique: a CREATE UNIQUE INDEX plus a “has already been taken” check in create and update |
code:string?^ or code:string^? | Optional and unique, in either order: NULL is allowed any number of times, other values once |
Field names are snake_case identifiers starting with a letter (published_at). Every table also gets id INTEGER PRIMARY KEY AUTOINCREMENT, created_at and updated_at (TEXT NOT NULL DEFAULT (datetime('now')), UTC, written like 2026-09-29 04:20:26); you do not declare them.
Summary
| Type | SQL column | Rust type | HTML form input | JSON | GraphQL | Generated validation |
|---|---|---|---|---|---|---|
string | TEXT | String | <input> | string | String | “can’t be blank” when required |
text | TEXT | String | <textarea rows="5"> | string | String | “can’t be blank” when required |
rich_text | TEXT | String (HTML) | the Trix editor: <input type="hidden"> + <trix-editor> | string (HTML, sanitized when saved) | String | “can’t be blank” on its text when required; cannot be unique |
integer | INTEGER | i64 | <input type="number" step="1"> | number | Int | within ±ocre::MAX_SAFE_INTEGER |
float | REAL | f64 | <input type="number" step="any"> | number | Float | none (forms: “is not a number”) |
boolean | INTEGER NOT NULL DEFAULT 0 | bool | <input type="checkbox" value="true"> | true / false | Boolean | none; cannot be optional |
date | TEXT | String | <input type="date"> | string YYYY-MM-DD | String | “is not a valid date” |
datetime | TEXT | String | <input type="datetime-local"> | string YYYY-MM-DD HH:MM[:SS] (space or T) | String | “is not a valid date and time” |
time | TEXT | String | <input type="time"> | string HH:MM[:SS] | String | “is not a valid time” |
decimal | TEXT | String | <input inputmode="decimal"> | string such as "19.99" | String | “is not a decimal number” |
uuid | TEXT | String | <input> | string | String | “is not a valid UUID” |
references | INTEGER REFERENCES <plural>(id) ON DELETE CASCADE, named <name>_id, indexed | i64 | <input type="number" step="1"> | number | Int | “must exist” in create/update |
attachment | four columns: <name>_key, _filename, _content_type (TEXT), _size (INTEGER) | ocre::storage::Upload in inputs; four columns plus an Attachment accessor in the record | <input type="file" accept="..."> | the four columns | the four columns (read only) | size and content type (v.file); “can’t be blank” when required |
json | TEXT CHECK (json_valid(<name>)) | ocre::serde_json::Value | <textarea rows="5" spellcheck="false" placeholder="{}"> | the JSON value itself | JSON scalar | forms: “is not valid JSON”; cannot be unique |
enum:<a>,<b>... | TEXT CHECK (<name> IN ('a', 'b')) | a generated Rust enum (Status) | <select> with one <option> per value | string, one of the values | not supported (--graphql refuses it) | forms: “is not included in the list”; cannot be unique |
public_id:token | TEXT NOT NULL with a unique index | String in the record (set by create), absent from New and Changes; the record’s id is not serialized | none | not sent | not supported with --graphql | none |
lock_version:integer | INTEGER NOT NULL DEFAULT 0 | i64 in the record, Option<i64> in Changes, absent from New | <input type="hidden"> | number | Int (patch only) | none; update answers 409 when stale |
polymorphic:<a>,<b>... | <name>_type TEXT CHECK (... IN ('a', 'b')) and <name>_id INTEGER, indexed together | a generated enum (CommentableType) and i64; an accessor returning Commentable | <select> and <input type="number"> | string and number | not supported | “must exist” in create/update |
attachments | a child table <model>_<singular> | a child model; attach_<name> / replace_<name> / purge_<name> on the parent | the show page’s <input type="file" multiple> | POST/GET/DELETE /api/<plural>/{id}/<name> | not supported | the child’s FILE rules |
Without ?, every column is NOT NULL. The sections below give the exact generated code for each type.
Aliases, for fields written the Loco or Rails way, produce exactly the same code as the type they name:
| Alias | Same as | Why |
|---|---|---|
int, small_int, big_int | integer | SQLite stores every integer in up to 8 bytes whatever the declared size; D1 returns them exactly within ±(2^53 - 1) |
double | float | SQLite REAL is a 64-bit float |
bool | boolean | |
date_time | datetime | |
jsonb | json | D1 stores JSON as text; json_valid checks it |
SQLite has no array column: store a list as a json field (tags:json, e.g. ["rust", "wasm"]).
Generated code per role
A model file (src/models/<model>.rs) has three structs, and HTML scaffolds add a form struct in src/<plural>.rs. The Rust type of a field depends on the struct:
| Struct | Role | Required field | Optional field (?) |
|---|---|---|---|
Post | A row read from D1, serialized as the JSON response | T | Option<T> |
NewPost | Input of create (JSON body, GraphQL input, parsed form) | T | Option<T> with #[serde(default, deserialize_with = "ocre::optional")] |
PostChanges | Input of update: absent fields keep their value | Option<T> | Option<Option<T>> with #[serde(default, deserialize_with = "ocre::patch")] |
PostForm (HTML scaffolds only) | The form as typed, so errors re-render the typed text | String (bool for checkboxes) | String, empty means None |
The serde helpers come from the ocre crate:
| Helper | Accepts | Result |
|---|---|---|
ocre::optional | missing key, null, a blank string, a typed value, or a string parsed with FromStr ("7" for an i64) | None for the first three, Some(value) otherwise; a string that does not parse is a 400 |
ocre::patch | same as optional | missing key = None (keep), null or blank string = Some(None) (clear), value = Some(Some(value)) |
ocre::patch_json | any JSON value | missing = None, null = Some(None), anything else (a JSON string stays a string) = Some(Some(value)) |
ocre::bool_from_sql | true/false or the integers 0/1 that D1 returns | bool |
ocre::json_from_sql | the JSON text D1 returns (or an already parsed value) | serde_json::Value |
ocre::optional_json_from_sql | same, or NULL | Option<serde_json::Value> |
Required fields in NewPost have no attribute: a missing key or a value of the wrong type ("qty": "3" for an integer) is rejected by the JSON extractor with a 400 before validation runs. Only boolean fields have a default in NewPost (#[serde(default)]: missing means false).
string
Short text on one line.
name TEXT NOT NULL,
summary TEXT,
// NewItem
pub name: String,
#[serde(default, deserialize_with = "ocre::optional")]
pub summary: Option<String>,
- Form:
<input name="name" value="{{ form.name }}" required>; therequiredattribute only on required fields. - Validation:
v.required("name", &self.name)(“can’t be blank”; whitespace only is blank). Optional strings have no check; an empty or whitespace-only form value is stored asNULL. - No length limit is generated. Add
v.max_length(..)invalidate()if you need one (D1 accepts strings up to 2,000,000 bytes, see Free-plan limits).
text
Same as string (column TEXT, Rust String, same validation), rendered as <textarea name="body" rows="5"> in forms. The difference is only the input.
rich_text
Formatted text edited with Trix (Action Text’s editor), stored as HTML in a TEXT column.
body TEXT NOT NULL,
summary TEXT,
// create / update, before validation
new.body = ocre::security::sanitize(&new.body);
changes.summary = changes.summary.map(|summary| summary.as_deref().map(ocre::security::sanitize));
// NewArticle::validate
v.required("body", &ocre::security::strip_tags(&self.body));
- Rust
String/Option<String>, holding HTML thatocre::security::sanitizecleaned before it was stored (from forms, JSON and GraphQL alike). - Form:
<input type="hidden" id="article_body" name="body" value="{{ form.body }}"><trix-editor input="article_body"></trix-editor>;_form.htmlloads Trix 2.1.19 from unpkg.com and hides its file button. - Views:
{{ article.body|rich_text }}on the show page,{{ article.body|plain_text|truncate(80) }}in lists (filters fromocre::filters, imported by the scaffold’s controller). - Cannot be unique (
error: rich_text field `body` cannot be unique). See Models and migrations.
integer
qty INTEGER NOT NULL,
stock INTEGER,
- Rust:
i64/Option<i64>. - Validation:
v.safe_integer("qty", self.qty), the range ±9,007,199,254,740,991 (ocre::MAX_SAFE_INTEGER, 2^53 - 1). D1 returns numbers as JavaScript numbers, so larger values could not be read back exactly. Messages: “must be greater than or equal to -9007199254740991” / “must be less than or equal to 9007199254740991”. - Forms parse the text with
v.number("qty", &self.qty)(“is not a number”) orv.optional_number(..)for optional fields. - JSON: a required integer must be a JSON number; an optional one also accepts a numeric string (
"stock": "7"is stored as 7) throughocre::optional.
float
price REAL NOT NULL,
discount REAL,
- Rust:
f64/Option<f64>. Form:<input type="number" step="any">, parsed withv.number(“is not a number”). - No generated validation: add
v.range("price", self.price, 0.0..=1_000_000.0)orv.check(..)invalidate()for business rules.
boolean
active INTEGER NOT NULL DEFAULT 0,
- SQLite has no boolean type: the column holds 0 or 1. The record reads it with
#[serde(deserialize_with = "ocre::bool_from_sql")] pub active: bool. NewItemhas#[serde(default)] pub active: bool(missing =false),ItemChangespub active: Option<bool>.- Form:
<input type="checkbox" name="active" value="true">. An unchecked box is not submitted, so the form struct defaults it tofalse. - A boolean cannot be optional:
flag:boolean?fails witherror: boolean field `flag` cannot be optionalandhint: booleans are true or false (a checkbox); drop the `?`. GraphQL inputs default it tofalse(#[graphql(default)]).
date
born_on TEXT NOT NULL,
due_on TEXT,
- Rust
String, stored as typed. Validation:v.date("born_on", &self.born_on)accepts only a real calendar date writtenYYYY-MM-DD(month lengths and leap years checked:2024-02-29passes,2026-02-30fails with “is not a valid date”). - Form:
<input type="date">, which browsers submit asYYYY-MM-DD.
datetime
starts_at TEXT NOT NULL,
ends_at TEXT,
- Rust
String, stored exactly as sent. Validation:v.datetime(..)acceptsYYYY-MM-DD HH:MMorYYYY-MM-DD HH:MM:SS, with a space orTbetween date and time (2026-09-29T18:30,2026-09-29 19:00:00). No time zone is accepted; store UTC. A date alone fails with “is not a valid date and time”. - Form:
<input type="datetime-local">, which browsers submit as2026-09-29T18:30. - Values are not normalized: a record created with
2026-09-29T18:30returns"starts_at":"2026-09-29T18:30", whilecreated_atuses SQLite’s2026-09-29 04:20:26. Compare them as text only when they use the same format.
time
opens_at TEXT NOT NULL,
closes_at TEXT,
- Rust
String, stored as typed. Validation:v.time("opens_at", &self.opens_at)acceptsHH:MMorHH:MM:SSfrom00:00to23:59:59(“is not a valid time”). No time zone. - Form:
<input type="time">, which browsers submit asHH:MM.
decimal
price TEXT NOT NULL,
discount TEXT,
- An exact number, for money: stored as its text (
"19.99"), so no digit is lost. Afloat(REAL) would store0.1 + 0.2as0.30000000000000004. - Rust
String. Validation:v.decimal("price", &self.price)accepts an optional sign, digits, then optionally a dot and digits (19.99,-3,+0.5); no exponent, no spaces, no thousands separator (“is not a decimal number”). - Form:
<input inputmode="decimal">(a numeric keyboard on phones). JSON: a string,"price": "19.99". - SQL compares the text:
ORDER BY pricesorts"10.00"before"9.50". Sort or sum withCAST(price AS REAL)when an approximation is fine, or do exact arithmetic in Rust (for example on integer cents).
uuid
token TEXT NOT NULL,
- Rust
String. Validation:v.uuid("token", &self.token)accepts the hyphenated form, any case (67e55044-10b1-426f-9247-bb680e5fe0c8), “is not a valid UUID” otherwise. - The generator does not fill it: set it in the handler, e.g. from
crypto.randomUUID()throughworker::js_sysor from random bytes. Add^to make it unique.
references
author:references creates the column author_id pointing to the authors table; the referenced model (src/models/author.rs) must already exist, otherwise the generator stops with error: src/models/author.rs does not exist and hint: generate the referenced model first, e.g. `ocre g model Author name:string` .
author_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
...
CREATE INDEX index_items_on_author_id ON items (author_id);
- Rust
i64(Option<i64>with?, which omitsNOT NULL). Deleting an author deletes its items (ON DELETE CASCADE); files of the deleted items stay in R2. An optional reference is set toNULLinstead (ON DELETE SET NULL). createandupdatecheck the row exists:v.check("author_id", !db.exists("SELECT 1 FROM authors WHERE id = ?1 LIMIT 1", ..), "must exist").- Associations:
item.author(&ctx)in the model,author.items(&ctx, page)added tosrc/models/author.rs. See Models and migrations. - Form:
<input type="number" step="1" name="author_id">labelled “Author”. JSON and GraphQL:author_id/authorId, a number. - Custom column:
author:references:writer_idnames the columnwriter_id(still pointing toauthors); the association method is thenitem.writer(&ctx)and the form label “Writer”. The column must be snake_case and end in_id(invalid foreign key column `writer` for `author`withhint: name the column in snake_case ending in `_id`, e.g. `author:references:writer_id`otherwise). Modifiers go after the type or the column:author:references?:writer_idandauthor:references:writer_id?are the same. Onlyreferencesandenumtake such an argument:type `string` of `title` takes no `:long`.
attachment
A file stored in R2 (binding STORAGE), described by four columns. image:attachment doc:attachment? generates:
image_key TEXT NOT NULL,
image_filename TEXT NOT NULL,
image_content_type TEXT NOT NULL,
image_size INTEGER NOT NULL,
doc_key TEXT,
doc_filename TEXT,
doc_content_type TEXT,
doc_size INTEGER,
/// Files `image` accepts; `validate()` checks each upload before anything is
/// stored. The whole request is held in the Worker's memory (128 MB): keep
/// `max_bytes` modest, and forms add it to their request limit.
pub const IMAGE: Rules = Rules {
max_bytes: 10 * 1024 * 1024,
content_types: &["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"],
};
// NewItem: set only by forms and the REST upload route, never by JSON
/// The file to store in R2 (checked against `IMAGE`). JSON cannot carry it.
#[serde(skip)]
pub image: Option<Upload>,
// ItemChanges, optional attachment
/// `Some(Some(file))` replaces the stored file, `Some(None)` removes it (the old one is deleted from R2).
#[serde(skip)]
pub doc: Option<Option<Upload>>,
- The record has the four columns plus
item.image()returning anocre::storage::Attachment(item.doc()returnsOption<Attachment>). - Validation:
v.file("image", image, &IMAGE), messages “is too large (maximum is 10 MB)” and “has an unsupported type (allowed: image/png, …)”. A required attachment also getsv.check("image", self.image.is_none(), "can't be blank")inNewItem::validate. - Forms:
<input type="file" name="image" accept="image/png,image/jpeg,...">,enctype="multipart/form-data", a “Remove” checkbox for optional files on the edit page, andGET /<plural>/{id}/<name>to download. - JSON APIs: attachments must be optional (
ocre g api Document file:attachmentfails witherror: attachment `file` must be optional in a JSON API); files are sent withPUT /api/<plural>/{id}/<name>as multipart. GraphQL exposes the four columns (fileKey, …) read only. - Restrictions: cannot be unique (
error: attachment `photo` cannot be unique), cannot be namededit,deleteornew(they clash with scaffold routes), and<name>_key,_filename,_content_type,_sizecannot be other fields of the same model.
See File storage for the upload and serving flow.
polymorphic
A reference to a record of one of several models. commentable:polymorphic:post,photo generates two fields, as if written commentable_type:enum:post,photo commentable_id:integer, plus:
CREATE INDEX index_comments_on_commentable ON comments (commentable_type, commentable_id);
/// The record a comment's `commentable` points to (`commentable_type` and `commentable_id`).
#[derive(Debug, Clone)]
pub enum Commentable {
Post(crate::models::post::Post),
Photo(crate::models::photo::Photo),
}
/// The table a `commentable_type` points into.
fn commentable_table(kind: CommentableType) -> &'static str {
match kind {
CommentableType::Post => "posts",
CommentableType::Photo => "photos",
}
}
create checks SELECT 1 FROM <table> WHERE id = ?1 for the pair (“must exist” on commentable_id), update when a change sets both. comment.commentable(&ctx) returns Option<Commentable>, and each listed model gets post.comments(&ctx, page). With ? both columns are optional. There is no foreign key: deleting a post leaves its comments. The listed models must exist (src/models/post.rs), except the model being generated.
attachments
Many files per record: photos:attachments on Album generates the child model AlbumPhoto (album:references file:attachment, its own migration and factory) and, on Album, attach_photos, replace_photos, purge_photos and the file deletion in delete. The name is plural and takes no ? or ^; other generators (migration, job) refuse the type. See Files.
json
Any JSON value (object, array, string, number, boolean), stored as its text.
settings TEXT NOT NULL CHECK (json_valid(settings)),
meta TEXT CHECK (json_valid(meta)),
// Item (the record)
#[serde(deserialize_with = "ocre::json_from_sql")]
pub settings: ocre::serde_json::Value,
#[serde(deserialize_with = "ocre::optional_json_from_sql")]
pub meta: Option<ocre::serde_json::Value>,
// NewItem
pub settings: ocre::serde_json::Value,
#[serde(default)]
pub meta: Option<ocre::serde_json::Value>,
// ItemChanges
pub settings: Option<ocre::serde_json::Value>,
#[serde(default, deserialize_with = "ocre::patch_json")]
pub meta: Option<Option<ocre::serde_json::Value>>,
- A
serde_json::Valuebinds as its compact JSON text withparams![value]; D1 returns the text, whichjson_from_sqlparses back. - JSON APIs take and return the value itself:
"settings": {"color": "red", "sizes": [1, 2]}is answered as the same object, not as a string. For an optional field, a JSON string stays a string:PATCHwith"meta": "{}"stores the string"{}", not an object;nullclears it. - Forms: a
<textarea rows="5" spellcheck="false" placeholder="{}">parsed withv.json(..); text that does not parse (including an empty required field) shows “is not valid JSON”. Optional fields usev.optional_json(..): empty meansNULL. - GraphQL: the
JSONscalar (async-graphql’s scalar forserde_json::Value). - Cannot be unique:
data:json^fails withhint: a unique index compares JSON text, where key order and spacing differ; drop the `^`. - Build values in Rust with
ocre::serde_json::json!({"color": "red"}).
enum
One of a fixed list of values, written after the type: status:enum:todo,doing,done. The values are distinct snake_case identifiers; the first one is the default.
status TEXT NOT NULL CHECK (status IN ('todo', 'doing', 'done')),
The model gets a Rust enum named after the field (status gives Status), stored as the value’s text:
/// Values of `status`, stored as their text (a `CHECK` in the migration
/// refuses others). Add a value: a variant here and a migration rebuilding
/// the `CHECK` (`ocre g migration rebuild_<table>`).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
pub enum Status {
#[default]
#[serde(rename = "todo")]
Todo,
#[serde(rename = "doing")]
Doing,
#[serde(rename = "done")]
Done,
}
Status::ALLlists the values in order,status.as_str()andDisplaygive the stored text,FromStrparses it back ("archived".parse::<Status>()is anErr), andocre::IntoParambinds it inparams![...]. The record,NewTaskandTaskChangesholdStatus(Option<Status>with?).- Form:
<select name="status" required>with an<option>per value, labelled with the humanized value (Todo); an optional enum starts with an empty option (NULL). The form struct keeps the text and parses it withv.one_of("status", ...)(“is not included in the list”), orv.optional_one_of(..). - JSON: the value as a string,
"status": "doing". Another string is refused with a 400 by the JSON extractor (serde’sunknown variant) before validation. - GraphQL: not supported yet:
ocre g api Ticket state:enum:open,closed --graphqlfails witherror: enum `state` is not supported with --graphql yetandhint: use `state:string` checked with `v.inclusion(...)` in the model, or generate the JSON API without --graphql. - Added to an existing table (
ocre g migration add_state_to_books state:enum:draft,live), a required enum gets the first value as default:ALTER TABLE books ADD COLUMN state TEXT NOT NULL CHECK (state IN ('draft', 'live')) DEFAULT 'draft';. - Errors:
s:enumgivesenum `s` has no values(hint: list them after the type, e.g. `s:enum:draft,published`);s:enum:A,bors:enum:a,agiveinvalid values `A,b` for enum `s`(hint: list distinct snake_case values after the type, e.g. `status:enum:draft,published`);s:enum:a,b^givesenum `s` cannot be unique(hint: a few values cannot be unique across many rows; drop the `^`).
lock_version
lock_version:integer, Rails’ magic column, turns on optimistic locking; it is the only way to write this field name (lock_version:string or lock_version:integer? fail with error: `lock_version` must be `lock_version:integer` ).
lock_version INTEGER NOT NULL DEFAULT 0,
// Article (the record)
/// Optimistic locking: bumped by every update; forms send it back unchanged.
pub lock_version: i64,
// ArticleChanges (NewArticle has no lock_version)
pub lock_version: Option<i64>,
updateaddslock_version = lock_version + 1andWHERE ... AND (?N IS NULL OR lock_version = ?N); a stale version isError::Conflict(409),Noneskips the check.- Forms: a hidden input on the edit page. JSON: returned with the record, sent back in
PATCHbodies. GraphQL:lockVersionin the patch input only. - On an existing table:
ocre g migration add_lock_version_to_<table> lock_version:integer. See Models and migrations.
public_id
public_id:token gives each row a random public id, 22 URL-safe characters (ocre::token::public_id(), 128 bits), set by create, and the generated controllers put it in URLs instead of the integer id: /videos/Xq3v9Lr0TzK1mB7aYw2PcQ instead of /videos/42, so pages cannot be found by counting and URLs do not reveal how many rows exist. It is the only way to write this field name, and the only use of the token type (public_id:string, code:token or public_id:token^ fail with error: a public id is `public_id:token` ).
public_id TEXT NOT NULL,
-- ...
CREATE UNIQUE INDEX index_videos_on_public_id ON videos (public_id);
#[serde(skip_serializing)]
pub id: i64,
pub public_id: String,
// ...
pub async fn find_by_public_id(ctx: &Ctx, public_id: &str) -> Result<Option<Video>>
- The model keeps the integer
idfor references,find,updateanddelete; JSON leaves it out. ocre g scaffold,ocre g apiandocre g resourceroute/{plural}/{id}with the public id: anIdextractor in the controller looks it up (one D1 query) and gives handlersId(id, key), the integer id and the public id. Links (paths::show(video.public_id)), redirects and the files ofphotos:attachments(whose rows get a public id too) use public ids. A public id no row has is a 404.- The generated request tests read
record.public_idfrom the factory, which sets a random one. - On an existing table,
ocre g migration add_public_id_to_<table> public_id:tokenadds the column, gives every row a random id (32 hex digits) and adds the unique index. ocre g api ... --graphqlrefuses it for now (GraphQL nodes are keyed by the integer id).
Modifiers
| Modifier | SQL | Rust | Validation | Not allowed for |
|---|---|---|---|---|
| none | NOT NULL | T | the type’s checks, “can’t be blank” for string/text/attachment | |
? | nullable column | Option<T>, Option<Option<T>> in Changes | the type’s checks when a value is present | boolean |
^ | CREATE UNIQUE INDEX index_<table>_on_<column> ON <table> (<column>) | same | “has already been taken” in create and update (one SELECT 1 ... LIMIT 1 per unique field) | attachment, json, enum |
?^ / ^? | nullable column with a unique index | Option<T> | uniqueness checked only when a value is given | boolean, attachment, json, enum |
The index protects against races between two concurrent requests: the check in create gives the friendly message, the index guarantees the rule.
Adding a field to an existing table
ocre g migration add_<columns>_to_<table> field:type... uses the same field syntax, with defaults for existing rows (run on a table items):
ocre g migration add_extras_to_items slug:string^ level:integer rating:float? done:boolean due:date data:json extra:json? photo:attachment? owner:references?
-- Migration: add_extras_to_items
-- Applied once, in file-name order. Never edit a migration after it has been applied.
ALTER TABLE items ADD COLUMN slug TEXT NOT NULL DEFAULT '';
ALTER TABLE items ADD COLUMN level INTEGER NOT NULL DEFAULT 0;
ALTER TABLE items ADD COLUMN rating REAL;
ALTER TABLE items ADD COLUMN done INTEGER NOT NULL DEFAULT 0;
ALTER TABLE items ADD COLUMN due TEXT NOT NULL DEFAULT '';
ALTER TABLE items ADD COLUMN data TEXT NOT NULL CHECK (json_valid(data)) DEFAULT '{}';
ALTER TABLE items ADD COLUMN extra TEXT CHECK (json_valid(extra));
ALTER TABLE items ADD COLUMN photo_key TEXT;
ALTER TABLE items ADD COLUMN photo_filename TEXT;
ALTER TABLE items ADD COLUMN photo_content_type TEXT;
ALTER TABLE items ADD COLUMN photo_size INTEGER;
ALTER TABLE items ADD COLUMN owner_id INTEGER REFERENCES owners(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX index_items_on_slug ON items (slug);
CREATE INDEX index_items_on_owner_id ON items (owner_id);
- Required text types get
DEFAULT '', numbersDEFAULT 0,jsonDEFAULT '{}',enumits first value; optional columns get no default. A required unique column (slug:string^) gives every existing row the same'', so creating the unique index fails (UNIQUE constraint failed: items.slug) as soon as the table has two rows: add it asslug:string?^and fill it afterwards. referencesandattachmentmust be optional here:error: `owner_id` must be optional when added to an existing table(hint: SQLite adds reference columns as NULL for existing rows: use `name:references?`), anderror: `pic` must be optional when added to an existing table(hint: existing rows have no file: use `name:attachment?`).- The migration generator does not check that the referenced model exists, and does not change the model: update the struct,
New<Model>,<Model>Changes,validate()and the SQL ofcreate/updateyourself (the command says so in itsNext:lines).
Names that are refused
Each refused field stops the generator before it writes anything. The inputs and the exact messages (ocre g model Thing <field>):
title
error: field `title` has no type
hint: write fields as `name:type`, e.g. `title:string`
Title:string
error: invalid field name `Title`
hint: use snake_case starting with a letter, e.g. `published_at`
title:varchar
error: unknown field type `varchar` for `title`
hint: types: string, 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>...; add `?` for optional, `^` for unique
type:string
error: field name `type` is reserved
hint: `id`, `created_at` and `updated_at` are generated; Rust and SQL keywords are not allowed. Pick another name, e.g. `kind` for `type`
a:string b:string a:text
error: field `a` is listed twice
hint: names must differ, and `<name>:attachment` also takes `<name>_key`, `<name>_filename`, `<name>_content_type` and `<name>_size`
avatar:attachment avatar_key:string
error: field `avatar_key` is listed twice
hint: names must differ, and `<name>:attachment` also takes `<name>_key`, `<name>_filename`, `<name>_content_type` and `<name>_size`
edit:attachment
error: attachment name `edit` clashes with a scaffold route
hint: `/<plural>/{id}/edit` is taken; pick another name, e.g. `edit_file`
flag:boolean?
error: boolean field `flag` cannot be optional
hint: booleans are true or false (a checkbox); drop the `?`
photo:attachment^
error: attachment `photo` cannot be unique
hint: every stored file gets its own random key already; drop the `^`
data:json^
error: json field `data` cannot be unique
hint: a unique index compares JSON text, where key order and spacing differ; drop the `^`
Reserved names (from crates/ocre-cli/src/generate/fields.rs):
- Generated columns:
id,created_at,updated_at. - Rust keywords:
as,async,await,box,break,const,continue,crate,dyn,else,enum,extern,false,fn,for,gen,if,impl,in,let,loop,match,mod,move,mut,pub,ref,return,self,static,struct,super,trait,true,try,type,unsafe,use,where,while,yield. - 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.
With --json, the same failure is one object on stdout, for example ocre g model Thing x:strin --json:
{"error":"unknown field type `strin` for `x`","hint":"types: string, 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>...; add `?` for optional, `^` for unique","ok":false}
What a JSON API returns
ocre g api Gadget name:string^ label:string? qty:integer stock:integer? price:float active:boolean born_on:date starts_at:datetime? author:references? file:attachment? settings:json meta:json? --graphql, then (with an author 1 in the database):
curl -s -X POST localhost:8787/api/gadgets -H 'content-type: application/json' \
-d '{"name":"Lamp","qty":3,"price":19.5,"active":true,"born_on":"2026-09-29","starts_at":"2026-09-29T18:30","author_id":1,"settings":{"color":"red","sizes":[1,2]}}'
{"id":1,"name":"Lamp","label":null,"qty":3,"stock":null,"price":19.5,"active":true,"born_on":"2026-09-29","starts_at":"2026-09-29T18:30","author_id":1,"file_key":null,"file_filename":null,"file_content_type":null,"file_size":null,"settings":{"color":"red","sizes":[1,2]},"meta":null,"created_at":"2026-09-29 04:20:26","updated_at":"2026-09-29 04:20:26"}
Invalid values are all reported at once with status 422:
curl -s -X POST localhost:8787/api/gadgets -H 'content-type: application/json' \
-d '{"name":"","qty":9007199254740992,"price":1,"born_on":"2026-02-30","starts_at":"2026-09-29","author_id":42,"settings":1}'
{"error":{"fields":{"author_id":["must exist"],"born_on":["is not a valid date"],"name":["can't be blank"],"qty":["must be less than or equal to 9007199254740991"],"starts_at":["is not a valid date and time"]},"message":"Validation failed","status":422}}
Wrong JSON types and missing required keys fail earlier, with a 400 from the JSON extractor:
{"error":{"message":"Failed to deserialize the JSON body into the target type: qty: invalid type: string \"3\", expected i64 at line 1 column 21","status":400}}
{"error":{"message":"Failed to deserialize the JSON body into the target type: missing field `settings` at line 1 column 53","status":400}}
On GraphQL, field names are camelCase and optional fields are nullable (label: String, stock: Int), required ones non-null (qty: Int!, price: Float!, active: Boolean!, bornOn: String!). Patch inputs use MaybeUndefined for optional fields, so an omitted field keeps its value and null clears it:
{"data":{"gadget":{"name":"Lamp","qty":3,"price":19.5,"active":true,"bornOn":"2026-09-29","startsAt":"2026-09-29T18:30","authorId":1,"fileKey":null,"settings":{"color":"red","sizes":[1,2]},"meta":"{}"}}}
Writing the attributes by hand
When you add a column with a migration, copy the attributes of the generated code. A complete module with a boolean, a JSON column, an optional JSON column and patch fields:
// src/models/preference.rs
use ocre::serde_json::Value;
use serde::{Deserialize, Serialize};
/// A row of a `preferences` table: `enabled INTEGER NOT NULL DEFAULT 0`,
/// `data TEXT NOT NULL CHECK (json_valid(data))`, `extra TEXT CHECK (json_valid(extra))`,
/// `note TEXT`, `level INTEGER`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Preference {
pub id: i64,
#[serde(deserialize_with = "ocre::bool_from_sql")]
pub enabled: bool,
#[serde(deserialize_with = "ocre::json_from_sql")]
pub data: Value,
#[serde(deserialize_with = "ocre::optional_json_from_sql")]
pub extra: Option<Value>,
pub note: Option<String>,
pub level: Option<i64>,
pub created_at: String,
pub updated_at: String,
}
/// Values for a new preference.
#[derive(Debug, Clone, Deserialize)]
pub struct NewPreference {
#[serde(default)]
pub enabled: bool,
pub data: Value,
#[serde(default)]
pub extra: Option<Value>,
#[serde(default, deserialize_with = "ocre::optional")]
pub note: Option<String>,
#[serde(default, deserialize_with = "ocre::optional")]
pub level: Option<i64>,
}
/// Changes: a missing key keeps the value, `null` clears an optional one.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PreferenceChanges {
pub enabled: Option<bool>,
pub data: Option<Value>,
#[serde(default, deserialize_with = "ocre::patch_json")]
pub extra: Option<Option<Value>>,
#[serde(default, deserialize_with = "ocre::patch")]
pub note: Option<Option<String>>,
#[serde(default, deserialize_with = "ocre::patch")]
pub level: Option<Option<i64>>,
}
impl NewPreference {
/// The checks the generators write for `level:integer?`.
pub fn validate(&self) -> ocre::Validator {
let mut v = ocre::Validator::new();
if let Some(level) = self.level {
v.safe_integer("level", level);
}
v
}
}
See also
- Models and migrations: the generated model, queries and associations.
- Validations: every
Validatorrule and message. - Generators:
ocre g model,ocre g scaffold,ocre g api,ocre g migration. - File storage: attachments.
- JSON APIs and GraphQL: request and response formats.
ocre::Validatorandocre::MAX_SAFE_INTEGERin the rustdoc.
Configuration
This page describes every configuration file of an Ocre app (cloudflare.config.ts, wrangler.config.ts, package.json, tsconfig.json, .dev.vars, Cargo.toml, rust-toolchain.toml), the binding names Ocre requires, and each Worker variable and secret the ocre crate reads, with its format, default, where to set it in development and production, and the error when it is missing.
Before you start
- An Ocre app created with
ocre new(see CLI commands). The files below are shown asocre newand the generators write them;docs-appstands for your app name. - Its npm packages installed:
ocre newrunsnpm install(skip it with--no-install, then runnpm installyourself). The TypeScript files import their types fromnode_modules. - Setting production secrets needs a Cloudflare login (
ocre login, which runscf auth login). - Values of the free-plan limits quoted in comments are dated September 2026; see Free-plan limits for the sources.
Files at a glance
| File | Written by | Committed | Purpose |
|---|---|---|---|
cloudflare.config.ts | ocre new, then generators (ocre g job, schedule, cache, auth, --realtime, attachments) and ocre deploy (KV ids) | yes | Worker name, logs, bindings (D1, R2, KV, Queues, Durable Objects, rate limiting, email), plain-text variables, queue consumers and cron schedules, exported Durable Object classes |
wrangler.config.ts | ocre new | yes | The build command (worker-build) and the static-assets directory |
package.json, package-lock.json | ocre new and its npm install | yes | The pinned cf, wrangler and typescript versions |
tsconfig.json | ocre new | yes | Type checking of the two .ts files, for your editor and npx tsc -p . |
.dev.vars | ocre new | no (.gitignore) | Secrets and variable overrides for ocre dev only |
Cargo.toml | ocre new, then ocre g api --graphql and --realtime (features) | yes | Rust dependencies, Ocre features, API-only mode |
rust-toolchain.toml | ocre new | yes | Stable Rust with the wasm32-unknown-unknown target |
rustfmt.toml | ocre new | yes | The formatting style generators and ocre ci use |
Every ocre command that runs inside an app looks for the nearest cloudflare.config.ts in the current directory or its parents. An app that still has only a wrangler.toml is refused with a hint pointing to Upgrading from wrangler.toml.
cloudflare.config.ts
The app’s Cloudflare configuration, read by Cloudflare’s cf CLI (cf dev, cf deploy) and by Ocre. ocre new docs-app writes:
// The app's Cloudflare configuration: bindings, triggers, exported classes.
// Types come from `cf/config`, so your editor shows every option; check the
// file with `npx tsc -p .` (or `ocre doctor`). `ocre g ...` adds entries at the
// `// ocre:` markers: keep them, and keep Ocre's entries as literals.
import { bindings, defineConfig, exports, triggers } from "cf/config";
export default defineConfig({
worker: {
name: "docs-app",
compatibilityDate: "2026-09-01",
// Built by worker-build (see wrangler.config.ts); `ocre dev` builds with --dev.
entrypoint: "build/index.js",
// Workers Logs: every request (method, URL, status, CPU time) and every
// console line, searchable in the dashboard. Free plan: 200,000 events a
// day, kept 3 days; beyond that, logs are sampled, never billed.
observability: { enabled: true },
// Files in public/ (robots.txt, images, CSS...) are served by Cloudflare
// before the Worker runs: free, and not counted as Worker requests.
// public/_headers sets their headers (e.g. long caching); a single-page
// app would add `assets: { notFoundHandling: "single-page-application" },`.
env: {
// The first `ocre deploy` creates this database; no id needed.
DB: bindings.d1({ name: "docs-app" }),
// Plain-text variables. Secrets go in .dev.vars for `ocre dev` and in
// `ocre secrets push NAME --file <file>` for production; .dev.vars
// overrides these locally.
// Sender for `ocre::mail::send`: "noreply@yourdomain.com" or "Name <noreply@yourdomain.com>".
MAIL_FROM: bindings.text("docs-app <noreply@example.com>"),
// How production sends mail (`ocre dev` only prints it: MAIL_ADAPTER=log in .dev.vars):
// "resend" needs the RESEND_API_KEY secret (free: 100 emails/day, 3,000/month);
// "cloudflare" needs the EMAIL binding below (any recipient needs the
// Workers Paid plan; the free plan only reaches verified addresses of the account).
// MAIL_ADAPTER: bindings.text("resend"),
// Cloudflare Email Service binding, for MAIL_ADAPTER "cloudflare". `ocre dev`
// simulates it and prints each message.
// EMAIL: bindings.sendEmail(),
// ocre:env
},
// Receiving email: run `ocre g mailbox`, deploy, then in the Cloudflare
// dashboard Email Routing > Routing rules, send an address to this Worker.
triggers: [
// ocre:triggers
],
exports: {
// ocre:exports
},
},
});
defineConfig, bindings, triggers and exports come from the cf/config module of the cf package, so an editor with TypeScript support completes and checks every option once npm install has run.
How Ocre reads and edits it
-
Markers. Generators insert their entries on the line right after one of three marker comments, which must stay on their own line:
Marker Inside Entries added there // ocre:envworker.envbindings and variables: DB,JOBS,STORAGE,CACHE,CHANNELS,AUTH_RATE_LIMITER,EMAIL// ocre:triggersworker.triggersqueue consumers ( ocre g job) and cron schedules (ocre g schedule)// ocre:exportsworker.exportsthe OcreChannelDurable Object class (first--realtimescaffold)A generator that needs a marker which is gone stops with
cloudflare.config.ts is missing the `// ocre:env` marker(or the other marker) and a hint saying where to put it back. -
Literals only. Ocre reads the file itself, without running Node: it looks for the
KEY: bindings.<kind>(...),triggers.<kind>(...)andKEY: exports.<kind>(...)calls insidedefineConfig({ worker: { ... } })and parses their arguments as object literals (unquoted keys and trailing commas are fine; comments are skipped). Write the values Ocre needs (the workername,DB’sname, queue, bucket and cron values, KV ids) as string or number literals. A variable, a template string or a spread in one of them is an error naming the canonical form, for examplecloudflare.config.ts defines `DB` in a form Ocre cannot readwith the hintwrite `DB: bindings.d1({ name: "docs-app" }),`. Values Ocre does not read can be any TypeScript. -
Checking.
npx tsc -p .type-checks both.tsfiles against cf’s types (a wrongperiod: 30on a rate limiter givesType '30' is not assignable to type '10 | 60').ocre doctorruns it too, and validates the file with cf’s own loader.
Canonical entries
What each generator inserts, for an app named docs-app (most come with a comment on the free-plan limits, inserted above them):
| Added by | Marker | Entry |
|---|---|---|
ocre new | (written in place) | DB: bindings.d1({ name: "docs-app" }), |
ocre g cache | // ocre:env | CACHE: bindings.kv(),; ocre deploy rewrites it to CACHE: bindings.kv({ id: "<id>" }), |
first ocre g job | // ocre:env | JOBS: bindings.queue({ name: "docs-app-jobs" }), |
first ocre g job | // ocre:triggers | triggers.queue({ name: "docs-app-jobs", deadLetterQueue: "docs-app-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }), |
ocre g job <Name> --queue urgent | both | JOBS_URGENT: bindings.queue({ name: "docs-app-jobs-urgent" }), and its own triggers.queue({ name: "docs-app-jobs-urgent", deadLetterQueue: "docs-app-jobs-urgent-failed", maxBatchSize: 10, maxBatchTimeout: 1, maxRetries: 5 }), |
ocre g schedule | // ocre:triggers | triggers.scheduled({ schedule: "0 3 * * *" }),, one per expression |
first attachment field | // ocre:env | STORAGE: bindings.r2({ name: "docs-app-storage" }), |
first --realtime scaffold | // ocre:env | CHANNELS: bindings.durableObject({ worker: "docs-app", exportName: "OcreChannel" }), |
first --realtime scaffold | // ocre:exports | OcreChannel: exports.durableObject({ storage: "sqlite" }), |
ocre g auth | // ocre:env | AUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "2964407", simple: { limit: 10, period: 60 } }), |
| you | // ocre:env | EMAIL: bindings.sendEmail(), (uncomment it for MAIL_ADAPTER "cloudflare") |
| you | // ocre:env | KEY: bindings.text("value"), for a plain-text variable |
ocre g mailbox adds nothing to the file. ocre destroy never edits cloudflare.config.ts (like Cargo.toml): remove the entries yourself.
Top-level and worker keys
| Key | Value | Notes |
|---|---|---|
accountId (top level) | a 32-character account id | Written before worker: as accountId: "<id>", when you pass ocre new --account-id <id> or pick an account in the interactive ocre new; needed when your login has several accounts. Without it, cf uses the logged-in account (or CLOUDFLARE_ACCOUNT_ID). |
worker.name | the app name | The Worker name; the URL is https://<name>.<your-subdomain>.workers.dev. ocre deploy also names the KV namespaces after it (<name>-cache). |
worker.compatibilityDate | 2026-09-01 | The workerd behavior the Worker runs with. Change it only on purpose. |
worker.entrypoint | build/index.js | The JavaScript shim worker-build writes next to the WebAssembly module. |
worker.observability | { enabled: true } | Workers Logs: each request (method, URL, status, CPU time) and each line the Worker logs, including Ocre’s [ocre] error lines, is kept and searchable in the dashboard (Workers & Pages > your Worker > Logs). On the free plan: 200,000 log events a day, kept 3 days; above that, events are sampled, never billed (September 2026). Remove it or set enabled: false to keep only live logs. Ocre reads nothing from it. See Deployment. |
worker.assets | absent | { notFoundHandling: "single-page-application" } for a single-page app. The directory itself is set in wrangler.config.ts. |
env: DB (D1)
DB: bindings.d1({ name: "docs-app" }),, written by ocre new.
- The key must be
DB:ctx.db()looks the binding up by this name, and everyocredatabase command looks for it. nameis the database name.ocre deploycreates the database on the first deploy whencf d1 list --namedoes not find it; remote commands resolve the name to the database id the same way, so noidis needed.- Migrations are the numbered SQL files of
migrations/(cf’s default directory; there is nomigrations_dirkey).
Without a DB entry, every ocre command that needs the database stops before running anything:
error: cloudflare.config.ts has no D1 database bound to `DB`
hint: add `DB: bindings.d1({ name: "docs-app" }),` inside `worker.env`
If the Worker runs without the binding anyway (a hand-written cf deploy of another config), ctx.db()? fails with a 500 and logs [ocre] D1 binding `DB` is missing (...) followed by the fix.
env: plain-text variables
KEY: bindings.text("value"), entries are plain-text Worker variables, deployed with the code on every ocre deploy. ocre new sets MAIL_FROM and leaves MAIL_ADAPTER commented out. Add ALLOWED_ORIGINS when another site calls the app, ALLOWED_HOSTS to answer only on your own host names, and your own variables (read them with ctx.env().var("NAME"), see Reading your own variables). Never put secrets here: the file is committed.
env: EMAIL (send_email)
EMAIL: bindings.sendEmail(),
The Cloudflare Email Service binding used when MAIL_ADAPTER is "cloudflare". ocre new writes it commented out; uncomment it to use it. The key must be EMAIL. Without it, sending fails with a 500 whose log says cannot send email: the send_email binding `EMAIL` is missing (...) followed by the fix. See Email.
env: CHANNELS and exports: OcreChannel (Durable Objects)
Added by the first ocre g scaffold <Model> ... --realtime:
// in worker.env, after // ocre:env
// The realtime channels' Durable Objects (`ocre::realtime`).
CHANNELS: bindings.durableObject({ worker: "docs-app", exportName: "OcreChannel" }),
// in worker.exports, after // ocre:exports
// Realtime channels (`ocre::realtime`): one Durable Object per channel holds the
// browsers' WebSockets, hibernated between broadcasts so idle connections cost
// no duration. The free plan only accepts SQLite-backed classes.
OcreChannel: exports.durableObject({ storage: "sqlite" }),
The binding key must be CHANNELS and the export OcreChannel (the class Ocre’s realtime feature exports); worker is the app’s own Worker name. ocre deploy needs no extra step: cf deploy creates the namespace from the export. Without the binding, ocre::realtime::broadcast and WebSocketUpgrade::connect fail with a 500 whose log names the entries to add. See Realtime.
env: STORAGE (R2)
Added by the first generator with an attachment field:
// Files (`ocre::storage`, `attachment` fields): an R2 bucket. `ocre dev` keeps a
// local copy under .wrangler/state; `ocre deploy` creates the bucket if needed.
// Free plan: 10 GB stored, 1M writes and 10M reads a month; deletes are free.
STORAGE: bindings.r2({ name: "docs-app-storage" }),
The key must be STORAGE. ocre deploy checks each R2 name with cf r2 buckets get and creates the missing ones with cf r2 buckets create. R2 must be enabled once in the dashboard (it asks for a payment method even for the free tier); when it is not, the deploy stops with a hint saying so (Cloudflare API code 10042). See File storage.
env: JOBS and triggers.queue (Queues)
Added by the first ocre g job:
// in worker.env
// Background jobs (`ocre g job`): the Worker sends jobs to this queue and runs
// them (src/jobs/). `ocre deploy` creates it and its dead-letter queue. Free
// plan: 10,000 Queues operations a day (a job costs 3: write, read, delete; a
// retry one more read), messages kept 24 hours, 128 KB each.
JOBS: bindings.queue({ name: "docs-app-jobs" }),
// in worker.triggers
// Runs the jobs of docs-app-jobs: up to 10 messages per run, waiting at most 5 s
// to fill a batch. Each run is one Worker request with 10 ms of CPU on the free
// plan: lower maxBatchSize for CPU-heavy jobs. A failing job is retried with a
// growing delay (30 s, 1 min, 3 min, 9 min, 27 min), then moved to
// docs-app-jobs-failed, where it stays 24 hours (dashboard: Queues > docs-app-jobs-failed).
triggers.queue({ name: "docs-app-jobs", deadLetterQueue: "docs-app-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }),
| Key | Value | Meaning |
|---|---|---|
| binding key | JOBS | Required name: ocre::jobs::enqueue looks it up (JOBS_<NAME> for a named queue) |
name | <app>-jobs | The same queue for the binding (producer) and the trigger (consumer): the app’s Worker sends and runs its own jobs |
maxBatchSize | 10 | Messages per consumer run (Cloudflare allows up to 100) |
maxBatchTimeout | 5 | Seconds to wait to fill a batch (up to 60) |
maxRetries | 5 | Deliveries after the first before the message goes to the dead-letter queue |
deadLetterQueue | <app>-jobs-failed | Where failed messages are kept (24 hours on the free plan) |
ocre deploy lists the account’s queues (cf queues list) and creates every queue named here (bindings, triggers and dead-letter queues) that is missing, before deploying. Without the binding, enqueue fails with a 500 whose log says to run ocre g job <Name>. See Background jobs and schedules.
triggers.scheduled (Cron Triggers)
Added by ocre g schedule, one entry per expression, run by src/schedules/:
triggers.scheduled({ schedule: "0 3 * * *" }),
Cron expressions are in UTC. The free plan allows 5 Cron Triggers per account (all Workers together); ocre g schedule counts the triggers.scheduled entries of the file and warns when the app goes past 5.
env: CACHE (Workers KV)
Added by ocre g cache:
// Cached values for `ocre::cache` (Workers KV), added by `ocre g cache`. The
// first `ocre deploy` creates the namespace and writes its id here; `ocre dev`
// uses a local one. Free plan: 100,000 reads and 1,000 writes a day, 1 GB.
CACHE: bindings.kv(),
The key must be CACHE. For each KV binding without an id, ocre deploy looks for a namespace titled <name>-<binding> (lowercased, _ becomes -: docs-app-cache), creates it when missing, and rewrites the entry to CACHE: bindings.kv({ id: "<id>" }),. Commit that change so every machine deploys to the same namespace. Without the binding, ocre::cache functions fail with a 500 whose log says to run ocre g cache. See Caching.
env: AUTH_RATE_LIMITER (rate limiting)
Added by ocre g auth:
// `ocre g auth`: login, sign-up, token and emailed-link routes allow 10 attempts
// a minute per IP address and Cloudflare location (Workers Rate Limiting,
// free plan, no storage used). `period` is 10 or 60 seconds.
AUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "2964407", simple: { limit: 10, period: 60 } }),
| Key | Value | Meaning |
|---|---|---|
| binding key | AUTH_RATE_LIMITER | The binding the generated throttle (in src/auth_api.rs) passes to ocre::security::rate_limit |
namespace | an integer as a string, derived from the app name | Bindings with the same namespace share counters across the account’s Workers: keep it unique per app |
simple.limit | 10 | Requests allowed per key and period; the next one gets 429 Too Many Requests |
simple.period | 60 | The window in seconds: 10 or 60 only (the type rejects anything else) |
Add more bindings.rateLimit(...) entries with other keys for your own routes and call ocre::security::rate_limit(&ctx, "NAME", &key).await? (see Sessions, flash and security). The binding is on the free plan and uses no D1, KV or Durable Object operation; counters are per Cloudflare location and approximate; ocre dev simulates it. Without the binding, rate_limit fails with a 500 whose log names the entry to add.
Binding names Ocre requires
| Binding | cloudflare.config.ts entry | Used by | Added by |
|---|---|---|---|
DB | bindings.d1 | ctx.db(), models, ocre migrate, ocre sql, ocre db ... | ocre new |
STORAGE | bindings.r2 | ocre::storage, attachment fields | first generator with an attachment field |
JOBS | bindings.queue (plus triggers.queue) | ocre::jobs::enqueue, enqueue_in, ocre::mail::deliver_later | first ocre g job |
CACHE | bindings.kv | ocre::cache | ocre g cache |
CHANNELS | bindings.durableObject (export OcreChannel) | ocre::realtime | first --realtime scaffold |
EMAIL | bindings.sendEmail | ocre::mail with MAIL_ADAPTER "cloudflare" | commented out by ocre new |
The names are constants of the crate (ocre::storage::STORAGE_BINDING, ocre::jobs::QUEUE_BINDING, ocre::cache::CACHE_BINDING, ocre::realtime::CHANNELS_BINDING, ocre::mail::EMAIL_BINDING); they cannot be renamed. Every missing-binding error is an Error::Internal: the visitor gets a 500 page (or {"error": {"status": 500, "message": ...}} from JSON handlers), and the Worker log gets the full message prefixed with [ocre], including the fix.
Rate limiting bindings are the exception: ocre::security::rate_limit takes the binding name as an argument, so AUTH_RATE_LIMITER is only the name the ocre g auth code uses (RATE_LIMITER in src/auth_api.rs).
wrangler.config.ts
cf dev, cf build and the build step of cf deploy hand the build to the app’s own wrangler (the wrangler devDependency), which reads this file:
// How the Rust code is built and where the static files are, read by cf
// (`cf dev`, `cf deploy`) through the app's wrangler. Bindings and triggers
// live in cloudflare.config.ts.
import { defineWranglerConfig } from "wrangler/experimental-config";
export default defineWranglerConfig({
// `ocre dev` sets OCRE_BUILD=--dev (fast, unoptimized); deploys build --release.
build: { command: 'cargo install -q "worker-build@^0.8" && worker-build ${OCRE_BUILD:---release}' },
assetsDirectory: "public",
types: { generate: false },
});
build.command builds the Rust crate to WebAssembly with worker-build (installed with cargo install on first use). ${OCRE_BUILD:---release} makes the mode depend on the OCRE_BUILD environment variable:
| Command | OCRE_BUILD | Build |
|---|---|---|
ocre dev | --dev | Unoptimized, fast to compile |
ocre deploy and every other ocre command that builds | --release | lto = true, opt-level = "z" (from Cargo.toml) |
npx cf deploy run by hand | unset | --release (the default after :-) |
Chain a CSS or JavaScript bundler before worker-build in build.command (see Assets).
assetsDirectory: "public": files in public/ are served by Workers Static Assets before the Worker runs. Requests that match a file cost no Worker request and no CPU (billing). Limits on the free plan: 20,000 files per Worker version, 25 MiB per file (limits, September 2026).
package.json
{
"name": "docs-app",
"private": true,
"type": "module",
"devDependencies": {
"cf": "1.0.0-beta.5",
"typescript": "5.9.3",
"wrangler": "4.144.0"
}
}
- The versions are pinned exactly, and
ocre new’snpm installwritespackage-lock.json: commit both, so every machine and CI run the samecf.ocre doctorwarns when the installedcfdiffers from the version Ocre expects, orwrangleris older than 4.136. cfruns dev, deploy and every Cloudflare API call;wrangleris what cf delegates the build to, and what Ocre uses for local D1 commands (see Why wrangler still appears);typescriptchecks the config files."type": "module"lets cf loadcloudflare.config.tswithout a module-type warning on every call.- Node.js 22 or newer is required (
cf’sengines).
tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["cloudflare.config.ts", "wrangler.config.ts"]
}
It only covers the two config files: without it, editors and tsc cannot resolve cf/config. Run npx tsc -p . after editing cloudflare.config.ts by hand; nothing is emitted.
.dev.vars
ocre new writes a git-ignored .dev.vars with a random local secret and the log mail adapter:
SECRET_KEY_BASE=2db19cad9ab790ae4ef78858a74ecbc0300efd7e700b6513da53bcbd89e2b69e4eb28523c9cd31ea53617afc4495996fac76e80bc29233a1ae1dd464bfe3dd80
MAIL_ADAPTER=log
- Format: one
NAME=valueper line (dotenv). - Only
ocre dev(cf dev) reads it; it never reaches Cloudflare. A name in both.dev.varsand abindings.text(...)entry takes the.dev.varsvalue locally, which is howMAIL_ADAPTER=logkeeps development from sending real mail. .gitignorelists.dev.vars,.dev.vars.*,.prod.vars,.envand.env.*. Each clone needs its own: copy the lines above with a value fromocre secret.ocre devprints what the Worker receives; secrets are hidden (output of an app namedftappwith an attachment field):
Using secrets defined in .dev.vars
Your Worker has access to the following bindings:
Binding Resource Mode
env.DB (ftapp) D1 Database local
env.STORAGE (ftapp-storage) R2 Bucket local
env.MAIL_FROM ("ftapp <noreply@example.com>") Environment Variable local
env.SECRET_KEY_BASE ("(hidden)") Environment Variable local
env.MAIL_ADAPTER ("(hidden)") Environment Variable local
Variables and secrets
Cloudflare gives a Worker two kinds of text values, read the same way in code:
Variables (bindings.text) | Secrets | |
|---|---|---|
| Where | cloudflare.config.ts, committed | Encrypted on Cloudflare; never in the repository |
| Set in production | edit cloudflare.config.ts, then ocre deploy | ocre secrets push NAME --file .prod.vars (values read from that git-ignored file, never from the command line) |
Set for ocre dev | bindings.text(...), or .dev.vars to override | .dev.vars |
| Use for | Sender address, adapter names, allowed origins and hosts | SECRET_KEY_BASE, API keys, OAuth client secrets |
# .prod.vars: NAME=value lines, git-ignored, never committed
ocre secrets push RESEND_API_KEY --file .prod.vars # uploads the value from the file
ocre secrets list # names only, never values
ocre secret # a new SECRET_KEY_BASE value for .prod.vars
The free plan allows 64 variables and secrets per Worker, 5 KB each (limits, September 2026).
Code reads a secret with ctx.secret("NAME").await?, wherever it is kept: a Worker secret, a .dev.vars value, or a Secrets Store secret (below). Ocre’s own secrets that it reads asynchronously (RESEND_API_KEY, VAPID_PRIVATE_KEY, OAuth client secrets) go through it too.
Secrets Store: shared secrets
Secrets Store (open beta, 100 secrets per account on every plan) keeps secrets at the account level: one Resend or RunPod key for every app of the account, rotated once. A Worker reads one through a binding:
// worker.env in cloudflare.config.ts
RESEND_API_KEY: bindings.secretsStoreSecret({ storeId: "<store id>", secretName: "RESEND_API_KEY" }),
ocre secrets push RESEND_API_KEY --store --file .prod.vars # stores it, adds the binding
ocre deploy
- In code nothing changes:
ctx.secret("RESEND_API_KEY").await?reads the binding (one call to the store per read). ocre devandocre test --e2ecopy the.dev.varsvalue of each such binding into the local store before starting (cf devgives the binding the local store’s value and ignores.dev.vars), and printRESEND_API_KEY: .dev.vars value copied into the local Secrets Store. Without a.dev.varsvalue,ctx.secretfails with the fix.SECRET_KEY_BASEand theR2_*settings stay Worker secrets: Ocre reads them without waiting (sessions, cookies, presigned URLs), andocre secrets push --storerefuses them.- A binding and a Worker secret cannot share a name: delete the Worker secret (dashboard: Workers > the Worker > Settings > Variables and Secrets) after moving it to the store.
- Like a Worker secret, a store secret cannot be read back: keep its value in
.prod.varsor a password manager.
The ocre crate reads the following names.
SECRET_KEY_BASE
- Kind: secret. Required as soon as a request touches the session, the flash or a JWT.
- Meaning: the root key of the app. The session cookie’s AES-256-GCM key is derived from it, and
ocre::jwtderives its HS256 signing key from it with a different label, so one secret covers cookies and tokens. - Format: at least 64 characters.
ocre secretprints a new random value of 128 hex characters (--json:{"command":"secret","ok":true,"secret":"..."}). - Development:
ocre newwrites one to.dev.vars. - Production:
ocre deploycheckscf workers secrets list; when the Worker has noSECRET_KEY_BASE(first deploy), it uploads the one of.prod.vars, or generates one, uploads it and appends it to.prod.vars(back that file up: Cloudflare never returns a secret, and encrypted columns are lost with it); it is uploaded with the deploy (cf deploy --secrets-file, from a temporary.wrangler/ocre-secrets.jsonreadable only by you and deleted afterwards). Every deploy passes that file,{}when there is no secret to add: cf keeps a Worker’s existing secrets (including those ofocre secrets push) only when--secrets-fileis passed; deployed without it, the new version has none. A deploy that created the secret printsCreated the SECRET_KEY_BASE secret on Cloudflare. An existing secret is never replaced. If the secrets list fails for another reason than a missing Worker, the deploy stops rather than risk overwriting it. - Rotation: uploading a new value (
ocre secret, put it in.prod.varsasSECRET_KEY_BASE=..., thenocre secrets push SECRET_KEY_BASE --file .prod.vars) alone makes every session cookie and JWT signed with the old value invalid: everyone is signed out. To rotate without signing anyone out, keep the old value inSECRET_KEY_BASE_PREVIOUSfirst. - Errors: when it is missing or shorter than 64 characters, requests that read an existing session cookie or change the session answer 500 and log (captured from
ocre devwith the line removed from.dev.vars):
✘ [ERROR] [ocre] the SECRET_KEY_BASE secret is not set. Fix: run `ocre secret`, put the value in .dev.vars as SECRET_KEY_BASE=... for `ocre dev` (`ocre new` does this), and deploy with `ocre deploy`, which uploads it
The short-value message is SECRET_KEY_BASE is shorter than 64 characters. followed by the same fix. Requests that do not use the session (no cookie, no flash) still work, which is why a missing secret can go unnoticed until the first form submission. See Sessions, flash and security.
SECRET_KEY_BASE_PREVIOUS
- Kind: secret. Optional; set only while rotating
SECRET_KEY_BASE. - Meaning: previous
SECRET_KEY_BASEvalues (Rails’cookies_rotations). A session cookie encrypted with one of them is still read, then re-encrypted with the current key in the same response; a JWT signed with one of them still verifies until it expires. - Format: comma-separated, newest first; spaces around values are ignored; each value at least 64 characters.
- Production: Worker secrets cannot be read back, so upload the value you are about to replace as
SECRET_KEY_BASE_PREVIOUStogether with the new one. In.prod.vars(git-ignored):
SECRET_KEY_BASE_PREVIOUS=<the current SECRET_KEY_BASE>
SECRET_KEY_BASE=<a new value from `ocre secret`>
ocre secrets push SECRET_KEY_BASE_PREVIOUS SECRET_KEY_BASE --file .prod.vars # one upload
- Removal: delete
SECRET_KEY_BASE_PREVIOUSin the dashboard (Workers & Pages > your Worker > Settings > Variables and Secrets) once the longest session lifetime (two weeks forocre g auth) and the JWT lifetime (one hour) have passed; visitors who did not come back by then start with an empty session. - Errors: a value shorter than 64 characters makes every request that uses the session or a JWT answer 500 and log
SECRET_KEY_BASE_PREVIOUS has a value shorter than 64 characters. Fix: list old SECRET_KEY_BASE values, comma-separated, newest first.
ALLOWED_ORIGINS
- Kind: variable (
bindings.text). Optional. - Meaning: other origins (a separate frontend, an admin app) allowed to call this app from a browser. Listed origins get CORS headers (methods GET, POST, PUT, PATCH, DELETE; headers
Content-Type,Authorization,Accept; credentials allowed) and pass the CSRF check that otherwise refuses cross-site unsafe requests with 403. - Format: comma-separated origins, scheme and host (and port if any). Spaces and a trailing
/are ignored; invalid entries are skipped.
// in worker.env of cloudflare.config.ts
ALLOWED_ORIGINS: bindings.text("https://app.example.com, https://admin.example.com"),
- Default: unset or empty, no CORS layer at all: only same-origin browser requests may change data.
- Development: add it to
worker.env(or.dev.vars) with the frontend’s local origin, such ashttp://localhost:5173. - Errors: none; a malformed entry is dropped silently, so check the spelling when a request still gets 403. See Sessions, flash and security.
ALLOWED_HOSTS
- Kind: variable (
bindings.text). Optional. - Meaning: the host names the app answers to (Rails’
config.hosts). A request for any otherHostgets a plain-text403 Forbidden: blocked host. Add it to ALLOWED_HOSTS to allow it.before the session, CSRF check or any handler runs. - Format: comma-separated host names, case-insensitive, without scheme or port. An entry starting with
.also allows every subdomain:.example.comallowsexample.comandwww.example.com.
ALLOWED_HOSTS: bindings.text("example.com, .example.com"),
- Default: unset or empty, every host is allowed.
- Always allowed:
localhost,127.0.0.1and[::1], with any port, soocre devkeeps working. - Why on Workers: Cloudflare only routes your own host names to the Worker, but the same Worker also answers on
<name>.<subdomain>.workers.devand on preview URLs. Listing only your custom domain keeps visitors and search engines on it (list theworkers.devhost too while you still use it). - Errors: none at startup; a typo blocks your own domain with the 403 above.
OAuth client secrets
- Names:
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET,GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET(client_id_secretandclient_secret_secretofocre::oauth::GITHUBandGOOGLE). - Kind: secrets. Required for each provider given to
ocre g auth --oauth. - Values: the client id and secret of the OAuth app you register with the provider, with the callback URL
https://<your host>/auth/<provider>/callback(andhttp://localhost:8787/auth/<provider>/callbackforocre dev, in a second OAuth app for GitHub). - Development:
ocre g auth --oauthappends commented lines to.dev.vars; uncomment them and fill in the values. - Production: put both in
.prod.varsand runocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars;ocre secrets listshows which are set. - Errors (500, logged) when one is missing: pressing “Continue with GitHub” logs
the GITHUB_CLIENT_ID secret is not set. Fix: add it to .dev.vars, and `ocre secrets push GITHUB_CLIENT_ID --file .prod.vars`; the callback logsthe GITHUB_CLIENT_SECRET secret is not set. Fix: put it in .dev.vars for `ocre dev` and run `ocre secrets push GITHUB_CLIENT_SECRET --file .prod.vars` for production. See Authentication.
MAIL_ADAPTER
- Kind: variable. Required to send mail (
ocre::mail::send,deliver_later, the auth emails). - Values:
log(print the whole email to the Worker log between[ocre mail]lines, send nothing),resend(Resend’s HTTP API, needsRESEND_API_KEY),cloudflare(Email Service through theEMAILbinding). Surrounding spaces are ignored. - Default: none. Nothing is guessed from which keys exist, so a development machine holding a real key never sends by accident.
- Development:
ocre newwritesMAIL_ADAPTER=logto.dev.vars. - Production: uncomment
MAIL_ADAPTER: bindings.text("resend"),(or"cloudflare") inworker.env, thenocre deploy. - Errors (500, logged):
- unset:
cannot send email: MAIL_ADAPTER is not set. Fix: set MAIL_ADAPTER to "resend" (with the RESEND_API_KEY secret) or "cloudflare" (with the EMAIL: bindings.sendEmail() binding) in worker.env of cloudflare.config.ts, as MAIL_ADAPTER: bindings.text("resend"); `ocre new` puts MAIL_ADAPTER=log in .dev.vars so `ocre dev` only logs mail - another value, for example
smtp:cannot send email: unknown MAIL_ADAPTER "smtp" (expected log, resend or cloudflare). Fix: ...(same fix).
- unset:
See Email.
MAIL_FROM
- Kind: variable. Required to send mail, with every adapter.
- Format:
noreply@yourdomain.comorName <noreply@yourdomain.com>(the name may be quoted). With Resend or Cloudflare, the domain must be verified with that provider. - Default:
ocre newwritesMAIL_FROM: bindings.text("<app> <noreply@example.com>"); replaceexample.comwith your domain before sending real mail. - Errors (500, logged): unset:
cannot send email: MAIL_FROM is not set. Fix: add MAIL_FROM: bindings.text("App <noreply@yourdomain.com>") to worker.env in cloudflare.config.ts; unparsable:cannot send email: MAIL_FROM "<value>" is not an address. Fix: use "noreply@yourdomain.com" or "App <noreply@yourdomain.com>".
RESEND_API_KEY
- Kind: secret. Required when
MAIL_ADAPTER = "resend". - Format: the API key from resend.com/api-keys, sent as
Authorization: Bearer <key>tohttps://api.resend.com/emails. - Production:
ocre secrets push RESEND_API_KEY --file .prod.vars. - Development: not needed with
MAIL_ADAPTER=log. To send real mail fromocre dev, putRESEND_API_KEY=...andMAIL_ADAPTER=resendin.dev.vars. - Errors (500, logged) when missing or blank:
cannot send email: the RESEND_API_KEY secret is not set. Fix: create a key at https://resend.com/api-keys and run `ocre secrets push RESEND_API_KEY --file .prod.vars` (and put it in .dev.vars to send from `ocre dev`). - Free plan of Resend: 100 emails a day, 3,000 a month (quotas, September 2026).
R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY
- Kind:
R2_ACCOUNT_IDandR2_BUCKETare variables (bindings.text);R2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEYare secrets. Required by presigned URLs and direct uploads (storage::presign_get,presign_put,serve_redirect,direct_upload,attach_direct_upload); nothing else reads them. - Values: the account ID (R2 overview page), the bucket name (
<app>-storage), and the two keys of an R2 API token with “Object Read & Write” on that bucket (dashboard: R2 > Manage API tokens). The secret key also signs the keys handed out bydirect_upload: replacing it invalidates uploads in progress. - Production: the variables in
worker.env, the secrets withocre secrets push R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY --file .prod.vars. Development: all four in.dev.vars(the URLs point to the real bucket, notocre dev’s simulation). - Errors (500, logged) when one is missing or blank:
presigned R2 URLs need R2_ACCOUNT_ID, R2_BUCKET (not set). Fix: add `R2_ACCOUNT_ID: bindings.text("<account id>"),` and ..., naming every missing one. See File storage.
STORAGE_PUBLIC_URL
- Kind: variable. Optional; required by
storage::public_url. - Value: the base URL of a public bucket, an
r2.devURL or a custom domain connected to it (dashboard: R2 > bucket > Settings > Public access), e.g.https://files.example.com. - Errors (500, logged) when missing:
public file URLs need the STORAGE_PUBLIC_URL variable. Fix: .... See File storage.
D1_REPLICAS
- Kind: variable. Optional.
- Value:
onroutes each request’s queries through a D1 session (read replicas, each visitor reading their own writes); anything else, or no variable, queries the database directly. Enable read replication on the database too. See Read replicas.
CACHE_STORE
- Kind: variable. Optional.
- Value:
kv(default) storesocre::cachevalues in theCACHEnamespace;nullturns caching off without code changes.ocre dev --no-cachewritesCACHE_STORE=nullinto.dev.varsand--cacheremoves it. See Caching.
APP_URL
- Kind: variable. Optional; required by
ocre::mail::url. - Value: the app’s public base URL (
https://www.example.com,http://localhost:8787in.dev.vars), joined to paths for links in emails sent from jobs, where there is no request to read the host from.
LOG_LEVEL
- Kind: variable. Optional.
- Value:
debug,info,warn,errororoff(warningandfatalare accepted): the lowest levelctx.log()and Ocre write. Default:debuginocre dev(debug build),infoafterocre deploy(release build). An unknown value is the default. - Read on every request, job batch and cron run. See Errors, logging and debugging.
LOG_FORMAT
- Kind: variable. Optional.
- Value:
json(one JavaScript object per line, whose fields Workers Logs indexes) ortext(INFO message key=value ...). Default:textinocre dev,jsonafterocre deploy.
SENTRY_DSN
- Kind: secret. Optional; read only when the app registers
ocre::errors::Sentry(see Reporting errors). - Value: the project’s DSN,
https://<key>@<host>/<project id>, from Sentry or a Sentry-compatible service. - When missing, reports are only logged. A value that is not a DSN is logged as
[ocre] SENTRY_DSN is not a Sentry DSN (...)and nothing is sent. The optionalSENTRY_RELEASEvariable names the release in each event.
Reading your own settings
Declare the app’s own variables and secrets as a struct and read it with ctx.config() (Loco’s settings, Rails’ config.x and credentials). Each field reads the variable of the same name in upper case, or else the secret; values are converted to the field’s type (numbers, bool as true/false/1/0, Vec from a comma-separated list, unit enums by name). Option and #[serde(default)] fields may be missing:
// src/support.rs
use axum::{Router, extract::State, routing::get};
use ocre::{Ctx, Result};
use serde::Deserialize;
/// `SUPPORT_EMAIL: bindings.text(...)` in cloudflare.config.ts, `SUPPORT_HOURS`
/// optional, `HELPDESK_TOKEN` a secret (`ocre secrets push`).
#[derive(Deserialize)]
pub struct Settings {
pub support_email: String,
pub support_hours: Option<String>,
#[serde(default)]
pub helpdesk_token: String,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/support", get(support))
}
async fn support(State(ctx): State<Ctx>) -> Result<String> {
let settings: Settings = ctx.config()?;
let hours = settings.support_hours.unwrap_or_else(|| "9-17 UTC".to_owned());
Ok(format!("Write to {} ({hours})", settings.support_email))
}
Register the module in src/lib.rs (mod support; under // ocre:modules, .merge(support::routes()) under // ocre:routes). A missing required field answers 500 and logs the fix:
[ocre] Worker variable or secret `SUPPORT_EMAIL` is missing. Fix: add `SUPPORT_EMAIL: bindings.text("..."),` to worker.env in cloudflare.config.ts, or for a secret run `ocre secrets push SUPPORT_EMAIL --file .prod.vars`; in `ocre dev`, add `SUPPORT_EMAIL=...` to .dev.vars
Reading costs one environment lookup per field, no binding call. Unit tests build the struct with ocre::config::from_vars([("SUPPORT_EMAIL", "help@example.com")]). ctx.env() stays available for bindings Ocre does not wrap (AI, Vectorize…).
Environments
An Ocre app has two environments, chosen by the build, not by a variable (Rails’ RAILS_ENV, Loco’s LOCO_ENV):
| Development | Production | |
|---|---|---|
| Build | debug: ocre dev, cargo test, ocre test | release: ocre deploy |
ocre::config::Environment::current() | Development | Production |
| Settings | .dev.vars overrides cloudflare.config.ts | cloudflare.config.ts variables and secrets |
| Logs | debug, text | info, JSON |
| Errors | development error page, Server-Timing | generic error page; details in logs and reporters |
| Database | local D1 in .wrangler/state | the remote D1 database |
Rails’ test environment is the development build run natively by cargo test. A staging copy is a second Worker: a copy of the app directory with another worker.name and database name in cloudflare.config.ts, deployed with ocre deploy. Ocre reads cloudflare.config.ts as literal values, so it does not use cf’s defineConfig((ctx) => ...) form with --mode.
Initializers: the start function
ocre new writes a start function in src/lib.rs, run once when a Worker instance starts, before its first request, job or cron run (Rails’ config/initializers, Loco’s Hooks::before_run and initializers):
#[event(start)]
fn start() {
ocre::errors::subscribe(ocre::errors::Sentry);
}
It has no environment (bindings and variables come with each request), so it registers things: error subscribers, static values built with std::sync::LazyLock. Its CPU time counts against the first request (10 ms on the free plan). Per-request setup belongs in axum middleware on routes(), which sees the Ctx.
Environment variables of the ocre CLI
These are read by the ocre command (and cf) on your machine, not by the Worker:
| Name | Read by | Meaning |
|---|---|---|
OCRE_BUILD | build.command of wrangler.config.ts | --dev or --release; set by ocre for every build (see wrangler.config.ts) |
CLOUDFLARE_API_TOKEN | cf | Authenticates without ocre login (CI); the CLI’s hints mention it when a Cloudflare call fails |
CLOUDFLARE_ACCOUNT_ID | cf | Picks the account when the token or login sees several, instead of accountId in cloudflare.config.ts |
CF_SEND_TELEMETRY | cf | false turns off cf’s anonymous usage telemetry (on by default; cf cli telemetry disable does the same for good). Ocre does not change it |
Cargo.toml
ocre new docs-app (full-stack, with --ocre-path) writes:
[package]
name = "docs-app"
version = "0.1.0"
edition = "2024"
publish = false
# Standalone: the app builds even when created inside another Cargo workspace.
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
ocre = { path = "/path/to/ocre/crates/ocre" }
worker = { version = "0.8.7", features = ["http", "axum", "d1"] }
axum = { version = "0.8.9", default-features = false, features = ["form", "json", "query"] }
askama = "0.16.1"
serde = { version = "1.0", features = ["derive"] }
# `strip` breaks wasm-bindgen ("externref table required"); keep symbols.
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"
Without --ocre-path, the dependency is ocre = { git = "https://github.com/tgeselle/ocre.rs" }.
Ocre features
| Feature | Default | Enables | Turned on by |
|---|---|---|---|
html | yes | askama templates, render, HTML error pages, the Htmx extractor | on in full-stack apps; off in API-only apps (default-features = false) |
graphql | no | ocre::graphql (async-graphql, GraphiQL) | ocre g api <Model> ... --graphql, which also adds the async-graphql dependency |
realtime | no | ocre::realtime and the OcreChannel Durable Object class | the first ocre g scaffold <Model> ... --realtime |
After both generators, the dependency lines read:
ocre = { path = "/path/to/ocre/crates/ocre", features = ["realtime", "graphql"] }
async-graphql = { version = "7.2.1", default-features = false, features = ["graphiql", "custom-error-conversion"] }
GraphQL adds about 1.1 MB of WebAssembly and 20 to 60 ms of CPU each time a Worker instance starts (see Cost model); only turn it on when a client needs it.
API-only mode
ocre new docs-app --api writes Ocre without its default html feature, no askama, and this table at the end:
[dependencies]
ocre = { git = "https://github.com/tgeselle/ocre.rs", default-features = false }
worker = { version = "0.8.7", features = ["http", "axum", "d1"] }
axum = { version = "0.8.9", default-features = false, features = ["form", "json", "query"] }
serde = { version = "1.0", features = ["derive"] }
...
[package.metadata.ocre]
# JSON only: `ocre g scaffold` generates APIs, Ocre's `html` feature is off.
mode = "api"
The generators read mode = "api": ocre g scaffold then generates a JSON API (like ocre g api), and ocre g mailer builds the email text with format! instead of askama templates.
rustfmt.toml
ocre new writes the formatting style Ocre’s own code uses:
max_width = 120
use_small_heuristics = "Max"
Generators pipe the Rust they write through rustfmt with it, and ocre ci runs cargo fmt --check, so a generated app is formatted from the start. Change it freely: later generated code follows it.
rust-toolchain.toml
[toolchain]
channel = "stable"
targets = ["wasm32-unknown-unknown"]
rustup reads it in the app directory and installs the stable toolchain with the WebAssembly target on first use. A Rust installed without rustup (for example Homebrew’s rust) ignores it and has no wasm target; ocre dev, ocre deploy and ocre new --deploy then stop with the wasm32-unknown-unknown target is not installed for rustc at <sysroot> and the hint use a rustup toolchain (Homebrew's `rust` has no wasm target) and run `rustup target add wasm32-unknown-unknown` . See Installation.
See also
- CLI commands:
ocre dev,ocre deploy,ocre secret. - Generators: which generator adds which
cloudflare.config.tsentry. - Upgrading from wrangler.toml: converting an older app.
- Deployment: the first deploy, secrets and remote migrations.
- Free-plan limits and Cost model.
npx cf schema <command>andnpx cf cli search "..."for cf’s own commands and options; thecf/configtypes for every key ofcloudflare.config.ts.
Free-plan limits
This page lists the Cloudflare Workers Free plan limits that matter to an Ocre app, as published by Cloudflare in September 2026, with the source of each value and what Ocre does about it.
Before you start
- The values apply to an app deployed with
ocre deployon a Cloudflare account without Workers Paid.ocre dev(local workerd) enforces none of them. - Most limits are per account and per day (reset at 00:00 UTC), shared by every Worker of the account, not per app.
- Cloudflare changes its limits. Each row links to the page the value was read from (September 2026); check it before relying on a number. For how an app spends these budgets and a worked daily example, see Cost model.
Workers
| Limit | Free plan (September 2026) | Source | What Ocre does |
|---|---|---|---|
| Requests | 100,000 a day per account; past it, error 1027 until midnight UTC | Workers limits | Files in public/ are served by Workers Static Assets, free and unlimited, without running the Worker. A WebSocket connection counts once, not per message. |
| Static asset requests | free and unlimited; 20,000 files per version, 25 MiB per file | Static assets billing, limits | assetsDirectory: "public" in wrangler.config.ts from ocre new. Turning on Workers Cache makes asset requests count (Workers Cache pricing); Ocre does not enable it. |
| CPU time | 10 ms per invocation (HTTP request, Cron Trigger, queue consumer batch); occasional overruns are tolerated per isolate, frequent ones end in error 1102 | CPU time | Waiting on D1, KV, R2 or fetch does not count. Passwords are hashed with WebCrypto PBKDF2 (native, about 5.5 ms) instead of WebAssembly; R2 downloads are streamed without passing through WebAssembly; GraphQL (20 to 60 ms at instance start) is opt-in. |
| Memory | 128 MB per isolate, shared by the concurrent requests it runs | Memory | Uploads are read into memory: generated attachment rules default to 10 MB per file and forms refuse bodies over the sum of their files’ limits plus 1 MB (413). |
| Request body size | 100 MB (Cloudflare Free plan); larger bodies get 413 before the Worker runs | Request limits | Keep max_bytes of attachment Rules in the tens of MB. |
| Worker size | 64 MiB uncompressed | Worker size | Release builds use opt-level = "z" and LTO; the blog starter measured 420 KB of WebAssembly (130 KB gzipped). GraphQL adds about 1.1 MB. |
| Startup time | 1 second to evaluate the global scope | Startup time | The blog starter measured 4 ms. Translations are parsed on first use, not at startup. |
| Subrequests | 50 per invocation; 1,000 to Cloudflare services | Subrequests | Generated pages run one D1 query (lists, show) or a few (create/update add one check per unique field and reference). find_many loads many rows in one query per 100 ids instead of one per row. |
| Variables and secrets | 64 per Worker, 5 KB each | Environment variables | Ocre needs at most four: SECRET_KEY_BASE, MAIL_FROM, MAIL_ADAPTER, RESEND_API_KEY (plus ALLOWED_ORIGINS when used). |
| Cron Triggers | 5 per account | Account plan limits | ocre g schedule warns when the app has more than 5; run several tasks from one cron. |
| Workers Logs | 200,000 log events a day, kept 3 days | Workers Logs pricing | Ocre logs only errors and job/cron outcomes ([ocre ...] lines); the log mail adapter prints whole emails, so do not use it in production. |
D1 (database, binding DB)
| Limit | Free plan (September 2026) | Source | What Ocre does |
|---|---|---|---|
| Rows read | 5 million a day (rows scanned, not rows returned) | D1 pricing | Generated lists are paginated (Page: default 50, at most 100) and ordered by the primary key; every references column gets an index, every unique field a unique index. |
| Rows written | 100,000 a day; each index on a written column adds one row written | D1 pricing | Sessions live in an encrypted cookie (no rows); API keys update last_used_at at most once an hour. |
| Storage | 5 GB per account; 500 MB per database; 10 databases per account | D1 limits | One database per app (database_name = app name). Files go to R2, not D1. |
| Queries per invocation | 50 | D1 limits | find_many instead of find in a loop. |
| Bound parameters | 100 per query | D1 limits | find_many splits ids into chunks of 100. |
| Row or string size | 2,000,000 bytes (2 MB) | D1 limits | Keep large text and files in R2 (attachment fields). |
| SQL statement length | 100 KB | D1 limits | Queries use ?N placeholders, never inlined values. |
| Numbers | D1 returns numbers as JavaScript numbers (exact up to 2^53 - 1) | ocre::MAX_SAFE_INTEGER | Generated integer validations reject values beyond ±9,007,199,254,740,991. |
Queues (background jobs, binding JOBS)
| Limit | Free plan (September 2026) | Source | What Ocre does |
|---|---|---|---|
| Operations | 10,000 a day; a delivered message costs 3 (write, read, delete), each retry 1 more read, a dead-lettered message 1 more write; each 64 KB of a message counts as one operation | Queues pricing | One message per job: about 3,300 jobs a day without retries. |
| Retention | 24 hours, not configurable | Queues pricing | Retries (30 s, 1 min, 3 min, 9 min, 27 min) end about 40 minutes after the first failure; the dead-letter queue <app>-jobs-failed keeps failures 24 hours. |
| Message size | 128 KB (1 KB = 1,000 bytes, including about 100 bytes of metadata) | Queues limits | enqueue refuses messages over 127,000 bytes: cannot enqueue a <n> byte message: Cloudflare Queues messages hold 128 KB at most. Fix: store large data in D1 or R2 and put its id in the job. |
| Delay | 24 hours, on send and on retry | Delay messages | enqueue_in refuses longer delays and names the fix (a scheduled task, or a due time in D1). |
| Batches | up to 100 messages, 60 s wait | Queues limits | max_batch_size = 10, max_batch_timeout = 5: each consumer run handles up to 10 jobs within one invocation’s 10 ms of CPU. |
| Retries | up to 100 | Queues limits | max_retries = 5. |
| Queues | 10,000 per account | Queues limits | Two per app: <app>-jobs and <app>-jobs-failed, created by ocre deploy. |
R2 (files, binding STORAGE)
| Limit | Free plan (September 2026, per month) | Source | What Ocre does |
|---|---|---|---|
| Storage | 10 GB-month (Standard storage class) | R2 pricing | Files of deleted and replaced records are deleted; files of rows removed by ON DELETE CASCADE are not. |
| Class A operations | 1 million a month (PutObject, ListObjects, …) | R2 pricing | One per upload. |
| Class B operations | 10 million a month (GetObject, HeadObject, …) | R2 pricing | One per download or 304. |
| Deletes, egress | free | R2 pricing | |
| Activation | R2 must be enabled once in the dashboard, which asks for a payment method even for the free tier | R2 pricing | ocre deploy stops with a hint when the account lacks it (API code 10042). |
Past the free tier, R2 bills (October 2026, R2 pricing): Standard storage $0.015 per GB-month, Class A operations $4.50 per million, Class B $0.36 per million; egress stays free. Rounding is up to the next unit (1.1 GB-month bills 2). Video is where this shows: a 4K video is several GB, so a few of them pass the 10 GB. Examples for Standard storage: 100 GB kept a month is (100 - 10) × $0.015 = $1.35; 1 TB is $14.85. A 5 GB file sent with storage::multipart_uploads is 514 Class A operations (512 parts, the create and the complete), well within the free million. Keep storage in check by deleting what users no longer need (a schedule that deletes files older than N days, storage::purge_unattached for abandoned uploads).
Beyond the Worker: programs and heavy compute
A Worker runs WebAssembly in a V8 isolate: it cannot start a program (ffmpeg, ffprobe, ImageMagick, Python), has no filesystem, and gets 10 ms of CPU per invocation on the free plan. Work of that kind runs elsewhere and reports back, with ocre g external_job (Run work on another service):
- a GPU service billed per second (RunPod serverless, Modal, Replicate…) for AI inference and video encoding;
- Cloudflare Containers, which run any image (
ffmpegincluded) next to the Worker, on the Workers Paid plan; - any server or function of your own that answers HTTP.
Probing a video (duration, resolution) to price it belongs there too: the service reports it in an event’s output. Cloudflare’s Media Transformations and Stream (paid) cover some video work without a container.
Workers KV (cache, binding CACHE)
| Limit | Free plan (September 2026) | Source | What Ocre does |
|---|---|---|---|
| Reads | 100,000 a day | KV limits | ocre::cache::fetch reads once per call. |
| Writes | 1,000 a day (to different keys); 1 per second to the same key | KV limits | The scarcest free resource: each miss of fetch, each write and each delete is one. Past a limit, fetch logs [ocre cache] ... failed and computes the value instead of failing. |
| Deletes, lists | 1,000 a day each | Workers pricing, KV | ocre::cache::delete is one delete; Ocre never lists. |
| Storage | 1 GB per account and per namespace | KV limits | Values are JSON; keep them small. |
| Key size | 512 bytes | KV limits | Longer or empty keys are refused with an error naming the fix. |
| Value size | 25 MiB | KV limits | |
| Minimum TTL | 60 seconds (expirationTtl) | Write key-value pairs | fetch and write refuse shorter TTLs (ocre::cache::MIN_TTL). |
| Consistency | writes can take up to 60 seconds to be visible elsewhere | Write key-value pairs | Not for data that must be read back immediately. |
Durable Objects (realtime, binding CHANNELS)
| Limit | Free plan (September 2026) | Source | What Ocre does |
|---|---|---|---|
| Requests | 100,000 a day; incoming WebSocket messages count 1 per 20; outgoing messages are free | Durable Objects pricing | One request per connection (and reconnection) and one per broadcast. Browsers only listen, so they send no messages. |
| Duration | 13,000 GB-s a day (at 128 MB, about 28 hours awake) | Durable Objects pricing | OcreChannel uses the WebSocket Hibernation API: objects are only billed while handling a connection or a broadcast. |
| Storage backend | SQLite-backed classes only | Durable Objects pricing | new_sqlite_classes = ["OcreChannel"]; the channel stores nothing. |
| Classes, storage | 100 classes per account, 5 GB storage per account | Durable Objects limits | One class, no storage. |
| Received WebSocket message size | 32 MiB | Durable Objects limits | Client messages are ignored. |
| Limit | Free plan (September 2026) | Source | What Ocre does |
|---|---|---|---|
| Email Routing (receiving) | free and unlimited; 200 routing rules per domain, 200 destination addresses per account, 25 MiB per incoming message | Email Service pricing, limits | ocre g mailbox wires the Worker’s email event; each received message is one invocation with 10 ms of CPU. |
Email Service (sending, MAIL_ADAPTER = "cloudflare") | on Workers Free, only to verified destination addresses of the account (free); any recipient needs Workers Paid (3,000 a month included, then $0.35 per 1,000); 5 MiB per message, 50 recipients | Email Service pricing, limits | Fine for mail to yourself; use Resend for sign-up and password-reset mail on the free plan. |
Resend (sending, MAIL_ADAPTER = "resend") | 100 emails a day, 3,000 a month (each recipient counts; received emails too); up to 3 verified domains | Resend quotas | deliver_later retries provider failures through the jobs queue. |
What happens at a limit
| Resource | Behavior past the limit | Ocre’s handling |
|---|---|---|
| Worker requests | Error 1027 page (or the request bypasses the Worker if its route fails open) until 00:00 UTC | none possible inside the app |
| CPU | Error 1102 “Worker exceeded resource limits” when overruns become frequent | Design: I/O in handlers, heavy work in jobs |
| D1 rows | Queries fail | Errors become 500 responses, logged with [ocre] |
| KV | Operations of that type fail until 00:00 UTC | fetch falls back to computing the value |
| Queues | Sends fail | enqueue returns the error; the handler decides |
| Durable Objects | Requests fail | Broadcast failures are logged ([ocre realtime] broadcast to <channel> failed: ...) and the request still succeeds |
See also
- Cost model: how a request spends these limits, with a daily budget example.
- Configuration: the
cloudflare.config.tsentries behind each binding. - Background jobs and schedules, File storage, Caching, Realtime, Email.
- Cloudflare Workers pricing for the Workers Paid plan.
Ocre API index
Every public item of the ocre crate (all features) with its path, signature and
first doc sentence, grouped by module. Full documentation: the rustdoc pages, or
cargo doc -p ocre --all-features --open.
Generated by python3 scripts/api-index.py from rustdoc JSON; do not edit by hand.
ocre
ocre::serde_json(re-export): JSON values (serde_json::Value, thejson!macro) forjsonfields, without addingserde_jsonto the app.pub use serde_jsonocre::locales(macro): Declares the app’s locales as aLocalesstatic, compilinglocales/<code>.ymlinto the binary.macro_rules! localesocre::params(macro): Builds the parameter list of aDbquery:params![title, id].macro_rules! paramsocre::bulk(mod): Many rows in one D1 statement (Rails’insert_all,upsert_all, and anupdate_allwith a value per row).ocre::cache(mod): Caching: read-through values in Workers KV, HTTPCache-Control/ETagand 304 responses.ocre::config(mod): Typed app configuration from Worker variables and secrets, and the environment (development or production).ocre::encryption(mod): Attribute encryption for model columns: values are encrypted in Rust before they reach D1 and decrypted when rows are read, like Rails’encrypts.ocre::errors(mod): Error reporting (Rails’Rails.error): report errors with context to the logs and to services such as Sentry.ocre::events(mod): Structured events (Rails 8.1’sRails.event): named facts about what the app did, with a payload, for analytics, audit trails or a data warehouse.ocre::filters(mod, featurehtml): askama filters for Ocre’shelpers(featurehtml):{{ post.price|number_to_currency("$") }}.ocre::graphql(mod, featuregraphql): GraphQL support (featuregraphql).ocre::helpers(mod): View helpers: numbers, dates and text formatted like Rails’number_to_currency,time_ago_in_words,excerpt…ocre::i18n(mod): Translations:locales/*.yml,%{name}interpolation, plurals, locale per request.ocre::jobs(mod): Background jobs (Cloudflare Queues) and scheduled tasks (Cron Triggers).ocre::jwt(mod): JSON Web Tokens (HS256) for API clients.ocre::log(mod): Structured logging to Workers Logs: levels, request-scoped fields, JSON lines (Rails’Rails.loggerand tagged logging).ocre::mail(mod): Email: send with adapters (log, Resend, Cloudflare), receive from Email Routing.ocre::oauth(mod): “Sign in with GitHub / Google”: the OAuth 2.0 authorization code flow with PKCE.ocre::password(mod): Password hashing (PBKDF2-HMAC-SHA256).ocre::push(mod, featurepush): Web push notifications: messages a browser shows even when the app’s page is closed (the Push API, with the service worker ofocre g pwa).ocre::realtime(mod, featurerealtime): Realtime updates: WebSocket channels on a Durable Object, HTML broadcasts for htmx (featurerealtime).ocre::replicas(mod): D1 read replicas: reads go to a nearby copy of the database, writes to the primary, and each visitor still reads what they wrote (Rails’ automatic role switching, without a second database to configure).ocre::security(mod): Security helpers beyond whatservealways does: policies, rate limits, safe redirects, HTML cleaning.ocre::seo(mod): Search engines and language models: JSON-LD, sitemaps andllms.txt.ocre::sse(mod): Server-Sent Events: a response that sends events as they happen (Rails’ActionController::LivewithSSE).ocre::storage(mod): File storage in Cloudflare R2: multipart uploads, attachments, streamed downloads.ocre::testing(mod, featuretesting): Test helpers for Ocre apps, like Rails’ActionDispatch::IntegrationTestandActiveSupport::Testing: a request client for the running app, the test database, the server log, time travel and assertions.ocre::token(mod): Random tokens for emailed links and API keys, stored as digests.ocre::webhooks(mod): Webhooks: signatures for calls in both directions, and processing each event once.ocre::bool_from_sql(fn): Deserializes a SQLite boolean column (INTEGER 0/1) intobool.pub fn bool_from_sql<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error>ocre::encode_path(fn): A route path with its non-ASCII characters percent-encoded, so Unicode routes match (Rails’ Unicode routes).pub fn encode_path(path: &str) -> Stringocre::error_page(fn, featurehtml): Renders error responses with the app’s own template (Rails’public/404.htmland500.html).pub fn error_page(response: Response, render: impl FnOnce(&ErrorPage) -> Result<Html<String>>) -> Responseocre::escape_like(fn): Escapes%,_and\sotextmatches literally in aLIKE ... ESCAPE '\'pattern (Rails’sanitize_sql_like).pub fn escape_like(text: &str) -> Stringocre::json_from_sql(fn): Deserializes a JSON column (the JSON text D1 returns) intoserde_json::Value.pub fn json_from_sql<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<serde_json::Value, D::Error>ocre::now(fn): Current Unix time in seconds, on Workers and in native tests.pub fn now() -> i64ocre::optional(fn): Deserializes an optional field wherenull, a missing value or an empty string isNone.pub fn optional<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error> where D: Deserializer<'de>, T: Deserialize<'de> + FromStr, T::Err: Displayocre::optional_json_from_sql(fn): Likejson_from_sqlfor aNULL-able JSON column:NULLisNone.pub fn optional_json_from_sql<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>ocre::patch(fn): Deserializes a field of a partial update (PATCH) into keep / clear / set.pub fn patch<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> where D: Deserializer<'de>, T: Deserialize<'de> + FromStr, T::Err: Displayocre::patch_json(fn): Deserializes an optional JSON field of a partial update into keep / clear / set.pub fn patch_json<'de, D>(deserializer: D) -> Result<Option<Option<serde_json::Value>>, D::Error> where D: Deserializer<'de>ocre::redirect_back(fn): Redirects to the page the request came from, or tofallback(Rails’redirect_back_or_to).pub fn redirect_back(headers: &HeaderMap, fallback: &str) -> Redirectocre::remote_ip(fn): The client’s IP address from theCF-Connecting-IPheader, for code that has the headers but no extractor.pub fn remote_ip(headers: &HeaderMap) -> Option<IpAddr>ocre::render(fn, featurehtml): Renders an askama template (compiled at build time) into an HTML response.pub fn render<T: Template>(template: &T) -> Result<Html<String>>ocre::serve(fn): Runs one request through the application router: the Workerfetchentry point.pub async fn serve(routes: Router<Ctx>, req: HttpRequest, env: Env) -> worker::Result<web_sys::Response>ocre::sleep(fn): Waitsdurationwithout using CPU (JavaScript’ssetTimeout): pacing forssestreams and polling.pub fn sleep(duration: std::time::Duration) -> impl Future<Output = ()> + Sendocre::ApiError(struct): Handler error for JSON endpoints: anErrorrendered as a JSON body.pub struct ApiError(pub Error)- Implements:
Debug,From,IntoResponse
- Implements:
ocre::Batches(struct): Batches of rows by increasing id, fromQuery::batches: Rails’find_in_batcheswithout holding a cursor open.pub struct Batches<T>ocre::Batches::resume_after(fn): Starts afterid(Rails’start:, exclusive), or from the first row withNone.pub fn resume_after(mut self, id: Option<i64>) -> Selfocre::Batches::after(fn): The last id read so far (Nonebefore the first batch).pub fn after(&self) -> Option<i64>ocre::Batches::is_done(fn): Whether the last batch was shorter than the batch size: no row is left.pub fn is_done(&self) -> boolocre::Batches::statement(fn): TheSELECTof the next batch.pub fn statement(&self) -> Statementocre::Batches::advance(fn): Records a batch just read: its last id, and whether it was the last batch.pub fn advance(&mut self, rows: &[T])ocre::Batches::next(fn): Runs the next batch: up to the batch size of rows after the last id read, orNoneonce every row was read.pub async fn next(&mut self, db: &Db) -> Result<Option<Vec<T>>>
ocre::Cookies(struct): The request’s cookies; what handlers set goes out with the response.pub struct Cookies(/* private fields */)ocre::Cookies::get(fn): The value of the plain cookiename.pub fn get(&self, name: &str) -> Option<String>ocre::Cookies::signed(fn): The value of the signed cookiename;Nonewhen absent or changed by the client.pub fn signed(&self, name: &str) -> Result<Option<String>>ocre::Cookies::encrypted(fn): The value of the encrypted cookiename;Nonewhen absent or changed by the client.pub fn encrypted(&self, name: &str) -> Result<Option<String>>ocre::Cookies::set(fn): Sets a plain cookie;max_ageNonemakes it last until the browser closes.pub fn set(&self, name: &str, value: &str, max_age: Option<Duration>) -> Result<()>ocre::Cookies::set_signed(fn): Sets a signed cookie: the browser sees the value, and a changed one reads asNone.pub fn set_signed(&self, name: &str, value: &str, max_age: Option<Duration>) -> Result<()>ocre::Cookies::set_encrypted(fn): Sets an encrypted cookie: the browser can neither read nor change the value.pub fn set_encrypted(&self, name: &str, value: &str, max_age: Option<Duration>) -> Result<()>ocre::Cookies::remove(fn): Deletes the cookienamefrom the browser.pub fn remove(&self, name: &str)- Implements:
Clone,FromRequestParts
ocre::Created(struct):201 Createdresponse with a JSON body, for create endpoints.pub struct Created<T>(pub T)- Implements:
IntoResponse
- Implements:
ocre::Ctx(struct): Per-request application context: the Worker environment with typed access to its bindings.pub struct Ctxocre::Ctx::log(fn): The logger of this request, job batch or cron run (seeocre::log).pub fn log(&self) -> &Loggerocre::Ctx::errors(fn): The error reporter of this request, job batch or cron run (seeocre::errors).pub fn errors(&self) -> &Reporterocre::Ctx::events(fn): Structured events of this request or job (Rails’Rails.event), seeocre::events.pub fn events(&self) -> &Eventsocre::Ctx::config(fn): The app’s settings, read from Worker variables and secrets intoT(seeocre::config).pub fn config<T: DeserializeOwned>(&self) -> Result<T>ocre::Ctx::env(fn): The raw Workers environment, for bindings Ocre does not wrap yet.pub fn env(&self) -> &Envocre::Ctx::secret(fn): The secretname, wherever it is kept: a Worker secret (ocre secrets push), a.dev.varsvalue inocre dev, or a secret of the account’s Secrets Store bound to the Worker in cloudflare.config.ts (NAME: bindings.secretsStoreSecret({ storeId, secretName }), whichocre secrets push NAME --storewrites).pub fn secret(&self, name: &str) -> impl Future<Output = Result<String>> + Send + use<>ocre::Ctx::db(fn): The application database: the D1 bindingDB.pub fn db(&self) -> Result<Db>ocre::Ctx::db_named(fn): Another D1 database of the app, by its binding name (Rails’ multiple databases).pub fn db_named(&self, binding: &str) -> Result<Db>- Implements:
Clone
ocre::Db(struct): Handle to the application’s D1 (SQLite) database, fromCtx::db.pub struct Dbocre::Db::uncached(fn): This handle without the per-request query cache: every query goes to D1.pub fn uncached(self) -> Selfocre::Db::all(fn): Runs a query and returns every row, deserialized intoT.pub fn all<'q, T: DeserializeOwned>(&self, sql: &'q str, params: Vec<Param>) -> impl Future<Output = Result<Vec<T>>> + Send + use<'q, T>ocre::Db::first(fn): Runs a query and returns its first row, if any, deserialized intoT.pub fn first<'q, T: DeserializeOwned>(&self, sql: &'q str, params: Vec<Param>) -> impl Future<Output = Result<Option<T>>> + Send + use<'q, T>ocre::Db::execute(fn): Runs a statement that returns no rows (INSERT,UPDATE,DELETE) and gives the number of rows changed.pub fn execute<'q>(&self, sql: &'q str, params: Vec<Param>) -> impl Future<Output = Result<usize>> + Send + use<'q>ocre::Db::exists(fn): Whether a query returns at least one row.pub fn exists<'q>(&self, sql: &'q str, params: Vec<Param>) -> impl Future<Output = Result<bool>> + Send + use<'q>ocre::Db::batch(fn): Runs every statement in one transaction and returns the rows changed by each one.pub fn batch(&self, statements: Vec<Statement>) -> impl Future<Output = Result<Vec<usize>>> + Send + '_
ocre::ErrorPage(struct, featurehtml): What an HTML error page may show: the status, a message safe for users, and validation errors.pub struct ErrorPageocre::ErrorPage::status(field): HTTP status of the response, e.g.404:{{ error.status.as_u16() }}in a template.pub status: StatusCodeocre::ErrorPage::message(field): Message for the user:Not found,Validation failed, a bad request’s own message…pub message: Stringocre::ErrorPage::fields(field): Field errors of a 422 ({{ field.full_message() }}), empty otherwise.pub fields: Vec<FieldError>- Implements:
Clone,Debug
ocre::FieldError(struct): One failed validation: the field name plus the message without it, as in Rails’errors.pub struct FieldErrorocre::FieldError::field(field): Field name as in the form or JSON body ("title","author_id").pub field: Stringocre::FieldError::message(field): Message without the field name ("can't be blank").pub message: Stringocre::FieldError::new(fn): Builds an error forfieldwithmessage(without the field name).pub fn new(field: impl Into<String>, message: impl Into<String>) -> Selfocre::FieldError::key(fn): The Rails translation key of the check that failed ("blank","too_long"…), if any.pub fn key(&self) -> Option<&'static str>ocre::FieldError::full_message(fn): The message prefixed with the humanized field name:"Title can't be blank".pub fn full_message(&self) -> String- Implements:
Clone,Debug,Display,PartialEq,Serialize
ocre::Flash(struct): Flash messages set by the previous request, as an extractor.pub struct Flash(/* private fields */)ocre::Flash::get(fn): The message of the givenkind, if the previous request set one.pub fn get(&self, kind: &str) -> Option<&str>ocre::Flash::notice(fn): Thenoticemessage (success), same asflash.get("notice").pub fn notice(&self) -> Option<&str>ocre::Flash::alert(fn): Thealertmessage (failure), same asflash.get("alert").pub fn alert(&self) -> Option<&str>ocre::Flash::iter(fn): Every(kind, message)pair.pub fn iter(&self) -> impl Iterator<Item = (&str, &str)>ocre::Flash::is_empty(fn): Whether there is no message.pub fn is_empty(&self) -> boolocre::Flash::now(fn): These messages plusmessageofkind, for this response only (Rails’flash.now): render a page with a message without storing it in the session, e.g. a form shown again with an alert.pub fn now(self, kind: &str, message: impl Into<String>) -> Self- Implements:
Clone,Debug,Default,Eq,FromRequestParts,PartialEq
ocre::Htmx(struct, featurehtml): Extractor telling whether htmx sent the request:Htmx(true)when it hasHX-Request: true.pub struct Htmx(pub bool)- Implements:
Clone,Copy,Debug,FromRequestParts
- Implements:
ocre::HxRedirect(struct, featurehtml): Response telling htmx to load another page:200 OKwith theHX-Redirectheader.pub struct HxRedirect(pub String)- Implements:
Clone,Debug,Eq,IntoResponse,PartialEq
- Implements:
ocre::Json(struct): JSON request body extractor and response, whose failures are JSON too.pub struct Json<T>(pub T)- Implements:
Clone,Copy,Debug,Default,FromRequest,IntoResponse
- Implements:
ocre::Markdown(struct): A Markdown response:text/markdown; charset=utf-8(Rails’render markdown:).pub struct Markdown(pub String)- Implements:
Clone,Debug,Eq,IntoResponse,PartialEq
- Implements:
ocre::NestedForm(struct): Form extractor for bracketed field names, like Rails’params(post[title],tag_ids[],lines[0][qty]).pub struct NestedForm<T>(pub T)ocre::NestedForm::parse(fn): Decodes anapplication/x-www-form-urlencodedstring (a body or a query string) with bracketed names.pub fn parse(input: &str) -> Result<T>- Implements:
Clone,Copy,Debug,Default,FromRequest
ocre::Page(struct):?limit=&offset=pagination for list endpoints, as an extractor.pub struct Pageocre::Page::limit(field): Rows to return,1..=100.pub limit: i64ocre::Page::offset(field): Rows to skip,0or more.pub offset: i64ocre::Page::DEFAULT_LIMIT(const):limitwhen the query string has none: 50.pub const DEFAULT_LIMIT: i64 = 50ocre::Page::MAX_LIMIT(const): Largest acceptedlimit: 100.pub const MAX_LIMIT: i64 = 100ocre::Page::new(fn): Builds a page after checking the bounds:1..=100forlimit,0..foroffset.pub fn new(limit: i64, offset: i64) -> Result<Self, Error>ocre::Page::next(fn): The page after this one, orNonewhenreturned(the rows this page got) is less thanlimit.pub fn next(&self, returned: usize) -> Option<Self>ocre::Page::previous(fn): The page before this one, orNoneon the first page.pub fn previous(&self) -> Option<Self>ocre::Page::query(fn): The query string of this page, without?:offset=50, withlimit=first when it is not the default.pub fn query(&self) -> Stringocre::Page::links(fn):Linkheader (RFC 8288) pointing JSON clients at thenext,prevandfirstpages ofpath.pub fn links(&self, path: &str, returned: usize) -> PageLinks- Implements:
Clone,Copy,Debug,FromRequestParts,PartialEq
ocre::PageLinks(struct): TheLinkheader built byPage::links, as a response part (nothing when there is no other page).pub struct PageLinks(/* private fields */)- Implements:
Clone,Debug,IntoResponseParts
- Implements:
ocre::Paginated(struct): A page of rows plus the total, for pagination links and JSON envelopes.pub struct Paginated<T>ocre::Paginated::items(field): The rows of this page.pub items: Vec<T>ocre::Paginated::total(field): Rows matching the query, all pages together.pub total: i64ocre::Paginated::limit(field): Rows per page.pub limit: i64ocre::Paginated::offset(field): Rows skipped before this page.pub offset: i64ocre::Paginated::current_page(fn): 1-based number of this page.pub fn current_page(&self) -> i64ocre::Paginated::total_pages(fn): Number of pages (at least 1, even with no row).pub fn total_pages(&self) -> i64ocre::Paginated::has_next(fn): Whether rows follow this page.pub fn has_next(&self) -> boolocre::Paginated::has_previous(fn): Whether rows come before this page.pub fn has_previous(&self) -> boolocre::Paginated::next_page(fn): The next page, if any.pub fn next_page(&self) -> Option<Page>ocre::Paginated::previous_page(fn): The previous page, if any (never a negative offset).pub fn previous_page(&self) -> Option<Page>ocre::Paginated::map(fn): Converts the items, keeping the counts (e.g. rows into view structs).pub fn map<U>(self, f: impl FnMut(T) -> U) -> Paginated<U>- Implements:
Clone,Debug,PartialEq,Serialize
ocre::Param(struct): A value bound to a?Nplaceholder of a D1 query.pub struct Param(/* private fields */)- Implements:
Clone,Debug,IntoParam,PartialEq
- Implements:
ocre::Query(struct): ASELECTon one table, built step by step, whose values are always bound parameters.pub struct Query<T>ocre::Query::table(fn): Starts a query ontable:SELECT * FROM <table>.pub fn table(table: &'static str) -> Selfocre::Query::scope(fn): Applies a scope:f(self).pub fn scope(self, f: impl FnOnce(Self) -> Self) -> Selfocre::Query::select(fn): Selectscolumns(an SQL select list) instead of*; the rows becomeU.pub fn select<U>(mut self, columns: &'static str) -> Query<U>ocre::Query::distinct(fn):SELECT DISTINCT: drops duplicate rows.pub fn distinct(mut self) -> Selfocre::Query::join(fn): Adds a join clause, written in full:JOIN ...orLEFT JOIN ....pub fn join(mut self, clause: &'static str) -> Selfocre::Query::eq(fn):column = value.pub fn eq(mut self, column: &'static str, value: impl IntoParam) -> Selfocre::Query::ne(fn):column != value.pub fn ne(mut self, column: &'static str, value: impl IntoParam) -> Selfocre::Query::gt(fn):column > value.pub fn gt(mut self, column: &'static str, value: impl IntoParam) -> Selfocre::Query::gte(fn):column >= value.pub fn gte(mut self, column: &'static str, value: impl IntoParam) -> Selfocre::Query::lt(fn):column < value.pub fn lt(mut self, column: &'static str, value: impl IntoParam) -> Selfocre::Query::lte(fn):column <= value.pub fn lte(mut self, column: &'static str, value: impl IntoParam) -> Selfocre::Query::between(fn):column BETWEEN low AND high, both bounds included.pub fn between(mut self, column: &'static str, low: impl IntoParam, high: impl IntoParam) -> Selfocre::Query::is_in(fn):column IN (?, ?, ...).pub fn is_in<V: IntoParam>(mut self, column: &'static str, values: impl IntoIterator<Item = V>) -> Selfocre::Query::not_in(fn):column NOT IN (?, ?, ...).pub fn not_in<V: IntoParam>(mut self, column: &'static str, values: impl IntoIterator<Item = V>) -> Selfocre::Query::is_null(fn):column IS NULL.pub fn is_null(mut self, column: &'static str) -> Selfocre::Query::is_not_null(fn):column IS NOT NULL.pub fn is_not_null(mut self, column: &'static str) -> Selfocre::Query::like(fn):column LIKE pattern, with the pattern as given:%and_are wildcards.pub fn like(mut self, column: &'static str, pattern: impl IntoParam) -> Selfocre::Query::not_like(fn):column NOT LIKE pattern(wildcards as given, seelike).pub fn not_like(mut self, column: &'static str, pattern: impl IntoParam) -> Selfocre::Query::contains(fn):columncontainstext(case-insensitive for ASCII), with%,_and\intextmatched literally (seeescape_like).pub fn contains(mut self, column: &'static str, text: &str) -> Selfocre::Query::starts_with(fn):columnstarts withtext(wildcards escaped, seecontains).pub fn starts_with(mut self, column: &'static str, text: &str) -> Selfocre::Query::ends_with(fn):columnends withtext(wildcards escaped, seecontains).pub fn ends_with(mut self, column: &'static str, text: &str) -> Selfocre::Query::where_sql(fn): Adds a raw SQL condition with bare?placeholders, bound toparamsin order.pub fn where_sql(mut self, fragment: &'static str, params: Vec<Param>) -> Selfocre::Query::where_associated(fn): Keeps rows with at least one row intablepointing to them throughforeign_key(Rails’where.associatedon a has-many side).pub fn where_associated(mut self, table: &'static str, foreign_key: &'static str) -> Selfocre::Query::where_missing(fn): Keeps rows that no row oftablepoints to throughforeign_key(Rails’where.missingon a has-many side): posts without comments.pub fn where_missing(mut self, table: &'static str, foreign_key: &'static str) -> Selfocre::Query::date_range(fn): Filterscolumnbetween two optional bounds, like Loco’sDateRangeBuilder.pub fn date_range<V: IntoParam>(self, column: &'static str, from: Option<V>, to: Option<V>) -> Selfocre::Query::unscope_where(fn): Removes every condition added so far (Rails’unscope(:where)); chain new ones after it for Rails’rewhere.pub fn unscope_where(mut self) -> Selfocre::Query::unscope_limit(fn): Removes the limit and offset set so far (Rails’unscope(:limit, :offset)).pub fn unscope_limit(mut self) -> Selfocre::Query::reverse_order(fn): Reverses the order (Rails’reverse_order):ASCterms becomeDESCand back; terms without a direction (order_in, a bareorder_sql) getDESC.pub fn reverse_order(mut self) -> Selfocre::Query::any(fn): Matches rows meeting at least one of the conditionsfadds:(a OR b ...).pub fn any(mut self, f: impl FnOnce(Self) -> Self) -> Selfocre::Query::not(fn): Matches rows that do not meet all the conditionsfadds:NOT (a AND b ...).pub fn not(mut self, f: impl FnOnce(Self) -> Self) -> Selfocre::Query::none(fn): Matches no row (WHERE 0), like Rails’none: a scope can return it when a filter makes the result empty.pub fn none(mut self) -> Selfocre::Query::group_by(fn):GROUP BY columns; combine withselectfor the aggregates andhavingto filter groups.pub fn group_by(mut self, columns: &'static str) -> Selfocre::Query::having(fn): Adds aHAVINGcondition (raw SQL with bare?placeholders) on the groups.pub fn having(mut self, fragment: &'static str, params: Vec<Param>) -> Selfocre::Query::order_asc(fn):ORDER BY column ASC, after any order already set.pub fn order_asc(self, column: &'static str) -> Selfocre::Query::order_desc(fn):ORDER BY column DESC, after any order already set.pub fn order_desc(self, column: &'static str) -> Selfocre::Query::order_by(fn):ORDER BY column <direction>, after any order already set.pub fn order_by(mut self, column: &'static str, direction: Direction) -> Selfocre::Query::order_in(fn): Orderscolumnby an explicit list of values (Rails’in_order_of): rows whose value is not listed come last.pub fn order_in<V: IntoParam>(mut self, column: &'static str, values: impl IntoIterator<Item = V>) -> Selfocre::Query::order_sql(fn): Adds a rawORDER BYterm, e.g.lower(title)orpublished_at DESC NULLS LAST.pub fn order_sql(mut self, term: &'static str) -> Selfocre::Query::reorder(fn): Removes the order set so far (Rails’reorderwhen followed by a new order).pub fn reorder(mut self) -> Selfocre::Query::limit(fn): Returns at mostlimitrows.pub fn limit(mut self, limit: i64) -> Selfocre::Query::offset(fn): Skips the firstoffsetrows.pub fn offset(mut self, offset: i64) -> Selfocre::Query::page(fn): Limit and offset from aPage(?limit=&offset=).pub fn page(self, page: Page) -> Selfocre::Query::explain_statement(fn):EXPLAIN QUERY PLANof theSELECT(Rails’explain): how SQLite finds the rows, e.g.SEARCH posts USING INDEX index_posts_on_author_id (author_id=?)or a fullSCAN posts.pub fn explain_statement(&self) -> Statementocre::Query::batches(fn): Walks the matching rows in batches ofsize, ordered by id (Rails’find_in_batches/in_batches, andfind_eachwith a loop over each batch).pub fn batches(self, size: i64, id: fn(&T) -> i64) -> Batches<T>ocre::Query::to_statement(fn): TheSELECTstatement, with placeholders numbered?1, ?2....pub fn to_statement(&self) -> Statementocre::Query::count_statement(fn):SELECT COUNT(*) AS countof the matching rows, ignoring order, limit and offset.pub fn count_statement(&self) -> Statementocre::Query::exists_statement(fn):SELECT 1 ... LIMIT 1: whether any row matches, stopping at the first.pub fn exists_statement(&self) -> Statementocre::Query::value_statement(fn):SELECT <expression> AS value, keeping conditions, order and limits: one column of every matching row (Rails’pluck), or with an aggregate expression (SUM(price)) the calculation over the matching rows (order and limits are dropped then, as they do not apply).pub fn value_statement(&self, expression: &'static str) -> Statementocre::Query::aggregate_statement(fn):SELECT <aggregate> AS valueover the matching rows, without order or limits.pub fn aggregate_statement(&self, expression: &'static str) -> Statementocre::Query::update_statement(fn):UPDATE <table> SET ... WHERE ...on the matching rows (Rails’update_all): no validation, noupdated_atchange unless listed.pub fn update_statement(&self, sets: Vec<(&'static str, Param)>) -> Statementocre::Query::delete_statement(fn):DELETE FROM <table> WHERE ...on the matching rows (Rails’delete_all).pub fn delete_statement(&self) -> Statementocre::Query::all(fn): Runs the query and returns every matching row.pub async fn all(&self, db: &Db) -> Result<Vec<T>>ocre::Query::first(fn): Runs the query withLIMIT 1and returns the first row, if any (Rails’first,takeandfind_by).pub async fn first(&self, db: &Db) -> Result<Option<T>>ocre::Query::paginate(fn): Runs the query and itscount_statementforpage: the rows plus the total for pagination links.pub async fn paginate(&self, db: &Db, page: Page) -> Result<Paginated<T>>ocre::Query::first_or_create(fn): Returns the first matching row, or runscreatewhen there is none (Rails’find_or_create_by; with aNew...value built in memory instead of saved,find_or_initialize_by).pub async fn first_or_create<F, Fut>(&self, db: &Db, create: F) -> Result<T> where F: FnOnce() -> Fut, Fut: Future<Output = Result<T>>ocre::Query::create_or_first(fn): Runscreatefirst and, when it fails because the value is already taken, returns the existing row instead (Rails’create_or_find_by).pub async fn create_or_first<F, Fut>(&self, db: &Db, create: F) -> Result<T> where F: FnOnce() -> Fut, Fut: Future<Output = Result<T>>ocre::Query::explain(fn): The query plan, one line per step (Rails’explain):SEARCHmeans an index is used,SCANa full table read.pub async fn explain(&self, db: &Db) -> Result<Vec<String>>ocre::Query::count(fn): Number of matching rows (COUNT(*)), ignoring order and limits.pub async fn count(&self, db: &Db) -> Result<i64>ocre::Query::exists(fn): Whether any row matches (SELECT 1 ... LIMIT 1; Rails’exists?/any?).pub async fn exists(&self, db: &Db) -> Result<bool>ocre::Query::pluck(fn): Values of one column (or expression) of the matching rows (Rails’pluck/ids).pub async fn pluck<V: DeserializeOwned>(&self, db: &Db, expression: &'static str) -> Result<Vec<V>>ocre::Query::aggregate(fn): An aggregate over the matching rows:SUM(price),AVG(rating),MIN(created_at),MAX(views),COUNT(DISTINCT author_id)…pub async fn aggregate<V: DeserializeOwned>(&self, db: &Db, expression: &'static str) -> Result<Option<V>>ocre::Query::update_all(fn): Updates every matching row without validation (Rails’update_all) and returns how many changed.pub async fn update_all(&self, db: &Db, sets: Vec<(&'static str, Param)>) -> Result<usize>ocre::Query::delete_all(fn): Deletes every matching row (Rails’delete_all) and returns how many were deleted.pub async fn delete_all(&self, db: &Db) -> Result<usize>- Implements:
Clone,Debug
ocre::RemoteIp(struct): Extractor for the client’s IP address, from Cloudflare’sCF-Connecting-IPheader.pub struct RemoteIp(pub Option<IpAddr>)- Implements:
Clone,Copy,Debug,Eq,FromRequestParts,PartialEq
- Implements:
ocre::RequestId(struct): Extractor for an identifier of the request, to correlate log lines and error reports.pub struct RequestId(pub String)- Implements:
Clone,Debug,Eq,FromRequestParts,PartialEq
- Implements:
ocre::Session(struct): The current request’s session, stored in an encrypted cookie, as an extractor.pub struct Session(/* private fields */)ocre::Session::expire_in(fn): Makes the session expiresecondsfrom now; the cookie still ends with the browser session.pub fn expire_in(&self, seconds: i64) -> Result<()>ocre::Session::remember_for(fn): Keeps the session forseconds, across browser restarts (“remember me”).pub fn remember_for(&self, seconds: i64) -> Result<()>ocre::Session::expires_at(fn): When the session expires, in Unix seconds, ifexpire_inorremember_forset it.pub fn expires_at(&self) -> Result<Option<i64>>ocre::Session::get(fn): Returns the value stored underkey, orNoneif absent or not deserializable asT.pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>>ocre::Session::insert(fn): Storesvalueunderkey, replacing any previous value.pub fn insert(&self, key: &str, value: impl Serialize) -> Result<()>ocre::Session::remove(fn): Removeskeyand returns whether it was there.pub fn remove(&self, key: &str) -> Result<bool>ocre::Session::clear(fn): Empties the session (sign out); flash messages set during this request are kept.pub fn clear(&self) -> Result<()>ocre::Session::flash(fn): Storesmessageunderkindto show on the next request, usually after a redirect.pub fn flash(&self, kind: &str, message: impl Into<String>) -> Result<()>ocre::Session::flashes(fn): Returns the flash messages set by the previous request and removes them from the session.pub fn flashes(&self) -> Result<Flash>ocre::Session::keep_flash(fn): Keepsflashfor the next request too (Rails’flash.keep), e.g. when a page read the messages but redirects again before showing them.pub fn keep_flash(&self, flash: &Flash) -> Result<()>- Implements:
Clone,FromRequestParts
ocre::Statement(struct): One SQL statement with its parameters, forDb::batch.pub struct Statementocre::Statement::sql(field): SQL text with?1, ?2...placeholders.pub sql: Stringocre::Statement::params(field): Values for the placeholders, in order (build withparams!).pub params: Vec<Param>ocre::Statement::new(fn): Pairssqlwith itsparams.pub fn new(sql: impl Into<String>, params: Vec<Param>) -> Self- Implements:
Clone,Debug,PartialEq
ocre::Validator(struct): Collects field errors with Rails-style messages;finishfails with all of them.pub struct Validatorocre::Validator::file_content(fn): Checks that an upload’s bytes match its declared content type (Active Storage’s content-type identification).pub fn file_content(&mut self, field: &str, upload: &Upload) -> &mut Selfocre::Validator::file(fn): Checks an uploaded file againstrules: its size and its content type.pub fn file(&mut self, field: &str, upload: &Upload, rules: &Rules) -> &mut Selfocre::Validator::new(fn): Creates a validator with no errors.pub fn new() -> Selfocre::Validator::check(fn): Addsmessageforfieldwhenfailedis true: the building block for custom rules.pub fn check(&mut self, field: &str, failed: bool, message: impl Into<String>) -> &mut Selfocre::Validator::required(fn): Checks thatvalueis not empty after trimming whitespace (“can’t be blank”).pub fn required(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::max_length(fn): Checks thatvaluehas at mostmaxcharacters (Unicode scalar values, not bytes).pub fn max_length(&mut self, field: &str, value: &str, max: usize) -> &mut Selfocre::Validator::min_length(fn): Checks thatvaluehas at leastmincharacters (Unicode scalar values, not bytes).pub fn min_length(&mut self, field: &str, value: &str, min: usize) -> &mut Selfocre::Validator::range(fn): Checks thatvalueis withinrange, bounds included.pub fn range<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, range: RangeInclusive<T>) -> &mut Selfocre::Validator::safe_integer(fn): Checks thatvalueis an integer D1 can store and return exactly (±2^53 - 1).pub fn safe_integer(&mut self, field: &str, value: i64) -> &mut Selfocre::Validator::inclusion(fn): Checks thatvalueis one ofallowed(“is not included in the list”).pub fn inclusion(&mut self, field: &str, value: &str, allowed: &[&str]) -> &mut Selfocre::Validator::exclusion(fn): Checks thatvalueis not one offorbidden(“is reserved”), like Rails’exclusion.pub fn exclusion(&mut self, field: &str, value: &str, forbidden: &[&str]) -> &mut Selfocre::Validator::length(fn): Checks thatvaluehas exactlylengthcharacters (Unicode scalar values): “is the wrong length (should be N characters)”.pub fn length(&mut self, field: &str, value: &str, length: usize) -> &mut Selfocre::Validator::greater_than(fn): Checks thatvalue > than(“must be greater than N”), like Rails’comparison.pub fn greater_than<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, than: T) -> &mut Selfocre::Validator::greater_than_or_equal_to(fn): Checks thatvalue >= min(“must be greater than or equal to N”).pub fn greater_than_or_equal_to<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, min: T) -> &mut Selfocre::Validator::less_than(fn): Checks thatvalue < than(“must be less than N”).pub fn less_than<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, than: T) -> &mut Selfocre::Validator::less_than_or_equal_to(fn): Checks thatvalue <= max(“must be less than or equal to N”).pub fn less_than_or_equal_to<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, max: T) -> &mut Selfocre::Validator::other_than(fn): Checks thatvalue != other(“must be other than N”).pub fn other_than<T: PartialEq + fmt::Display>(&mut self, field: &str, value: T, other: T) -> &mut Selfocre::Validator::confirmation(fn): Checks thatconfirmationequalsvalue, like Rails’confirmation: the error goes on<field>_confirmation(“doesn’t match Password”).pub fn confirmation(&mut self, field: &str, value: &str, confirmation: &str) -> &mut Selfocre::Validator::acceptance(fn): Checks that a checkbox was ticked (“must be accepted”), like Rails’acceptance.pub fn acceptance(&mut self, field: &str, accepted: bool) -> &mut Selfocre::Validator::absence(fn): Checks thatvalueis blank: empty or only whitespace (“must be blank”), like Rails’absence.pub fn absence(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::format(fn): Checks that every character ofvaluepassesallowed(“is invalid”): Rails’format, without regular expressions (no regex engine in the WebAssembly binary).pub fn format(&mut self, field: &str, value: &str, allowed: impl Fn(char) -> bool) -> &mut Selfocre::Validator::message(fn): Replaces the message of the check just before, if it failed (Rails’message:option).pub fn message(&mut self, message: impl Into<String>) -> &mut Selfocre::Validator::errors(fn): The errors collected so far, in the order the checks ran (Rails’errors).pub fn errors(&self) -> &[FieldError]ocre::Validator::email(fn): Checks thatvaluelooks like an e-mail address (“is invalid”).pub fn email(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::number(fn): Parses a required number typed as text (HTML forms), adding “is not a number” when it does not parse.pub fn number<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T>ocre::Validator::optional_number(fn): Parses an optional number typed as text: blank text isNonewithout an error.pub fn optional_number<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T>ocre::Validator::one_of(fn): Parses form text into one of an enum’s values, likenumber:Noneplus “is not included in the list” whenT::from_strrefuses it.pub fn one_of<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T>ocre::Validator::optional_one_of(fn): Likeone_offor an optional field: blank text isNonewithout error.pub fn optional_one_of<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T>ocre::Validator::json(fn): Parses a required JSON value typed as text (a<textarea>), adding “is not valid JSON” when it does not parse.pub fn json(&mut self, field: &str, text: &str) -> Option<serde_json::Value>ocre::Validator::optional_json(fn): Parses an optional JSON value typed as text: blank text isNonewithout an error.pub fn optional_json(&mut self, field: &str, text: &str) -> Option<serde_json::Value>ocre::Validator::date(fn): Checks thatvalueis a real calendar date writtenYYYY-MM-DD(“is not a valid date”).pub fn date(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::datetime(fn): Checks thatvalueisYYYY-MM-DD HH:MM[:SS], with a space orT(HTMLdatetime-local).pub fn datetime(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::time(fn): Checks thatvalueis a time of day writtenHH:MMorHH:MM:SS(HTML<input type="time">).pub fn time(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::uuid(fn): Checks thatvalueis a UUID in its hyphenated form, any case (“is not a valid UUID”).pub fn uuid(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::decimal(fn): Checks thatvalueis an exact decimal number such as-12.50(“is not a decimal number”).pub fn decimal(&mut self, field: &str, value: &str) -> &mut Selfocre::Validator::merge(fn): Adds the errors collected byother, e.g. a model’svalidate()after parsing a form.pub fn merge(&mut self, mut other: Validator) -> &mut Selfocre::Validator::is_valid(fn): Whether no error has been collected so far.pub fn is_valid(&self) -> boolocre::Validator::finish(fn): ReturnsOk(())when every check passed, and takes the collected errors otherwise.pub fn finish(&mut self) -> Result<(), Error>- Implements:
Debug,Default
ocre::Direction(enum): Sort direction forQuery::order_by, read from a query string asascordesc.pub enum Directionocre::Direction::Asc(variant): Smallest first (ASC).Ascocre::Direction::Desc(variant): Largest first (DESC).Descocre::Direction::as_sql(fn):ASCorDESC.pub fn as_sql(self) -> &'static str- Implements:
Clone,Copy,Debug,Default,Deserialize,Eq,PartialEq,Serialize
ocre::Error(enum): Handler error: an HTTP status plus, for client errors, a message the user may see.pub enum Errorocre::Error::NotFound(variant): 404 Not Found: the record or route does not exist.NotFoundocre::Error::BadRequest(variant): 400 Bad Request, with a message shown to the user.BadRequest(String)ocre::Error::Unauthorized(variant): 401 Unauthorized: missing or invalid credentials (password, session, token).Unauthorizedocre::Error::Forbidden(variant): 403 Forbidden: signed in, but not allowed to do this.Forbiddenocre::Error::Invalid(variant): 422 Unprocessable Entity: failed validations, one entry per field error.Invalid(Vec<FieldError>)ocre::Error::Conflict(variant): 409 Conflict: the record changed since it was read (optimistic locking with alock_versioncolumn), or a unique key already exists; the message is shown to the user.Conflict(String)ocre::Error::PayloadTooLarge(variant): 413 Payload Too Large: the request body is over a limit, with a message shown to the user.PayloadTooLarge(String)ocre::Error::TooManyRequests(variant): 429 Too Many Requests: a rate limit was hit.TooManyRequestsocre::Error::Internal(variant): 500 Internal Server Error; the message goes to the Worker logs only.Internal(String)ocre::Error::bad_request(fn): Builds a 400Error::BadRequestwhose message is shown to the user.pub fn bad_request(message: impl Into<String>) -> Selfocre::Error::internal(fn): Builds a 500Error::Internalwhose message is logged, never shown to the user.pub fn internal(message: impl Into<String>) -> Selfocre::Error::is_taken(fn): Whether the error says a unique value is already used: a “has already been taken” validation error (generated models check unique fields before writing), D1’sUNIQUE constraint failed(two requests raced past the check), or aConflict.pub fn is_taken(&self) -> bool- Implements:
Debug,Display,Error,From,IntoResponse
ocre::Format(enum): Response format a client asks for in itsAcceptheader, for actions that answer HTML or JSON (Rails’respond_to).pub enum Formatocre::Format::Html(variant):text/html: a page.Htmlocre::Format::Json(variant):application/json.Jsonocre::Format::Xml(variant):application/xmlortext/xml, e.g. an RSS or Atom feed.Xmlocre::Format::Text(variant):text/plain.Textocre::Format::Markdown(variant):text/markdown, e.g. for LLM clients (Rails’format.md); answer withMarkdown.Markdownocre::Format::Other(variant): Only types Ocre does not know, e.g.application/pdf.Otherocre::Format::from_headers(fn): Reads theAcceptheader; seeFormatfor the rules.pub fn from_headers(headers: &HeaderMap) -> Self- Implements:
Clone,Copy,Debug,Eq,FromRequestParts,PartialEq
ocre::IntoParam(trait): Types that can be bound as D1 query parameters.pub trait IntoParamocre::IntoParam::into_param(fn): Converts the value into a bound parameter.fn into_param(self) -> Param
ocre::OptionExt(trait): Extension forOption:option.or_404()?turns a missing record into a 404 response.pub trait OptionExt<T>ocre::OptionExt::or_404(fn): The value, orError::NotFound(404) whenNone.fn or_404(self) -> Result<T>
ocre::ApiResult(type):Resultfor JSON handlers: errors becomeApiErrorJSON responses.pub type ApiResult<T> = Result<T, ApiError>ocre::Result(type):ResultwithErroras the default error type, returned by Ocre APIs and handlers.pub type Result<T, E = Error> = std::result::Result<T, E>ocre::ALLOWED_HOSTS(const): Name of the Worker variable listing the host names the app answers to (Rails’config.hosts).pub const ALLOWED_HOSTS: &str = "ALLOWED_HOSTS"ocre::ALLOWED_ORIGINS(const): Name of the Worker variable listing extra origins that may call the app from a browser.pub const ALLOWED_ORIGINS: &str = "ALLOWED_ORIGINS"ocre::MAX_SAFE_INTEGER(const): Largest integer a JavaScript number (and so D1) represents exactly: 2^53 - 1.pub const MAX_SAFE_INTEGER: i64 = (1 <<53) - 1ocre::SECRET_KEY_BASE(const): Name of the Worker secret the session encryption key is derived from:SECRET_KEY_BASE.pub const SECRET_KEY_BASE: &str = "SECRET_KEY_BASE"ocre::SECRET_KEY_BASE_PREVIOUS(const): Name of the Worker secret listing the previousSECRET_KEY_BASEvalues, during a rotation.pub const SECRET_KEY_BASE_PREVIOUS: &str = "SECRET_KEY_BASE_PREVIOUS"ocre::SESSION_COOKIE(const): Name of the session cookie:_ocre_session.pub const SESSION_COOKIE: &str = "_ocre_session"
ocre::bulk
ocre::bulk::insert(fn):INSERT INTO table (columns) SELECT ... FROM json_each(?1): every row in one statement.pub fn insert<T: Serialize>(table: &str, columns: &[&str], rows: &[T]) -> Result<Statement>ocre::bulk::update(fn):UPDATE table SET ... FROM json_each(?1) WHERE table.key = ...: a value per row for many rows in one statement.pub fn update<T: Serialize>(table: &str, key: &str, columns: &[&str], rows: &[T], touch: bool) -> Result<Statement>ocre::bulk::upsert(fn):INSERT ... ON CONFLICT (key) DO UPDATE SET ...: inserts the rows, and updates the listed columns of those whosekeyalready exists (Rails’upsert_all).pub fn upsert<T: Serialize>(table: &str, key: &str, columns: &[&str], rows: &[T]) -> Result<Statement>
ocre::cache
ocre::cache::clear(fn): Deletes up tolimitcached values whose key starts withprefix(Rails’Rails.cache.clear, bounded):""for everything,"views/"for fragments,"posts/"for one family of keys.pub fn clear<'a>(ctx: &Ctx, prefix: &'a str, limit: usize) -> impl Future<Output = Result<crate::cache::Cleared>> + Send + use<'a>ocre::cache::delete(fn): Removeskey, e.g. after the data behind it changed (Rails’Rails.cache.delete).pub fn delete<'a>(ctx: &Ctx, key: &'a str) -> impl Future<Output = Result<()>> + Send + use<'a>ocre::cache::fetch(fn): Read-through cache, like Rails’Rails.cache.fetch: the value underkeyin KV, or else the result ofcompute.pub fn fetch<'a, T, F, Fut>(ctx: &Ctx, key: &'a str, ttl: Duration, compute: F) -> impl Future<Output = Result<T>> + Send + use<'a, T, F, Fut> where T: Serialize + DeserializeOwned, F: FnOnce() -> Fut, Fut: Future<Output = Result<T>>ocre::cache::fragment(fn, featurehtml): Fragment caching, like Rails’<% cache post do %>: the HTML stored underkey, or else the templatebuildreturns, rendered and stored.pub fn fragment<T, F>(ctx: &Ctx, key: &str, ttl: Duration, build: F) -> impl Future<Output = Result<Fragment>> + Send + use<T, F> where T: askama::Template, F: FnOnce() -> Tocre::cache::fragments(fn, featurehtml): Collection caching, like Rails’render collection:, cached: true: oneFragmentper item, read from KV in bulk.pub fn fragments<'a, I, T, K, F>(ctx: &Ctx, items: &'a [I], ttl: Duration, key: K, build: F) -> impl Future<Output = Result<Vec<Fragment>>> + Send + use<'a, I, T, K, F> where T: askama::Template, K: Fn(&I) -> String, F: Fn(&'a I) -> Tocre::cache::key(fn): Builds a cache key from its parts joined by/, like Rails’cache_key_with_version.pub fn key(parts: &[&(dyn fmt::Display + Sync)]) -> Stringocre::cache::read(fn): The value underkey, orNonewhen it is absent, expired, unreadable asT, or when KV fails.pub fn read<'a, T: DeserializeOwned>(ctx: &Ctx, key: &'a str) -> impl Future<Output = Result<Option<T>>> + Send + use<'a, T>ocre::cache::write(fn): Storesvalueas JSON underkeyforttl, replacing any previous value.pub fn write<'a, T: Serialize + ?Sized>(ctx: &Ctx, key: &'a str, value: &T, ttl: Duration) -> impl Future<Output = Result<()>> + Send + use<'a, T>ocre::cache::CacheControl(struct): ACache-Controlresponse header, built from one of four policies.pub struct CacheControlocre::cache::CacheControl::no_store(fn): Ano-storepolicy: never keep a copy (pages with secrets, one-time tokens).pub fn no_store() -> Selfocre::cache::CacheControl::no_cache(fn): Aprivate, no-cachepolicy: the browser keeps a copy but asks every time.pub fn no_cache() -> Selfocre::cache::CacheControl::private(fn): Aprivate, max-age=Npolicy: the browser reuses its copy formax_agewithout asking.pub fn private(max_age: Duration) -> Selfocre::cache::CacheControl::public(fn): Apublic, max-age=Npolicy: browsers and Cloudflare may reuse it for every visitor.pub fn public(max_age: Duration) -> Selfocre::cache::CacheControl::stale_while_revalidate(fn): Addsstale-while-revalidate, serving the old copy for up towindowwhile fetching a new one.pub fn stale_while_revalidate(self, window: Duration) -> Self- Implements:
Clone,Copy,Debug,Display,Eq,IntoResponseParts,PartialEq
ocre::cache::Cleared(struct): Whatcleardeleted.pub struct Clearedocre::cache::Cleared::deleted(field): How many values were deleted.pub deleted: usizeocre::cache::Cleared::more(field): Whether keys with the prefix remain.pub more: bool- Implements:
Clone,Copy,Debug,Default,Eq,PartialEq
ocre::cache::Conditional(struct): Extractor for the request’sIf-None-Match, to answer304 Not Modifiedwithout rendering.pub struct Conditionalocre::cache::Conditional::is_fresh(fn): Whether the client already has the versionetag.pub fn is_fresh(&self, etag: &ETag) -> boolocre::cache::Conditional::fresh_when(fn): Answers304 Not Modifiedwhen the client’s copy is current, or else renders the page.pub fn fresh_when<R: IntoResponse>(&self, etag: ETag, cache_control: CacheControl, render: impl FnOnce() -> Result<R>) -> Result<Response>- Implements:
Clone,Debug,Default,FromRequestParts
ocre::cache::ETag(struct): An entity tag naming the version of what a page shows: weak (W/"<hash>", the default) or strong ("<hash>").pub struct ETag(/* private fields */)ocre::cache::ETag::new(fn): A tag for a version string you build, e.g.format!("{}-{}", post.id, post.updated_at).pub fn new(version: impl AsRef<[u8]>) -> Selfocre::cache::ETag::strong(fn): A strong tag ("<hash>", Rails’strong_etag:) for a version string you build.pub fn strong(version: impl AsRef<[u8]>) -> Selfocre::cache::ETag::is_strong(fn): Whether the tag is strong (built withstrong).pub fn is_strong(&self) -> boolocre::cache::ETag::of(fn): A tag for any serializable data, hashed as its JSON:ETag::of(&(&posts, i18n.locale()))?.pub fn of<T: Serialize + ?Sized>(data: &T) -> Result<Self>ocre::cache::ETag::as_str(fn): The header value,W/"<32 hex characters>"(weak) or"<32 hex characters>"(strong).pub fn as_str(&self) -> &str- Implements:
Clone,Debug,Eq,IntoResponseParts,PartialEq
ocre::cache::Fragment(struct): A cached piece of HTML, fromfragmentorfragments.pub struct Fragment(/* private fields */)ocre::cache::Fragment::as_str(fn): The HTML.pub fn as_str(&self) -> &strocre::cache::Fragment::into_string(fn): The HTML as aString, e.g. for arealtimebroadcast.pub fn into_string(self) -> String- Implements:
Clone,Debug,Display,Eq,HtmlSafe,PartialEq
ocre::cache::CACHE_BINDING(const): Name of the KV namespace binding holding cached values:CACHE.pub const CACHE_BINDING: &str = "CACHE"ocre::cache::FRAGMENT_PREFIX(const): Prefix of the KV keys holdingfragments:views/, as in Rails.pub const FRAGMENT_PREFIX: &str = "views/"ocre::cache::LOG_PREFIX(const): Prefix of every line Ocre logs about the cache:[ocre cache].pub const LOG_PREFIX: &str = "[ocre cache]"ocre::cache::MIN_TTL(const): Shortest TTL KV accepts (expirationTtl): 60 seconds.pub const MIN_TTL: Duration = Duration::from_secs(60)ocre::cache::STORE_VAR(const): Name of the Worker variable choosing the cache store:CACHE_STORE.pub const STORE_VAR: &str = "CACHE_STORE"
ocre::config
ocre::config::from_vars(fn): ReadsTfrom the given variables: whatCtx::configdoes with the Worker’s environment.pub fn from_vars<'a, T: DeserializeOwned>(vars: impl IntoIterator<Item = (&'a str, &'a str)>) -> crate::Result<T>ocre::config::Environment(enum): Where the Worker runs, from its build profile (Rails’Rails.env, Loco’s environment).pub enum Environmentocre::config::Environment::Development(variant): Debug build:ocre dev,cargo test,ocre test.Developmentocre::config::Environment::Production(variant): Release build:ocre deploy.Productionocre::config::Environment::current(fn): The environment of this build.pub fn current() -> Selfocre::config::Environment::as_str(fn):developmentorproduction, as sent to error reporters.pub fn as_str(self) -> &'static strocre::config::Environment::is_development(fn): Whether this isDevelopment.pub fn is_development(self) -> bool- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::encryption
ocre::encryption::install(fn): Makesencryptorthe oneEncryptedandDeterministicuse.pub fn install(encryptor: Encryptor)ocre::encryption::installed(fn): The installed encryptor.pub fn installed() -> Result<Encryptor>ocre::encryption::is_encrypted(fn): Whethertextlooks like an encrypted value (starts withv1:).pub fn is_encrypted(text: &str) -> boolocre::encryption::Deterministic(struct): A column encrypted deterministically: equal values give equal stored texts, soquery().eq("email", Deterministic::from(email))finds the row and aUNIQUEindex works.pub struct Deterministic(pub String)ocre::encryption::Deterministic::as_str(fn): The plain value.encrypted_type!(Deterministic, encrypt_deterministic)- Implements:
Clone,Debug,Deserialize,Display,Eq,From,IntoParam,PartialEq,Serialize
ocre::encryption::Encrypted(struct): A column encrypted with a random nonce: reads decrypt, writes encrypt.pub struct Encrypted(pub String)ocre::encryption::Encrypted::as_str(fn): The plain value.encrypted_type!(Encrypted, encrypt)- Implements:
Clone,Debug,Deserialize,Display,Eq,From,IntoParam,PartialEq,Serialize
ocre::encryption::Encryptor(struct): Encrypts and decrypts column values with keys derived fromSECRET_KEY_BASE.pub struct Encryptorocre::encryption::Encryptor::new(fn): Derives the keys from the current secret and the previous ones (newest first).pub fn new(secret: &str, previous: &[&str]) -> Result<Self>ocre::encryption::Encryptor::encrypt(fn): Encryptsplaintextwith a random nonce:v1:plus URL-safe base64.pub fn encrypt(&self, plaintext: &str) -> Stringocre::encryption::Encryptor::encrypt_deterministic(fn): Encryptsplaintextso that equal values give equal texts (seeDeterministic).pub fn encrypt_deterministic(&self, plaintext: &str) -> Stringocre::encryption::Encryptor::deterministic_candidates(fn): The deterministic texts ofplaintextunder every key, current first: look rows up withQuery::is_induring a key rotation.pub fn deterministic_candidates(&self, plaintext: &str) -> Vec<String>ocre::encryption::Encryptor::decrypt(fn): Decrypts a value fromencryptorencrypt_deterministic, with the current key or a previous one.pub fn decrypt(&self, ciphertext: &str) -> Result<String>ocre::encryption::Encryptor::decrypt_or_plaintext(fn): Likedecrypt, but a value without thev1:prefix is returned as is: read a column while a data migration encrypts its existing rows (Rails’support_unencrypted_data).pub fn decrypt_or_plaintext(&self, text: &str) -> Result<String>- Implements:
Clone,Debug
ocre::errors
ocre::errors::subscribe(fn): Registers a subscriber for every report of this Worker instance (Rails’Rails.error.subscribe).pub fn subscribe(subscriber: impl Subscriber + 'static)ocre::errors::Delivery(struct): An HTTPPOSTaSubscriberasks Ocre to send for a report.pub struct Deliveryocre::errors::Delivery::url(field): Where toPOST.pub url: Stringocre::errors::Delivery::headers(field): Request headers, e.g. an API key.pub headers: Vec<(String, String)>ocre::errors::Delivery::body(field): The request body.pub body: String- Implements:
Clone,Debug,PartialEq
ocre::errors::Options(struct): Options of one report (Rails’handled:,severity:,context:,source:).pub struct Optionsocre::errors::Options::new(fn): The defaults: handled, warning, no context, sourceapplication.pub fn new() -> Selfocre::errors::Options::handled(fn): Whether the app recovered from the error (falsefor errors that failed the request or job).pub fn handled(mut self, handled: bool) -> Selfocre::errors::Options::severity(fn): The report’sSeverity.pub fn severity(mut self, severity: Severity) -> Selfocre::errors::Options::context(fn): Adds a context entry, merged over the request’sReporter::set_contextentries.pub fn context(mut self, key: &str, value: impl Serialize) -> Selfocre::errors::Options::source(fn): Where the error comes from, e.g.billing(Rails’source:).pub fn source(mut self, source: &str) -> Selfocre::errors::Options::except(fn): Does not send this report to the subscriber namedname(Rails’Rails.error.disable).pub fn except(mut self, subscriber: &'static str) -> Self- Implements:
Clone,Debug,Default
ocre::errors::Report(struct): One reported error, as subscribers receive it.pub struct Reportocre::errors::Report::class(field): The error’s type, e.g.ocre::error::Error(Sentry’s exception type).pub class: Stringocre::errors::Report::message(field): The error’s text (Display).pub message: Stringocre::errors::Report::handled(field): Whether the app recovered from it.pub handled: boolocre::errors::Report::severity(field): How bad it is.pub severity: Severityocre::errors::Report::context(field): The request’s context, then the report’s own entries.pub context: Map<String, Value>ocre::errors::Report::source(field): Where it comes from:application,ocre.request,ocre.job…pub source: Stringocre::errors::Report::timestamp(field): When it was reported, in Unix seconds.pub timestamp: i64ocre::errors::Report::environment(field): Development or production, from the build profile.pub environment: Environment- Implements:
Clone,Debug,PartialEq
ocre::errors::Reporter(struct): The error reporter of one request, job batch or cron run:Ctx::errors.pub struct Reporterocre::errors::Reporter::set_context(fn): Adds context to every later report of this request or job (Rails’Rails.error.set_context).pub fn set_context(&self, key: &str, value: impl Serialize)ocre::errors::Reporter::report(fn): Reports an error (Rails’Rails.error.report): logs it and queues it for the subscribers.pub fn report<E: Display + ?Sized>(&self, error: &E, options: Options)ocre::errors::Reporter::handle(fn): Reports the error ofresult, if any, as handled, and returns its value (Rails’Rails.error.handle).pub fn handle<T, E: Display>(&self, result: Result<T, E>) -> Option<T>ocre::errors::Reporter::record(fn): Reports the error ofresult, if any, as unhandled, and returnsresultunchanged (Rails’Rails.error.record).pub fn record<T, E: Display>(&self, result: Result<T, E>) -> Result<T, E>ocre::errors::Reporter::unexpected(fn): Reports something that should never happen (Rails’Rails.error.unexpected).pub fn unexpected(&self, message: impl Display)ocre::errors::Reporter::take(fn): Takes the reports made so far, for the subscribers (or a test).pub fn take(&self) -> Vec<Report>- Implements:
Clone,Debug,Default
ocre::errors::Sentry(struct): Sends reports to Sentry or a Sentry-compatible service, as envelopes (the Sentry ingestion protocol).pub struct Sentry- Implements:
Clone,Copy,Debug,Subscriber
- Implements:
ocre::errors::Severity(enum): How bad a report is (Rails’severity:).pub enum Severityocre::errors::Severity::Error(variant): A failure: the default ofReporter::recordand of Ocre’s own reports.Errorocre::errors::Severity::Warning(variant): Handled, worth looking at: the default ofReporter::reportandReporter::handle.Warningocre::errors::Severity::Info(variant): For information.Infoocre::errors::Severity::as_str(fn):error,warningorinfo, as Sentry names levels.pub fn as_str(self) -> &'static str- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::errors::Subscriber(trait): An error-reporting service (Rails’ error subscribers): turns aReportinto the request to send.pub trait Subscriber: Send + Syncocre::errors::Subscriber::name(fn): A short name, forOptions::exceptand failure logs:sentry.fn name(&self) -> &'static strocre::errors::Subscriber::deliver(fn): The request to send forreport, if any.fn deliver(&self, report: &Report, vars: &dyn Fn(&str) -> Option<String>) -> Option<Delivery>
ocre::errors::SENTRY_DSN(const): Worker secret holding the Sentry DSN:https://<key>@<host>/<project id>.pub const SENTRY_DSN: &str = "SENTRY_DSN"
ocre::events
ocre::events::subscribe(fn): Registers a subscriber for every event of this Worker instance (Rails’Rails.event.subscribe).pub fn subscribe(subscriber: impl Subscriber + 'static)ocre::events::Delivery(struct): An HTTPPOSTaSubscriberasks Ocre to send for a report.pub struct Deliveryocre::events::Delivery::url(field): Where toPOST.pub url: Stringocre::events::Delivery::headers(field): Request headers, e.g. an API key.pub headers: Vec<(String, String)>ocre::events::Delivery::body(field): The request body.pub body: String- Implements:
Clone,Debug,PartialEq
ocre::events::Event(struct): A structured event, fromEvents::notify.pub struct Eventocre::events::Event::name(field): What happened, e.g.order.placed.pub name: Stringocre::events::Event::payload(field): Its data; a payload that is not a JSON object is kept undervalue.pub payload: Map<String, Value>ocre::events::Event::tags(field): Tags of theEventsthat notified it (Events::tagged).pub tags: Map<String, Value>ocre::events::Event::context(field): The request’s or job’s context (Events::set_context).pub context: Map<String, Value>ocre::events::Event::timestamp(field): When it happened, in Unix seconds.pub timestamp: i64- Implements:
Clone,Debug,PartialEq
ocre::events::Events(struct): The event notifier of one request, job batch or cron run:Ctx::events.pub struct Eventsocre::events::Events::notify(fn): Records an event (Rails’Rails.event.notify): logs it and queues it for the subscribers.pub fn notify(&self, name: &str, payload: impl Serialize)ocre::events::Events::tagged(fn): This notifier with a tag added to its events (Rails’Rails.event.tagged).pub fn tagged(&self, key: &str, value: impl Serialize) -> Selfocre::events::Events::set_context(fn): Adds context to every later event of this request or job (Rails’Rails.event.set_context).pub fn set_context(&self, key: &str, value: impl Serialize)ocre::events::Events::take(fn): Takes the events not yet sent to the subscribers, oldest first.pub fn take(&self) -> Vec<Event>- Implements:
Clone,Debug,Default
ocre::events::Subscriber(trait): A destination for events (Rails’ event subscribers): turns anEventinto the request to send.pub trait Subscriber: Send + Syncocre::events::Subscriber::name(fn): A short name, for failure logs:analytics.fn name(&self) -> &'static strocre::events::Subscriber::emit(fn): The request to send forevent, if any.fn emit(&self, event: &Event, vars: &dyn Fn(&str) -> Option<String>) -> Option<Delivery>
ocre::filters (feature html)
ocre::filters::distance_of_time_in_words(struct) ``ocre::filters::distance_of_time_in_words::execute(fn)(value: impl Display, _: &dyn Values, to: T)- Implements:
Default
ocre::filters::excerpt(struct) ``ocre::filters::excerpt::execute(fn)(value: impl Display, _: &dyn Values, phrase: &str, radius: usize)- Implements:
Default
ocre::filters::highlight(struct) ``ocre::filters::highlight::execute(fn)(value: impl Display, _: &dyn Values, phrase: &str)- Implements:
Default
ocre::filters::number_to_currency(struct) ``ocre::filters::number_to_currency::execute(fn)(value: impl Display, _: &dyn Values, unit: &str)- Implements:
Default
ocre::filters::number_to_human(struct) ``ocre::filters::number_to_human::execute(fn)(value: impl Display, _: &dyn Values)- Implements:
Default
ocre::filters::number_to_human_size(struct) ``ocre::filters::number_to_human_size::execute(fn)(value: impl Display, _: &dyn Values)- Implements:
Default
ocre::filters::number_to_percentage(struct) ``ocre::filters::number_to_percentage::execute(fn)(value: impl Display, _: &dyn Values, precision: usize)- Implements:
Default
ocre::filters::number_with_delimiter(struct) ``ocre::filters::number_with_delimiter::execute(fn)(value: impl Display, _: &dyn Values)- Implements:
Default
ocre::filters::number_with_precision(struct) ``ocre::filters::number_with_precision::execute(fn)(value: impl Display, _: &dyn Values, precision: usize)- Implements:
Default
ocre::filters::plain_text(struct) ``ocre::filters::plain_text::execute(fn)(value: impl Display, _: &dyn Values)- Implements:
Default
ocre::filters::rich_text(struct) ``ocre::filters::rich_text::execute(fn)(value: impl Display, _: &dyn Values)- Implements:
Default
ocre::filters::strftime(struct) ``ocre::filters::strftime::execute(fn)(value: impl Display, _: &dyn Values, format: &str)- Implements:
Default
ocre::filters::time_ago_in_words(struct) ``ocre::filters::time_ago_in_words::execute(fn)(value: impl Display, _: &dyn Values)- Implements:
Default
ocre::filters::word_wrap(struct) ``ocre::filters::word_wrap::execute(fn)(value: impl Display, _: &dyn Values, width: usize)- Implements:
Default
ocre::graphql (feature graphql)
ocre::graphql::async_graphql(re-export)pub use async_graphqlocre::graphql::graphiql(fn): Renders GraphiQL, the in-browser query editor, pointed at/graphql.pub fn graphiql() -> Html<String>ocre::graphql::respond(fn): Executes aPOST /graphqlJSON body againstschemaand returns the JSON response.pub async fn respond<Q, M, S>(schema: &Schema<Q, M, S>, body: &[u8], data: impl Any + Send + Sync) -> Response where Q: ObjectType + 'static, M: ObjectType + 'static, S: SubscriptionType + 'staticocre::graphql::routes(fn): Returns a router serving GraphiQL onGET /graphqland queries onPOST /graphql.pub fn routes<Q, M, S>(schema: fn() -> &'static Schema<Q, M, S>) -> Router<Ctx> where Q: ObjectType + 'static, M: ObjectType + 'static, S: SubscriptionType + 'static
ocre::helpers
ocre::helpers::class_names(fn): Space-separated class names whose condition is true (Rails’class_names/token_list).pub fn class_names(classes: &[(&str, bool)]) -> Stringocre::helpers::current_page(fn): Whetherurlnames the page being shown (Rails’current_page?), for “you are here” links.pub fn current_page(current: &str, url: &str) -> boolocre::helpers::distance_of_time_in_words(fn): The time between two times, in words:5 minutes,2 days(Rails’distance_of_time_in_words).pub fn distance_of_time_in_words(from: impl Display, to: impl Display) -> Stringocre::helpers::excerpt(fn): The first match ofphrase(case-insensitive) withradiuscharacters around it,...where cut (Rails’excerpt).pub fn excerpt(value: impl Display, phrase: &str, radius: usize) -> Stringocre::helpers::highlight(fn): The text, HTML-escaped, with each match ofphrase(case-insensitive) in<mark>(Rails’highlight).pub fn highlight(value: impl Display, phrase: &str) -> Stringocre::helpers::number_to_currency(fn): Two decimals, thousands grouped,unitfirst:1234.5with"$"becomes$1,234.50,-3becomes-$3.00(Rails’number_to_currency).pub fn number_to_currency(value: impl Display, unit: &str) -> Stringocre::helpers::number_to_human(fn): Three significant digits and a word, Thousand to Quadrillion:1234567becomes1.23 Million(Rails’number_to_human).pub fn number_to_human(value: impl Display) -> Stringocre::helpers::number_to_human_size(fn): A byte count in 1024 steps:1536becomes1.5 KB(Rails’number_to_human_size, asstorage::human_size).pub fn number_to_human_size(value: impl Display) -> Stringocre::helpers::number_to_percentage(fn): Rounds toprecisiondecimals and adds%:12.345with 1 becomes12.3%(Rails’number_to_percentage).pub fn number_to_percentage(value: impl Display, precision: usize) -> Stringocre::helpers::number_with_delimiter(fn): Groups the integer part by thousands:1234567.891becomes1,234,567.891(Rails’number_with_delimiter).pub fn number_with_delimiter(value: impl Display) -> Stringocre::helpers::number_with_precision(fn): Rounds toprecisiondecimals:3.14159with 2 becomes3.14(Rails’number_with_precision).pub fn number_with_precision(value: impl Display, precision: usize) -> Stringocre::helpers::progress_bar(fn): A progress bar with the element idid: a<progress>atpercent(clamped to 0-100) and alabel(escaped), as HTML.pub fn progress_bar(id: &str, percent: i64, label: &str) -> Stringocre::helpers::strftime(fn): Formats a time in UTC withstrftimedirectives:%b %-d, %YgivesSep 29, 2026.pub fn strftime(value: impl Display, format: &str) -> Stringocre::helpers::time_ago_in_words(fn): The time fromvaluetonow, in words:about 3 hours(Rails’time_ago_in_words).pub fn time_ago_in_words(value: impl Display) -> Stringocre::helpers::time_zone_options(fn): The<option>s of everyTIME_ZONESname,selectedmarked (Rails’time_zone_select): write them in a<select>, and store the IANA name the form sends.pub fn time_zone_options(selected: &str) -> Stringocre::helpers::word_wrap(fn): Breaks lines longer thanwidthcharacters at spaces (Rails’word_wrap).pub fn word_wrap(value: impl Display, width: usize) -> Stringocre::helpers::TIME_ZONES(const): IANA time zone names, as browsers list them (Intl.supportedValuesOf("timeZone")): whattime_zone_optionsoffers andocre time-zonesprints.pub const TIME_ZONES: &[&str] = &[ "UTC", "Africa/Abidjan", "Africa/Accra", "Africa/Addis_Ababa", "Africa/Algiers", "Africa/Asmera", "Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Bissau", "Africa/Blantyre", "Africa/Brazzaville", "Africa/Bujumbura", "Africa/Cairo", "Africa/Casablanca", "Africa/Ceuta", "Africa/Conakry", "Africa/Dakar", "Africa/Dar_es_Salaam", "Africa/Djibouti", "Africa/Douala", "Africa/El_Aaiun", "Africa/Freetown", "Africa/Gaborone", "Africa/Harare", "Africa/Johannesburg", "Africa/Juba", "Africa/Kampala", "Africa/Khartoum", "Africa/Kigali", "Africa/Kinshasa", "Africa/Lagos", "Africa/Libreville", "Africa/Lome", "Africa/Luanda", "Africa/Lubumbashi", "Africa/Lusaka", "Africa/Malabo", "Africa/Maputo", "Africa/Maseru", "Africa/Mbabane", "Africa/Mogadishu", "Africa/Monrovia", "Africa/Nairobi", "Africa/Ndjamena", "Africa/Niamey", "Africa/Nouakchott", "Africa/Ouagadougou", "Africa/Porto-Novo", "Africa/Sao_Tome", "Africa/Tripoli", "Africa/Tunis", "Africa/Windhoek", "America/Adak", "America/Anchorage", "America/Anguilla", "America/Antigua", "America/Araguaina", "America/Argentina/La_Rioja", "America/Argentina/Rio_Gallegos", "America/Argentina/Salta", "America/Argentina/San_Juan", "America/Argentina/San_Luis", "America/Argentina/Tucuman", "America/Argentina/Ushuaia", "America/Aruba", "America/Asuncion", "America/Bahia", "America/Bahia_Banderas", "America/Barbados", "America/Belem", "America/Belize", "America/Blanc-Sablon", "America/Boa_Vista", "America/Bogota", "America/Boise", "America/Buenos_Aires", "America/Cambridge_Bay", "America/Campo_Grande", "America/Cancun", "America/Caracas", "America/Catamarca", "America/Cayenne", "America/Cayman", "America/Chicago", "America/Chihuahua", "America/Ciudad_Juarez", "America/Coral_Harbour", "America/Cordoba", "America/Costa_Rica", "America/Coyhaique", "America/Creston", "America/Cuiaba", "America/Curacao", "America/Danmarkshavn", "America/Dawson", "America/Dawson_Creek", "America/Denver", "America/Detroit", "America/Dominica", "America/Edmonton", "America/Eirunepe", "America/El_Salvador", "America/Fort_Nelson", "America/Fortaleza", "America/Glace_Bay", "America/Godthab", "America/Goose_Bay", "America/Grand_Turk", "America/Grenada", "America/Guadeloupe", "America/Guatemala", "America/Guayaquil", "America/Guyana", "America/Halifax", "America/Havana", "America/Hermosillo", "America/Indiana/Knox", "America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Tell_City", "America/Indiana/Vevay", "America/Indiana/Vincennes", "America/Indiana/Winamac", "America/Indianapolis", "America/Inuvik", "America/Iqaluit", "America/Jamaica", "America/Jujuy", "America/Juneau", "America/Kentucky/Monticello", "America/Kralendijk", "America/La_Paz", "America/Lima", "America/Los_Angeles", "America/Louisville", "America/Lower_Princes", "America/Maceio", "America/Managua", "America/Manaus", "America/Marigot", "America/Martinique", "America/Matamoros", "America/Mazatlan", "America/Mendoza", "America/Menominee", "America/Merida", "America/Metlakatla", "America/Mexico_City", "America/Miquelon", "America/Moncton", "America/Monterrey", "America/Montevideo", "America/Montserrat", "America/Nassau", "America/New_York", "America/Nome", "America/Noronha", "America/North_Dakota/Beulah", "America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/Ojinaga", "America/Panama", "America/Paramaribo", "America/Phoenix", "America/Port-au-Prince", "America/Port_of_Spain", "America/Porto_Velho", "America/Puerto_Rico", "America/Punta_Arenas", "America/Rankin_Inlet", "America/Recife", "America/Regina", "America/Resolute", "America/Rio_Branco", "America/Santarem", "America/Santiago", "America/Santo_Domingo", "America/Sao_Paulo", "America/Scoresbysund", "America/Sitka", "America/St_Barthelemy", "America/St_Johns", "America/St_Kitts", "America/St_Lucia", "America/St_Thomas", "America/St_Vincent", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Tijuana", "America/Toronto", "America/Tortola", "America/Vancouver", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", "Antarctica/Casey", "Antarctica/Davis", "Antarctica/DumontDUrville", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/McMurdo", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/Syowa", "Antarctica/Troll", "Antarctica/Vostok", "Arctic/Longyearbyen", "Asia/Aden", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Atyrau", "Asia/Baghdad", "Asia/Bahrain", "Asia/Baku", "Asia/Bangkok", "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Brunei", "Asia/Calcutta", "Asia/Chita", "Asia/Colombo", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Hebron", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", "Asia/Jakarta", "Asia/Jayapura", "Asia/Jerusalem", "Asia/Kabul", "Asia/Kamchatka", "Asia/Karachi", "Asia/Katmandu", "Asia/Khandyga", "Asia/Krasnoyarsk", "Asia/Kuala_Lumpur", "Asia/Kuching", "Asia/Kuwait", "Asia/Macau", "Asia/Magadan", "Asia/Makassar", "Asia/Manila", "Asia/Muscat", "Asia/Nicosia", "Asia/Novokuznetsk", "Asia/Novosibirsk", "Asia/Omsk", "Asia/Oral", "Asia/Phnom_Penh", "Asia/Pontianak", "Asia/Pyongyang", "Asia/Qatar", "Asia/Qostanay", "Asia/Qyzylorda", "Asia/Rangoon", "Asia/Riyadh", "Asia/Saigon", "Asia/Sakhalin", "Asia/Samarkand", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Srednekolymsk", "Asia/Taipei", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Tehran", "Asia/Thimphu", "Asia/Tokyo", "Asia/Tomsk", "Asia/Ulaanbaatar", "Asia/Urumqi", "Asia/Ust-Nera", "Asia/Vientiane", "Asia/Vladivostok", "Asia/Yakutsk", "Asia/Yekaterinburg", "Asia/Yerevan", "Atlantic/Azores", "Atlantic/Bermuda", "Atlantic/Canary", "Atlantic/Cape_Verde", "Atlantic/Faeroe", "Atlantic/Madeira", "Atlantic/Reykjavik", "Atlantic/South_Georgia", "Atlantic/St_Helena", "Atlantic/Stanley", "Australia/Adelaide", "Australia/Brisbane", "Australia/Broken_Hill", "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/Perth", "Australia/Sydney", "Europe/Amsterdam", "Europe/Andorra", "Europe/Astrakhan", "Europe/Athens", "Europe/Belgrade", "Europe/Berlin", "Europe/Bratislava", "Europe/Brussels", "Europe/Bucharest", "Europe/Budapest", "Europe/Busingen", "Europe/Chisinau", "Europe/Copenhagen", "Europe/Dublin", "Europe/Gibraltar", "Europe/Guernsey", "Europe/Helsinki", "Europe/Isle_of_Man", "Europe/Istanbul", "Europe/Jersey", "Europe/Kaliningrad", "Europe/Kiev", "Europe/Kirov", "Europe/Lisbon", "Europe/Ljubljana", "Europe/London", "Europe/Luxembourg", "Europe/Madrid", "Europe/Malta", "Europe/Mariehamn", "Europe/Minsk", "Europe/Monaco", "Europe/Moscow", "Europe/Oslo", "Europe/Paris", "Europe/Podgorica", "Europe/Prague", "Europe/Riga", "Europe/Rome", "Europe/Samara", "Europe/San_Marino", "Europe/Sarajevo", "Europe/Saratov", "Europe/Simferopol", "Europe/Skopje", "Europe/Sofia", "Europe/Stockholm", "Europe/Tallinn", "Europe/Tirane", "Europe/Ulyanovsk", "Europe/Vaduz", "Europe/Vatican", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zagreb", "Europe/Zurich", "Indian/Antananarivo", "Indian/Chagos", "Indian/Christmas", "Indian/Cocos", "Indian/Comoro", "Indian/Kerguelen", "Indian/Mahe", "Indian/Maldives", "Indian/Mauritius", "Indian/Mayotte", "Indian/Reunion", "Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville", "Pacific/Chatham", "Pacific/Easter", "Pacific/Efate", "Pacific/Enderbury", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Funafuti", "Pacific/Galapagos", "Pacific/Gambier", "Pacific/Guadalcanal", "Pacific/Guam", "Pacific/Honolulu", "Pacific/Kiritimati", "Pacific/Kosrae", "Pacific/Kwajalein", "Pacific/Majuro", "Pacific/Marquesas", "Pacific/Midway", "Pacific/Nauru", "Pacific/Niue", "Pacific/Norfolk", "Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn", "Pacific/Ponape", "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Saipan", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", "Pacific/Truk", "Pacific/Wake", "Pacific/Wallis", ]
ocre::i18n
ocre::i18n::check(fn): Checks locale files for syntax errors and keys missing from non-default locales.pub fn check(sources: &[(&str, &str)]) -> Vec<Problem>ocre::i18n::layer(fn): Makes the catalog available to theI18nextractor, as an axum layer.pub fn layer(locales: &'static Locales) -> Extension<&'static Catalog>ocre::i18n::Catalog(struct): All locales of an app, parsed: one table of dotted keys per locale code, the first being the default.pub struct Catalogocre::i18n::Catalog::load(fn): Parses(code, file contents)pairs into a catalog; the first pair is the default locale.pub fn load(sources: &[(&'static str, &'static str)]) -> Selfocre::i18n::Catalog::errors(fn): Parse errors fromload, one line each (locales/fr.yml line 3: ...).pub fn errors(&self) -> &[String]ocre::i18n::Catalog::default_locale(fn): Returns the default locale code: the first code given tolocales!.pub fn default_locale(&self) -> &'static strocre::i18n::Catalog::codes(fn): Returns every locale code, default first, e.g. for a language switcher.pub fn codes(&self) -> impl Iterator<Item = &'static str> + '_ocre::i18n::Catalog::locale(fn): Returns translations in localecode, for code outside requests such as mailers and jobs.pub fn locale(&'static self, code: &str) -> I18n- Implements:
Debug
ocre::i18n::HtmlTranslation(struct): ATranslationwritten as HTML, fromTranslation::html: the text as is, every value escaped.pub struct HtmlTranslation<'a>(/* private fields */)- Implements:
Clone,Debug,Display,HtmlSafe
- Implements:
ocre::i18n::I18n(struct): Axum extractor giving translations in the request’s locale, like Rails’I18n.twith a per-request locale.pub struct I18nocre::i18n::I18n::l(fn): Formats a date or time with a format of the locale, like Rails’l(value, format: :short).pub fn l(&self, value: impl fmt::Display, format: &str) -> Stringocre::i18n::I18n::number(fn): Groups thousands and writes the decimal separator of the locale:1 234 567,5in French.pub fn number(&self, value: impl fmt::Display) -> Stringocre::i18n::I18n::number_with_precision(fn): Rounds toprecisiondecimals with the locale’s decimal separator:3,14in French.pub fn number_with_precision(&self, value: impl fmt::Display, precision: usize) -> Stringocre::i18n::I18n::currency(fn): Formats an amount with two decimals andunitplaced as the locale does:1 234,50 €in French.pub fn currency(&self, value: impl fmt::Display, unit: &str) -> Stringocre::i18n::I18n::time_ago_in_words(fn): The time fromvaluetonowin words, in the locale:environ 3 heures.pub fn time_ago_in_words(&self, value: impl fmt::Display) -> Stringocre::i18n::I18n::distance_of_time_in_words(fn): The time between two times in words, in the locale (Rails’distance_of_time_in_words).pub fn distance_of_time_in_words(&self, from: impl fmt::Display, to: impl fmt::Display) -> Stringocre::i18n::I18n::model_name(fn): The name of a model forcountitems, frommodels.<model>(Rails’Post.model_name.human(count:)).pub fn model_name(&self, model: &str, count: impl Count) -> Stringocre::i18n::I18n::attribute(fn): The name of a model’s field, fromattributes.<model>.<field>, thenattributes.<field>(Rails’human_attribute_name).pub fn attribute(&self, model: &str, field: &str) -> Stringocre::i18n::I18n::error_message(fn): The message of a validation error in the locale, without the field name (Rails’errors.messages).pub fn error_message(&self, model: &str, error: &FieldError) -> Stringocre::i18n::I18n::full_message(fn): The translated field name and message of a validation error, aserrors.formatlays them out (Rails’full_message).pub fn full_message(&self, model: &str, error: &FieldError) -> Stringocre::i18n::I18n::locale(fn): Returns the locale code, e.g. for<html lang="{{ i18n.locale() }}">.pub fn locale(&self) -> &'static strocre::i18n::I18n::codes(fn): Returns every locale code of the app, default first, e.g. for a language switcher.pub fn codes(&self) -> impl Iterator<Item = &'static str> + 'staticocre::i18n::I18n::t(fn): Starts the translation of a dotted key, like Rails’t("posts.created").pub fn t<'a>(&self, key: &'a str) -> Translation<'a>ocre::i18n::I18n::scope(fn): Returns the same translations withscopeas the prefix of keys starting with.(Rails’ lazy lookup).pub fn scope(&self, scope: &'static str) -> Selfocre::i18n::I18n::in_locale(fn): Returns the same translations in localecode(Rails’locale:option), keeping the scope.pub fn in_locale(&self, code: &str) -> Selfocre::i18n::I18n::exists(fn): Whetherkeyhas a translation: in the locale, the default locale or the built-in translations.pub fn exists(&self, key: &str) -> boolocre::i18n::I18n::namespace(fn): Returns every translation underprefix, by key relative to it (Rails’ namespace lookup).pub fn namespace(&self, prefix: &str) -> BTreeMap<&'static str, &'static str>ocre::i18n::I18n::path(fn): Prefixespathwith the locale, for routes nested under/{locale}(Rails’default_url_options).pub fn path(&self, path: &str) -> Stringocre::i18n::I18n::alternates(fn): The absolute URL ofpathin every locale, the default first:(code, base_url + "/<code>" + path), for a sitemap’s alternates (Sitemap::add_localized).pub fn alternates(&self, base_url: &str, path: &str) -> Vec<(&'static str, String)>ocre::i18n::I18n::alternate_links(fn): The<link>elements a localized page’s<head>needs:canonical(this locale’s URL), onealternateper locale with itshreflang, andx-default(the default locale’s URL).pub fn alternate_links(&self, base_url: &str, path: &str) -> Stringocre::i18n::I18n::cookie(fn): Returns aSet-Cookievalue remembering this locale for a year.pub fn cookie(&self) -> String- Implements:
Clone,Copy,Debug,FromRequestParts
ocre::i18n::Translation(struct): A translation being built byI18n::t, displayed (looked up and interpolated) when written.pub struct Translation<'a>ocre::i18n::Translation::arg(fn): Sets the value for the%{name}placeholder.pub fn arg(mut self, name: &'a str, value: impl fmt::Display) -> Selfocre::i18n::Translation::count(fn): Sets the plural count: picks the form fornand fills%{count}.pub fn count(mut self, n: impl Count) -> Selfocre::i18n::Translation::or_key(fn): Tries another key when this one has no translation (Rails’default: :"other.key").pub fn or_key(mut self, key: &'a str) -> Selfocre::i18n::Translation::or(fn): Usestextwhen neither the key nor its alternatives have a translation (Rails’default: "text").pub fn or(mut self, text: impl Into<Cow<'a, str>>) -> Selfocre::i18n::Translation::html(fn): Marks the translation as HTML: its text is written as is, the values ofargescaped.pub fn html(self) -> HtmlTranslation<'a>- Implements:
Clone,Debug,Display
ocre::i18n::Problem(enum): A problem in the locale files, found bycheck.pub enum Problemocre::i18n::Problem::Invalid(variant): The file is not valid locale YAML; nothing in it is used.Invalid { locale: String, line: usize, message: String, }ocre::i18n::Problem::Missing(variant): A key of the default locale (or a plural form this language needs) is missing fromlocale.Missing { locale: String, key: String, }- Implements:
Clone,Debug,Display,PartialEq
ocre::i18n::Count(trait): A numberTranslation::countaccepts: every integer type, and references to them.pub trait Countocre::i18n::Count::to_count(fn): Returns the number asi64, ori64::MAXwhen it does not fit.fn to_count(&self) -> i64
ocre::i18n::Locales(type): The app’s translations: aCatalogparsed on first use, declared once as astatic.pub type Locales = LazyLock<Catalog>ocre::i18n::LOCALE_COOKIE(const): Name of the cookie that remembers a visitor’s chosen locale.pub const LOCALE_COOKIE: &str = "locale"ocre::i18n::LOCALE_PARAM(const): Name of the path parameterI18nreads the locale from.pub const LOCALE_PARAM: &str = "locale"
ocre::jobs
ocre::jobs::consume(fn): Runs a batch of queue messages through the app’sperform; the Worker’squeueentry point.pub async fn consume<J, F, Fut>(batch: MessageBatch<String>, env: Env, perform: F) -> worker::Result<()> where J: DeserializeOwned, F: Fn(Ctx, J) -> Fut, Fut: Future<Output = Result<()>>ocre::jobs::cron(fn): Runs the app’s task for the Cron Trigger that fired; the Worker’sscheduledentry point.pub async fn cron<F, Fut>(event: ScheduledEvent, env: Env, run: F) where F: FnOnce(Ctx, String) -> Fut, Fut: Future<Output = Result<()>>ocre::jobs::dev_routes(fn): Development endpoint listing recent jobs, served byocre devonly, for tests (Rails’assert_enqueued_withandassert_performed_jobs).pub fn dev_routes<S: Clone + Send + Sync + 'static>() -> axum::Router<S>ocre::jobs::enqueue(fn): Sendsjobto theJOBSqueue, to run in the background throughconsume.pub fn enqueue<J: Serialize>(ctx: &Ctx, job: &J) -> impl Future<Output = Result<()>> + Send + use<J>ocre::jobs::enqueue_all(fn): Sends every job ofjobsto theJOBSqueue in as few calls as possible, like Rails’perform_all_later.pub fn enqueue_all<J: Serialize>(ctx: &Ctx, jobs: &[J]) -> impl Future<Output = Result<()>> + Send + use<J>ocre::jobs::enqueue_in(fn): Sendsjobto theJOBSqueue likeenqueue, to run afterdelay.pub fn enqueue_in<J: Serialize>(ctx: &Ctx, job: &J, delay: Duration) -> impl Future<Output = Result<()>> + Send + use<J>ocre::jobs::lock(fn): Takes the lockkeyforowneruntilttlseconds from now;falsewhen another owner holds it and it has not expired.pub async fn lock(db: &crate::Db, key: &str, owner: &str, ttl: i64) -> Result<bool>ocre::jobs::queue(fn): A named queue, for jobs that must not wait behind others:ocre::jobs::queue(&ctx, "urgent").enqueue(&job).pub fn queue(ctx: &Ctx, name: &'static str) -> Queueocre::jobs::run_steps(fn): Runsstepfromcursorwhilebudgetcovers itscost(the calls one step makes), for work too large for one invocation: a page of rows per step, the next page’s cursor between them (Active Job’s continuations).pub async fn run_steps<C, F, Fut>(budget: &Budget, cost: u32, mut cursor: C, mut step: F) -> Result<Option<C>> where F: FnMut(C) -> Fut, Fut: Future<Output = Result<Step<C>>>ocre::jobs::unlock(fn): Releases the lockkeyifownerholds it.pub async fn unlock(db: &crate::Db, key: &str, owner: &str) -> Result<()>ocre::jobs::Budget(struct): How many calls an invocation may still make: D1 queries,fetches, KV and R2 operations, as the job counts them.pub struct Budgetocre::jobs::Budget::FREE_D1_QUERIES(const): D1 queries per invocation on the free plan: 50.pub const FREE_D1_QUERIES: u32 = 50ocre::jobs::Budget::FREE_SUBREQUESTS(const): Subrequests (fetch) per invocation on the free plan: 50.pub const FREE_SUBREQUESTS: u32 = 50ocre::jobs::Budget::new(fn): A budget ofcalls.pub fn new(calls: u32) -> Selfocre::jobs::Budget::take(fn): Takescallsfrom the budget when that many are left;false(and nothing taken) otherwise.pub fn take(&self, calls: u32) -> boolocre::jobs::Budget::left(fn): The calls not taken yet.pub fn left(&self) -> u32- Implements:
Debug
ocre::jobs::Queue(struct): A job queue, fromqueue: enqueue on it like on the default queue.pub struct Queueocre::jobs::Queue::enqueue(fn): Sendsjobto this queue, likeenqueuedoes todefault.pub fn enqueue<J: Serialize>(&self, job: &J) -> impl Future<Output = Result<()>> + Send + use<J>ocre::jobs::Queue::enqueue_in(fn): Sendsjobto this queue, to run afterdelay(24 hours at most), likeenqueue_in.pub fn enqueue_in<J: Serialize>(&self, job: &J, delay: Duration) -> impl Future<Output = Result<()>> + Send + use<J>ocre::jobs::Queue::enqueue_all(fn): Sends every job ofjobsto this queue in batches, likeenqueue_all.pub fn enqueue_all<J: Serialize>(&self, jobs: &[J]) -> impl Future<Output = Result<()>> + Send + use<J>- Implements:
Clone,Debug
ocre::jobs::Step(enum): What a step ofrun_stepsdid: more to do from the cursor, or done.pub enum Step<C>ocre::jobs::Step::Next(variant): Continue from this cursor (the next page, the last id handled).Next(C)ocre::jobs::Step::Done(variant): Nothing left.Done- Implements:
Clone,Debug,Eq,PartialEq
ocre::jobs::CRON_LOG_PREFIX(const): Prefix of every line Ocre logs about Cron Triggers, e.g.[ocre cron] 0 3 * * * done.pub const CRON_LOG_PREFIX: &str = "[ocre cron]"ocre::jobs::DEFAULT_QUEUE(const): Name of the queueenqueueandenqueue_inuse:default, bound asQUEUE_BINDING.pub const DEFAULT_QUEUE: &str = "default"ocre::jobs::LOCKS_TABLE_SQL(const): Thejob_lockstable oflockandunlock, created by the migration ofocre g job --lock.pub const LOCKS_TABLE_SQL: &str = "CREATE TABLE job_locks (key TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)ocre::jobs::LOG_PREFIX(const): Prefix of every line Ocre logs about jobs, e.g.[ocre jobs] send_welcome done.pub const LOG_PREFIX: &str = "[ocre jobs]"ocre::jobs::MAX_DELAY(const): Longest delay Cloudflare Queues accepts: 24 hours, forenqueue_inand retries.pub const MAX_DELAY: Duration = Duration::from_secs(24 * 60 * 60)ocre::jobs::QUEUE_BINDING(const): Name of the queue producer binding every Ocre app sends jobs to.pub const QUEUE_BINDING: &str = "JOBS"
ocre::jwt
ocre::jwt::decode(fn): Verifies a token from a client with the key derived fromSECRET_KEY_BASEand returns its claims.pub fn decode(ctx: &Ctx, token: &str) -> Result<Claims>ocre::jwt::decode_with(fn): Verifiestokenwithkeyat Unix timenow(seconds) and returns its claims.pub fn decode_with(key: &Key, token: &str, now: i64) -> Result<Claims>ocre::jwt::encode(fn): Signsclaimswith the HS256 key derived from theSECRET_KEY_BASEWorker secret.pub fn encode(ctx: &Ctx, claims: &Claims) -> Result<String>ocre::jwt::encode_with(fn): Signsclaimswithkeyand returns the compact tokenheader.payload.signature.pub fn encode_with(key: &Key, claims: &Claims) -> Stringocre::jwt::token_from(fn): The token of the firstlocationsentry that has a non-empty one.pub fn token_from(headers: &HeaderMap, uri: &Uri, locations: &[Location]) -> Option<String>ocre::jwt::Claims(struct): The payload of a token: who it is for and when it expires.pub struct Claimsocre::jwt::Claims::sub(field): Subject: the user id, as a string (the JWT standard’s type).pub sub: Stringocre::jwt::Claims::iat(field): Issued at, in Unix seconds.pub iat: i64ocre::jwt::Claims::exp(field): Expires at, in Unix seconds; the token is rejected from this second on.pub exp: i64ocre::jwt::Claims::new(fn): Claims forsub, issued now (crate::now) and valid forttl_seconds.pub fn new(sub: impl Into<String>, ttl_seconds: i64) -> Self- Implements:
Clone,Debug,Deserialize,Eq,PartialEq,Serialize
ocre::jwt::Key(struct): An HS256 signing key, derived fromSECRET_KEY_BASE.pub struct Key(/* private fields */)ocre::jwt::Key::from_secret_key_base(fn): Derives the key from aSECRET_KEY_BASEvalue: HMAC-SHA256 of a fixed label.pub fn from_secret_key_base(secret: &str) -> Self
ocre::jwt::Location(enum): Wheretoken_fromlooks for a token (Loco’sauth.jwt.location).pub enum Locationocre::jwt::Location::Bearer(variant):Authorization: Bearer <token>, the default for API clients.Bearerocre::jwt::Location::Query(variant): A query parameter, e.g.Query("token")for?token=....Query(&'static str)ocre::jwt::Location::Cookie(variant): A cookie, e.g.Cookie("token").Cookie(&'static str)- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::jwt::ALGORITHM(const): The only JWT algorithm Ocre signs with and accepts:HS256(HMAC-SHA256).pub const ALGORITHM: &str = "HS256"
ocre::log
ocre::log::Logger(struct): Writes log lines with a set of fields; cheap to clone (see the module documentation).pub struct Loggerocre::log::Logger::new(fn): A logger without fields.pub fn new() -> Selfocre::log::Logger::with(fn): A copy of this logger with one more field on every line (Rails’logger.tagged).pub fn with(&self, key: &str, value: impl Serialize) -> Selfocre::log::Logger::field(fn): The value of a field, e.g.request_id.pub fn field(&self, key: &str) -> Option<&Value>ocre::log::Logger::enabled(fn): Whether lines oflevelare written, under the currentLOG_LEVEL.pub fn enabled(&self, level: Level) -> boolocre::log::Logger::debug(fn): Writes adebugline.pub fn debug(&self, message: impl Display)ocre::log::Logger::info(fn): Writes aninfoline.pub fn info(&self, message: impl Display)ocre::log::Logger::warn(fn): Writes awarnline.pub fn warn(&self, message: impl Display)ocre::log::Logger::error(fn): Writes anerrorline.pub fn error(&self, message: impl Display)ocre::log::Logger::log(fn): Writes a line oflevelwhen it is enabled; the message is formatted only then.pub fn log(&self, level: Level, message: &dyn Display)ocre::log::Logger::line(fn): The linelogwrites, whatever the current level: JSON or text.pub fn line(&self, level: Level, message: &str, format: Format) -> String- Implements:
Clone,Debug,Default
ocre::log::Format(enum): How log lines are written: see the module documentation.pub enum Formatocre::log::Format::Json(variant): One JavaScript object per line, indexed by Workers Logs.Jsonocre::log::Format::Text(variant):LEVEL message key=value ..., for the terminal.Textocre::log::Format::parse(fn): Reads a format name:json, ortext(prettyandcompactaccepted, like Loco).pub fn parse(name: &str) -> Option<Self>- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::log::Level(enum): Severity of a log line (Rails’:debug,:info,:warn,:error).pub enum Levelocre::log::Level::Debug(variant): Details for development: SQL statements, request summaries.Debugocre::log::Level::Info(variant): Normal events worth keeping in production.Infoocre::log::Level::Warn(variant): Something unexpected that the app handled.Warnocre::log::Level::Error(variant): A failure: internal errors, failed jobs.Errorocre::log::Level::parse(fn): Reads a level name, case-insensitively:debug,info,warn(warning),error(fatal,unknown).pub fn parse(name: &str) -> Option<Self>ocre::log::Level::as_str(fn): The lowercase name written in log lines:debug,info,warn,error.pub fn as_str(self) -> &'static str- Implements:
Clone,Copy,Debug,Eq,Hash,Ord,PartialEq,PartialOrd
ocre::log::LOG_FORMAT(const): Worker variable choosing the line format:jsonortext.pub const LOG_FORMAT: &str = "LOG_FORMAT"ocre::log::LOG_LEVEL(const): Worker variable setting the lowest level logged:debug,info,warn,errororoff.pub const LOG_LEVEL: &str = "LOG_LEVEL"
ocre::mail
ocre::mail::address_with_name(fn): FormatsName <address>, quoting the name when needed, like Rails’email_address_with_name.pub fn address_with_name(name: &str, address: &str) -> Stringocre::mail::deliver_in(fn): Checksemailnow and sends it from the jobs queue afterdelay, like Rails’deliver_later(wait:).pub fn deliver_in(ctx: &Ctx, email: Email, delay: Duration) -> impl Future<Output = Result<()>> + Send + use<>ocre::mail::deliver_later(fn): Checksemailnow and sends it later from the background jobs queue, like Rails’deliver_later.pub fn deliver_later(ctx: &Ctx, email: Email) -> impl Future<Output = Result<()>> + Send + use<>ocre::mail::dev_routes(fn): Development pages for email, served byocre devonly, like Rails’/rails/mailersand Action Mailbox’s conductor.pub fn dev_routes<S: Clone + Send + Sync + 'static>(previews: &'static [Preview]) -> Router<S>ocre::mail::receive(fn): Runs the app’s mailboxhandlerfor an email from Cloudflare Email Routing; the Worker’semailentry point.pub async fn receive<F, Fut>(message: ForwardableEmailMessage, env: Env, handler: F) -> worker::Result<()> where F: FnOnce(Ctx, InboundEmail) -> Fut, Fut: Future<Output = Result<()>>ocre::mail::send(fn): Sendsemailnow, fromMAIL_FROM, with the adapter named byMAIL_ADAPTER.pub fn send(ctx: &Ctx, email: Email) -> impl Future<Output = Result<()>> + Send + use<>ocre::mail::url(fn): An absolute URL forpathon the app’s public address, theAPP_URLvariable (Rails’_urlhelpers in mailers).pub fn url(ctx: &Ctx, path: &str) -> Result<String>ocre::mail::Attachment(struct): A file attached to anEmail, or an inline image shown by its HTML.pub struct Attachmentocre::mail::Attachment::filename(field): File name shown by mail clients,invoice.pdf.pub filename: Stringocre::mail::Attachment::content_type(field): MIME type,application/pdforimage/png.pub content_type: Stringocre::mail::Attachment::content(field): The file’s bytes.pub content: Vec<u8>ocre::mail::Attachment::content_id(field): For inline images: the id the HTML refers to ascid:<id>.pub content_id: Option<String>- Implements:
Clone,Debug,Deserialize,PartialEq,Serialize
ocre::mail::Email(struct): An outgoing email: recipients, a subject, a plain-text body, and optionally HTML, headers and attachments.pub struct Emailocre::mail::Email::from(field): Sender, when notMAIL_FROM:billing@example.comorBilling <billing@example.com>, on a domain verified with the provider.pub from: Option<String>ocre::mail::Email::to(field):Torecipients,ada@example.comorAda <ada@example.com>; an invalid one makes sending a 400.pub to: Vec<String>ocre::mail::Email::cc(field):Ccrecipients, checked liketo.pub cc: Vec<String>ocre::mail::Email::bcc(field):Bccrecipients, checked liketo; the other recipients do not see them.pub bcc: Vec<String>ocre::mail::Email::subject(field): Subject line: one non-empty line of text.pub subject: Stringocre::mail::Email::text(field): Plain-text body; always sent, so every mail client can read it.pub text: Stringocre::mail::Email::html(field): Optional HTML body, shown instead oftextby clients that render HTML.pub html: Option<String>ocre::mail::Email::reply_to(field): Where replies go, when not to the sender; checked liketo.pub reply_to: Option<String>ocre::mail::Email::headers(field): Extra headers,(name, value): threading (In-Reply-To,References),List-Unsubscribe,X-....pub headers: Vec<(String, String)>ocre::mail::Email::attachments(field): Files attached to the email, and inline images the HTML shows withcid:.pub attachments: Vec<Attachment>ocre::mail::Email::delivery_method(field): Adapter for this email instead ofMAIL_ADAPTER(resendorcloudflare), set bydelivery_method; ignored whileMAIL_ADAPTERislog.pub delivery_method: Option<String>ocre::mail::Email::new(fn): Creates a text-only email to one recipient.pub fn new(to: impl Into<String>, subject: impl Into<String>, text: impl Into<String>) -> Selfocre::mail::Email::also_to(fn): Adds anotherTorecipient.pub fn also_to(mut self, address: impl Into<String>) -> Selfocre::mail::Email::cc(fn): Adds aCcrecipient.pub fn cc(mut self, address: impl Into<String>) -> Selfocre::mail::Email::bcc(fn): Adds aBccrecipient: it gets the email, the other recipients do not see it.pub fn bcc(mut self, address: impl Into<String>) -> Selfocre::mail::Email::from(fn): Sends fromaddressinstead of theMAIL_FROMvariable.pub fn from(mut self, address: impl Into<String>) -> Selfocre::mail::Email::delivery_method(fn): Sends this email with another adapter thanMAIL_ADAPTER:"resend"or"cloudflare"(Rails’delivery_method).pub fn delivery_method(mut self, adapter: impl Into<String>) -> Selfocre::mail::Email::html(fn): Adds the HTML version of the body, replacing any previous one.pub fn html(mut self, html: impl Into<String>) -> Selfocre::mail::Email::reply_to(fn): Sets theReply-Toaddress, so replies go there instead of to the sender.pub fn reply_to(mut self, address: impl Into<String>) -> Selfocre::mail::Email::header(fn): Adds a header, e.g.In-Reply-ToandReferencesto thread a reply, orList-Unsubscribe.pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Selfocre::mail::Email::attach(fn): Attaches a file, like Rails’attachments["invoice.pdf"] = bytes.pub fn attach(mut self, filename: impl Into<String>, content_type: impl Into<String>, content: Vec<u8>) -> Selfocre::mail::Email::inline(fn): Embeds an image that the HTML shows with<img src="cid:<content_id>">, like Rails’attachments.inline.pub fn inline(mut self, content_id: impl Into<String>, filename: impl Into<String>, content_type: impl Into<String>, content: Vec<u8>) -> Self- Implements:
Clone,Debug,Deserialize,PartialEq,Serialize
ocre::mail::InboundEmail(struct): An email that Cloudflare Email Routing delivered to the Worker, handed to the app’s mailbox.pub struct InboundEmailocre::mail::InboundEmail::from(fn): Returns the envelope sender (SMTPMAIL FROM), checked by Cloudflare.pub fn from(&self) -> &strocre::mail::InboundEmail::to(fn): Returns the envelope recipient: the address of this app that received the email.pub fn to(&self) -> &strocre::mail::InboundEmail::subject(fn): Returns the decodedSubjectheader, or""when it is missing.pub fn subject(&self) -> &strocre::mail::InboundEmail::header(fn): Returns the first header with this name (case-insensitive), decoded.pub fn header(&self, name: &str) -> Option<&str>ocre::mail::InboundEmail::headers(fn): Returns every header, in message order, as decoded(name, value)pairs.pub fn headers(&self) -> &[(String, String)]ocre::mail::InboundEmail::text(fn): Returns the firsttext/plainpart, decoded to UTF-8, orNonewhen there is none.pub fn text(&self) -> Option<&str>ocre::mail::InboundEmail::attachments(fn): Returns the files of the email, decoded: every part that is not the first text or HTML body.pub fn attachments(&self) -> &[Attachment]ocre::mail::InboundEmail::html(fn): Returns the firsttext/htmlpart, decoded to UTF-8, orNonewhen there is none.pub fn html(&self) -> Option<&str>ocre::mail::InboundEmail::raw(fn): Returns the whole message as received (RFC 5322 bytes), e.g. to store it in R2 or read attachments.pub fn raw(&self) -> &[u8]ocre::mail::InboundEmail::reject(fn): Bounces the email: the sending server gets a permanent SMTP error withreason.pub fn reject(&self, reason: &str)ocre::mail::InboundEmail::forward(fn): Forwards the email unchanged toto, which must be a verified destination address of the Cloudflare account.pub async fn forward(&self, to: &str) -> Result<()>
ocre::mail::Preview(struct): A mailer action shown at/ocre/dev/mailersinocre dev, rendered with sample data, like Rails’ mailer previews.pub struct Previewocre::mail::Preview::name(field):mailer/action, e.g.user/welcome; the page’s URL is/ocre/dev/mailers/preview/<name>.pub name: &'static strocre::mail::Preview::build(field): Builds the email with sample data.pub build: fn() -> Result<Email>ocre::mail::Preview::new(fn): A preview namedmailer/action, built bybuild.pub const fn new(name: &'static str, build: fn() -> Result<Email>) -> Self- Implements:
Clone,Copy,Debug
ocre::mail::APP_URL(const): Name of the Worker variable holding the app’s public address,https://shop.example.com, for links in emails.pub const APP_URL: &str = "APP_URL"ocre::mail::EMAIL_BINDING(const): Name of thebindings.sendEmail()binding used whenMAIL_ADAPTER = "cloudflare".pub const EMAIL_BINDING: &str = "EMAIL"ocre::mail::LOG_PREFIX(const): Prefix of every line Ocre logs about mail, e.g. inocre devoutput.pub const LOG_PREFIX: &str = "[ocre mail]"ocre::mail::MAIL_ADAPTER(const): Name of the Worker variable that chooses the adapter:log,resendorcloudflare.pub const MAIL_ADAPTER: &str = "MAIL_ADAPTER"ocre::mail::MAIL_FROM(const): Name of the Worker variable holding the sender address.pub const MAIL_FROM: &str = "MAIL_FROM"ocre::mail::RESEND_API_KEY(const): Name of the Worker secret holding the Resend API key, used whenMAIL_ADAPTER = "resend".pub const RESEND_API_KEY: &str = "RESEND_API_KEY"
ocre::oauth
ocre::oauth::authorize_url(fn): The URL to send the browser to: the provider’s sign-in page for this app.pub fn authorize_url(provider: &Provider, client_id: &str, redirect_uri: &str, state: &str, code_challenge: &str) -> Stringocre::oauth::exchange_code(fn): Trades the authorizationcodefrom the callback for an access token (one subrequest).pub fn exchange_code(ctx: &Ctx, provider: &'static Provider, redirect_uri: &str, code: &str, verifier: &str) -> impl Future<Output = Result<String>> + Send + use<>ocre::oauth::profile(fn): Reads the signed-in user’sProfilewith an access token (one subrequest; two for GitHub).pub fn profile(provider: &'static Provider, access_token: &str) -> impl Future<Output = Result<Profile>> + Send + use<>ocre::oauth::provider(fn): The provider calledname("github","google"), if Ocre knows it.pub fn provider(name: &str) -> Option<&'static Provider>ocre::oauth::Pkce(struct): A PKCE pair (RFC 7636,S256): keep the verifier in the session, send the challenge.pub struct Pkceocre::oauth::Pkce::verifier(field): The secret, sent with the token request (exchange_code).pub verifier: Stringocre::oauth::Pkce::challenge(field):BASE64URL(SHA-256(verifier)), sent in the authorization URL.pub challenge: Stringocre::oauth::Pkce::new(fn): A new random verifier (32 bytes) and its challenge.pub fn new() -> Selfocre::oauth::Pkce::challenge_for(fn): TheS256challenge ofverifier.pub fn challenge_for(verifier: &str) -> String- Implements:
Clone,Debug,Eq,PartialEq
ocre::oauth::Profile(struct): The user a provider signed in: its id there, and its email when verified.pub struct Profileocre::oauth::Profile::provider(field): The provider’sProvider::name.pub provider: &'static strocre::oauth::Profile::uid(field): The user’s stable id at the provider (GitHub’s numeric id, Google’ssub).pub uid: Stringocre::oauth::Profile::email(field): The user’s email, only when the provider says it is verified; lowercased.pub email: Option<String>ocre::oauth::Profile::name(field): The display name, when the user has one.pub name: Option<String>- Implements:
Clone,Debug,Eq,PartialEq
ocre::oauth::Provider(struct): An OAuth 2.0 provider: its endpoints and the scopes Ocre asks for.pub struct Providerocre::oauth::Provider::name(field): Lowercase name, used in routes (/auth/github) and stored with identities.pub name: &'static strocre::oauth::Provider::authorize_url(field): Where the browser is sent to sign in and approve the app.pub authorize_url: &'static strocre::oauth::Provider::token_url(field): Where the Worker trades the code for an access token.pub token_url: &'static strocre::oauth::Provider::userinfo_url(field): Where the Worker reads the signed-in user.pub userinfo_url: &'static strocre::oauth::Provider::scopes(field): Space-separated scopes: the user’s identity and email address only.pub scopes: &'static strocre::oauth::Provider::client_id_secret(field): Name of the Worker secret holding the OAuth app’s client id.pub client_id_secret: &'static strocre::oauth::Provider::client_secret_secret(field): Name of the Worker secret holding the OAuth app’s client secret.pub client_secret_secret: &'static str- Implements:
Debug,Eq,PartialEq
ocre::oauth::GITHUB(const): GitHub (OAuth app or GitHub App): scopesread:user user:email.pub const GITHUB: Provider = Providerocre::oauth::GOOGLE(const): Google (OpenID Connect): scopesopenid email profile.pub const GOOGLE: Provider = Providerocre::oauth::PROVIDERS(const): Every provider Ocre knows:ocre g auth --oauthaccepts these names.pub const PROVIDERS: [&Provider; 2] = [&GITHUB, &GOOGLE]
ocre::password
ocre::password::hash(fn): Hashespasswordwith a new random 16-byte salt into a self-describing digest.pub async fn hash(password: &str) -> Result<String>ocre::password::iterations(fn): The iteration count stored indigest, orNonewhen it is not in Ocre’s format.pub fn iterations(digest: &str) -> Option<u32>ocre::password::verify(fn): Whetherpasswordmatchesdigest(made byhash), compared in constant time.pub async fn verify(password: &str, digest: &str) -> Result<bool>ocre::password::ITERATIONS(const): Iterations for new digests: 100,000, the most Workers’ WebCrypto accepts.pub const ITERATIONS: u32 = 100_000
ocre::push (feature push)
ocre::push::encrypt(fn): Encryptspayloadfor a subscription (aes128gcmcontent coding, RFC 8291), with a new key and salt.pub fn encrypt(keys: &SubscriptionKeys, payload: &[u8]) -> Result<Vec<u8>>ocre::push::message(fn): The message the service worker ofocre g pwashows:{"title", "options": {"body", "data": {"path"}}}; clicking the notification openspath.pub fn message(title: &str, body: &str, path: &str) -> serde_json::Valueocre::push::send(fn): Encryptsmessage(JSON, e.g.message) forsubscriptionand posts it to its push service, signed with the app’s VAPID keys.pub fn send(ctx: &Ctx, subscription: &Subscription, message: &serde_json::Value, ttl: u32) -> impl Future<Output = Result<Sent>> + Send + use<>ocre::push::vapid_authorization(fn): TheAuthorizationheader of a push toendpoint(VAPID, RFC 8292):vapid t=<JWT signed with ES256>, k=<public key>, valid 12 hours.pub fn vapid_authorization(endpoint: &str, subject: &str, keys: &VapidKeys, now: i64) -> Result<String>ocre::push::Subscription(struct): What a browser’sPushSubscription.toJSON()gives: where and how to push to it.pub struct Subscriptionocre::push::Subscription::endpoint(field): The push service URL for this browser.pub endpoint: Stringocre::push::Subscription::keys(field): The browser’s keys.pub keys: SubscriptionKeys- Implements:
Clone,Debug,Deserialize,Eq,PartialEq,Serialize
ocre::push::SubscriptionKeys(struct): The browser’s public key and authentication secret, URL-safe base64.pub struct SubscriptionKeysocre::push::SubscriptionKeys::p256dh(field): P-256 public key (65 bytes, uncompressed).pub p256dh: Stringocre::push::SubscriptionKeys::auth(field): Authentication secret (16 bytes).pub auth: String- Implements:
Clone,Debug,Deserialize,Eq,PartialEq,Serialize
ocre::push::VapidKeys(struct): A VAPID key pair, URL-safe base64 without padding.pub struct VapidKeysocre::push::VapidKeys::public_key(field): The public key (65 bytes, uncompressed point):VAPID_PUBLIC_KEY, given to browsers.pub public_key: Stringocre::push::VapidKeys::private_key(field): The private key (32 bytes):VAPID_PRIVATE_KEY, a secret.pub private_key: Stringocre::push::VapidKeys::generate(fn): A new random key pair.pub fn generate() -> Self- Implements:
Clone,Debug,Eq,PartialEq
ocre::push::Sent(enum): What the push service did with a message.pub enum Sentocre::push::Sent::Delivered(variant): Accepted (201): the browser gets it when it is online, within the TTL.Deliveredocre::push::Sent::Gone(variant): The subscription no longer exists (404 or 410: the user unsubscribed or the browser dropped it): delete it.Gone- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::push::MAX_PAYLOAD(const): Largest message, in bytes: one 4,096-byte record less the encryption’s overhead.pub const MAX_PAYLOAD: usize = 4096 - 16 - 1ocre::push::VAPID_PRIVATE_KEY(const): Worker secret holding the private VAPID key (URL-safe base64, 32 bytes).pub const VAPID_PRIVATE_KEY: &str = "VAPID_PRIVATE_KEY"ocre::push::VAPID_PUBLIC_KEY(const): Worker variable holding the public VAPID key (URL-safe base64), which browsers subscribe with.pub const VAPID_PUBLIC_KEY: &str = "VAPID_PUBLIC_KEY"ocre::push::VAPID_SUBJECT(const): Worker variable holding the contact push services may write to:mailto:you@example.comor anhttps:URL.pub const VAPID_SUBJECT: &str = "VAPID_SUBJECT"
ocre::realtime (feature realtime)
ocre::realtime::append(fn): Builds a message that insertshtmlat the end of the element with idtarget(htmxbeforeend).pub fn append(target: &str, html: &str) -> Stringocre::realtime::broadcast(fn): Sendsmessage(an HTML fragment or JSON text) to every browser connected tochannel.pub fn broadcast(ctx: &Ctx, channel: &str, message: &str) -> impl Future<Output = Result<()>> + Send + use<>ocre::realtime::dev_routes(fn): Development endpoint listing recent broadcasts, served byocre devonly, for tests (Rails’assert_broadcasts).pub fn dev_routes<S: Clone + Send + Sync + 'static>() -> Router<S>ocre::realtime::prepend(fn): Builds a message that insertshtmlat the start of the element with idtarget(htmxafterbegin).pub fn prepend(target: &str, html: &str) -> Stringocre::realtime::remove(fn): Builds a message that removes the element with ididfrom the page (htmxdelete).pub fn remove(id: &str) -> Stringocre::realtime::update(fn): Builds a message that replaces the contents of the element with idtarget(htmxinnerHTML).pub fn update(target: &str, html: &str) -> Stringocre::realtime::OcreChannel(struct): The Durable Object class behind each realtime channel, exported by Ocre under the nameOcreChannel.pub struct OcreChannel- Implements:
DurableObject,From
- Implements:
ocre::realtime::WebSocketUpgrade(struct): Axum extractor for a WebSocket handshake (Upgrade: websocket), finished withconnect.pub struct WebSocketUpgradeocre::realtime::WebSocketUpgrade::identified_by(fn): Names who is connecting, like Action Cable’sidentified_by :current_user.pub fn identified_by(mut self, identity: impl Into<String>) -> Selfocre::realtime::WebSocketUpgrade::rebroadcast(fn): Relays what this client sends to the channel’s other subscribers, like a channel that rebroadcasts client data.pub fn rebroadcast(mut self) -> Selfocre::realtime::WebSocketUpgrade::connect(fn): Connects the browser tochannel, returning the101 Switching Protocolsresponse to send back.pub fn connect(self, ctx: &Ctx, channel: &str) -> impl Future<Output = Result<Response>> + Send + use<>- Implements:
Debug,FromRequestParts
ocre::realtime::CHANNELS_BINDING(const): Name of the Durable Object binding holding the channels, declared in cloudflare.config.ts.pub const CHANNELS_BINDING: &str = "CHANNELS"ocre::realtime::CHANNEL_CLASS(const): Name of the Durable Object class Ocre exports for channels (OcreChannel).pub const CHANNEL_CLASS: &str = "OcreChannel"ocre::realtime::LOG_PREFIX(const): Prefix of every line Ocre logs about realtime, e.g. inocre devoutput.pub const LOG_PREFIX: &str = "[ocre realtime]"ocre::realtime::MAX_CHANNEL_LEN(const): Longest channel name, in bytes.pub const MAX_CHANNEL_LEN: usize = 128ocre::realtime::MAX_IDENTITY_LEN(const): Longest identity given toWebSocketUpgrade::identified_by, in bytes.pub const MAX_IDENTITY_LEN: usize = 256ocre::realtime::MAX_REBROADCAST_BYTES(const): Longest client messageWebSocketUpgrade::rebroadcastrelays, in bytes; longer ones are dropped.pub const MAX_REBROADCAST_BYTES: usize = 16 * 1024
ocre::replicas
ocre::replicas::REPLICAS_VAR(const): Worker variable turning replicas on when set toon:D1_REPLICAS.pub const REPLICAS_VAR: &str = "D1_REPLICAS"
ocre::security
ocre::security::escape_javascript(fn): Escapes text for a JavaScript string literal in single, double or back quotes (Rails’escape_javascript).pub fn escape_javascript(text: &str) -> Stringocre::security::filter_json(fn): A JSON value with sensitive values replaced by"[FILTERED]", at any depth.pub fn filter_json(value: &Value) -> Valueocre::security::filter_parameters(fn): A query string or form body with sensitive values replaced by[FILTERED](Rails’filter_parameters).pub fn filter_parameters(query: &str) -> Stringocre::security::json_escape(fn): Escapes a JSON string for a<script>element (Rails’json_escape).pub fn json_escape(json: &str) -> Stringocre::security::rate_limit(fn): Counts one request forkeyagainst the Workers Rate Limiting bindingbinding; 429 when over the limit.pub async fn rate_limit(ctx: &Ctx, binding: &str, key: &str) -> Result<()>ocre::security::sanitize(fn): Cleans user-supplied HTML down toSANITIZE_TAGSandSANITIZE_ATTRIBUTES(Rails’sanitize).pub fn sanitize(html: &str) -> Stringocre::security::sanitize_with(fn): Likesanitize, with your own allowed tags and attributes (Rails’sanitize(html, tags:, attributes:)).pub fn sanitize_with(html: &str, tags: &[&str], attributes: &[&str]) -> Stringocre::security::strip_tags(fn): Removes every tag and comment and keeps the text, escaped (Rails’strip_tags).pub fn strip_tags(html: &str) -> Stringocre::security::url_from(fn): The URL to redirect to whencandidatepoints inside this app, elseNone(Rails’url_from).pub fn url_from(uri: &Uri, candidate: &str) -> Option<String>ocre::security::AllowBrowser(struct): Tower layer that answers406 Not Acceptableto browsers older than the versions it allows (Rails’allow_browser).pub struct AllowBrowserocre::security::AllowBrowser::new(fn): A policy that allows every browser; add limits withminimumanddeny.pub fn new() -> Selfocre::security::AllowBrowser::modern(fn): Rails’allow_browser versions: :modern: Safari 17.2, Chrome and Edge 120, Firefox 121, Opera 106, no Internet Explorer.pub fn modern() -> Selfocre::security::AllowBrowser::minimum(fn): Allowsbrowserfrom versionmajor.minoron; replaces an earlier rule for the same browser.pub fn minimum(self, browser: Browser, major: u32, minor: u32) -> Selfocre::security::AllowBrowser::deny(fn): Refuses every version ofbrowser(Rails’ie: false).pub fn deny(self, browser: Browser) -> Selfocre::security::AllowBrowser::page(fn): Replaces the HTML page sent with the 406 (by default a short “please upgrade your browser” page).pub fn page(mut self, html: impl Into<String>) -> Selfocre::security::AllowBrowser::allows(fn): Whether a request with thisUser-Agentpasses: a missing header or an unknown client always does.pub fn allows(&self, user_agent: Option<&str>) -> bool- Implements:
Clone,Debug,Default,Layer
ocre::security::AllowBrowserService(struct): The service built by theAllowBrowserlayer.pub struct AllowBrowserService<S>- Implements:
Clone,Debug,Service
- Implements:
ocre::security::BasicAuth(struct): HTTP Basic credentials fromAuthorization: Basic ..., as an extractor (Rails’http_basic_authenticate_with).pub struct BasicAuthocre::security::BasicAuth::username(field): The user name the client sent.pub username: Stringocre::security::BasicAuth::password(field): The password the client sent.pub password: Stringocre::security::BasicAuth::matches(fn): Whether the credentials areusernameandpassword, compared in constant time.pub fn matches(&self, username: &str, password: &str) -> boolocre::security::BasicAuth::challenge(fn):401 UnauthorizedwithWWW-Authenticate: Basic realm="Application": the browser asks again.pub fn challenge() -> Response- Implements:
Clone,Debug,Eq,FromRequestParts,PartialEq
ocre::security::ContentSecurityPolicy(struct): AContent-Security-Policyheader, built directive by directive, and the layer that sends it.pub struct ContentSecurityPolicyocre::security::ContentSecurityPolicy::new(fn): An empty policy: add directives with the builder methods.pub fn new() -> Selfocre::security::ContentSecurityPolicy::directive(fn): Sets any directive, e.g.directive("sandbox", &["allow-forms"]); replaces a previous value.pub fn directive(mut self, name: &str, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::default_src(fn):default-src: the fallback for every fetch directive not set.pub fn default_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::script_src(fn):script-src: where scripts may come from.pub fn script_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::style_src(fn):style-src: where stylesheets and inline styles may come from.pub fn style_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::img_src(fn):img-src: images and favicons.pub fn img_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::font_src(fn):font-src: web fonts.pub fn font_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::connect_src(fn):connect-src:fetch, XHR (htmx requests), WebSockets andEventSource.pub fn connect_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::media_src(fn):media-src:<audio>and<video>.pub fn media_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::object_src(fn):object-src:<object>and<embed>; set it toNONE.pub fn object_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::frame_src(fn):frame-src: pages this app may put in an<iframe>.pub fn frame_src(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::frame_ancestors(fn):frame-ancestors: sites that may put this app in a frame (the modernX-Frame-Options).pub fn frame_ancestors(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::form_action(fn):form-action: where forms may be submitted.pub fn form_action(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::base_uri(fn):base-uri: allowed<base href>values.pub fn base_uri(self, sources: &[&str]) -> Selfocre::security::ContentSecurityPolicy::upgrade_insecure_requests(fn):upgrade-insecure-requests: browsers loadhttp:resources over HTTPS.pub fn upgrade_insecure_requests(self) -> Selfocre::security::ContentSecurityPolicy::report_uri(fn):report-uri: where browsers POST violation reports (JSON), e.g. a route of the app.pub fn report_uri(self, uri: &str) -> Selfocre::security::ContentSecurityPolicy::report_to(fn):report-to: theReporting-Endpointsgroup violation reports go to.pub fn report_to(self, group: &str) -> Selfocre::security::ContentSecurityPolicy::report_only(fn): SendsContent-Security-Policy-Report-Only: browsers report violations but block nothing.pub fn report_only(mut self) -> Selfocre::security::ContentSecurityPolicy::header_name(fn):content-security-policy, orcontent-security-policy-report-onlyafterreport_only.pub fn header_name(&self) -> HeaderNameocre::security::ContentSecurityPolicy::header_value(fn): The header value, withNONCEreplaced by'nonce-<nonce>'(and dropped whennonceisNone).pub fn header_value(&self, nonce: Option<&str>) -> String- Implements:
Clone,Debug,Default,Eq,Layer,PartialEq
ocre::security::CspNonce(struct): The request’s Content-Security-Policy nonce, as an extractor (Rails’content_security_policy_nonce).pub struct CspNonce(/* private fields */)ocre::security::CspNonce::as_str(fn): The nonce value, without the'nonce-...'wrapper.pub fn as_str(&self) -> &str- Implements:
Clone,Debug,Display,Eq,FromRequestParts,PartialEq
ocre::security::PermissionsPolicy(struct): APermissions-Policyheader, built feature by feature, and the layer that sends it.pub struct PermissionsPolicyocre::security::PermissionsPolicy::new(fn): An empty policy: add features withallowanddeny.pub fn new() -> Selfocre::security::PermissionsPolicy::allow(fn): Allowsfeaturefor the listed origins only; replaces a previous rule for it.pub fn allow(mut self, feature: &str, allowlist: &[&str]) -> Selfocre::security::PermissionsPolicy::deny(fn): Turnsfeaturesoff everywhere:camera=().pub fn deny(self, features: &[&str]) -> Selfocre::security::PermissionsPolicy::header_value(fn): The header value.pub fn header_value(&self) -> String- Implements:
Clone,Debug,Default,Eq,Layer,PartialEq
ocre::security::PolicyService(struct): The service aContentSecurityPolicyorPermissionsPolicylayer wraps routes in.pub struct PolicyService<S, P>- Implements:
Clone,Service
- Implements:
ocre::security::Browser(enum): A browser family thatAllowBrowserrecognizes in theUser-Agentheader.pub enum Browserocre::security::Browser::Chrome(variant): Google Chrome and Chromium (Chrome/,CriOS/on iOS).Chromeocre::security::Browser::Edge(variant): Microsoft Edge (Edg/,EdgA/,EdgiOS/).Edgeocre::security::Browser::Firefox(variant): Mozilla Firefox (Firefox/).Firefoxocre::security::Browser::InternetExplorer(variant): Internet Explorer (MSIE,Trident/).InternetExplorerocre::security::Browser::Opera(variant): Opera (OPR/).Operaocre::security::Browser::Safari(variant): Apple Safari (Version/... Safari/).Safariocre::security::Browser::detect(fn): The browser and its(major, minor)version named by aUser-Agent, orNonefor anything else (bots,curl).pub fn detect(user_agent: &str) -> Option<(Browser, (u32, u32))>- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::security::BLOB(const):blob:URLs (files built in the browser).pub const BLOB: &str = "blob:"ocre::security::DATA(const):data:URLs (inline images, fonts).pub const DATA: &str = "data:"ocre::security::FILTERED_PARAMETERS(const): Parameter name fragments whose valuesfilter_parametersandfilter_jsonhide.pub const FILTERED_PARAMETERS: &[&str] = &["passw", "email", "secret", "token", "_key", "crypt", "salt", "certificate", "otp", "ssn", "cvv", "cvc"]ocre::security::HTTPS(const): Anyhttps:URL.pub const HTTPS: &str = "https:"ocre::security::NONCE(const): Placeholder for the request’s nonce: sent as'nonce-<value>'(Rails’content_security_policy_nonce).pub const NONCE: &str = "'nonce'"ocre::security::NONE(const):'none': nothing is allowed, in aContentSecurityPolicysource list.pub const NONE: &str = "'none'"ocre::security::SANITIZE_ATTRIBUTES(const): Attributessanitizekeeps on those tags: Rails’ safe list.pub const SANITIZE_ATTRIBUTES: &[&str] = &[ "abbr", "alt", "cite", "class", "datetime", "height", "href", "lang", "name", "src", "title", "width", "xml:lang", ]ocre::security::SANITIZE_TAGS(const): Tagssanitizekeeps: Rails’ safe list (Rails::HTML5::SafeListSanitizer).pub const SANITIZE_TAGS: &[&str] = &[ "a", "abbr", "acronym", "address", "b", "big", "blockquote", "br", "cite", "code", "dd", "del", "dfn", "div", "dl", "dt", "em", "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p", "pre", "samp", "small", "span", "strike", "strong", "sub", "sup", "time", "tt", "ul", "var", ]ocre::security::SELF(const):'self': the app’s own origin, in aContentSecurityPolicyorPermissionsPolicysource list.pub const SELF: &str = "'self'"ocre::security::STRICT_DYNAMIC(const):'strict-dynamic': scripts loaded by a nonced script are trusted too.pub const STRICT_DYNAMIC: &str = "'strict-dynamic'"ocre::security::UNSAFE_EVAL(const):'unsafe-eval': allowsevalandnew Function(htmx’shx-onandjs:need it).pub const UNSAFE_EVAL: &str = "'unsafe-eval'"ocre::security::UNSAFE_INLINE(const):'unsafe-inline': allows inline<style>/<script>andstyle=attributes.pub const UNSAFE_INLINE: &str = "'unsafe-inline'"
ocre::seo
ocre::seo::json_ld(fn):dataas a JSON-LD<script>for a page’s<head>.pub fn json_ld(data: &impl Serialize) -> Stringocre::seo::LlmsTxt(struct):/llms.txt: a site’s summary and links in Markdown, for language models (https://llmstxt.org).pub struct LlmsTxtocre::seo::LlmsTxt::new(fn): The site’s name (# title) and one-line summary (> summary).pub fn new(title: impl Into<String>, summary: impl Into<String>) -> Selfocre::seo::LlmsTxt::details(fn): Free text after the summary.pub fn details(mut self, text: impl Into<String>) -> Selfocre::seo::LlmsTxt::link(fn): A link in thesectionlist (sections keep the order they first appear in).pub fn link(mut self, section: &str, title: &str, url: &str, description: &str) -> Self- Implements:
Clone,Debug,Default,Display,Eq,IntoResponse,PartialEq
ocre::seo::Sitemap(struct):/sitemap.xml: the pages search engines should crawl.pub struct Sitemapocre::seo::Sitemap::MAX_URLS(const): Most URLs in one sitemap file.pub const MAX_URLS: usize = 50_000ocre::seo::Sitemap::new(fn): An empty sitemap.pub fn new() -> Selfocre::seo::Sitemap::add(fn): Adds a page.pub fn add(&mut self, url: SitemapUrl) -> &mut Selfocre::seo::Sitemap::add_localized(fn): Adds a page in every language:urlsis(hreflang, absolute URL)for each locale, the first being the default (alsox-default).pub fn add_localized(&mut self, urls: &[(&str, &str)], lastmod: Option<&str>) -> &mut Selfocre::seo::Sitemap::len(fn): The URLs added so far.pub fn len(&self) -> usizeocre::seo::Sitemap::is_empty(fn): Whether no URL was added.pub fn is_empty(&self) -> boolocre::seo::Sitemap::to_xml(fn): The sitemap document (UTF-8 XML, values escaped).pub fn to_xml(&self) -> String- Implements:
Clone,Debug,Default,Eq,IntoResponse,PartialEq
ocre::seo::SitemapUrl(struct): A page of aSitemap: its absolute URL, last change and other languages.pub struct SitemapUrlocre::seo::SitemapUrl::loc(field): Absolute URL (https://example.com/fr/pricing).pub loc: Stringocre::seo::SitemapUrl::lastmod(field): Last change,YYYY-MM-DDor a full W3C date-time; omitted whenNone.pub lastmod: Option<String>ocre::seo::SitemapUrl::alternates(field): The page in each language, the page itself included:(hreflang, absolute URL), plus("x-default", url)for the language-picking version.pub alternates: Vec<(String, String)>- Implements:
Clone,Debug,Default,Eq,PartialEq
ocre::sse
ocre::sse::Event(re-export)pub use axum::response::sse::Eventocre::sse::Sse(re-export)pub use axum::response::sse::Sseocre::sse::stream(fn): An event stream:step(state)gives the next event and the next state, orNoneto end the stream.pub fn stream<T, F, Fut>(state: T, step: F) -> Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static> where T: Send + 'static, F: FnMut(T) -> Fut + Send + 'static, Fut: Future<Output = Option<(Event, T)>> + Send + 'static
ocre::storage
ocre::storage::analyze(fn): Reads a file’s signature and, for images, its width and height (Active Storage’sanalyze).pub fn analyze(bytes: &[u8]) -> Analysisocre::storage::attach_direct_upload(fn): Finishes a direct upload (or amultipart_uploadsone): checks the object behindsigned_keyagainstrulesand returns itsAttachment.pub fn attach_direct_upload(ctx: &Ctx, field: &str, signed_key: &str, filename: &str, rules: &Rules) -> impl Future<Output = Result<Attachment>> + Send + use<>ocre::storage::check_multipart(fn): Checks the declared file againstrulesand lays it out in parts, asmultipart_uploadsdoes before creating the upload.pub fn check_multipart(field: &str, size: u64, content_type: &str, rules: &Rules, max_part: u64) -> Result<(u64, u16)>ocre::storage::column_changes(fn): Builds the parameters of anUPDATEof one attachment’s columns: a change flag, then the fourcolumns.pub fn column_changes(change: Option<Option<&Attachment>>) -> [Param; 5]ocre::storage::columns(fn): Builds the query parameters for the four columns of one attachment, in column order.pub fn columns(attachment: Option<&Attachment>) -> [Param; 4]ocre::storage::delete(fn): Deletes the object atkey.pub fn delete(ctx: &Ctx, key: &str) -> impl Future<Output = Result<()>> + Send + use<>ocre::storage::delete_attachments(fn): Deletes the objects of everySomeattachment in one R2 call.pub fn delete_attachments(ctx: &Ctx, attachments: &[Option<Attachment>]) -> impl Future<Output = Result<()>> + Send + use<>ocre::storage::direct_upload(fn): Starts a direct upload: checks what the browser declares againstrulesand presigns aPUTof a new key underprefix.pub fn direct_upload(ctx: &Ctx, prefix: &str, field: &str, request: &DirectUploadRequest, rules: &Rules) -> Result<DirectUpload>ocre::storage::direct_upload_script(fn): ServesDIRECT_UPLOAD_JSatGET /ocre/direct-upload.js: merge it into the routes, then load it in the layout with<script src="/ocre/direct-upload.js" defer></script>.pub fn direct_upload_script<S: Clone + Send + Sync + 'static>() -> axum::Router<S>ocre::storage::exists(fn): Returns whether an object exists atkey(Active Storage’sexist?); aheadwithout the details.pub fn exists(ctx: &Ctx, key: &str) -> impl Future<Output = Result<bool>> + Send + use<>ocre::storage::head(fn): Describes the object atkeywithout reading it: size, content type, ETag, upload time;Nonewhen it does not exist.pub fn head(ctx: &Ctx, key: &str) -> impl Future<Output = Result<Option<StoredObject>>> + Send + use<>ocre::storage::human_size(fn): Formats a byte count for people, in powers of 1024 like Rails’number_to_human_size.pub fn human_size(bytes: u64) -> Stringocre::storage::list(fn): Lists one page of the objects whose key starts withprefix, in key order, with their size, type and upload time.pub fn list(ctx: &Ctx, prefix: &str, cursor: Option<&str>, limit: u32) -> impl Future<Output = Result<Listing>> + Send + use<>ocre::storage::multipart_uploads(fn): Routes that upload large files to R2 in parts, resumably (S3 multipart uploads), for thefieldof a form.pub fn multipart_uploads(path: &str, prefix: &'static str, field: &'static str, rules: &'static Rules) -> Router<Ctx>ocre::storage::part_layout(fn): How to cutsizebytes into parts no larger thanmax_part:(part_size, part_count).pub fn part_layout(field: &str, size: u64, max_part: u64) -> Result<(u64, u16)>ocre::storage::presign_get(fn): Presigns aGETof an attachment on R2’s S3 API, validexpires_inseconds (at most 7 days).pub fn presign_get(ctx: &Ctx, attachment: &Attachment, disposition: Disposition, expires_in: u64) -> Result<String>ocre::storage::presign_parts(fn): PresignedPUTURLs ofpartsof the uploadupload_idofkey, validexpires_inseconds.pub fn presign_parts(endpoint: &S3Endpoint, key: &str, upload_id: &str, parts: &[u16], now: i64, expires_in: u64) -> Result<PartUrls>ocre::storage::presign_put(fn): Presigns aPUTof exactlysizebytes of typecontent_typeatkey, validexpires_inseconds.pub fn presign_put(ctx: &Ctx, key: &str, content_type: &str, size: u64, expires_in: u64) -> Result<String>ocre::storage::public_url(fn): The permanent public URL ofkeyin a public bucket:<STORAGE_PUBLIC_URL>/<key>(Active Storage’spublic: true).pub fn public_url(ctx: &Ctx, key: &str) -> Result<String>ocre::storage::purge_unattached(fn): Deletes the objects underprefixolder thanmax_ageseconds that notable.columnrow references, one listing page per call.pub fn purge_unattached(ctx: &Ctx, prefix: &str, table: &str, column: &str, max_age: i64, cursor: Option<&str>) -> impl Future<Output = Result<Purged>> + Send + use<>ocre::storage::read(fn): Reads a whole object into memory, or returnsNonewhenkeydoes not exist.pub fn read(ctx: &Ctx, key: &str) -> impl Future<Output = Result<Option<Vec<u8>>>> + Send + use<>ocre::storage::read_first(fn): Reads the firstlengthbytes of an object (all of it when shorter), orNonewhenkeydoes not exist.pub fn read_first(ctx: &Ctx, key: &str, length: u64) -> impl Future<Output = Result<Option<Vec<u8>>>> + Send + use<>ocre::storage::send_data(fn): Sends bytes built by the handler as a file (Rails’send_data): a CSV export, a generated image, an.icsfile.pub fn send_data(data: impl Into<Bytes>, filename: &str, content_type: &str, disposition: Disposition) -> Responseocre::storage::serve(fn): Streams a stored file to the client, with the headers a browser needs for caching, seeking and saving it.pub fn serve(ctx: &Ctx, attachment: &Attachment, headers: &HeaderMap, disposition: Disposition) -> impl Future<Output = Result<Response>> + Send + use<>ocre::storage::serve_redirect(fn): Answers302 Foundto a presignedGETof the attachment (Active Storage’s redirect mode).pub fn serve_redirect(ctx: &Ctx, attachment: &Attachment, disposition: Disposition, expires_in: u64) -> Result<Response>ocre::storage::store(fn): Stores anUploadin R2 under a new random key starting withprefix, and returns theAttachmentto save.pub fn store(ctx: &Ctx, prefix: &str, upload: Upload) -> impl Future<Output = Result<Attachment>> + Send + use<>ocre::storage::store_body(fn): Streamsbody(exactlysizebytes) into R2 without holding it in memory, and returns itsAttachment.pub fn store_body(ctx: &Ctx, prefix: &str, filename: &str, content_type: &str, size: u64, body: Body) -> impl Future<Output = Result<Attachment>> + Send + use<>ocre::storage::store_bytes(fn): Stores bytes built by the app (a generated report, an export) likestore.pub fn store_bytes(ctx: &Ctx, prefix: &str, filename: &str, content_type: &str, bytes: Vec<u8>) -> impl Future<Output = Result<Attachment>> + Send + use<>ocre::storage::Analysis(struct): Whatanalyzefound in a file’s bytes.pub struct Analysisocre::storage::Analysis::content_type(field): The type told by the file’s signature (magic bytes), orNonefor other files (text, CSV, SVG…).pub content_type: Option<&'static str>ocre::storage::Analysis::width(field): Width in pixels, for PNG, GIF, WebP and JPEG images whose header is in the bytes.pub width: Option<u32>ocre::storage::Analysis::height(field): Height in pixels, likewidth.pub height: Option<u32>- Implements:
Clone,Copy,Debug,Default,Eq,PartialEq
ocre::storage::Attachment(struct): A stored file, as saved with its record in four columns.pub struct Attachmentocre::storage::Attachment::key(field): Object key in the R2 bucket:<prefix>/<22 random URL-safe characters>.pub key: Stringocre::storage::Attachment::filename(field): The uploader’s file name, without directories or control characters.pub filename: Stringocre::storage::Attachment::content_type(field): Content type, lowercase and without parameters (image/png).pub content_type: Stringocre::storage::Attachment::size(field): Size in bytes.pub size: i64ocre::storage::Attachment::human_size(fn): Formats the size for people withhuman_size:512 bytes,2 KB,1.5 MB.pub fn human_size(&self) -> String- Implements:
Clone,Debug,Default,Deserialize,Eq,PartialEq,Serialize
ocre::storage::CompletedPart(struct): A part R2 stored: its number and theETagit answered.pub struct CompletedPartocre::storage::CompletedPart::part_number(field): From 1.pub part_number: u16ocre::storage::CompletedPart::etag(field): The part’sETag, quoted or not.pub etag: String- Implements:
Clone,Debug,Deserialize,Eq,PartialEq,Serialize
ocre::storage::DirectUpload(struct): A direct upload the browser may perform:PUTthe file tourlwithheaders, then submitsigned_key.pub struct DirectUploadocre::storage::DirectUpload::signed_key(field): The object key and its signature (<prefix>/<22 characters>.<43 characters>); the form submits it.pub signed_key: Stringocre::storage::DirectUpload::url(field): PresignedPUTURL on R2’s S3 API.pub url: Stringocre::storage::DirectUpload::headers(field): Headers thePUTmust carry, exactly (Content-Type); the browser addsContent-Lengthitself.pub headers: BTreeMap<String, String>ocre::storage::DirectUpload::sign(fn): Checksrequestagainstrulesand presigns aPUTof a new key underprefix, validexpires_inseconds.pub fn sign(endpoint: &S3Endpoint, prefix: &str, field: &str, request: &DirectUploadRequest, rules: &Rules, now: i64, expires_in: u64) -> Result<Self>- Implements:
Clone,Debug,Deserialize,Eq,PartialEq,Serialize
ocre::storage::DirectUploadRequest(struct): What the browser declares before a direct upload: the file’s name, type and size.pub struct DirectUploadRequestocre::storage::DirectUploadRequest::filename(field): File name, as the browser gives it (cleaned up when attached).pub filename: Stringocre::storage::DirectUploadRequest::content_type(field): Declared content type; signed into the upload URL, so R2 stores this one.pub content_type: Stringocre::storage::DirectUploadRequest::size(field): Declared size in bytes; signed into the upload URL asContent-Length.pub size: u64- Implements:
Clone,Debug,Default,Deserialize,Eq,PartialEq,Serialize
ocre::storage::FinishRequest(struct): The browser finishes (partsset) or abandons an upload.pub struct FinishRequestocre::storage::FinishRequest::signed_key(field): From theMultipartUpload.pub signed_key: Stringocre::storage::FinishRequest::upload_id(field): From theMultipartUpload.pub upload_id: Stringocre::storage::FinishRequest::parts(field): Every part, as R2 answered them; empty to abort.pub parts: Vec<CompletedPart>- Implements:
Clone,Debug,Deserialize,Eq,PartialEq
ocre::storage::Listing(struct): One page oflist: the objects, and the cursor of the next page.pub struct Listingocre::storage::Listing::objects(field): Objects in key order.pub objects: Vec<StoredObject>ocre::storage::Listing::cursor(field): Pass it to the nextlistcall;Noneon the last page.pub cursor: Option<String>- Implements:
Clone,Debug,Default,Deserialize,Eq,PartialEq,Serialize
ocre::storage::Multipart(struct): Extractor for amultipart/form-databody of at mostLIMITbytes.pub struct Multipart<const LIMIT: usize>(pub MultipartForm)- Implements:
Debug,FromRequest
- Implements:
ocre::storage::MultipartForm(struct): The parsed parts of a multipart body: text fields and files, by field name.pub struct MultipartFormocre::storage::MultipartForm::form(fn): Deserializes the text fields intoT, exactly like axum’sForm.pub fn form<T: DeserializeOwned>(&self) -> Result<T>ocre::storage::MultipartForm::text(fn): Returns the first text field namedname, orNonewhen it was not sent.pub fn text(&self, name: &str) -> Option<&str>ocre::storage::MultipartForm::file(fn): Takes the first file sent asname, leaving the others.pub fn file(&mut self, name: &str) -> Option<Upload>ocre::storage::MultipartForm::files(fn): Takes every file sent asnameorname[](an<input type="file" multiple>), in order.pub fn files(&mut self, name: &str) -> Vec<Upload>- Implements:
Clone,Debug,Default,PartialEq
ocre::storage::MultipartUpload(struct): A started multipart upload, sent to the browser.pub struct MultipartUploadocre::storage::MultipartUpload::signed_key(field): The object key and its signature, as for a direct upload: the form submits it.pub signed_key: Stringocre::storage::MultipartUpload::upload_id(field): R2’s id of the upload.pub upload_id: Stringocre::storage::MultipartUpload::part_size(field): Bytes per part; the last part holds the rest.pub part_size: u64ocre::storage::MultipartUpload::part_count(field): Number of parts, numbered from 1.pub part_count: u16- Implements:
Clone,Debug,Deserialize,Eq,PartialEq,Serialize
ocre::storage::PartUrls(struct): Where toPUTeach part:{"urls": {"1": "https://..."}}.pub struct PartUrlsocre::storage::PartUrls::urls(field): Part number to URL: presigned on R2, or a route of the Worker.pub urls: BTreeMap<u16, String>- Implements:
Clone,Debug,Eq,PartialEq,Serialize
ocre::storage::PartsRequest(struct): The browser asks where to send parts:{"signed_key", "upload_id", "parts": [1, 2, 3]}.pub struct PartsRequestocre::storage::PartsRequest::signed_key(field): From theMultipartUpload.pub signed_key: Stringocre::storage::PartsRequest::upload_id(field): From theMultipartUpload.pub upload_id: Stringocre::storage::PartsRequest::parts(field): Part numbers, from 1; at mostMAX_PARTS_PER_REQUEST.pub parts: Vec<u16>- Implements:
Clone,Debug,Deserialize,Eq,PartialEq
ocre::storage::Purged(struct): What onepurge_unattachedcall did: the keys it deleted, and where the next call resumes.pub struct Purgedocre::storage::Purged::deleted(field): Keys deleted by this call.pub deleted: Vec<String>ocre::storage::Purged::cursor(field): Cursor of the next page of the listing;Noneonce the prefix was listed to the end.pub cursor: Option<String>- Implements:
Clone,Debug,Default,Deserialize,Eq,PartialEq,Serialize
ocre::storage::Rules(struct): Describes what a file field accepts: a size limit and a content-type allowlist.pub struct Rulesocre::storage::Rules::max_bytes(field): Largest accepted file, in bytes (u64: multipart uploads take files over 4 GB, more than ausizeholds in WebAssembly).pub max_bytes: u64ocre::storage::Rules::content_types(field): Accepted content types, exact and lowercase (image/png).pub content_types: &'static [&'static str]ocre::storage::Rules::allows(fn): Returns whethercontent_typeis in the allowlist, ignoring case and parameters.pub fn allows(&self, content_type: &str) -> bool- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::storage::S3Endpoint(struct): An S3-compatible bucket and the credentials to presign URLs for it.pub struct S3Endpointocre::storage::S3Endpoint::host(field): Host name of the S3 API, without scheme (<account_id>.r2.cloudflarestorage.com).pub host: Stringocre::storage::S3Endpoint::region(field): Signing region:autofor R2.pub region: Stringocre::storage::S3Endpoint::bucket(field): Bucket name, put first in the path (/<bucket>/<key>, path style); empty when the host already names the bucket.pub bucket: Stringocre::storage::S3Endpoint::access_key_id(field): Access key ID of the credentials.pub access_key_id: Stringocre::storage::S3Endpoint::secret_access_key(field): Secret access key of the credentials.pub secret_access_key: Stringocre::storage::S3Endpoint::r2(fn): The S3 endpoint of an R2 bucket: host<account_id>.r2.cloudflarestorage.com, regionauto, path-style URLs.pub fn r2(account_id: &str, bucket: &str, access_key_id: &str, secret_access_key: &str) -> Selfocre::storage::S3Endpoint::presign(fn): Presigns a request onkey(AWS SigV4, query-string form) and returns itshttps://URL.pub fn presign(&self, method: &str, key: &str, headers: &[(&str, &str)], query: &[(&str, &str)], now: i64, expires_in: u64) -> Result<String>- Implements:
Clone,Debug,Eq,PartialEq
ocre::storage::StoredObject(struct): An object in the bucket, asheadandlistdescribe it (without its bytes).pub struct StoredObjectocre::storage::StoredObject::key(field): Object key.pub key: Stringocre::storage::StoredObject::size(field): Size in bytes.pub size: u64ocre::storage::StoredObject::content_type(field): Content type recorded with the object (application/octet-streamwhen none was).pub content_type: Stringocre::storage::StoredObject::etag(field): R2’s entity tag, unquoted (the MD5 of the content for single-part uploads).pub etag: Stringocre::storage::StoredObject::uploaded_at(field): Upload time, in Unix seconds (compare withcrate::now).pub uploaded_at: i64ocre::storage::StoredObject::filename(field): File name recorded bystoreand friends;Nonefor objects uploaded directly.pub filename: Option<String>ocre::storage::StoredObject::attachment(fn): TheAttachmentof this object underfilename(cleaned up), with its recorded size and type.pub fn attachment(&self, filename: &str) -> Attachment- Implements:
Clone,Debug,Default,Deserialize,Eq,PartialEq,Serialize
ocre::storage::Upload(struct): A file received in amultipart/form-datarequest, before it is stored.pub struct Uploadocre::storage::Upload::filename(field): File name sent by the browser, cleaned up.pub filename: Stringocre::storage::Upload::content_type(field): Content type sent by the browser, lowercase without parameters.pub content_type: Stringocre::storage::Upload::bytes(field): The file’s bytes.pub bytes: axum::body::Bytesocre::storage::Upload::new(fn): An upload made by the app (Active Storage’sattach(io:, filename:, content_type:)), cleaned up like a browser’s.pub fn new(filename: &str, content_type: &str, bytes: impl Into<Bytes>) -> Selfocre::storage::Upload::size(fn): Returns the size of the file in bytes.pub fn size(&self) -> u64- Implements:
Clone,Debug,Default,PartialEq
ocre::storage::Variant(struct): A resized version of an image, made by Cloudflare Image Transformations (Active Storage’s variants).pub struct Variantocre::storage::Variant::new(fn): A variant with no resizing: onlyformat=auto.pub const fn new() -> Selfocre::storage::Variant::width(fn): Sets the largest width, in pixels.pub const fn width(mut self, pixels: u32) -> Selfocre::storage::Variant::height(fn): Sets the largest height, in pixels.pub const fn height(mut self, pixels: u32) -> Selfocre::storage::Variant::fit(fn): Sets how the image fits thewidth×heightbox; without it, Cloudflare scales the image down to fit.pub const fn fit(mut self, fit: Fit) -> Selfocre::storage::Variant::quality(fn): Sets the JPEG/WebP/AVIF quality, 1 to 100 (values outside are clamped).pub const fn quality(mut self, quality: u8) -> Selfocre::storage::Variant::path(fn): The same-origin path of this variant ofsource:/cdn-cgi/image/<options>/<source>.pub fn path(&self, source: &str) -> String- Implements:
Clone,Copy,Debug,Default,Eq,PartialEq
ocre::storage::Disposition(enum): Tellsservewhether the browser shows a file or downloads it.pub enum Dispositionocre::storage::Disposition::Inline(variant): Shows the file in the page or tab when its type is safe to display, and downloads anything else.Inlineocre::storage::Disposition::Download(variant): Always downloads the file, with its original name.Download- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::storage::Fit(enum): How aVariantfits the image in itswidth×heightbox (Cloudflare’sfitoption).pub enum Fitocre::storage::Fit::ScaleDown(variant): LikeContain, but never enlarges a smaller image (Active Storage’sresize_to_limit).ScaleDownocre::storage::Fit::Contain(variant): Fits inside the box, keeping the aspect ratio (resize_to_fit).Containocre::storage::Fit::Cover(variant): Fills the box, cropping what overflows (resize_to_fill).Coverocre::storage::Fit::Crop(variant): LikeCover, but never enlarges a smaller image.Cropocre::storage::Fit::Pad(variant): LikeContain, then pads to the exact box size (resize_and_pad).Pad- Implements:
Clone,Copy,Debug,Eq,PartialEq
ocre::storage::CACHE_CONTROL(const):Cache-Controlof files sent byserve: browsers keep them but revalidate each time.pub const CACHE_CONTROL: &str = "private, no-cache"ocre::storage::DIRECT_UPLOAD_JS(const): The browser side of direct uploads (Active Storage’sactivestorage.js): a script that sends the files of<input type="file" data-direct-upload-url="...">to R2 before its form is submitted.pub const DIRECT_UPLOAD_JS: &str = include_str!("storage/direct_upload.js")ocre::storage::MAX_EXPIRES_IN(const): Longest lifetime of a presigned URL, in seconds: 7 days, the SigV4 maximum.pub const MAX_EXPIRES_IN: u64 = 604_800ocre::storage::MAX_PART(const): Largest part R2 accepts: 5 GiB.pub const MAX_PART: u64 = 5 * 1024 * MIBocre::storage::MAX_PARTS(const): Most parts in one upload.pub const MAX_PARTS: u64 = 10_000ocre::storage::MAX_PARTS_PER_REQUEST(const): Most part URLs answered at once.pub const MAX_PARTS_PER_REQUEST: usize = 1_000ocre::storage::MAX_WORKER_PART(const): Largest part sent through the Worker (its request limit is 100 MB).pub const MAX_WORKER_PART: u64 = 95 * MIBocre::storage::PART_SIZE(const): Size of the parts: 10 MiB, or more for a file that would need over 10,000.pub const PART_SIZE: u64 = 10 * MIBocre::storage::R2_ACCESS_KEY_ID(const): Worker secret holding the access key ID of an R2 API token, for presigned R2 URLs.pub const R2_ACCESS_KEY_ID: &str = "R2_ACCESS_KEY_ID"ocre::storage::R2_ACCOUNT_ID(const): Worker variable holding the Cloudflare account ID, for presigned R2 URLs.pub const R2_ACCOUNT_ID: &str = "R2_ACCOUNT_ID"ocre::storage::R2_BUCKET(const): Worker variable holding the R2 bucket name (<app>-storage), for presigned R2 URLs.pub const R2_BUCKET: &str = "R2_BUCKET"ocre::storage::R2_SECRET_ACCESS_KEY(const): Worker secret holding the secret access key of an R2 API token, for presigned R2 URLs.pub const R2_SECRET_ACCESS_KEY: &str = "R2_SECRET_ACCESS_KEY"ocre::storage::STORAGE_BINDING(const): Name of the R2 binding holding every file:STORAGE: bindings.r2({ name: "<app>-storage" })in cloudflare.config.ts.pub const STORAGE_BINDING: &str = "STORAGE"ocre::storage::STORAGE_PUBLIC_URL(const): Name of the Worker variable holding the base URL of a public bucket, forpublic_url.pub const STORAGE_PUBLIC_URL: &str = "STORAGE_PUBLIC_URL"
ocre::testing (feature testing)
ocre::testing::block_on(re-export): Runs a future to completion on the test thread:asynchandlers, helpers andocre::passwordin plain unit tests (no async runtime runs outside workerd).pub use pollster::block_onocre::testing::assert_changes(fn): Asserts thatblockchanges whatexpressionreturns (Rails’assert_changes), and returns(before, after).pub fn assert_changes<T: PartialEq + Debug>(mut expression: impl FnMut() -> T, block: impl FnOnce()) -> (T, T)ocre::testing::assert_difference(fn): Asserts thatblockchanges the numberexpressionreturns bydifference(Rails’assert_difference), and returns the block’s value.pub fn assert_difference<R>(mut expression: impl FnMut() -> i64, difference: i64, block: impl FnOnce() -> R) -> Rocre::testing::assert_no_changes(fn): Asserts thatblockleaves whatexpressionreturns unchanged (Rails’assert_no_changes).pub fn assert_no_changes<T: PartialEq + Debug>(mut expression: impl FnMut() -> T, block: impl FnOnce())ocre::testing::assert_no_difference(fn): Asserts thatblockleaves the numberexpressionreturns unchanged (Rails’assert_no_difference).pub fn assert_no_difference<R>(expression: impl FnMut() -> i64, block: impl FnOnce() -> R) -> Rocre::testing::count(fn): Number of rows oftablein the test database, forassert_difference.pub fn count(table: &str) -> i64ocre::testing::eventually(fn): Retriescheckevery 100 ms for up to 30 seconds until it returnsSome, for effects that happen later: a queued job, a cron, a broadcast.pub fn eventually<T>(check: impl FnMut() -> Option<T>) -> Tocre::testing::fixture(fn): The row oftableloaded from the fixturelabel(Rails’posts(:first)).pub fn fixture(table: &str, label: &str) -> Map<String, Value>ocre::testing::fixture_id(fn): Theidof the fixture labelledlabel(Rails’users(:david).id): the loader oftests/fixtures/*.ymlgives a record without an explicitidthe CRC-32 of its label modulo 2^30 - 1, like Rails.pub fn fixture_id(label: &str) -> i64ocre::testing::freeze_time(fn): Stopscrate::nowat the current second (Rails’freeze_time); returns it.pub fn freeze_time() -> i64ocre::testing::insert(fn): Inserts a row intotableof the test database and returns itsid: what generated factories (tests/factories/) call.pub fn insert(table: &str, values: &[(&str, Value)]) -> i64ocre::testing::quote(fn):valueas a SQL literal: strings quoted ('doubled), numbers as is, booleans as1/0, null asNULL, arrays and objects as JSON text.pub fn quote(value: impl Into<Value>) -> Stringocre::testing::redact(fn): Replaces values that change on every run by placeholders, so a snapshot or anassert_eq!on a whole page or JSON body is stable (Loco’scleanup_*filters): UUIDs become<UUID>, ISO 8601 dates and times (2026-01-01,2026-01-01T12:00:00Z,2026-01-01 12:00:00) become<DATE>, and runs of 32 or more hexadecimal or base64url characters (tokens, digests) become<TOKEN>.pub fn redact(text: &str) -> Stringocre::testing::sequence(fn): A unique number per call in the test process, for unique test data (FactoryBot’ssequence): 1, 2, 3…pub fn sequence() -> u64ocre::testing::sql(fn): Rows returned byquery, run on the test database (Rails’ActiveRecord::Base.connection.select_all).pub fn sql(query: &str) -> Vec<Map<String, Value>>ocre::testing::travel(fn): Movescrate::nowbysecondsfrom its current value and freezes it there (Rails’travel 1.day).pub fn travel(seconds: i64)ocre::testing::travel_back(fn): Returnscrate::nowto the real clock (Rails’travel_back).pub fn travel_back()ocre::testing::travel_to(fn): Makescrate::nowreturnunixon this thread untiltravel_back(Rails’travel_to, frozen).pub fn travel_to(unix: i64)ocre::testing::var(fn): A variable of the app under test: the environment variablename, else its value in.dev.vars(the fileocre devloads), as the Worker sees it.pub fn var(name: &str) -> Option<String>ocre::testing::Broadcast(struct): A message broadcast to a realtime channel, fromClient::broadcasts.pub struct Broadcastocre::testing::Broadcast::id(field): Position in the capture, from 1.pub id: u64ocre::testing::Broadcast::channel(field): The channel it went to (posts).pub channel: Stringocre::testing::Broadcast::message(field): The message: HTML or JSON text.pub message: String- Implements:
Clone,Debug,Deserialize,Eq,PartialEq
ocre::testing::Client(struct): An HTTP client for request tests, with a cookie jar, like a browser tab (Rails’ integration session).pub struct Clientocre::testing::Client::new(fn): A client for the serverocre test --e2estarted (TEST_URL).pub fn new() -> Selfocre::testing::Client::with_base_url(fn): A client for the app atbase(http://localhost:8787), e.g. a runningocre dev.pub fn with_base_url(base: &str) -> Selfocre::testing::Client::header(fn): Sendsname: valuewith every request (Rails’headers:); replaces an earlier value of the same header.pub fn header(mut self, name: &str, value: &str) -> Selfocre::testing::Client::cross_site(fn): Requests as a form posted from another site would (Sec-Fetch-Site: cross-site): Ocre’s CSRF protection answers 403 to unsafe methods.pub fn cross_site(self) -> Selfocre::testing::Client::htmx(fn): Requests as htmx does (HX-Request: true), Rails’xhr: true.pub fn htmx(self) -> Selfocre::testing::Client::get(fn):GET path.pub fn get(&mut self, path: &str) -> Responseocre::testing::Client::post(fn):POST pathwith anapplication/x-www-form-urlencodedbody, as an HTML form sends it.pub fn post(&mut self, path: &str, form: &impl Serialize) -> Responseocre::testing::Client::post_json(fn):POST pathwithvalueas a JSON body.pub fn post_json(&mut self, path: &str, value: &impl Serialize) -> Responseocre::testing::Client::patch_json(fn):PATCH pathwithvalueas a JSON body.pub fn patch_json(&mut self, path: &str, value: &impl Serialize) -> Responseocre::testing::Client::put_json(fn):PUT pathwithvalueas a JSON body.pub fn put_json(&mut self, path: &str, value: &impl Serialize) -> Responseocre::testing::Client::delete(fn):DELETE path.pub fn delete(&mut self, path: &str) -> Responseocre::testing::Client::request(fn): Sendsmethod pathwith an optional(content type, body): any method, any body (multipart uploads, raw bytes).pub fn request(&mut self, method: &str, path: &str, body: Option<(&str, Vec<u8>)>) -> Responseocre::testing::Client::follow_redirect(fn): Follows the redirectresponseanswered:GETof itsLocation(Rails’follow_redirect!).pub fn follow_redirect(&mut self, response: &Response) -> Responseocre::testing::Client::cookie(fn): The cookienameas the server set it (percent-encoded), if the jar has it.pub fn cookie(&self, name: &str) -> Option<&str>ocre::testing::Client::set_cookie(fn): Sets a cookie sent with the next requests, as if the server had set it.pub fn set_cookie(&mut self, name: &str, value: &str)ocre::testing::Client::session(fn): The session data (Rails’sessionin tests), decrypted with the app’sSECRET_KEY_BASE(the environment variable, else.dev.vars): empty without a session cookie.pub fn session(&self) -> Map<String, Value>ocre::testing::Client::flash(fn): The flash message ofkind(notice,alert…) set by the last request for the next page (Rails’flash[:notice]after an action).pub fn flash(&self, kind: &str) -> Option<String>ocre::testing::Client::deliveries(fn): Emails the app sent withMAIL_ADAPTER = "log"(Rails’ActionMailer::Base.deliveries), oldest first: the last 20, kept by the Worker instance.pub fn deliveries(&mut self) -> Vec<Email>ocre::testing::Client::broadcasts(fn): Messages the app broadcast to realtime channels (Rails’assert_broadcasts), oldest first: the last 50, kept by the Worker instance.pub fn broadcasts(&mut self) -> Vec<Broadcast>ocre::testing::Client::jobs(fn): Jobs the app enqueued and ran (Rails’assert_enqueued_with,assert_performed_jobs): the last 50 of each, oldest first, kept by the Worker instance.pub fn jobs(&mut self) -> Jobsocre::testing::Client::receive_email(fn): Delivers an email to the app’s mailbox (ocre g mailbox), as Cloudflare Email Routing would (Rails’receive_inbound_email_from_mail): a plain-text message posted to the local server’s email endpoint (POST /cdn-cgi/local/email?from=&to=), which runs the Worker’semailevent.pub fn receive_email(&mut self, from: &str, to: &str, subject: &str, body: &str) -> Response- Implements:
Debug
ocre::testing::EnqueuedJob(struct): A job sent to a queue, fromClient::jobs.pub struct EnqueuedJobocre::testing::EnqueuedJob::id(field): Position in the capture, from 1 (shared with runs).pub id: u64ocre::testing::EnqueuedJob::queue(field): The queue:default, or the name given toocre::jobs::queue.pub queue: Stringocre::testing::EnqueuedJob::job(field): The job as the app serialized it:{"send_welcome": {"user_id": 7}}.pub job: Valueocre::testing::EnqueuedJob::name(fn): The job’s name: the key of its JSON object (send_welcome), asPerformedJob::jobnames it.pub fn name(&self) -> Option<&str>- Implements:
Clone,Debug,Deserialize,PartialEq
ocre::testing::Jobs(struct): Jobs the app enqueued and ran, fromClient::jobs.pub struct Jobsocre::testing::Jobs::enqueued(field): Jobs sent to a queue, oldest first.pub enqueued: Vec<EnqueuedJob>ocre::testing::Jobs::performed(field): Jobs the queue consumer ran, oldest first.pub performed: Vec<PerformedJob>- Implements:
Clone,Debug,Default,Deserialize,PartialEq
ocre::testing::Log(struct): The test server’s log (cf devoutput:console.log, job and cron lines, errors), read from the position ofLog::markon.pub struct Logocre::testing::Log::mark(fn): The log from now on (TEST_LOG, else.wrangler/test-state/dev.log).pub fn mark() -> Selfocre::testing::Log::text(fn): What was logged since the mark.pub fn text(&self) -> Stringocre::testing::Log::wait_for(fn): Waits up to 30 seconds for a line containingneedleand returns it.pub fn wait_for(&self, needle: &str) -> String- Implements:
Clone,Debug
ocre::testing::PerformedJob(struct): A job run by the queue consumer, fromClient::jobs.pub struct PerformedJobocre::testing::PerformedJob::id(field): Position in the capture, from 1 (shared with enqueued jobs).pub id: u64ocre::testing::PerformedJob::job(field): The job’s name (send_welcome), ormailfordeliver_lateremails.pub job: Stringocre::testing::PerformedJob::outcome(field):done,discarded(an error a retry cannot fix) orretried.pub outcome: String- Implements:
Clone,Debug,Deserialize,Eq,PartialEq
ocre::testing::Response(struct): A response in a request test, with chainable assertions (Rails’assert_response,assert_redirected_to,assert_match).pub struct Responseocre::testing::Response::status(field): HTTP status code.pub status: u16ocre::testing::Response::headers(field): Headers in the order received, names lowercase.pub headers: Vec<(String, String)>ocre::testing::Response::body(field): Body as text (invalid UTF-8 replaced); empty forHEAD, 204 and 304.pub body: Stringocre::testing::Response::header(fn): The first value of the headername(any case).pub fn header(&self, name: &str) -> Option<&str>ocre::testing::Response::location(fn): TheLocationheader of a redirect.pub fn location(&self) -> Option<&str>ocre::testing::Response::json(fn): The body parsed as JSON (Rails’response.parsed_body).pub fn json<T: DeserializeOwned>(&self) -> Tocre::testing::Response::assert_status(fn): Asserts the status code (Rails’assert_response 201).pub fn assert_status(&self, status: u16) -> &Selfocre::testing::Response::assert_success(fn): Asserts a 2xx status (Rails’assert_response :success).pub fn assert_success(&self) -> &Selfocre::testing::Response::assert_redirect_to(fn): Asserts a 3xx redirect tolocation(Rails’assert_redirected_to).pub fn assert_redirect_to(&self, location: &str) -> &Selfocre::testing::Response::assert_contains(fn): Asserts the body containstext(HTML-escaped as templates escape it:'is').pub fn assert_contains(&self, text: &str) -> &Selfocre::testing::Response::assert_not_contains(fn): Asserts the body does not containtext.pub fn assert_not_contains(&self, text: &str) -> &Selfocre::testing::Response::assert_header(fn): Asserts the headernamehasvalue.pub fn assert_header(&self, name: &str, value: &str) -> &Self- Implements:
Clone,Debug
ocre::testing::TEST_LOG(const): Environment variable holding the path of the test server’s log.pub const TEST_LOG: &str = "OCRE_TEST_LOG"ocre::testing::TEST_STATE(const): Environment variable holding the local state directory of the test run (.wrangler/test-state).pub const TEST_STATE: &str = "OCRE_TEST_STATE"ocre::testing::TEST_URL(const): Environment variable holding the base URL of the serverocre test --e2estarted.pub const TEST_URL: &str = "OCRE_TEST_URL"
ocre::token
ocre::token::constant_time_eq(fn): Whetheraequalsb, compared in constant time for equal lengths.pub fn constant_time_eq(a: &[u8], b: &[u8]) -> boolocre::token::digest(fn): SHA-256 oftokenas 64 lowercase hex characters: the value to store and look up.pub fn digest(token: &str) -> Stringocre::token::generate(fn): A new random token:TOKEN_BYTESsecure random bytes as URL-safe base64 without padding (43 characters).pub fn generate() -> Stringocre::token::public_id(fn): A random id for URLs: 22 URL-safe characters (128 bits), the value of apublic_id:tokencolumn.pub fn public_id() -> Stringocre::token::TOKEN_BYTES(const): Random bytes in a token generated bygenerate: 32 (256 bits).pub const TOKEN_BYTES: usize = 32
ocre::webhooks
ocre::webhooks::once(fn): Runseffectfor the eventevent_idofsourceunless it already ran.pub async fn once<T, F, Fut>(db: &Db, source: &str, event_id: &str, payload: &[u8], effect: F) -> Result<Delivery<T>> where F: FnOnce() -> Fut, Fut: Future<Output = Result<T>>ocre::webhooks::post_signed(fn): POSTsbodyas JSON tourl, signed: the HMAC-SHA256 of the body withsecretinX-Signature(lowercase hex, seesign), andAuthorization: Bearer <bearer>when given (API keys of services such as RunPod).pub fn post_signed(url: &str, secret: &[u8], bearer: Option<&str>, body: &serde_json::Value) -> impl Future<Output = Result<Answer>> + Send + use<>ocre::webhooks::sign(fn): HMAC-SHA256 ofmessagewithsecret, as lowercase hex: the signature to send in a header (X-Signature) of an outgoing call.pub fn sign(secret: &[u8], message: &[u8]) -> Stringocre::webhooks::sign_standard(fn): Thewebhook-signatureheader value (v1,<base64>) of a Standard Webhooks delivery ofbodywith thisidandtimestamp(Unix seconds): whatverify_standardchecks.pub fn sign_standard(secret: &str, id: &str, timestamp: i64, body: &[u8]) -> Result<String>ocre::webhooks::verify(fn): Checks thatsignatureis the HMAC-SHA256 ofmessagewithsecret, in constant time.pub fn verify(secret: &[u8], message: &[u8], signature: &str) -> Result<()>ocre::webhooks::verify_standard(fn): Checks a Standard Webhooks delivery (Svix, Resend, and other providers):webhook-signatureholds one or more space-separatedv1,<base64>signatures of{id}.{timestamp}.{body}keyed with the base64 part of thewhsec_...secret, and thewebhook-timestampmust be withintoleranceseconds ofnow(replays of an old delivery are refused).pub fn verify_standard(secret: &str, headers: &axum::http::HeaderMap, body: &[u8], tolerance: i64, now: i64) -> Result<String>ocre::webhooks::Answer(struct): The answer ofpost_signed: status and body.pub struct Answerocre::webhooks::Answer::status(field): HTTP status.pub status: u16ocre::webhooks::Answer::text(field): Body, as text.pub text: String- Implements:
Clone,Debug,Eq,PartialEq
ocre::webhooks::Delivery(enum): Whatoncedid with a delivery.pub enum Delivery<T>ocre::webhooks::Delivery::Processed(variant): The effect ran (first delivery, or a retry after a failure) and returned this.Processed(T)ocre::webhooks::Delivery::Duplicate(variant): Already processed, or being processed by another invocation: nothing ran.Duplicate- Implements:
Clone,Debug,Eq,PartialEq
ocre::webhooks::STALE_AFTER(const): A delivery leftprocessingthis long (seconds) is taken over by the next one: the invocation that claimed it died before finishing.pub const STALE_AFTER: i64 = 300ocre::webhooks::TABLE_SQL(const): Thewebhook_eventstableoncerecords deliveries in, created by the migration ofocre g webhook.pub const TABLE_SQL: &str = "CREATE TABLE webhook_events (id INTEGER PRIMARY KEY, source TEXT NOT NULL, event_id TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 1, error TEXT, received_at INTEGER NOT NULL, processed_at INTEGER, UNIQUE (source, event_id))
Architecture
An Ocre app is one Rust crate compiled to WebAssembly and run as a single Cloudflare Worker, with ocre::serve wrapping a plain axum router. This page follows a request through the app, maps each Ocre feature to the Cloudflare product behind it, and explains the WebAssembly constraints that shape the framework.
Before you start
Nothing to install to read this page. The code shown comes from apps made with ocre new blog --starter blog and the generators named next to it; Installation and the tutorial get you one.
The pieces
| Piece | What it is | Where |
|---|---|---|
| Your app | A Rust crate with crate-type = ["cdylib"]: src/lib.rs holds the Worker entry points and routes(); the generators write models, controllers, templates and migrations next to it | your repository |
ocre | The framework crate: serve, Ctx, Db, Error, sessions, validations, and one module per Cloudflare product (jobs, storage, cache, mail, realtime…) | crates/ocre |
worker | Cloudflare’s workers-rs 0.8: Rust bindings to the Workers JavaScript runtime, the #[event(...)] entry-point macros, and the axum integration | dependency of both |
worker-build | Compiles the crate to wasm32-unknown-unknown, runs wasm-bindgen (and wasm-opt in release), and writes build/index.js plus build/index_bg.wasm | run by the build.command of wrangler.config.ts |
cf | Cloudflare’s CLI: cf dev runs the Worker locally in workerd, cf deploy uploads it, and its API commands create D1 databases, queues, buckets and secrets. It delegates the build and the local server to wrangler | the app’s package.json (npm install) |
| wrangler | Cloudflare’s previous CLI, pinned next to cf: cf runs it for builds, and Ocre for local D1 commands (see Why wrangler still appears) | the app’s package.json |
ocre CLI | Generators, plus commands that drive cf (ocre dev, ocre deploy, ocre migrate, ocre sql…) | crates/ocre-cli |
ocre dev and production run the same runtime, workerd: locally, cf dev (through the app’s wrangler) simulates D1 (SQLite files under .wrangler/state), Queues, R2, KV and Durable Objects, and ocre dev builds without optimizations (worker-build --dev) to compile faster.
A request, step by step
flowchart TD
B[Browser] --> E[Cloudflare edge]
E -->|path matches a file in public/| S[Workers Static Assets: file served, the Worker does not run]
E -->|otherwise| F["fetch entry point in src/lib.rs"]
F --> SV["ocre::serve(routes(), req, env)"]
SV --> H[Security headers] --> C[CORS, when ALLOWED_ORIGINS is set] --> X[CSRF check] --> SE[Session cookie] --> R["axum router: your handler"]
R --> O{R2 file stream?}
O -->|yes| W1["web_sys::Response around R2's stream"]
O -->|no| W2[axum response converted to a JavaScript Response]
-
Static files first.
wrangler.config.tsdeclaresassetsDirectory: "public". Cloudflare serves a request that matches a file inpublic/(/robots.txt, images, CSS) from Workers Static Assets, without invoking the Worker: no Worker request counted, no CPU used. -
The Worker’s
fetchevent. Every other request runs the entry pointocre newwrites intosrc/lib.rs:#[event(fetch)] async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> worker::Result<worker::web_sys::Response> { ocre::serve(routes(), req, env).await } -
ocre::serve. It builds aCtxfrom the Worker’s environment and gives it to the router as axum state, reads theSECRET_KEY_BASEsecret and theALLOWED_ORIGINSvariable, and wraps the router in the middleware every Ocre app runs, outermost first:Layer Does Costs Security headers Adds X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin,X-XSS-Protection: 0,X-Permitted-Cross-Domain-Policies: none, and HSTS on HTTPS; a handler’s own value winsnothing CORS Only when ALLOWED_ORIGINSlists origins: answers preflights and adds CORS headers for themnothing CSRF Refuses with 403 an unsafe request (or a WebSocket handshake) that a browser sends from another site, judged by Sec-Fetch-Site, elseOriginagainstHostnothing Session Decrypts the _ocre_sessioncookie on first use, re-encrypts it intoSet-Cookiewhen a handler changed itno D1 row, no KV operation A missing or short
SECRET_KEY_BASEdoes not break every request: only handlers that touch the session get a 500 whose log names the fix. The details of each layer are in Security model. -
Your handler. Handlers are plain axum handlers. They reach Cloudflare through
State(ctx): State<Ctx>:// src/stats.rs (register it with `mod stats;` and `.merge(stats::routes())` in src/lib.rs) use axum::{Router, extract::State, routing::get}; use ocre::{Ctx, Result, params}; use serde::Deserialize; #[derive(Deserialize)] struct Count { count: i64, } pub fn routes() -> Router<Ctx> { Router::new().route("/stats", get(stats)) } /// `GET /stats`: one D1 query through the `DB` binding, and a plain-text variable. async fn stats(State(ctx): State<Ctx>) -> Result<String> { let row: Option<Count> = ctx.db()?.first("SELECT COUNT(*) AS count FROM posts", params![]).await?; let sender = ctx.env().var("MAIL_FROM")?.to_string(); Ok(format!("{} posts; mail from {sender}", row.map_or(0, |row| row.count))) }ctx.db()is the D1 bindingDB;ctx.env()is the raw Workers environment, for variables, secrets and bindings Ocre does not wrap. A handler error is a response (an HTML error page, or JSON throughApiError); internal details are logged, never sent. -
The response.
serveturns the axum response into a JavaScriptResponsewithworker::response_to_wasm, except for files from R2 (next section).
Streaming files from R2
An axum body passes through Rust: each chunk is copied from JavaScript into WebAssembly memory and back out, which costs CPU on large files and loses Content-Length. ocre::storage::serve avoids that. It returns an axum response whose headers are set (type, length, ETag, Range, Content-Disposition) and puts R2’s ReadableStream in the response’s extensions; ocre::serve sees it and builds a web_sys::Response from those headers with R2’s stream as the body. The bytes go from R2 to the client inside the JavaScript runtime and never enter WebAssembly.
That is why a generated fetch returns worker::Result<worker::web_sys::Response> rather than an axum or worker::Response: serve needs to hand back a JavaScript response it built itself. See File storage.
Cloudflare products behind each feature
One Worker handles every kind of event: HTTP requests, queue batches, cron runs and incoming email. Each invocation has its own CPU budget (10 ms on the free plan). The bindings are declared in cloudflare.config.ts; the generator that first needs one adds it.
| Feature | Cloudflare product | Binding or config | Ocre API | Added by |
|---|---|---|---|---|
| Static files | Workers Static Assets | [assets] directory = "public" | none | ocre new |
| Models, migrations | D1 (SQLite) | DB: bindings.d1({ name }) | ctx.db(), Db::all / first / execute / batch, params! | ocre new |
| Sessions, flash | none: an encrypted cookie | SECRET_KEY_BASE secret | Session, Flash | ocre new (.dev.vars), ocre deploy |
| Background jobs | Queues | JOBS: bindings.queue({ name }), triggers.queue(...), a dead-letter queue | ocre::jobs::enqueue, enqueue_in; entry point consume | ocre g job |
| Scheduled tasks | Cron Triggers | [triggers] crons | entry point ocre::jobs::cron | ocre g schedule |
| Files | R2 | STORAGE: bindings.r2({ name }) | ocre::storage | first attachment field |
| Realtime | Durable Objects (WebSocket Hibernation) | CHANNELS: bindings.durableObject(...), OcreChannel: exports.durableObject(...) | ocre::realtime, the OcreChannel class (feature realtime) | ocre g scaffold ... --realtime |
| Cache | Workers KV | CACHE: bindings.kv() | ocre::cache | ocre g cache |
| Sending email | Resend’s HTTP API, or Email Service | MAIL_ADAPTER, MAIL_FROM; RESEND_API_KEY or EMAIL: bindings.sendEmail() | ocre::mail::send, deliver_later | ocre new (bindings.text), ocre g mailer |
| Receiving email | Email Routing | a routing rule in the dashboard | entry point ocre::mail::receive | ocre g mailbox |
The generators add the matching entry point to src/lib.rs; each hands the event and the environment to Ocre, which builds a Ctx and calls your code (from the fixture app of these docs, after ocre g mailbox, ocre g job and ocre g schedule):
/// Incoming email from Cloudflare Email Routing, handled in src/mailbox.rs.
#[worker::event(email)]
async fn email(message: worker::ForwardableEmailMessage, env: worker::Env, _ctx: worker::Context) -> worker::Result<()> {
ocre::mail::receive(message, env, mailbox::receive).await
}
/// Background jobs from the `JOBS` queue, run by `jobs::perform` (src/jobs/mod.rs).
#[worker::event(queue)]
async fn queue(batch: worker::MessageBatch<String>, env: worker::Env, _ctx: worker::Context) -> worker::Result<()> {
ocre::jobs::consume(batch, env, jobs::perform).await
}
/// Cron Triggers (`triggers.scheduled` in cloudflare.config.ts), run by `schedules::run` (src/schedules/mod.rs).
#[worker::event(scheduled)]
async fn scheduled(event: worker::ScheduledEvent, env: worker::Env, _ctx: worker::ScheduleContext) {
ocre::jobs::cron(event, env, schedules::run).await
}
The app’s Worker is both producer and consumer of its jobs queue, and the realtime Durable Object class OcreChannel ships inside the same WebAssembly module: there is one deployable unit. What each product costs on the free plan is in Cost model and Free-plan limits.
WebAssembly constraints
The Worker is built for wasm32-unknown-unknown and runs inside a V8 isolate. That rules out much of the usual Rust server stack:
- No threads, no filesystem, no sockets, no tokio. Crates that need them (sqlx, tokio, reqwest with native TLS) do not compile for this target. Outgoing HTTP goes through
worker::Fetch, or reqwest withdefault-features = false, whose WebAssembly build callsfetch; databases go through bindings. Futures are driven by JavaScript promises (wasm-bindgen-futures) on the isolate’s single thread. - No system clock.
std::time::SystemTime::now()panics on this target;ocre::now()reads JavaScript’sDate.now(), which Workers advance only between I/O operations. For the same reason, Ocre expires the session cookie with a fixed past date rather than computing one. - Randomness and crypto from WebCrypto. Random bytes (session nonces, salts, tokens) come from
crypto.getRandomValuesthroughgetrandom’sjsfeature. Password hashing callscrypto.subtle.deriveBits(PBKDF2), which runs natively in workerd: bcrypt or argon2 compiled to WebAssembly would not fit the free plan’s 10 ms of CPU. Sendwithout threads. axum requires handler futures to beSend, but JavaScript handles (the environment, a D1 database, a stream) are not. Ocre wraps each one inworker::send::SendWrapper(and futures inSendFuture), which is sound because a Worker runs your code on a single thread. As a result every Ocre type isSend, and a handler needs#[worker::send]only when it awaits a future from another crate that holds a JavaScript value (worker::Fetch, reqwest,JsFuture): see Futures that are not Send.- Size and start-up time. The release profile of a generated app uses
lto = true,codegen-units = 1andopt-level = "z", andworker-buildrunswasm-opt; symbols are kept because stripping breakswasm-bindgen. The blog starter’sindex_bg.wasmis 766,215 bytes (September 2026). Opt-in cargo features keep costly code out of apps that do not use it: GraphQL adds about 1.1 MB and 20-60 ms of CPU when an instance starts.
Ocre and the app also compile natively, for cargo test: code that calls the runtime is behind #[cfg(target_arch = "wasm32")] or only reachable through a Ctx, and ocre::password uses a pure-Rust PBKDF2 there. See Testing an Ocre app.
Crate layout
The framework crate keeps what cannot be generated: the parts that talk to the JavaScript runtime, and security-sensitive primitives.
crates/ocre/
src/
lib.rs serve, Ctx, Db, params!, Error, Json, Session, Validator... (re-exports)
protect.rs security headers, CORS, CSRF, session layer
session.rs encrypted cookie sessions and flash
api.rs JSON responses and errors, Page
validate.rs Validator
password.rs, token.rs, jwt.rs auth primitives
jobs.rs, storage.rs, cache.rs, mail.rs, realtime.rs, i18n.rs, graphql.rs
error.rs, sql.rs, fields.rs, clock.rs, view.rs, htmx.rs, ...
runtime/ everything that calls the Workers JavaScript runtime:
ctx.rs, d1.rs, jobs.rs, storage.rs, cache.rs, mail.rs,
realtime.rs (the OcreChannel Durable Object), crypto.rs, jwt.rs
tests/ unit tests, one file per source file
Code under runtime/ only runs inside workerd; it is tested end to end with generated apps (crates/ocre-cli/tests/system/e2e.rs), everything else by native unit tests.
| Cargo feature | Default | Enables |
|---|---|---|
html | yes | askama templates (render), HTML error pages, the Htmx extractor. API-only apps (ocre new --api) turn it off |
graphql | no | The graphql module (async-graphql); ocre g api ... --graphql turns it on |
realtime | no | The realtime module and the exported OcreChannel Durable Object class; ocre g scaffold ... --realtime turns it on |
See also
- Why generated code: what lives in your app rather than in the
ocrecrate - Security model: the middleware layers in detail
- Cost model and Free-plan limits
- Deployment: how
ocre deployprovisions each product - API index and the rustdoc reference
Why generated code
Ocre’s generators write models, controllers, templates and migrations into your app as plain Rust files that you own, instead of hiding them behind macros, derives or an ORM. This page explains why, how generators change files that already exist, what the approach costs, and how it compares with Rails and Loco.
Before you start
Nothing to install to read this page. The examples come from an app made with ocre new shop (and ocre new blog --starter blog for the Post model); every command shown was run with the ocre CLI.
What a generator writes
ocre g scaffold Product name:string price:float
create src/models/mod.rs
create migrations/0001_create_products.sql
create src/models/product.rs
create src/products.rs
create templates/products/index.html
create templates/products/show.html
create templates/products/new.html
create templates/products/edit.html
create templates/products/_form.html
update src/lib.rs
Next:
ocre migrate
ocre dev
open http://localhost:8787/products
That is 438 lines: the SQL migration (7), the model (160), the controller with its routes, path helpers and form parsing (188) and five askama templates (83). The model file holds the struct, the create and update inputs (NewProduct, ProductChanges), their validate(), every query, and empty callbacks (before_create, after_update…). For example, create in the blog starter’s src/models/post.rs:
pub async fn create(ctx: &Ctx, mut new: NewPost) -> Result<Post> {
before_create(ctx, &mut new).await?;
let db = ctx.db()?;
new.validate().finish()?;
let record: Post = db
.first("INSERT INTO posts (title, body, published) VALUES (?1, ?2, ?3) RETURNING *", params![new.title, new.body, new.published])
.await?
.ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))?;
after_create(ctx, &record).await?;
Ok(record)
}
Nothing is generated at compile time or at run time: what you read is what runs.
Why not macros, derives or an ORM
-
The code that runs is in the repository. An agent (or a person) that opens
src/models/post.rssees every SQL statement, validation rule and association of posts, in one file, without expanding a macro or reading framework internals. Agents write better changes when they can read the existing code for the same thing. -
Everything is greppable.
grep -rn "FROM posts" srcfinds every query on a table;ocre routeslists every route with its handler, read from the source. -
Every query is visible. On the free plan, D1 counts rows read and written per day and a request has 10 ms of CPU. A query hidden behind an ORM call or a lazy association is where N+1 queries come from; generated code shows the one query per call, and lists load their associations with
find_manyandpreload_<parents>(100 ids per query). -
Local changes stay local. A rule for one model is an edit in that model’s file, not a framework option. Adding a query is adding a function next to the generated ones:
// Added to src/models/post.rs, next to the generated `all` // (shown as its own module here, hence the `use` of `Post`). use ocre::{Ctx, Page, Result, params}; use crate::models::post::Post; /// Published posts, newest first. pub async fn published(ctx: &Ctx, page: Page) -> Result<Vec<Post>> { ctx.db()? .all( "SELECT * FROM posts WHERE published = 1 ORDER BY id DESC LIMIT ?1 OFFSET ?2", params![page.limit, page.offset], ) .await } -
Small binaries. There is no ORM in the WebAssembly module: no schema read at run time, no lazy loading, no identity map.
ocre::Query(whatpost::query().eq("published", true).order_desc("id")builds) only assembles one SQL string and its parameters, andto_statement()shows exactly what it will send.
Ocre still uses derives where the result is standard and stable: serde (Deserialize, Serialize) for rows and forms, askama’s Template for views. The framework’s own macros are params! (a list of SQL parameters) and locales! (translations compiled into the binary). What Ocre does not do is put application behavior (queries, validations, routes, callbacks) behind them.
How generators change existing files
Generators create new files and never overwrite one. Running a scaffold twice fails and writes nothing:
ocre g scaffold Product name:string price:float
error: src/products.rs already exists
hint: generators create new files only; edit the existing file, or add a migration with `ocre g migration`
To change a model after it exists, add a migration (ocre g migration add_sku_to_products sku:string?) and edit the model by hand: its struct, New<Model>, <Model>Changes, validate() and the SQL of create and update. ocre g scaffold and ocre g api reuse a model that already exists, so an HTML scaffold and a JSON API can share one.
Files that several generators extend (src/lib.rs, src/models/mod.rs, src/jobs/mod.rs…) carry marker comments. A generator inserts its line right after the marker, with the marker’s indentation, and leaves the rest of the file alone:
| Marker | File | Receives |
|---|---|---|
// ocre:modules | src/lib.rs | mod <name>; for each new module |
// ocre:routes | src/lib.rs, in routes() | .merge(<module>::routes()) |
// ocre:models | src/models/mod.rs | pub mod <model>; |
// ocre:associations | src/models/<model>.rs, in impl <Model> | has-many, has-one and has-many-through functions such as post.comments(&ctx, page), from a references field in another model |
// ocre:jobs, // ocre:job-variants, // ocre:job-dispatch | src/jobs/mod.rs | the job’s module, its Job variant and its perform arm |
// ocre:schedules, // ocre:schedule-dispatch | src/schedules/mod.rs | the task’s module and its cron arm |
// ocre:mailers | src/mailers/mod.rs | pub mod <mailer>; |
// ocre:channels | src/realtime.rs | a channel name anyone may open |
// ocre:graphql-queries, // ocre:graphql-mutations | src/graphql.rs | the resource’s query and mutation types |
Keep the markers when you edit these files. Without one, the generator stops, names the marker and where it belongs, and writes nothing: generators collect every change first and only write when all of them succeeded. With // ocre:routes removed from src/lib.rs:
ocre g scaffold Order total:float
error: src/lib.rs is missing the `// ocre:modules` or `// ocre:routes` marker
hint: put `// ocre:modules` on its own line where `mod` declarations go, and `// ocre:routes` inside the `Router::new()` chain
No orders file was created. Generators also add entries to cloudflare.config.ts (bindings such as JOBS, STORAGE, CACHE, CHANNELS) and turn on Cargo features in Cargo.toml (graphql, realtime) the first time a feature needs them.
Trade-offs
- Improvements to generators do not reach existing code. When a new Ocre version generates a better controller, files you generated earlier stay as they were; there is no
ocre updateor regeneration. To see what changed, generate the same resource in a scratch app (ocre new scratch --yes, then the sameocre g ...command) and compare it with yours. - Schema changes are manual. A migration that adds or removes a column does not update the model; the struct, the inputs,
validate()and the SQL are edited by hand (the generatedAGENTS.mdsays so to agents). - More code in the repository. A resource is a few hundred lines you review, own and keep consistent. Uniformity is a convention: generated files start alike, and your edits can make them diverge.
- No undo. There is no
ocre destroy; delete the files and the marker lines a generator added (thecreatedandupdatedlists of its output, or--json, name them).
What stays in the framework crate
The ocre crate keeps what must be right once and is the same in every app. These parts improve when you update the dependency (cargo update -p ocre for the default git dependency on https://github.com/tgeselle/ocre.rs), with no change to generated files:
- Everything that talks to the Workers JavaScript runtime: D1 (
Db), Queues (jobs), R2 (storage), KV (cache), Durable Objects (realtime), email sending and receiving (mail). - The middleware of
ocre::serve: encrypted cookie sessions, CSRF protection, CORS, security headers. - Security primitives:
password(PBKDF2 through WebCrypto),token(random tokens and digests),jwt(HS256). - Shared types and parsers:
Errorand its HTML/JSON rendering,Validator,Json,Page, multipart parsing, the translation file parser, ETag andCache-Controlhandling.
The line is the same one Rails 8 draws for authentication: ocre g auth writes users, sessions, password resets and API keys into the app, and calls ocre::password, ocre::token and ocre::jwt for the cryptography.
Compared with Rails and Loco
| Rails | Loco | Ocre | |
|---|---|---|---|
| Model | A class inheriting ApplicationRecord: columns come from the database schema at run time; queries are built by Active Record | SeaORM entities generated from the database schema into src/models/_entities/ (regenerated by cargo loco db entities), plus a model file for your code | One Rust file: struct, inputs, validations, callbacks and queries, never regenerated |
| Queries | Built at run time by Active Record | Built by SeaORM | ocre::Query chains in the model (one table, bound values), or SQL written out |
| After a schema change | Nothing to update in the model | Regenerate the entities | Edit the model |
All three generate controllers, views and migrations; the difference is the model. Rails also has rails destroy to remove what a generator wrote; Ocre has no equivalent.
Ocre generates more of the application than either, because on Cloudflare’s free plan every query and every kilobyte of WebAssembly counts, and because code an agent can read is code an agent can change safely.
See also
- Generators: every generator and what it writes, e.g.
ocre g scaffoldandocre g migration - Models and migrations: editing a generated model after a migration
- Architecture: what the
ocrecrate does at run time - Rails generators and Loco models
Security model
Ocre protects every app by default against session tampering, cross-site request forgery, clickjacking, MIME sniffing and leaked error details, and the code ocre g auth generates adds password, token, JWT and API-key handling built on small framework primitives. This page describes each protection as the code implements it, the reasons behind the choices, and what is left to you.
Before you start
Nothing to install to read this page. The framework parts are in the ocre crate (session.rs, protect.rs, security.rs and security/, password.rs, token.rs, jwt.rs, oauth.rs, storage.rs, realtime.rs, error.rs); the authentication parts exist in an app after ocre g auth (src/auth.rs, src/auth_api.rs, src/models/user.rs, src/models/auth_token.rs, src/models/api_key.rs, and with its options src/models/user_session.rs and src/oauth.rs). Everything depends on one secret, SECRET_KEY_BASE, which ocre new writes to .dev.vars and ocre deploy uploads once (see Configuration).
At a glance
| Threat | What Ocre does | Where | Your part |
|---|---|---|---|
| Reading or forging the session | Cookie encrypted and authenticated with AES-256-GCM | ocre::serve | Keep SECRET_KEY_BASE secret |
| Cross-site request forgery | Browsers’ cross-site unsafe requests get 403, without tokens | ocre::serve | Never change data in a GET handler |
| Cross-origin reads by other sites | No CORS headers unless ALLOWED_ORIGINS lists the origin | ocre::serve | List only origins you control |
| Clickjacking, MIME sniffing | X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff and more | ocre::serve | none |
| Requests for other host names | 403 for hosts not in ALLOWED_HOSTS, when set | ocre::serve | List your domains |
| XSS in pages | askama escapes {{ value }}; generated full-stack apps send a Content-Security-Policy without inline scripts | templates, src/lib.rs | Never mark user input |safe unless it went through ocre::security::sanitize |
| SQL injection | Values bind to ?1, ?2 placeholders through params![...]; Query takes column names as &'static str | Db, Query | Never build SQL with format! from input |
| Stolen database: passwords | PBKDF2-HMAC-SHA256 digests, 100,000 iterations | ocre::password | none |
| Stolen database: links and keys | Only SHA-256 digests of tokens are stored | ocre::token, auth models | Keep it that way for your own tokens |
| JWT algorithm confusion | Only HS256 is accepted, none included in the refusal | ocre::jwt | Keep TTLs short |
| Uploaded HTML or SVG running as the app | Served as application/octet-stream downloads | ocre::storage::serve | Check who may download |
| Cross-site WebSocket hijacking | Handshakes checked like forms | ocre::serve, src/realtime.rs | Authorize channels in connect |
| Leaking internals in errors | Internal messages are logged, clients see Internal server error | ocre::Error | Use Error::internal for unexpected failures |
| Open redirect after login | Only local paths are remembered and followed (ocre::security::url_from) | src/auth.rs | Use url_from for your own redirects |
| Account enumeration | Same answers whether an email has an account; a password hash runs either way | auth controllers, user.rs | none |
| Brute force, credential stuffing | 10 attempts a minute per IP address and action on every route that checks a password or sends an email | src/auth_api.rs (throttle), ocre::security::rate_limit | Keep the AUTH_RATE_LIMITER binding; limit your own costly routes |
SECRET_KEY_BASE: one secret, two keys
Both the session key and the JWT key are derived from the SECRET_KEY_BASE Worker secret, so an app has a single secret to create, upload and rotate:
- Session key: the cookie crate’s
Key::derive_from(HKDF-SHA256) turns the secret into the key of its private (AES-256-GCM) cookie jar. - JWT key: HMAC-SHA256 of the fixed label
ocre/jwt/hs256keyed with the secret, so it differs from the session key.
ocre secret generates 128 hexadecimal characters (512 bits). A value shorter than 64 characters is refused: handlers that use the session or a JWT answer 500, and the log names the fix; other routes keep working.
ocre deploy uploads a new secret only when the Worker has none, and never replaces an existing one. Rotating it yourself (a new ocre secret value uploaded with ocre secrets push SECRET_KEY_BASE --file .prod.vars) signs every user out and invalidates every JWT at once, which is what you want after a leak; API keys and emailed links, stored as digests in D1, keep working. For a planned rotation, put the old value in the SECRET_KEY_BASE_PREVIOUS secret first: keys derived from it still decrypt cookies (which are re-encrypted with the new key on the same response) and verify JWTs, but never encrypt or sign anything new. Anyone who learns the secret can forge a session for any user id and mint JWTs: rotate it, without SECRET_KEY_BASE_PREVIOUS, if it leaks.
Sessions
The session is a JSON object stored in the _ocre_session cookie, encrypted and authenticated with AES-256-GCM, like Rails’ default cookie store. The browser can neither read nor change it; a cookie that does not decrypt (tampered, or made with a key that is neither SECRET_KEY_BASE nor listed in SECRET_KEY_BASE_PREVIOUS) starts an empty session. Nothing is stored on the server: sessions cost no D1 row and no KV operation.
The cookie is sent with HttpOnly, SameSite=Lax, Path=/, and Secure on HTTPS requests. By default it has no Expires or Max-Age, so browsers treat it as a session cookie; session.remember_for(seconds) adds Max-Age (“remember me”). A session that would exceed 4,096 bytes fails the request with a 500 whose log says to store ids rather than records.
What a cookie store implies:
- Revocation needs an expiry or D1. Logging out clears the browser’s cookie, but a copy taken earlier still decrypts.
session.expire_in(seconds)andremember_forstore an expiry inside the encrypted cookie, checked on every request, so a copy stops working after it;ocre g authsigns users in for two weeks. To end one session, or all of a user’s sessions, before that,ocre g auth --db-sessionskeeps a row per session in D1 and the cookie holds only a random token whose digest finds it: deleting the row signs that device out on its next request. - No fixation.
auth::sign_inempties the session before storinguser_id(pending flash messages stay), so nothing from before the login carries over, and there is no server-side session id to fix in advance. - Ids only. The generated auth code stores
user_id(or the session token) and, while redirecting to the login page, the page to return to. Put ids in the session, never records, balances, nonces or secrets: an old copy of the cookie could be replayed until it expires.
Cross-site request forgery without tokens
Forms carry no CSRF token. Instead, Ocre refuses unsafe requests that a browser sends on behalf of another site, using the headers browsers add to every request. This is the check Go 1.25 ships as http.CrossOriginProtection. For each request, in order:
GET,HEADandOPTIONSpass, except WebSocket handshakes (Upgrade: websocket), which are checked like forms.- An
Originlisted inALLOWED_ORIGINSpasses. Sec-Fetch-Site: same-originornone(typed in the address bar, a bookmark) passes; any other value,same-siteincluded, gets 403Forbidden: cross-site request. Add the origin to ALLOWED_ORIGINS to allow it.- Without
Sec-Fetch-Site(older browsers): when bothOriginandHostare present, the host inOriginmust equalHost, else 403Forbidden: cross-origin request. Add the origin to ALLOWED_ORIGINS to allow it. - A request with neither header does not come from a browser page (curl, a mobile app, a server) and passes: it cannot carry a victim’s cookies by accident.
SameSite=Lax on the session cookie is a second layer: browsers do not send it with cross-site POSTs at all.
Consequences:
- A
GEThandler must never change data: it is not checked. - Subdomains are other sites here: a form on
admin.example.composting toexample.comissame-site, notsame-origin, and gets 403 unless listed inALLOWED_ORIGINS. - JSON clients authenticated with a Bearer token are not affected (they are not browsers, or send no cookie).
CORS
Without ALLOWED_ORIGINS, ocre::serve adds no CORS layer: browsers let other sites send requests but not read the answers, and the CSRF check refuses their unsafe ones. With it (a comma-separated bindings.text variable such as "https://app.example.com, https://admin.example.com"), the listed origins get CORS headers for GET, POST, PUT, PATCH and DELETE, with the Content-Type, Authorization and Accept request headers, and credentials allowed. Credentials mean cookies: a listed origin can act as the signed-in user, and it also passes the CSRF check. List only frontends you control.
Security headers
Every response gets these headers unless the handler set its own value:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | SAMEORIGIN |
Referrer-Policy | strict-origin-when-cross-origin |
X-XSS-Protection | 0 (turns off the obsolete browser filter) |
X-Permitted-Cross-Domain-Policies | none |
Strict-Transport-Security | max-age=63072000 (two years), on HTTPS requests only |
Full-stack apps made by ocre new also add a Content-Security-Policy (scripts only from the app and unpkg.com, no inline scripts or on*= handlers, object-src 'none', frame-ancestors 'self') and a Permissions-Policy (camera, microphone, geolocation, payment and USB off) as layers in src/lib.rs, built with ocre::security::ContentSecurityPolicy and PermissionsPolicy; a handler’s own header or a nested router’s layer overrides them. API-only apps get neither, since they serve no HTML.
Host authorization
When the ALLOWED_HOSTS variable is set ("example.com, .example.com"), a request whose Host is not listed gets 403 Forbidden: blocked host before the session, the CSRF check or any handler runs; a leading . allows subdomains, and localhost, 127.0.0.1 and [::1] always pass for ocre dev. DNS rebinding cannot reach a Worker (Cloudflare routes only your own host names to it), so the point on Workers is to answer only on your domain and not on workers.dev or preview URLs.
Passwords
ocre::password::hash produces pbkdf2_sha256$100000$<salt>$<hash>: PBKDF2-HMAC-SHA256 with 100,000 iterations, a 16-byte random salt and a 32-byte hash (base64). verify recomputes it with the digest’s own salt and count and compares in constant time.
Why PBKDF2: on the free plan a request has 10 ms of CPU. bcrypt or argon2 compiled to WebAssembly would not fit; WebCrypto’s crypto.subtle.deriveBits runs PBKDF2 natively in workerd, outside the WebAssembly module. One hash was measured at 5.5 ms of CPU. Workers cap PBKDF2 at 100,000 iterations, below OWASP’s 600,000 recommendation for this algorithm; the count is stored in each digest, so it can grow later (ocre::password::iterations(digest) reads it back).
In the generated auth code, passwords must be 8 to 128 characters, emails are trimmed and lowercased, User never serializes password_digest, and a login with an unknown email runs a hash anyway so response times do not reveal which emails have accounts. Errors name the failed step, never the password.
Emailed tokens: password reset and magic links
ocre::token::generate()returns 32 random bytes (256 bits) as 43 URL-safe characters.- The
auth_tokenstable keeps onlyocre::token::digest(token), its SHA-256 in hex: a leaked database holds no working link. A fast hash is enough for long random values. - A token is valid 15 minutes and works once: it is consumed by a single
DELETE ... RETURNING, so two concurrent uses cannot both succeed. Issuing a new link cancels the user’s previous one of the same purpose. - The emailed link opens a page with a button that POSTs, so mail scanners that follow links do not use it up.
- The request forms answer the same (“If an account exists for that email, …”) whether or not the email has an account.
- Links use the request’s origin; Cloudflare routes requests by host name, so it is one of the app’s own hosts.
JWT
ocre::jwt issues and checks HS256 tokens with sub (the user id), iat and exp; ocre g auth issues them from POST /api/auth/token with a one-hour lifetime. decode checks the shape, then that the header’s alg is exactly HS256 (a token naming none or any other algorithm is refused before its signature is looked at), then the signature in constant time, then exp against the current time. Every failure is the same 401, without saying which check failed.
JWTs cannot be revoked before they expire; BearerUser looks the user up on every request, so a deleted user’s tokens stop working. For long-lived access, use API keys.
API keys
POST /api/auth/keys creates a key with ocre::token::generate() and returns it once; the api_keys table stores its SHA-256 digest (unique index), with user_id, name and last_used_at. BearerUser treats a Bearer value containing a dot as a JWT and anything else as an API key, looked up by digest. Keys are revoked with DELETE /api/auth/keys/{id}, which only deletes the caller’s own keys. last_used_at is written at most once an hour, to save D1 writes.
Authorization is your code
Ocre authenticates; deciding what a user may see is application code. CurrentUser (HTML) redirects visitors to /login and brings them back afterwards (GET requests only, local paths only: //evil.example and https://... are ignored). BearerUser (JSON) answers 401. Filter every query by the owner:
// src/notes.rs: a page only its owner may see. After `ocre g auth`;
// assumes a `notes` table with `id`, `user_id` and `body` columns.
use axum::{
Router,
extract::{Path, State},
routing::get,
};
use ocre::{Ctx, OptionExt, Result, params};
use serde::Deserialize;
use crate::auth::CurrentUser;
#[derive(Deserialize)]
struct Note {
id: i64,
body: String,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/notes/{id}", get(show))
}
/// Another user's note is a 404, like a missing one: its existence is not revealed.
async fn show(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser, Path(id): Path<i64>) -> Result<String> {
let note: Note = ctx
.db()?
.first("SELECT id, body FROM notes WHERE id = ?1 AND user_id = ?2", params![id, user.id])
.await?
.or_404()?;
Ok(format!("Note {}: {}", note.id, note.body))
}
Error::Forbidden (403) is there for checks that should say “not allowed” rather than “not found”. There are no roles or permissions.
Files
- Keys. Objects are stored under
<prefix>/<22 random characters>(128 bits), never derived from the file name, and never reused. - Names. The uploader’s file name loses its directories and control characters and is cut to 200 characters; it is only used in
Content-Disposition(ASCIIfilename=plus UTF-8filename*=, RFC 6266). - Types. Uploads are checked against the model’s exact allowlist (
Rules::content_types, no wildcards:image/*would admit SVG). The type comes from the browser; nothing sniffs file contents. - Serving.
storage::serveshows inline only types that cannot run scripts (raster images, PDF, plain text, audio, video). HTML, SVG, XML and JavaScript are sent asapplication/octet-streamattachments, and every other type as an attachment, so an uploaded file never runs in the app’s origin. Responses carryCache-Control: private, no-cacheandnosniff. - Access. A file route is as protected as its handler: check that the user may see the record before calling
serve.
Realtime
A WebSocket handshake is a GET, but browsers send cookies with it and let any site open one, so Ocre checks it like a form: a handshake from another site gets 403. In the app, src/realtime.rs routes GET /realtime/{channel} to connect, which lists the channels that may be opened (others are 404) and can require a user (CurrentUser works, since the session cookie comes with the handshake). Channel names must be 1 to 128 ASCII letters, digits, _, -, . or : (400 otherwise). Everyone connected to a channel receives every broadcast, so private data needs one channel per user or record. What clients send is ignored, and broadcasts are HTML: render them with askama so they are escaped.
Errors
Error::Internal (and any worker::Error converted with ?) is logged as [ocre] <message> and answered as Internal server error, in HTML pages, JSON ({"error": {"status": 500, "message": "Internal server error"}}) and GraphQL alike. A failed D1 query logs D1’s error and the SQL with its ?N placeholders; Ocre does not add the bound values. Client errors (400, 404, 413, 422, 429) show their message, which you choose. To log request parameters yourself, pass them through ocre::security::filter_parameters or filter_json, which replace passwords, tokens, keys and similar values with [FILTERED].
OAuth sign-in
ocre g auth --oauth github,google uses the authorization code flow with PKCE (ocre::oauth). The random state and the PKCE verifier are kept in the encrypted session between the redirect and the callback; the callback compares the state in constant time, so another site cannot sign a visitor in to the attacker’s account (login CSRF). The client secret stays in a Worker secret and is only sent from the Worker to the provider’s token endpoint. An existing account is linked only through an email address the provider reports as verified, and a new account created that way has no password.
What Ocre does not do yet
- Sign out everywhere needs
ocre g auth --db-sessions; cookie sessions end at logout, at their expiry (expire_in) or whenSECRET_KEY_BASEchanges. - Roles and permissions. Authorization is the ownership checks you write.
- Two-factor authentication and account lockout. Rate limiting slows guessing; it does not lock an account.
- Encrypted or signed cookies other than the session. Put such values in the session.
See also
- Sessions, flash and security: using sessions, flash,
ALLOWED_ORIGINSin an app - Authentication: what
ocre g authgenerates and how to use it - File storage and Realtime
- Deployment: secrets in production and rate limiting rules
- Configuration:
SECRET_KEY_BASE,ALLOWED_ORIGINS - Rustdoc:
ocre::password,ocre::token,ocre::jwt
Cost model
This page explains how an Ocre app spends the Cloudflare Workers Free plan: what counts as a request, where the 10 ms of CPU per request go, what each generated query costs in D1 rows, why KV writes are the scarcest resource, what a background job costs in Queues operations, and when a growing app should move to Workers Paid.
Before you start
- This is an explanation, not a task: read it when designing a feature or when a limit is getting close. The exact limits, with their sources, are on Free-plan limits (values of September 2026).
- It assumes an Ocre app from
ocre new, with the code the generators write:ocre g scaffoldfor pages,ocre g jobfor jobs,ocre g cachefor KV,--realtimefor WebSockets. - Measured numbers come from the Ocre README (production
wrangler tailon the free plan, andwrangler devon an Apple M5 Max). Everything else is computed from Cloudflare’s published rules and marked as an estimate.
The daily budgets
| Budget | Free plan (September 2026) | Spent by |
|---|---|---|
| Worker requests | 100,000 a day per account | Every HTTP request that is not a static file, every WebSocket connection |
| CPU | 10 ms per invocation | Rust code running in WebAssembly, JSON and HTML rendering, crypto |
| D1 rows read | 5,000,000 a day | Rows scanned by queries |
| D1 rows written | 100,000 a day | Inserted, updated and deleted rows, plus one per index touched |
| KV reads / writes | 100,000 / 1,000 a day | ocre::cache |
| Queues operations | 10,000 a day | Background jobs: 3 per job |
| Durable Object requests | 100,000 a day | Realtime connections and broadcasts |
| R2 | 10 GB, 1M uploads, 10M downloads a month | attachment fields |
Daily budgets reset at 00:00 UTC and are shared by every Worker of the account.
What counts as a request
A Worker request is one invocation of the app’s fetch entry point. In an Ocre app:
| Traffic | Worker requests | Why |
|---|---|---|
A file in public/ (CSS, images, robots.txt) | 0 | Workers Static Assets serves it before the Worker runs; asset requests are free and unlimited (billing) |
An HTML page, a JSON call, GET /up | 1 | |
| A form submission in a scaffold | 2 | POST /posts answers with a redirect, and the browser then loads GET /posts/{id} |
A 304 Not Modified from Conditional::fresh_when | 1 | The Worker still runs (and queries D1); it only skips rendering |
A response served by Workers Cache ([cache] enabled = true) | 1 | Cache hits use no CPU but still count, and enabling the cache also makes static asset requests count (pricing); Ocre does not enable it |
| A realtime WebSocket | 1 per connection or reconnection | Messages over an open WebSocket are not Worker requests (pricing) |
| A queue consumer batch, a cron run, an incoming email | 1 invocation each | Each has its own 10 ms of CPU. Cloudflare’s pricing page does not say whether they count toward the 100,000 requests; budget them as if they do |
Subrequests (D1 queries, KV and R2 calls, fetch, broadcasts to a Durable Object) are not billed as requests, but a free invocation may make at most 50 subrequests and 50 D1 queries.
CPU per request
CPU time is the time the Worker spends computing. Waiting for D1, KV, R2 or fetch does not count, so a handler that runs three queries and renders a template stays well under 10 ms. Cloudflare tolerates occasional overruns per isolate and ends requests with error 1102 only when they become frequent (CPU time).
Measured on the blog starter (release build, production, free plan, wrangler tail, 55 requests; README “Measured”):
| Route | CPU median | CPU max | Wall median |
|---|---|---|---|
GET / (list, 1 D1 query) | 2 ms | 23 ms (1 of 40, likely a new isolate) | 18 ms |
GET /posts/:id | 2 ms | 4 ms | 16.5 ms |
POST /posts (insert) | 3 ms | 6 ms | 28 ms |
Other measured costs:
| Work | CPU | Where measured |
|---|---|---|
| Worker startup (blog starter, 420 KB of WebAssembly) | 4 ms | README “Measured” |
| One password hash (PBKDF2-HMAC-SHA256, 100,000 iterations, WebCrypto) | 5.5 ms | wrangler dev, 100 hashes in 550 ms |
A login request (one hash) against GET /up | 9.5 ms against 3.5 ms | wrangler dev |
GraphQL schema at each new Worker instance (--graphql) | 20 to 60 ms | wrangler tail |
| Splitting a 10 MB multipart upload / copying it to R2 | 1.2 ms / 0.15 ms | V8, WebAssembly |
What follows from these numbers:
- Ordinary pages use a fifth to a third of the budget. The rest is headroom for templates and validation, not for heavy computation.
- A password hash takes about half the budget, so a request must never hash twice. Sign-up, login and password changes are the only requests that hash; API keys are checked with SHA-256, which costs almost nothing. bcrypt or argon2 compiled to WebAssembly would not fit.
- GraphQL’s startup cost exceeds the budget on every new instance. That is tolerated when it is infrequent, which is why
--graphqlis opt-in. - Downloads from R2 are streamed by the runtime and cost almost no CPU; uploads are read into memory and split, about 1.2 ms per 10 MB.
- Work that does not fit goes into a job: each consumer batch is a separate invocation with its own 10 ms, and a cron task should only query ids and enqueue one job per item.
D1 rows
D1 bills rows scanned, not rows returned, and every write to an indexed column writes the index too (D1 pricing). The queries a generated model runs (src/models/<model>.rs):
| Function | SQL | Rows (estimate from D1’s rules) |
|---|---|---|
all(ctx, page) | SELECT * FROM posts ORDER BY id DESC LIMIT ?1 OFFSET ?2 | The rows SQLite walks along the primary key: up to limit + offset. Deep pages cost more than the first one. |
count(ctx) | SELECT COUNT(*) AS count FROM posts | Scans the table: grows with the table. Scaffold pages do not call it. |
find(ctx, id) | SELECT * FROM posts WHERE id = ?1 | 1 read |
find_many(ctx, &ids) | SELECT * FROM posts WHERE id IN (?1, ...), 100 ids per query | 1 read per id found, one query per 100 ids |
create | INSERT ... RETURNING *, after one SELECT 1 ... LIMIT 1 per unique field and per reference | 1 row written plus 1 per index on the table; 1 read per check (indexed) |
update | UPDATE ... RETURNING * with the same checks | 1 row written plus 1 per index whose column is written |
delete | DELETE ... RETURNING * | 1 row written, plus the rows deleted by ON DELETE CASCADE and their indexes |
post.comments(ctx, page) (association) | WHERE post_id = ?1 on an indexed column | the matching comments, up to the page limit |
Every references column gets an index and every unique field a unique index, so lookups by them read only the matching rows. A WHERE on another column scans the table: add an index in a migration (CREATE INDEX ...) when a column is filtered often, and accept that each write then costs one more row.
The most common way to waste rows and queries is N+1: loading a list, then one record per item. Listing 50 comments with comment.post(&ctx) in a loop runs 51 queries, and a free invocation may run only 50. find_many loads the posts in one query:
// src/recent_comments.rs
use std::collections::HashMap;
use axum::{Router, extract::State, routing::get};
use ocre::{ApiResult, Ctx, Json, Page};
use serde::Serialize;
use crate::models::{comment, post};
#[derive(Serialize)]
pub struct RecentComment {
author: String,
body: String,
post_title: Option<String>,
}
pub fn routes() -> Router<Ctx> {
Router::new().route("/api/recent_comments", get(recent))
}
/// Two queries whatever the page size: the comments, then their posts.
async fn recent(State(ctx): State<Ctx>, page: Page) -> ApiResult<Json<Vec<RecentComment>>> {
let comments = comment::all(&ctx, page).await?;
let mut ids: Vec<i64> = comments.iter().map(|comment| comment.post_id).collect();
ids.sort_unstable();
ids.dedup();
let posts: HashMap<i64, post::Post> =
post::find_many(&ctx, &ids).await?.into_iter().map(|post| (post.id, post)).collect();
let recent = comments
.into_iter()
.map(|comment| RecentComment {
post_title: posts.get(&comment.post_id).map(|post| post.title.clone()),
author: comment.author,
body: comment.body,
})
.collect();
Ok(Json(recent))
}
A SQL JOIN through ctx.db()?.all::<T>(..) is the other option; see Avoid N+1 queries.
Sessions cost no rows: they live in an encrypted cookie. API keys write last_used_at at most once an hour for the same reason.
KV writes, the scarcest resource
Workers KV allows 100,000 reads but only 1,000 writes and 1,000 deletes a day on the free plan. With ocre::cache::fetch(&ctx, key, ttl, compute):
- every call is one read;
- every miss (first call, or after the TTL expired) runs
computeand is one write; - every
ocre::cache::delete(after changing the data behind a key) is one delete, and everywriteone write.
A key that is read often is rewritten each time its TTL expires, so it costs up to 86,400 / ttl writes a day: 24 for a one-hour TTL, 1,440 for the one-minute minimum, which alone is more than the daily budget. With one-hour TTLs, about 40 hot keys fit. Keys per user or per record multiply this: one key per visitor is a write per visitor.
Past the limit, fetch keeps working: it logs [ocre cache] ... failed and computes the value every time, so the app gets slower rather than broken. For whole pages, HTTP caching is free: Conditional::fresh_when answers 304 without rendering, and CacheControl lets browsers keep responses. See Caching.
Queues operations per job
A job is one queue message. Cloudflare counts operations per message (Queues pricing):
| Event | Operations |
|---|---|
enqueue (write), delivery (read), acknowledgement (delete) | 3 |
Each retry after an Err from perform | +1 read |
| Moving to the dead-letter queue after the 5th retry | +1 write |
| Each 64 KB of a message beyond the first | the counts above again |
With 10,000 operations a day, that is about 3,300 jobs a day when none fails. A job that fails every time costs 3 + 5 retries + 1 dead-letter write = 9 operations. Keep messages small (ids, not records) so each stays in one 64 KB unit; Ocre refuses messages over 128 KB. ocre::mail::deliver_later is one job per email.
Consumer runs take up to 10 messages (maxBatchSize: 10, waiting at most 5 s), and each run is one invocation with 10 ms of CPU for the whole batch. Jobs should be I/O (D1, mail, fetch); lower maxBatchSize in cloudflare.config.ts for CPU-heavy ones.
Realtime and Durable Objects
Each realtime channel is one OcreChannel Durable Object using the WebSocket Hibernation API:
| Event | Worker requests | Durable Object requests |
|---|---|---|
| A browser opens (or reopens) the page’s WebSocket | 1 | 1 |
ocre::realtime::broadcast to a channel | 0 (a subrequest of the request that broadcasts) | 1, whatever the number of browsers |
| A message to each browser | 0 | 0 (outgoing messages are free) |
Between broadcasts, hibernated sockets cost no duration. Broadcast once per change, not once per row in a loop. The free plan allows 100,000 Durable Object requests and 13,000 GB-s of duration a day (pricing).
A worked daily budget
A small blog on the free plan, with comments, accounts, a cached statistic, email and a live comment list. Assumed traffic for one day, and the cost computed with the rules above (estimates, not measurements):
| Activity | Worker requests | D1 reads | D1 writes | Other |
|---|---|---|---|---|
| 1,000 views of the post list (50 per page) | 1,000 | 50,000 | CSS and images from public/: free | |
2,000 views of a post (find) | 2,000 | 2,000 | ||
100 comments posted (POST + redirect; reference check; insert into comments, which has the post_id index) | 200 | 200 | 200 | |
20 sign-ups (one password hash each; users has a unique email) | 40 | 20 | 40 | your code queues 20 welcome emails with deliver_later |
30 password resets (the generated src/passwords.rs sends the email directly with mail::send) | 60 | 60 | 60 | 30 emails on Resend |
| 20 welcome-email jobs in consumer batches | up to 20 | 20 | 60 Queues operations; 20 emails on Resend | |
| 1 nightly cron | 1 | a few | ||
| Home page statistic in KV, one-hour TTL, on the list page | 24 recomputations | 1,000 KV reads, 24 KV writes | ||
| 300 realtime connections, 100 broadcasts | 300 | 400 Durable Object requests | ||
| Total | about 3,600 of 100,000 | about 52,300 of 5,000,000 | about 300 of 100,000 | KV writes 24 of 1,000; Queues 60 of 10,000 |
Almost every budget is below 5%. The one that is not is email: 50 emails is half of Resend’s 100 a day. Traffic can grow about twenty-fold before Worker requests run out, while a campaign that makes 100 people sign up in a day already uses the whole email quota. The CPU budget is per request, so it does not grow with traffic: 2 to 3 ms for pages and about 5.5 ms more for the requests that hash a password.
To see real numbers for your app, use the Cloudflare dashboard: Workers & Pages > your Worker > Metrics (requests, CPU time, errors), and Storage & databases > D1 > your database > Metrics (rows read and written).
When to move to Workers Paid
Workers Paid costs a minimum of $5 a month per account and raises every budget at once (Workers pricing, September 2026):
| Resource | Free | Paid (included, then pay as you go) |
|---|---|---|
| Worker requests | 100,000 a day | 10 million a month, then $0.30 per million |
| CPU | 10 ms per invocation | 30 million CPU-ms a month, then $0.02 per million; 30 s per invocation by default, up to 5 minutes |
| Subrequests | 50 per invocation | 10,000 per invocation |
| D1 | 5 million reads, 100,000 writes a day, 5 GB | 25 billion reads, 50 million writes a month, 5 GB; then usage pricing |
| KV | 100,000 reads, 1,000 writes a day, 1 GB | 10 million reads, 1 million writes a month, 1 GB; then usage pricing |
| Queues | 10,000 operations a day, 24-hour retention | 1 million operations a month, then $0.40 per million; retention 4 days by default, up to 14 |
| Durable Objects | 100,000 requests, 13,000 GB-s a day | 1 million requests, 400,000 GB-s a month; then usage pricing |
| Cron Triggers | 5 per account | 250 per account |
| Email Service sending | verified addresses only | any recipient, 3,000 a month included, then $0.35 per 1,000 |
Move when one of these happens, not before:
- The daily request limit is reached (visitors get error 1027), or is regularly above half.
- Requests fail with error 1102 because a route needs more CPU than the free plan tolerates (large imports, image processing, many password hashes).
[ocre cache] ... failedlines appear every day: KV writes run out.- The app needs more than about 3,000 jobs a day, messages kept longer than 24 hours, or more than 5 Cron Triggers in the account.
- The app must send email to any recipient through Cloudflare (
MAIL_ADAPTER = "cloudflare"); with Resend, its own paid plans are the alternative.
The generated app runs unchanged on Workers Paid: no Ocre setting depends on the plan. Only the defaults written for the free plan (max_batch_size = 10, one-hour cache TTLs, 10 MB attachment limits) become worth revisiting.
See also
- Free-plan limits: every limit with its source.
- Architecture: how a request flows through
ocre::serve. - Caching, Background jobs and schedules, Realtime, Authentication.
- Configuration: the
cloudflare.config.tssettings mentioned here.