Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PartRead it whenPages
Getting startedYou are new: install the tools, then build and deploy a live Q&A app step by stepInstallation, Tutorial
GuidesYou need to do one thing (add a model, send email, run a job…)One page per domain, from Models to Deployment
ReferenceYou need exact facts: every command and flag, field types, configuration keys, limitsCLI, Generators, Field types, Configuration, Limits, API index
ExplanationsYou want to know why Ocre works the way it doesArchitecture, 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 .md to its URL (/guides/models is /guides/models.md).
  • /llms.txt lists every page with a one-line description; /llms-full.txt is 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.md with the conventions, commands and free-plan limits of that app; read it first, then these docs for details.
  • With --json, every ocre command 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 git if you want ocre new --git or 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):

QuestionAnswersFlag 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 nameocre 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 starterEmpty (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).

FlagEffectDefault without prompts
<name>App name and directory (./<name>)required
--api / --full-stackAPI only (JSON, no templates, Ocre’s html feature off), or HTML pagesfull-stack
--starter empty|blogblog adds a Post resource (title, body, published)empty
--login / --no-loginLog in to Cloudflare if needed (opens a browser)no login
--account-id <id>Account to deploy to; required when the login has severalnone
--git / --no-gitRun git initno git
--deploy / --no-deployDeploy right away (implies --login)no deploy
--yes, -yNever prompt, even in a terminal
--ocre-path <dir>Depend on a local crates/ocre checkout instead of the Git repositoryGit dependency
--no-installSkip npm install in the new app (offline); run it yourself before ocre dev. ocre doctor reports it until you doinstall
--jsonPrint 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

FileContents
Cargo.tomlThe app crate (cdylib), depending on ocre, worker, axum, askama (full-stack only) and serde; a standalone [workspace]; release profile tuned for size
cloudflare.config.tsThe 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.tsThe build command (installs worker-build and compiles to WebAssembly) and public/ as static assets
package.json, package-lock.jsonThe pinned cf, wrangler and typescript, installed in node_modules/ by npm install; commit both files
tsconfig.jsonType checking of the two .ts files, for your editor and npx tsc -p .
rust-toolchain.tomlStable Rust with the wasm32-unknown-unknown target
.gitignoreBuild output, node_modules/, .wrangler/ (local database), .cloudflare/, .dev.vars, .prod.vars and .env*
AGENTS.mdConventions, commands and free-plan limits for AI agents working on the app
migrations/D1 SQL migrations, applied in order (empty for now)
public/robots.txtStatic files, served by Cloudflare before the Worker runs
src/lib.rsThe Worker entry point and router: GET / (home page) and GET /up (health check)
templates/layout.html, templates/home.htmlaskama templates: the page layout (with htmx) and the home page
.dev.varsLocal-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

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-unknown target, Node.js 22 or newer, and the ocre CLI (ocre --version prints ocre 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 at http://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:token gives each event 22 random URL-safe characters, set by create, 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:references adds user_id with a foreign key to users, event.user(&ctx) on the event and user.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:

FileWhat changed
Cargo.tomlOcre’s realtime feature
cloudflare.config.tsThe 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.rsGET /realtime/{channel}, where browsers connect, and connect, which decides who may listen to which channel
templates/layout.htmlhtmx’s WebSocket extension, after htmx
src/questions.rsAfter 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, broadcast loads the event’s questions in their room order and sends the whole list, rendered by templates/questions/_list.html, wrapped by realtime::update("questions", ...): htmx replaces the contents of #questions in 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> and host:<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. answer and delete take CurrentUser (visitors are sent to /login) and hosted answers 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&amp;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):

  1. In cloudflare.config.ts, in worker.env, uncomment MAIL_ADAPTER: bindings.text("resend"), and set MAIL_FROM to an address on a domain verified in Resend, for example MAIL_FROM: bindings.text("Live Q&A <noreply@yourdomain.com>"),.
  2. Store the API key as a secret: put RESEND_API_KEY=<key> in .prod.vars (git-ignored), then run ocre secrets push RESEND_API_KEY --file .prod.vars. A Worker must exist before it can have secrets, so run this after the first ocre deploy if the Worker is new.

See Email for the cloudflare adapter and Configuration for every variable.

ocre deploy

ocre deploy

In order, ocre deploy:

  1. Checks the locale files (when the app has translations) and the wasm32-unknown-unknown target, as ocre dev does.
  2. Looks up the D1 database named in cloudflare.config.ts (qa) with cf d1 list and creates it the first time.
  3. Creates the other Cloudflare resources cloudflare.config.ts names that are missing: queues, R2 buckets, KV namespaces without an id. This app uses none of them; the Durable Object namespace of the realtime channels is created by the deploy itself, from the OcreChannel export.
  4. Asks cf workers secrets list whether the Worker has SECRET_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.
  5. Applies the pending migrations to the production database (cf d1 migrations apply), before the new code goes live.
  6. Deploys with cf deploy --secrets-file, which builds the Worker in release mode (optimized for size, slower to compile than ocre dev) and uploads it. The secrets file (.wrangler/ocre-secrets.json, deleted afterwards) holds the new SECRET_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

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), whose Post model has title:string body:text published:boolean.
  • ocre dev or ocre migrate applies 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

FunctionSQLReturns
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 ?2Vec<Author>, newest first
count(ctx)SELECT COUNT(*) AS count FROM authorsi64
find(ctx, id)SELECT * FROM authors WHERE id = ?1 LIMIT ?2Option<Author>
find_many(ctx, &ids)SELECT * FROM authors WHERE id IN (?1, ?2, ...), 100 ids per queryVec<Author>, in no particular order
create(ctx, new)before_create, validate(), database checks, INSERT ... RETURNING *, after_createthe new Author, or Error::Invalid (422)
update(ctx, id, changes)before_update, validate(), database checks, UPDATE ... RETURNING *, after_updateSome(Author), None when the id does not exist, or Error::Invalid
delete(ctx, id)before_delete, DELETE FROM authors WHERE id = ?1 RETURNING *, after_deletetrue, 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:

FunctionCalledTypical use
before_create(ctx, &mut new)before validate() and the INSERTnormalize input (trim, lowercase an email), fill in defaults
after_create(ctx, &record)after the INSERTsend an email, enqueue a job
before_update(ctx, id, &mut changes)before validate() and the UPDATEnormalize 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 DELETErefuse 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:

RailsIn Ocre
before_validation, after_validationbefore_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_committhe after_* functions: each generated write is committed when it returns (D1 auto-commits every statement)
after_rollbackthe 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 :abortreturn an Err from a before_* function
callback objects, shared callbacksa function in a module of your own, called from several models’ callbacks
dependent: :destroy running the children’s callbacksin 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.suppressnone: 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:

FieldsMigrationGenerated
post:referencespost_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, indexbelongs to: comment.post(ctx); has many: post.comments(ctx, page)
author:references?author_id INTEGER REFERENCES authors(id) ON DELETE SET NULL, indexthe 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 indexhas 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:referencesthe references, plus a unique index on the paira 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, indexbelongs 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, indexa 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):

FunctionInReturns
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 optionIn Ocre
dependent: :destroy / :nullify / :restrict_with_errorON 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: truea 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: truecrate::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 attributescall 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: truecommentable:polymorphic:post,photo (see Polymorphic references)
has_many_attachedphotos: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. create and update run the HTML through ocre::security::sanitize before writing it: scripts, styles, event handlers and javascript: 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.html loads Trix 2.1.19 from unpkg.com (allowed by the generated Content-Security-Policy: script-src and style-src list https://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 an attachment field.
  • 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 from ocre::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/_content partial.

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:

NameSQLArguments
create_<table>CREATE TABLE with id, the fields, created_at, updated_at, plus indexesfields
add_<anything>_to_<table>ALTER TABLE <table> ADD COLUMN ... per column, plus indexesfields, 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 INDEXcolumn 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 elseempty 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):

RailsSQLite, in the migration
default:, null: falseviews 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_constraintCHECK (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_defaulta 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:

MistakeFix
A column should not existocre g migration remove_<column>_from_<table>
A column has the wrong nameocre g migration rename_<column>_to_<new>_in_<table>
A column has the wrong type or constraintocre g migration rebuild_<table>, then edit it
A table should not existocre g migration drop_<table>
An index is missing or wrongadd_index_to_<table> / remove_index_from_<table>
A migration changed data wronglya 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_slugs writes an empty numbered file for the SQL. It runs once, locally and on ocre 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 run or 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, match their input onto a fixed name, as below.

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.

MethodSQL
eq(col, v), ne(col, v)col = ?, col != ? (eq(col, None) matches nothing: use is_null)
gt, gte, lt, ltecol > ?, >=, <, <=
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_withcol 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

MethodSQL
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:

MethodRunsReturns
all(&db)the queryVec<T>, every row in memory: bound it with limit or page
first(&db)the query with LIMIT 1Option<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 limitsi64
exists(&db)SELECT 1 ... LIMIT 1bool (Rails’ exists?)
pluck::<V>(&db, expr)SELECT expr AS value, keeping order and limitsVec<V> (Rails’ pluck, ids)
aggregate::<V>(&db, expr)SELECT SUM(price) AS value ..., without order or limitsOption<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 matchedT (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 failedT (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 callOption<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.

MethodUse forReturns
db.all::<T>(sql, params)SELECT returning rowsVec<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 rowsrows changed (usize)
db.exists(sql, params)SELECT 1 ... LIMIT 1bool
db.batch(statements)several ocre::Statements in one transactionrows 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 bool with params!, read it with #[serde(deserialize_with = "ocre::bool_from_sql")], compare in SQL with published = 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 into i64, so generated models validate integer fields with v.safe_integer(..), and an i64 beyond it is bound as decimal text.
  • Dates are TEXT: created_at is YYYY-MM-DD HH:MM:SS in UTC; compare with SQLite’s datetime('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 with ocre::json_from_sql.
  • Indexes: rows read counts every row scanned. WHERE and ORDER BY on an unindexed column scan the whole table; references and ^ fields are indexed, add others with CREATE INDEX in 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.

BuilderSQLRails
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 columnsupsert_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 touchupdate_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, update and delete writes with one statement, so it is atomic on its own. Their uniqueness and “must exist” checks are separate reads: keep the UNIQUE index and REFERENCES constraint as the real guarantee.
  • Writes to several rows or tables that must succeed together go into one batch: build the statements with Statement::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 WHERE of 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_version with every record; clients send it back in PATCH bodies ({"title": "New", "lock_version": 3}) and get a JSON 409 when someone else saved first. GraphQL patches take lockVersion.
  • 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:

TypeStored textCan be searched
ocre::encryption::Encrypteda new random nonce each write: the same value never gives the same textno: only read and written
ocre::encryption::Deterministicthe nonce comes from the value (HMAC-SHA256): equal values give equal textsyes, 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. Ctx installs them the first time a Worker instance handles a request: one key derivation, then microseconds of CPU per value. Without a valid SECRET_KEY_BASE (64 characters or more), reading an encrypted row fails with a 500 whose log names the fix, and binding one writes NULL. Losing SECRET_KEY_BASE loses the data: keep a copy.
  • Rotation. Put the old secret in SECRET_KEY_BASE_PREVIOUS (comma-separated for several) when you change SECRET_KEY_BASE (see Security): old values still decrypt, new writes use the new key. Deterministic lookups need deterministic_candidates until a job such as reencrypt has 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 BY and aggregates (the database sees only ciphertext), and a UNIQUE index on an Encrypted column. 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):

  1. turn replication on for the database (dashboard: D1 > database > Settings);
  2. set the variable in cloudflare.config.ts (and D1_REPLICAS=on in .dev.vars to 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 ModelIn Ocre
API, Model, AttributeAssignment, Attributesa 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 validatorsvalidate() -> 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_validationplain 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::Testsnot 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 supportedInstead
down migrations, db:rollback, db:migrate:redo, reversible changeD1 migrations are forward-only: a new migration, or D1 Time Travel (see Undo a migration)
Model.transaction do ... end, savepoints, after_commitdb.batch (see Transactions); a job enqueued after the write
Lazy loading, strict_loadingassociations 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 typesa 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 generatorsan 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 structsserde and Validator (see Form objects and plain structs)
Fixtures’ created_at/updated_at filled automatically, ERB in fixture fileswrite the values in db/fixtures/*.yml; YAML anchors and << share them
readonly recordsrecords are plain values: nothing saves them except the model’s update
Enum fields with --graphqla string field with v.inclusion(..)
Joins across databasestwo queries, one per database

See also

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 from ocre g model, ocre g scaffold or ocre 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:references and ocre 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).

MethodPasses whenMessage
v.required("title", &title)not empty after trimming whitespace (Rails’ presence)can't be blank
v.absence("nickname", &nickname)empty or only whitespacemust 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 charactersis too short (minimum is 6 characters)
v.length("zip", &zip, 5)exactly 5 charactersis 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 > 0must be greater than 0
v.greater_than_or_equal_to("age", age, 18)value >= 18must be greater than or equal to 18
v.less_than("discount", discount, 100)value < 100must be less than 100
v.less_than_or_equal_to("guests", guests, 12)value <= 12must be less than or equal to 12
v.other_than("floor", floor, 13)value != 13must be other than 13
v.safe_integer("stock", stock)within ±ocre::MAX_SAFE_INTEGER (2^53 - 1), what D1 returns exactlymust 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 valuesis not included in the list
v.exclusion("username", &username, &["admin", "root"])none of the listed valuesis 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_confirmationdoesn't match Password
v.acceptance("terms_of_service", accepted)the checkbox bool is truemust 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 exponentis not a decimal number
v.uuid("token", &token)hyphenated UUID, any caseis not a valid UUID
v.check("guests", failed, "message")failed is false: any rule of your ownyour 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 erroris 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 Noneis 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 Noneis not valid JSON
v.file("image", &upload, &IMAGE)the upload is within Rules::max_bytes and has an allowed content typeis 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:

RuleWhereGenerated 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 takencreate and update, with db.exists(..)fields marked ^, and the pair of references of a join model
Reference: must existcreate and update, with db.exists(..)references fields (optional ones only when set)
Normalizing input before the checks (trim, lowercase)before_create / before_update callbacksnone: 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&#39;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:

RailsIn Ocre
on: :create / on: :updateNew<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_optionsan 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 classesa function taking &mut Validator (check_dates above), reused from any model
validates_associatedv.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: truereturn 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_allquery().update_all(..), db.execute(..), db.batch(..): no validation runs
validators, validators_on(:attr)read validate(): rules are code, not declarations to list

See also

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, whose src/posts.rs is the scaffold of Post title:string body:text published:boolean.
  • ocre dev running to try the pages (it serves http://localhost:8787 unless 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))
}
MethodPathHandlerDoes
GET/postsindexList, newest first (?limit=&offset=, 50 by default), with Previous / Next links
GET/posts/newnewEmpty form
POST/postscreateCreate, then redirect to /posts/{id} with a flash; 422 and the form again when invalid
GET/posts/{id}showOne record, or 404
GET/posts/{id}/editeditForm filled with the record
POST/posts/{id}updateUpdate, then redirect; 422 and the form again when invalid
POST/posts/{id}/deletedeleteDelete, 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:

RailsOcre (axum)
resources :posts (seven actions, helpers)ocre g scaffold: the routes, handlers and paths
resources :photos, :booksone 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, resolvefunctions 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 :commentablea function returning a Router<Ctx>, merged or nested in each resource
shallow: truedeclare the member routes without the parent prefix
Subdomain / request constraintscheck Host or headers in a middleware or the handler (below)
.:format segmentsthe 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 filesone routes() per module
Unicode pathswrap 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 segmentsone route per language, pointing to the same handler (see Translations)
rails routesocre 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.

ExtractorFromGivesOn failure
State(ctx): State<Ctx>the router stateCtx: ctx.db()?, ctx.env() and the bindingsnever fails
Path(id): Path<i64>{id} in the paththe parsed value; a tuple or struct for several400 Invalid URL: Cannot parse ...
Query(q): Query<T>the query string, into a serde structT400
Form(form): Form<T>a URL-encoded form bodyT415 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 bodyTJSON 400 (see JSON APIs)
page: ocre::Page?limit=&offset=page.limit (1-100, default 50), page.offsetJSON 400 limit must be between 1 and 100
session: ocre::Sessionthe encrypted session cookieget, insert, remove, clear, flashits methods fail with a 500 (logged) when SECRET_KEY_BASE is missing
cookies: ocre::Cookiesthe request’s cookiesget/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::Flashmessages 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::Formatthe Accept headerFormat::Html, Json, Xml, Text or Othernever fails
Htmx(is_htmx): ocre::Htmxthe HX-Request: true headerboolnever fails
RemoteIp(ip): ocre::RemoteIpCloudflare’s CF-Connecting-IPOption<IpAddr>never fails
RequestId(id): ocre::RequestIdCloudflare’s CF-Ray, else X-Request-Id, else randomStringnever fails
headers: HeaderMapall request headersheaders.get("user-agent"), cookies…never fails
OriginalUri(uri), method: Methodthe request linethe full URI (path and query), the methodnever fails
ocre::storage::Multipart<LIMIT>a multipart/form-data bodytext fields and files413 above LIMIT bytes (see File storage)
i18n: ocre::i18n::I18nthe request’s localetranslations (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’s fetch, with its usual API (json, headers, query strings). cargo add reqwest --no-default-features --features json.
  • worker::Fetch, the worker crate’s thin wrapper over fetch.
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

ReturnResponse
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_CONTENTa 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 branchhandlers that answer differently per case
Err(ocre::Error)the error page with the error’s status

ocre::Error and its statuses:

VariantStatusPage shows
Error::NotFound404Not found
Error::BadRequest(msg), built with Error::bad_request("...")400the message
Error::Unauthorized401Unauthorized
Error::Forbidden403Forbidden
Error::Invalid(fields)422Validation failed and each field error
Error::PayloadTooLarge(msg)413the message
Error::Internal(msg), built with Error::internal("...")500Internal 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:

MiddlewareOn 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_idocre::RequestId: Cloudflare’s CF-Ray, shown next to each request in Workers Logs
remote_ipocre::RemoteIp, from CF-Connecting-IP, which Cloudflare sets and clients cannot forge
compressionCloudflare compresses responses at the edge (Brotli, gzip)
etag / conditional GETocre::cache::Conditional and ETag (see Caching)
limit_payloadaxum’s 2 MB DefaultBodyLimit, per route with .layer(DefaultBodyLimit::max(n))
corsthe ALLOWED_ORIGINS variable, or tower-http’s CorsLayer on a router
secure_headersset by ocre::serve; a handler’s own value wins
catch_panicnot possible: a panic aborts the WebAssembly instance; return Err
timeout_requestCloudflare 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-Bynot sent; add it with a layer if you want it
Server timing, benchmarkthe 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, deletethe .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

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), whose Post has title, body, published, created_at and updated_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 dev rebuilds the Worker to show a change (save a .rs file under src/ 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:

RailsOcre (askama)
ERB <%= %> / <% %>{{ expression }} / {% statement %}; values are HTML-escaped
Implicit rendering of show.html.erbexplicit: 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_feeda template with ext = "xml" (below)
render status: :unprocessable_entity(StatusCode::UNPROCESSABLE_ENTITY, render(&view)?)
head :no_contentreturn StatusCode::NO_CONTENT
DoubleRenderErrorimpossible: a handler returns one response
local_assigns, strict localsstruct fields; optional ones are Option<T> ({% if let Some(x) = x %})
Template compilation and cachingdone 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

Railsaskama
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 collectionsan enum and {% match item %}{% when Item::Post with (post) %}...{% endmatch %}
Partial layoutswrap 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):

FilterOutputRails
{{ views|number_with_delimiter }}1,234,567number_with_delimiter
{{ ratio|number_with_precision(2) }}3.14number_with_precision
{{ price|number_to_currency("$") }}$1,234.50number_to_currency
{{ rate|number_to_percentage(1) }}12.3%number_to_percentage
{{ size|number_to_human_size }}1.5 KBnumber_to_human_size
{{ visits|number_to_human }}1.23 Millionnumber_to_human
{{ post.created_at|time_ago_in_words }} agoabout 3 hours agotime_ago_in_words
{{ from|distance_of_time_in_words(to) }}2 daysdistance_of_time_in_words
{{ post.created_at|strftime("%b %-d, %Y") }}Sep 29, 2026l / 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 charactersword_wrap
{{ ocre::helpers::class_names([("active", current)]) }}active when currentclass_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 querycurrent_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 themtime_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:

RailsHTML 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_selecta <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 attributesbracketed names read by ocre::NestedForm: post[title], comments[0][body] (below), saved together with batch
time_zone_selecta <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 FormBuilderaskama macros, as errors_for above
_method override for PATCH/DELETEnot used: HTML routes use POST /posts/{id} and POST /posts/{id}/delete (see Controllers)
authenticity_tokennot 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::Sitemap builds /sitemap.xml (add, or add_localized for a page in every locale with hreflang alternates and x-default; 50,000 URLs per file). It is a response: return it from a handler.
  • ocre::seo::LlmsTxt builds /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; its templates/layout.html loads 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.

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.

RailsOcre
Turbo Drivehx-boost="true" (on in the layout)
Turbo Frameshx-get/hx-post with hx-target, and the Htmx extractor
Turbo Streamshx-swap-oob fragments; ocre::realtime over WebSockets
data-turbo-method, data-turbo-confirma <form method="post"> button, hx-confirm
request.js with the CSRF headerhtmx requests; no token needed
Stimulusscript files in public/, or Alpine.js
Import maps, jsbundlingsee Assets

See also

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. Its wrangler.config.ts has:

    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

RailsOcre
public/ filespublic/, served by Cloudflare before the Worker
Propshaft load pathsone directory, public/
Digests, .manifest.json, assets:precompilenot needed: ETag revalidation after each deploy; _headers for long caching of versioned paths
asset_host / CDNCloudflare’s edge
stylesheet_link_tag, javascript_include_tag, image_tag<link>, <script>, <img> with the path
tailwindcss-rails, cssbundling-rails, jsbundling-railsthe tool’s CLI in [build] command
importmap-railsan <script type="importmap">
Development servingocre dev serves public/ uncached

See also

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 use ocre g api Product name:string^ price:float stock:integer? --graphql in the blog starter app, and ocre dev serving http://localhost:8787.
  • Nothing else: no binding beyond the DB database 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

MethodPathHandlerSuccess
GET/api/products?limit=&offset=index200, a JSON array, newest first; limit 1-100 (default 50), offset from 0; a Link header to the other pages
GET/api/products/{id}show200, the record
POST/api/productscreate201, the created record; every required field must be sent
PATCH/api/products/{id}update200, the updated record; only the fields sent change
DELETE/api/products/{id}delete204, 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.

StatusWhenBody
400Body 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}}
401Error::Unauthorized (e.g. missing Bearer token after ocre g auth); adds WWW-Authenticate: Bearer{"error":{"message":"Unauthorized","status":401}}
403Error::Forbidden{"error":{"message":"Forbidden","status":403}}
404Unknown id{"error":{"message":"Not found","status":404}}
413Error::PayloadTooLarge (uploads over the limit)the message
422Failed validation{"error":{"fields":{...},"message":"Validation failed","status":422}}
500Anything 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 PATCHstock in ProductChangesEffect
{} (field missing)Nonekeep
{"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.

TypeUse
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::Pageextractor: ?limit=&offset=, JSON 400 when out of range
axum::http::StatusCoderesponse 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):

OperationArgumentsReturns
productslimit (default 50), offset (default 0)[Product!]!, newest first
productidProduct or null
createProductinput: NewProductInput!Product!
updateProductid, patch: ProductPatch!Product!, error with status 404 for an unknown id
deleteProductidtrue, 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

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 the ocre crate and applied by ocre::serve in src/lib.rs; no generator is needed.
  • The SECRET_KEY_BASE secret (see Configuration). ocre new writes a random one to .dev.vars for ocre dev; ocre deploy uploads 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 by ocre dev on the default port 8787.

What every request goes through

ocre::serve(routes(), req, env) wraps the app’s router with five layers, outermost first:

OrderLayerEffect
1Security headersAdds nosniff, SAMEORIGIN framing, a referrer policy and more to every response; HSTS on HTTPS
2Host authorizationOnly when ALLOWED_HOSTS is set: requests for other host names get 403
3CORSOnly when ALLOWED_ORIGINS is set: answers preflights and adds Access-Control-* headers for those origins
4Cross-origin protection (CSRF)Refuses unsafe requests a browser sends from another site, with 403
5SessionDecrypts 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.

MethodReturnsEffect
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")?boolRemoves 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()?FlashThe 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 neither SECRET_KEY_BASE nor listed in SECRET_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, and Secure when the request came over HTTPS (always on *.workers.dev; not on http://localhost). By default it has no Max-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 methodReturns
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:

EnvironmentSource
ocre dev.dev.vars (git-ignored), written by ocre new
ProductionA 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:

  1. GET, HEAD and OPTIONS requests pass (they must not change data), except WebSocket handshakes, which are checked like forms because browsers send cookies with them.
  2. A request whose Origin is listed in ALLOWED_ORIGINS passes.
  3. When the browser sent Sec-Fetch-Site (all current browsers do), same-origin and none (typed in the address bar, bookmarks) pass; anything else (cross-site, same-site) is refused.
  4. Older browsers that send only Origin: it must match the Host header, else the request is refused.
  5. 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, PATCH and DELETE handlers. A GET handler that changes data is not protected. Generated scaffolds follow this (POST /posts/{id}/delete, POST /logout).
  • htmx requests (hx-post, hx-delete…) and your own fetch() 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’s Sec-Fetch-Site header does that job.
  • The session cookie is also SameSite=Lax: browsers do not send it with cross-site POSTs 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 headers Content-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
HeaderValueEffect
X-Content-Type-OptionsnosniffBrowsers trust Content-Type instead of guessing
X-Frame-OptionsSAMEORIGINOther sites cannot put the app in a frame (clickjacking)
Referrer-Policystrict-origin-when-cross-originOther sites see only the origin, never paths with tokens
X-XSS-Protection0Turns off the obsolete, exploitable XSS auditor of old browsers
X-Permitted-Cross-Domain-PoliciesnoneNo Flash/PDF cross-domain policy files
Strict-Transport-Securitymax-age=63072000HTTPS 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>&#60;script&#62;alert(1)&#60;/script&#62;</td><td>Hi</td>...

Rules:

  • Never apply |safe to user input; it turns escaping off.
  • .txt templates (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", &params.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 in src/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) sets Content-Type, Content-Length and a Content-Disposition with a cleaned file name. Types a browser could run (HTML, SVG, XML, JavaScript) are sent as application/octet-stream, even with Disposition::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, Range and If-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 in Content-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::sanitize drops style attributes and <style> elements, so user HTML cannot restyle the page (CSS injection, as in the MySpace worm) or load images through url(...). Keep style out of the lists you pass to sanitize_with.
  • Never put user text inside a <style> element or a style="..." 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 sanitize before 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, no javascript: URLs or on*= 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

NeedUse
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 requestocre::security::url_from(&uri, &target) returns it only for paths or URLs of this app (no open redirect, no CR/LF)
Show user HTMLocre::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 parametersocre::security::filter_parameters(query) / filter_json(&value) hide passwords, tokens, keys, emails
Password-protect a staging pageocre::security::BasicAuth extractor, auth.matches(user, password), BasicAuth::challenge()

What is not included

See also

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 auth runs once per app and refuses to run when a User model or a users table exists.
  • The SECRET_KEY_BASE secret, used for session cookies and JWTs; ocre new writes one to .dev.vars (see Sessions, flash and security).
  • For magic links and password resets in production: a mail adapter (MAIL_ADAPTER, see Email). In ocre dev the emails are printed in the console.
  • The outputs below come from an app created with ocre new shop --starter blog, served by ocre dev on 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:

FileFull-stackAPI-onlyContents
migrations/*_create_users.sqlyesyesusers: email (unique, COLLATE NOCASE), password_digest (empty for OAuth-only users), confirmed_at, timestamps
migrations/*_create_auth_tokens.sqlyesSingle-use emailed tokens: user_id, purpose (password_reset, magic_link or email_confirmation), digest (unique), expires_at
migrations/*_create_api_keys.sqlyesyesapi_keys: user_id, name, digest (unique), last_used_at
src/models/user.rsyesyesUser (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.rsyesissue, peek, consume (single use; valid 15 minutes, a day for email confirmation: valid_minutes)
src/models/api_key.rsyesyescreate (returns the key once), for_user, revoke, authenticate
src/auth.rsyesCurrentUser, ConfirmedUser, OptionalUser, sign_in, sign_out, origin, SESSION_SECONDS, OAUTH_PROVIDERS
src/registrations.rsyesGET/POST /signup, GET /account (an example protected page), POST /account/delete
src/sessions.rsyesGET/POST /login, POST /logout, GET/POST /magic_link, GET/POST /magic_link/{token}
src/passwords.rsyesGET /passwords/new, POST /passwords, GET/POST /passwords/{token}
src/confirmations.rsyessend_confirmation; POST /confirmations (a new link), GET/POST /confirmations/{token}
templates/auth/*.htmlyesThe pages
src/auth_api.rsyesyesBearerUser, 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.tsyesyesThe AUTH_RATE_LIMITER binding

Two options add more, in full-stack apps only:

OptionAdds
--db-sessionsmigrations/*_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,googlemigrations/*_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).

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&#39;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:

ExtractorGivesVisitor without a session
CurrentUser(user): CurrentUserUserRedirected 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): ConfirmedUserUser with a confirmed emailLike CurrentUser; an unconfirmed user is redirected to /account with “Please confirm your email address first.”
OptionalUser(user): OptionalUserOption<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:

RouteBodyAnswer
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/meThe user of the token
GET /api/auth/keysThe 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:

JWTAPI key
Lifetime1 hour (TOKEN_TTL_SECONDS in src/auth_api.rs)Until revoked
RevocableNo, it expiresYes, DELETE /api/auth/keys/{id}
Check costOne 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 forApps that log in with a passwordScripts, 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::revoke in src/models/api_key.rs does this.
  • Answer Error::NotFound for other users’ records, so ids of other users’ data are not confirmed. Use Error::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 scaffold do not check ownership; add CurrentUser and the user_id filter 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):

ItemWhat it doesCost
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 comparisonSame as hash
ocre::password::iterations(&digest)The iteration count stored in a digestNone
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 tokenNone
ocre::token::constant_time_eq(a, b)Compares secrets without timing leaksNone
ocre::jwt::encode(&ctx, &Claims::new(user.id.to_string(), ttl))?HS256 JWT with sub, iat, exp; key derived from SECRET_KEY_BASEOne 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-unknownNone
Error::Unauthorized401; JSON answers add WWW-Authenticate: Bearer
Error::Forbidden403, 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. Without MAIL_ADAPTER in production, POST /magic_link and POST /passwords answer 500 and the log says cannot 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_LIMITER entry in cloudflare.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, /passwords and /api/auth/* adds a limit before the Worker runs (see Deployment).
  • OAuth. With --oauth, put the provider’s ..._CLIENT_ID and ..._CLIENT_SECRET in .prod.vars and upload them with ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars, and register the production callback URL with the provider.
  • Secret. ocre deploy creates SECRET_KEY_BASE on 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. CurrentUser only 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 button POSTs. Links use the request’s origin.
  • JWTs: HS256 only, key derived from SECRET_KEY_BASE with a fixed label (so it differs from the cookie key); no separate JWT_SECRET. Rotating SECRET_KEY_BASE signs everyone out of sessions and tokens at once, unless the old value is kept in SECRET_KEY_BASE_PREVIOUS for 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_at written 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::Forbidden is there for your own checks.
  • Revoking a JWT before it expires (use API keys for long-lived access).

See also

Email

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_FROM in cloudflare.config.ts and MAIL_ADAPTER=log in .dev.vars, so ocre dev prints every email instead of sending it.
  • Mailers: ocre g mailer. Receiving: ocre g mailbox. Sending later: one ocre g job (it wires the queue deliver_later uses).
  • 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 by ocre dev on 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?;
EmailMeaning
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, attachmentsPublic, 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:

ProblemResult
A recipient (to, cc, bcc) or reply_to is not an email addressError::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 reachedError::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_ADAPTERDeliveryConfigurationFree-plan limits (September 2026)
logPrints 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 pagesnone; ocre new writes MAIL_ADAPTER=log to .dev.vars, which overrides cloudflare.config.ts in ocre devnone
resendPOST https://api.resend.com/emailsRESEND_API_KEY secret; MAIL_FROM on a domain verified in ResendResend free plan: 100 emails a day, 3,000 a month, one domain; any recipient
cloudflareCloudflare Email Service through the EMAIL send_email bindingEMAIL: bindings.sendEmail() in cloudflare.config.ts; MAIL_FROM on a domain onboarded to Email ServiceWorkers 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)

  1. 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.

  2. Store the key as a Worker secret: put RESEND_API_KEY=<key> in .prod.vars (git-ignored), then

    ocre secrets push RESEND_API_KEY --file .prod.vars
    

    (the Worker must exist: after the first ocre deploy).

  3. In cloudflare.config.ts, in worker.env, set the adapter and a sender on the verified domain:

    MAIL_FROM: bindings.text("Shop <noreply@yourdomain.com>"),
    MAIL_ADAPTER: bindings.text("resend"),
    
  4. ocre deploy. .dev.vars keeps MAIL_ADAPTER=log, so ocre dev still only prints. To send for real from ocre dev, put MAIL_ADAPTER=resend and RESEND_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)

  1. Onboard the sending domain to Cloudflare Email Service in the dashboard.

  2. In cloudflare.config.ts, in worker.env, uncomment the binding ocre new left there, and set the adapter:

    MAIL_FROM: bindings.text("Shop <noreply@yourdomain.com>"),
    MAIL_ADAPTER: bindings.text("cloudflare"),
    EMAIL: bindings.sendEmail(),
    
  3. 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 viewOcre
message.subject, message.topass 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_namethe 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_helpermethods on the template struct, or the app’s filters module
url_for, *_url, image_urlocre::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_action and 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 of ocre::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 returns Result; handle its error where you call it. A deliver_later email 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))
PageShows
/ocre/dev/mailersThe 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.jsonThe emails sent, as JSON ([{"id": 1, "from": "...", "email": {"to": [...], "subject": ..., "text": ...}}]), for end-to-end tests
/ocre/dev/mailboxA 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:

MethodReturns
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.

  1. Deploy the app with the mailbox: ocre deploy.
  2. In the Cloudflare dashboard, enable Email Routing for the domain; Cloudflare adds the MX and TXT records it needs.
  3. For forwarding, add each target as a destination address and confirm it from the email Cloudflare sends.
  4. 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, and email.to() tells them apart.

Nothing is added to cloudflare.config.ts for receiving.

See also

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.rs answers GET /push/key (the public VAPID key), POST /push/subscriptions (saves a browser’s subscription; the same browser again updates it) and POST /push/subscriptions/delete, and sends with notify_all(&ctx, &message), or notify_user(&ctx, user_id, &message) when the app has ocre g auth (subscriptions then carry the signed-in user’s id).
  • public/push.js, loaded by the layout, subscribes the browser when an element with data-push-subscribe is clicked (browsers only ask for permission after a click): <button data-push-subscribe>Notify me</button>. It fires push:subscribed, or push:error (whose default is an alert).
  • The table push_subscriptions holds each browser’s endpoint and keys.
  • .dev.vars gets a VAPID key pair (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) and VAPID_SUBJECT, the contact push services may write to (mailto: or https:).
  • Cargo.toml turns on Ocre’s push feature (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

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

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.

FileWhat the generator writes
src/jobs/send_welcome.rspub 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.rsThe 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.rsmod jobs; and the queue entry point (first job only)
cloudflare.config.tsThe 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:

CallDoes
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).awaitRuns 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: 1 to its triggers.queue({ ... }) entry in cloudflare.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 lock import_csv:<account_id> (a row in job_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 whether owner got the lock (the owner holding it gets it again, extended) and ocre::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?;
    result

    Each 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 returnsOcreLike Rails
Ok(())Acknowledges it
Err(Error::NotFound), BadRequest, Unauthorized, Forbidden, Invalid, PayloadTooLargeLogs discarded, not retried and acknowledges it: another try would fail the same waydiscard_on, ActiveJob::DeserializationError
Any other Err (Internal, TooManyRequests)Retries it with a growing delayretry_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]:

LineMeaning
[ocre jobs] <job> doneperform 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 doneA 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_at column on users:

    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)
FileWhat the generator writes
src/schedules/nightly_cleanup.rspub async fn run(ctx: &Ctx) -> Result<()>, which does nothing yet
src/schedules/mod.rsrun(ctx, cron), which matches the expression that fired to a task; keep the // ocre:schedules and // ocre:schedule-dispatch markers
cloudflare.config.tsThe expression, triggers.scheduled({ schedule: "0 3 * * *" }), after // ocre:triggers
src/lib.rsmod 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.

PhraseCronRuns
every minute* * * * *Every minute (the shortest interval Cron Triggers allow)
every 15 minutes*/15 * * * *Every 15 minutes
every hour, hourly0 * * * *At the start of every hour
every 6 hours0 */6 * * *At 00:00, 06:00, 12:00 and 18:00
every day at 3am, daily at 03:00, at 3am0 3 * * *Every day at 03:00
midnight on tuesdays0 0 * * TUETuesdays at midnight
every monday and friday at 9:3030 9 * * MON,FRIMondays and Fridays at 09:30
every weekday at 6pm0 18 * * MON-FRIMonday to Friday at 18:00
every weekend at noon0 12 * * SAT,SUNSaturdays and Sundays at 12:00
weekly, every week0 0 * * SUNSundays at midnight
monthly, every month0 0 1 * *The first day of each month, at midnight
every month at 2am0 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:

NeedOcre
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 dataocre db seed
Recurring workocre 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 prints Created queue <name> on Cloudflare (for example Created queue shop-jobs on Cloudflare and Created queue shop-jobs-failed on Cloudflare); with --json they are listed in provisioned as "queue shop-jobs".
  • cf deploy then registers the consumer and the triggers.scheduled crons. 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:

LimitValueWhat Ocre does
Queues operations10,000 a day; a message costs 3 (write, read, delete), each retry 1 more read, a dead-lettered message 1 more writeOne message per job; about 3,300 jobs a day, whatever the queue; discarded jobs are not retried
Retention24 hours on Free (not configurable)Retries stop long before: the last one comes after about 40 minutes
Message size128 KB; 100 messages and 256 KB per sendBatchenqueue refuses larger jobs with an error naming the fix (pass ids); enqueue_all splits lists into batches
QueuesNamed queues cost nothing to createocre g job --queue <name> adds one, with its own consumer
Delay24 hours, on send and on retryenqueue_in refuses longer delays
BatchesUp to 100 messages, 60 s waitmax_batch_size = 10, max_batch_timeout = 5: one consumer run (one Worker invocation) per 10 jobs
CPU10 ms per invocation, for requests, cron runs and consumer batchesJobs 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 Triggers5 per accountocre g schedule warns past 5 in the app; run several tasks from one cron

See also

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 dev simulates R2.
  • The generator that adds the first attachment field also adds the STORAGE R2 binding to cloudflare.config.ts; nothing else to configure.
  • The protected download example uses crate::auth::CurrentUser, created by ocre 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:

PieceWhat it does
Migrationimage_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 modelLargest 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 / PhotoChangesimage: 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 / deleteStore 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 formsenctype="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.rsLargest 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}/notesStreams the file with storage::serve (404 when the optional file is absent)
cloudflare.config.tsSTORAGE: 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&#39;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:

RouteEffect
GET /api/documents/{id}/fileThe file, streamed like the HTML scaffold’s (404 when there is none)
PUT /api/documents/{id}/fileMultipart 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}/fileRemoves 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:

MethodDoes
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.

ItemUse
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::DownloadShow 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:

NameKindValue
R2_ACCESS_KEY_IDsecretThe token’s access key ID
R2_SECRET_ACCESS_KEYsecretThe token’s secret access key (also signs the keys of direct uploads)
R2_ACCOUNT_IDvariableThe account ID shown on the R2 overview page
R2_BUCKETvariable<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:

  1. The page asks the app to start an upload, with the file’s name, type and size. storage::direct_upload checks them against the field’s Rules (422 otherwise), picks a new key under a prefix of its own, and answers a presigned PUT URL, the headers to send with it and a signed_key.
  2. The browser PUTs the file to that URL. The URL signs Content-Type and Content-Length, so R2 refuses a file of another type or size than declared.
  3. The form is submitted with the signed_key (and the file name) instead of the file. storage::attach_direct_upload checks the signature (only keys this app issued are accepted, so nobody can claim another record’s file), runs head on the object, checks its size and type against the Rules again (deleting a refused object) and returns its Attachment.

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:

  1. POST /videos/uploads with the file’s name, type and size: checked against VIDEO, then the upload is created in R2 (one class A operation). The answer is the signed key, R2’s upload_id, the part size (10 MiB, larger for files that would need over 10,000 parts) and the number of parts.
  2. POST /videos/uploads/parts with the part numbers still to send: where to PUT each one.
  3. Each part is PUT there (one class A operation each: a 5 GB video is 512 parts). The script keeps every finished part’s number and ETag in localStorage, under the URL, file name, size and modification date.
  4. POST /videos/uploads/complete with every part: R2 assembles the object (one class A operation).
  5. POST /videos/uploads/abort drops 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: presigned PUT URLs (valid 24 hours), so the file never passes through the Worker, whatever its size. The bucket’s CORS rule must allow PUT and expose ETag.
  • Through the Worker otherwise: in ocre dev (whose local R2 has no S3 API) and without the R2_* settings. Each part is one request to PUT /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_age well 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.dev hosts 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::serve sends Cache-Control: private, no-cache; for public images, serve the source with a public cache header (see the CACHE_CONTROL example 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; file stands in when nothing is left. Content-Disposition carries them as ASCII filename= plus UTF-8 filename*= 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-stream downloads even with Disposition::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 Rules allowlist limits them, and v.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 head before they are attached.
  • Validation before storage: v.file(..) runs before store, 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):

ResourceFree every monthOcre use
Storage10 GB-monthEvery stored file; replaced and deleted files are removed by the generated models
Class A operations1,000,000Each upload (store, store_bytes, store_body, a direct upload’s PUT) and each list page is one
Class B operations10,000,000Each serve (downloads and 304s alike), read, read_first, head/exists and presigned download is one
Deletesfreedelete, delete_attachments
EgressfreeDownloads 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 Multipart extractor holds the whole request in memory while R2 gets a copy. Keep max_bytes in 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::serve attaches R2’s stream to the response and ocre::serve answers with it directly (this is why the Worker’s fetch returns worker::web_sys::Response), so a download costs almost no CPU whatever its size.
  • For a single large file, store_body streams a raw body (curl -T big.zip) of known Content-Length into R2 with flat memory; it checks no Rules, 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, serve and the other runtime functions use the STORAGE binding. S3Endpoint presigns 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’s delete, so their files stay in R2. Delete them in the parent’s delete if that matters; photos:attachments children are deleted this way by the generated code.

See also

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:

  1. It checks the signature: the HMAC-SHA256 of the raw body with the secret PAYMENTS_WEBHOOK_SECRET, sent in the X-Signature header as hex, sha256=<hex> (GitHub’s form) or base64. A missing or wrong signature is a 401.
  2. It parses the body as JSON and reads the event’s id (a string or a number); without one, 400.
  3. It runs handle(&ctx, &event) through webhooks::once, which records the event in webhook_events and skips it if it was already processed.
  4. 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 styleHeaderMessage 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 Webhookswebhook-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) into webhook_events with the raw payload and status processing; the pair is unique, so a second delivery of the same event inserts nothing and gets Delivery::Duplicate without running the effect;
  • runs the effect; on success marks the event processed; on failure marks it failed with 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 processing for 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 POSTs request_body(&job, webhook) to UPSCALE_URL, signed with UPSCALE_SECRET (X-Signature), plus Authorization: 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 job submitted and keeps the id of the answer as external_id; anything else keeps it queued with the error.
  • Receive events. The service POSTs JSON to that address: a status (running, done, failed, or RunPod’s IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED, CANCELLED, TIMED_OUT), and optionally progress, output (or result) and error. 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 (a running event after done is ignored), and no event log is needed.
  • Sweep. src/schedules/upscale_jobs_sweep.rs runs every 5 minutes (--sweep "every 10 minutes" to change it; it uses one of the 5 Cron Triggers of the free plan) and calls upscale_jobs::sweep: jobs submitted or running without news for STALE_AFTER (an hour) become failed, and queued jobs whose submission failed more than RETRY_AFTER seconds ago are submitted again, 10 per run, until MAX_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

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 --realtime scaffold adds everything to Cargo.toml and cloudflare.config.ts, and ocre deploy creates the Durable Object namespace.
  • The private-channel example uses crate::auth::OptionalUser, created by ocre g auth (see Authentication); the job example needs a job from ocre 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 as CHANNELS. 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 connect enabled 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:

PieceWhat it does
templates/messages/_row.htmlOne table row with id="message_<id>", included by the index and rendered for broadcasts
templates/messages/index.htmlWraps the table in <div hx-ext="ws" ws-connect="/realtime/messages">; the <tbody> has id="messages"
src/messages.rsAfter 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 withMessageEffect 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 &#60;b&#62;sharp&#60;/b&#62;</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:

RequestStatus
/realtime/announcements, anonymous101 Switching Protocols
/realtime/nope, anonymous404 Not Found
/realtime/user:1, anonymous401 Unauthorized
/realtime/user:1, signed in as user 1101 Switching Protocols
/realtime/user:2, signed in as user 1403 Forbidden
/realtime/announcements with Sec-Fetch-Site: cross-site403 Forbidden
/realtime/announcements without Upgrade: websocket400

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.connect answers 400 for other names (a connect that lists its channels answers 404 first) and broadcast fails 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’ params of a subscription) are the route’s: the channel name in the path, plus any query string read with axum’s Query extractor in connect.

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>}. from is set by the server (null without identified_by), so a client cannot pretend to be someone else; data is 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 calls broadcast.
// 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 is upscale_jobs:<public_id>; src/realtime.rs accepts the channels starting with upscale_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), broadcasts progress_html(&job), the bar with the id upscale_jobs_<public_id>;
  • GET /upscale_jobs/<public_id>/progress answers the bar wrapped in its subscription, <div hx-ext="ws" ws-connect="/realtime/upscale_jobs:<public_id>">...</div>. A page that has htmx and the ws extension 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):

ResourceFree planRealtime use
Durable Object requests100,000 a day1 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 duration13,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 requests100,000 a day1 per connection; a broadcast is a subrequest of the request that sends it
Durable Object limitsSQLite-backed classes only; 32,768 WebSockets per objectnew_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:

  1. Turn on the feature in Cargo.toml: ocre = { ..., features = ["realtime"] } (see Configuration).
  2. Add the CHANNELS binding and the OcreChannel export shown above to cloudflare.config.ts.
  3. Add a connect route like src/realtime.rs above (any path; WebSocketUpgrade is the extractor) and merge it in routes().
  4. For htmx pages, load the WebSocket extension in the <head> of templates/layout.html, as shown in the table above.
  5. 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()). WebSocketUpgrade rejects a request without Upgrade: websocket with 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 CableOcre
ApplicationCable::Connection, identified_by, reject_unauthorized_connectionThe connect handler: extractors, identified_by, Err(Error::Forbidden)
Connection and channel callbacks, rescue_fromCode before upgrade.connect(..) and its Result; the channel object runs no app code
Channel classes, subscribed, stream_from, stream_forOne channel name per stream (post:12), checked in connect
Channel paramsThe route’s path and query string
Client actions (perform)Ordinary routes (htmx hx-post) that broadcast
Rebroadcasting client dataupgrade.rebroadcast()
ActionCable.server.broadcast, broadcast_toocre::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 originsThe connect route’s path, the ws-connect URL, the same-site check of ocre::serve
Standalone cable server, worker poolEach 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.createhtmx’s ws extension, or new WebSocket(url)

See also

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 cache once; it adds the CACHE binding (see Setting up the KV cache). HTTP caching needs no setup.
  • The examples use the Post model of the blog starter (ocre new <name> --starter blog) and the I18n extractor, which needs ocre 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)
SavesSlow or costly work: D1 aggregates over many rows, third-party API callsRendering and bandwidth: 304 Not Modified without a body
Where the copy livesWorkers 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
Setupocre g cacheNone

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 dev uses a local namespace (under .wrangler/state); its startup bindings table lists env.CACHE as a local KV Namespace.
  • ocre deploy gives each bindings.kv() entry without an id the namespace titled <worker>-<binding> (blog-cache): it links an existing namespace with that title, or runs cf kv namespaces create, then rewrites the entry to CACHE: bindings.kv({ id: "..." }),. Commit that change so every later deploy uses the same namespace.
  • The binding is not in ocre new apps 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; fetch and write return 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 write or delete. 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):

FunctionReturnsKV costOn KV failure
ocre::cache::fetch(&ctx, key, ttl, compute)Result<T>1 read; a miss adds 1 writeLogged; computes the value
ocre::cache::read::<T>(&ctx, key)Result<Option<T>>: None when absent, expired or undecodable1 readLogged; None
ocre::cache::write(&ctx, key, &value, ttl)Result<()>1 writeError (500)
ocre::cache::delete(&ctx, key)Result<()>; deleting an absent key succeeds1 writeError (500)
ocre::cache::clear(&ctx, prefix, limit)Result<Cleared>: how many were deleted, and more when keys with the prefix remain1 list + 1 write per keyError (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_at of every record the fragment shows. Editing a record changes its key, so the old fragment is never read again and expires with its TTL: no delete, 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_at of the records inside, e.g. cache::key(&[&"posts", &post.id, &post.updated_at, &newest_comment_at, &"card-v1"]) with newest_comment_at from SELECT MAX(updated_at) FROM comments WHERE post_id = ?1. Rails’ touch: true does the same by updating the parent’s updated_at when a child changes; do it in the child’s save when the parent’s key only uses post.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 SELECT run twice with the same parameters through ctx.db() (or ctx.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 ... RETURNING through first) 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, read and fragment read each KV key at most once per request, and remember what write and delete did (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_STOREStore
absent or kvWorkers KV, the CACHE namespace
nullNone: 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:

TTLWrites a day, per keyKeys that fit in 1,000 writes
60 s (minimum)1,440none: one key alone is over the budget
5 minutes2883
1 hour24about 40
1 day1about 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.

ConstructorHeaderUse for
CacheControl::no_store()no-storeSecrets, one-time tokens
CacheControl::no_cache()private, no-cachePer-visitor pages, with an ETag: the browser keeps a copy and asks every time
CacheControl::private(ttl)private, max-age=NPer-visitor data that may be stale for N seconds without asking
CacheControl::public(ttl)public, max-age=NResponses identical for every visitor
.stale_while_revalidate(window)adds stale-while-revalidate=NAfter 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, put does nothing. It is also local to one data center, and the Worker still runs (and counts) for every request.
  • Workers Cache (cache: { enabled: true } in worker of cloudflare.config.ts) works on workers.dev too and serves CacheControl::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 in public/, which are otherwise free (pricing). Its cache key ignores cookies and Accept-Language, so only mark responses public when they are the same for every visitor; responses with Set-Cookie are 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

RailsOcre
Rails.cache.fetch/read/write/deleteocre::cache::fetch/read/write/delete
cache view helper, cache_key_with_versionocre::cache::fragment in the handler, ocre::cache::key
render collection:, cached: trueocre::cache::fragments (KV bulk reads)
Template digestsA 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 storesWorkers KV, the one store; CACHE_STORE=null for none
Query cache, local cachePer-request caches in Ctx
fresh_when, stale?, http_cache_foreverConditional::fresh_when, CacheControl::public(..)
bin/rails dev:cacheCACHE_STORE=null in .dev.vars

See also

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 Post model 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") includes locales/en.yml and locales/fr.yml (paths from the app root) with include_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 the I18n extractor. Keep it last in routes(), after the // ocre:routes marker, so it covers every route; generators insert new routes above it. Without it, I18n answers 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:

RuleDetail
One root keyThe file starts with its code on its own line (fr:), at column 0; everything else is indented under it
IndentationSpaces, not tabs; siblings use the same indentation (2 spaces by convention)
KeysASCII letters, digits, _ and -, followed by : or by : at the end of the line; no duplicate keys
ValuesStrings on the same line: double-quoted (escapes \n, \t, \", \\, \/, \uXXXX), single-quoted ('' for a quote), or plain
Plain valuesMust 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)
CommentsLines starting with #, and # ... after a value
Not supportedLists (- 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} (value is anything Display). A placeholder without a value stays as written.
  • i18n.t("hello.posts").count(n) picks the plural form for n and 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 a String (flash messages, JSON, emails).
  • i18n.path("/posts") is /fr/posts for 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:

  1. The {locale} path segment, for routes nested with .nest("/{locale}", routes). An unknown code is a 404.
  2. The locale cookie, when it names a known locale.
  3. Accept-Language, by quality order: an exact code first, then the same language (fr-CH matches fr, pt matches pt-BR).
  4. 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.

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 requiresRule
English and every language not listed belowone, otherone for 1
French (fr), Portuguese (pt), Hindi (hi), Persian (fa), Bengali (bn)one, otherone for 0 and 1
Russian (ru), Ukrainian (uk), Belarusian (be)one, few, many, otherone for 1, 21, 31…; few for 2-4, 22-24…; many otherwise
Polish (pl)one, few, many, otherone for 1 only; few for 2-4, 22-24…; many otherwise
Czech (cs), Slovak (sk)one, few, otherone for 1, few for 2-4
Arabic (ar)zero, one, two, few, many, other0, 1, 2; few for 3-10 (mod 100); many for 11-99 (mod 100)
Hebrew (he, iw)one, two, other1, 2
Japanese (ja), Chinese (zh), Korean (ko), Vietnamese (vi), Thai (th), Indonesian (id), Malay (ms), Lao (lo), Burmese (my)otheralways 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) show translation missing: fr.hello.posts in its place, so gaps are visible. Real output of /hello with Accept-Language: fr before hello.posts was added to fr.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 shows translation 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 replaces translation 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>, &lt;b&gt;Ada&lt;/b&gt;.. 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.

CallEnglishFrench
i18n.l("2026-09-29", "long")September 29, 202629 septembre 2026
i18n.l("2026-09-29 14:05:00", "short")29 Sep 14:0529 sept. 14h05
i18n.l(at, "%A %-d %B")Tuesday 29 Septembermardi 29 septembre
i18n.number(1234567.5)1,234,567.51 234 567,5
i18n.number_with_precision(3.14159, 2)3.143,14
i18n.currency(1234.5, "€")€1,234.501 234,50 €
i18n.time_ago_in_words(post.created_at)about 3 hoursenviron 3 heures
i18n.distance_of_time_in_words(from, to)2 days2 jours
  • l(value, format): a format name is looked up in date.formats.<name> for a date without time and in time.formats.<name> otherwise (built in: default, short, long); a format containing % is a pattern with the directives of strftime. Month and day names come from date.month_names, date.abbr_month_names, date.day_names (Sunday first) and date.abbr_day_names, written as comma-separated lists since locale files have no YAML lists; %p uses time.am/time.pm. An unknown format name shows translation missing: fr.date.formats.<name>; a value that is not a time is returned unchanged.
  • Numbers use number.format.delimiter and number.format.separator (French: a no-break space and ,); currency lays out the unit with number.currency.format.format (%u the unit, %n the number: %u%n in English, %n %u in French). For amounts in cents, divide first: i18n.currency(cents as f64 / 100.0, "€").
  • time_ago_in_words uses 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) reads models.post, a text or plural forms. Without it, the humanized name (blog_post gives Blog post) for every count: Ocre has no runtime inflector, so plurals of other languages are translation keys.
  • attribute("post", "title") reads attributes.post.title, then attributes.title, then humanizes the field like FieldError::full_message (author_id gives Author).

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:

  1. errors.models.post.attributes.title.blank
  2. errors.models.post.blank
  3. errors.attributes.title.blank
  4. errors.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.

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 same I18n in German, keeping the scope; LOCALES.locale(code) does it without a request. The default locale is the first code of ocre::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.erb by 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::load takes any (code, text) pairs of &'static str, so translations can come from generated Rust constants or other include_str! files as well as locales/*.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 by ocre i18n missing before deploying.

Translating generated scaffolds

Generated scaffolds, auth pages and mailers contain English strings. To translate one, for example the posts index:

  1. 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."
    
  2. Add i18n: I18n to 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 })
    }
  3. Replace the text in the template: <h1>{{ i18n.t("posts.index.title") }}</h1>, <a href="/posts/new">{{ i18n.t("posts.index.new") }}</a>.

  4. Flash messages are set in Rust: session.flash("notice", i18n.t("posts.created").to_string())?; (take i18n: I18n in create too).

  5. The generated templates/layout.html has <html lang="en">. To make it follow the request, every view that extends the layout needs an i18n field; then write <html lang="{{ i18n.locale() }}">.

  6. 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

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 to routes() in src/lib.rs.
  • ocre dev runs a debug build: the development error page, Server-Timing and debug log lines only exist there. ocre deploy builds in release mode.
  • Reading production logs with ocre logs needs 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:

VariableValuesDefault in ocre devDefault after ocre deploy
LOG_LEVELdebug, info, warn, error, off (warning, fatal accepted)debuginfo
LOG_FORMATjson, texttextjson
// 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

LineLevelWhen
GET /posts 200 in 40 ms (db: 3 queries, 12 ms), with -> /posts/7 for a redirectdebugevery request
event order.placed {"order_id":42} with the event’s tagsinfoctx.events().notify(..)
SQL (1.2 ms) SELECT ... with duration_ms (and failed on errors)debugevery D1 statement through ctx.db()
[ocre] <message> with error_class, handled, sourceerrora handler returned Error::Internal (a 500)
panicked at src/posts.rs:12:5: <message>errora panic, which ends the request
[ocre jobs] ..., [ocre cron] ..., [ocre mail] ...info / errorjobs, 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:

ErrorStatusThe client sees
Error::NotFound (option.or_404()?)404Not found
Error::bad_request("...")400its message
Error::Unauthorized401Unauthorized
Error::Forbidden403Forbidden
Error::Conflict("...")409its message
Error::PayloadTooLarge("...")413its message
Error::Invalid(fields) (validations)422Validation failed and the field errors
Error::TooManyRequests429Too many requests. Try again later.
Error::internal("..."), and ? on a worker::Error500Internal 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(())
}
CallRailsHandledSeverity
ctx.errors().report(&err, Options::new())Rails.error.reporttrue (option)warning (option)
ctx.errors().handle(result) returns Option<T>Rails.error.handle; .unwrap_or(x) is fallback:truewarning
ctx.errors().record(result) returns resultRails.error.recordfalseerror
ctx.errors().unexpected("...")Rails.error.unexpected: panics in debug builds, reports in release buildstrueerror
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-Id on every response: the id of the request’s log lines and error reports (Cloudflare’s CF-Ray id when present). Handlers get it with the ocre::RequestId extractor.
  • Server-Timing in ocre 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 any Debug value in the ocre dev terminal; 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) }} its Debug form (Rails’ debug and inspect helpers).
  • Unit tests run natively: cargo test (or ocre test) can use a debugger such as rust-lldb or an IDE on the app’s model and helper code.
  • Chrome DevTools. The local dev server started by ocre dev exposes the V8 inspector (wrangler’s --inspector-port, 9229 by default): open chrome://inspect to 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 (LIMIT queries, streamed files) rather than hunting leaks with Valgrind-style tools.

See also

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 use ocre new blog --starter blog, whose Post model has title:string, body:text and published:boolean).
  • Rust installed with rustup; the app’s rust-toolchain.toml adds the wasm32-unknown-unknown target.
  • Node.js 22 or newer and the app’s npm packages (npm install, run by ocre new).

What you can test, and where

CodeNative cargo testRequest tests (ocre test --e2e)
Pure functions, a model’s validate(), mailer functions (the Email they build), askama templatesyesyes
ocre::password, ocre::token, ocre::jwt::{encode_with, decode_with}yesyes
Model queries, uniqueness and reference checks, callbacksnoyes
Handlers with State(ctx), Session, Flash, Cookies, CurrentUser; CSRF and security headersnoyes
Sending mail, jobs, crons, R2 files, KV cache, realtime broadcasts, mailboxesnoyes
Pages whose behavior needs JavaScript (htmx, Trix, uploads)nobrowser 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

FileWritten byWhat it holds
tests/app.rsocre new/up and the home page answer
tests/<plural>.rsocre g scaffoldlist, show, create, update, delete and a rejected invalid record
tests/api_<plural>.rsocre g apithe same through the JSON API
tests/factories/<model>.rsocre g model (and scaffold, api)valid, unique attributes: post(), .insert(), .form(), .json()
tests/fixtures/<table>.ymlyou, or ocre db dump --dir tests/fixturesnamed rows loaded into the test database before each run
tests/system/<name>.spec.tsocre 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:

  1. cargo test and the wasm32 check;
  2. a fresh test database in .wrangler/test-state (the development data in .wrangler/state is untouched): migrations, then tests/fixtures/*.yml;
  3. one server for the whole run (the app’s wrangler, as cf dev runs it) on port 8788 (--port to change it), logging to .wrangler/test-state/dev.log;
  4. cargo test -- --ignored --test-threads=1, with OCRE_TEST_URL, OCRE_TEST_STATE and OCRE_TEST_LOG set for ocre::testing;
  5. tests/e2e.sh with BASE_URL, when the app has one;
  6. 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):

MethodDoes
get, post(path, &form), post_json, patch_json, put_json, delete, requestsend 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() then log.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

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 new runs npm install; after --no-install or in a fresh clone, run npm install in the app).
  • Rust installed with rustup (the app’s rust-toolchain.toml adds the wasm32-unknown-unknown target) and Node.js 22 or newer (Cloudflare’s cf CLI needs it).
  • A Cloudflare account; the free plan is enough. Sign up at https://dash.cloudflare.com/sign-up.
  • If the app has attachment fields (a STORAGE: bindings.r2(...) entry in cloudflare.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 in CLOUDFLARE_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 writes accountId: "<id>", at the top of cloudflare.config.ts, before worker:. Without --account-id, ocre new --login fails with this Cloudflare login has several accounts and a hint listing them as id (name).
  • Existing app: add accountId: "<id>", inside defineConfig({ ... }) yourself, or set CLOUDFLARE_ACCOUNT_ID in the environment. npx cf auth whoami lists 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, and npx cf auth activate <profile> . in the app directory binds it to that app, so every ocre command 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 {})"]
  1. 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 that rustc has the wasm32-unknown-unknown target (if not: the wasm32-unknown-unknown target is not installed for rustc at <sysroot>, with the hint to use a rustup toolchain), and that node_modules has the app’s cf and wrangler (if not: the app's npm packages are not installed ..., with the hint to run npm install).
  2. Database. It looks for the DB database (DB: bindings.d1({ name: "blog" })) with cf d1 list --name blog and creates it with cf d1 create --name blog when missing. The config needs no database id: Ocre resolves the name to the id each time.
  3. Queues. For every queue cloudflare.config.ts names (bindings.queue and triggers.queue names, and each deadLetterQueue, written by the first ocre g job), it checks cf queues list and runs cf queues create --queue-name <name> for the missing ones. A consumer of a missing queue would fail the deploy.
  4. R2 buckets. Each bindings.r2({ name }) (<app>-storage, written by the first attachment field) is checked with cf r2 buckets get and created with cf r2 buckets create when 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 run ocre deploy again”.
  5. KV namespaces. Each bindings.kv() entry without an id (the CACHE binding of ocre g cache) gets the namespace titled <worker name>-<binding>, lowercased with _ as - (blog-cache). If cf kv namespaces list already has it, it is linked; otherwise cf kv namespaces create makes it. Either way ocre deploy rewrites the entry to CACHE: bindings.kv({ id: "<id>" }), in cloudflare.config.ts: commit that change.
  6. SECRET_KEY_BASE. It runs cf workers secrets list --worker <name>. When the Worker has no SECRET_KEY_BASE (or does not exist yet), it takes the one of .prod.vars, or else generates a random one (128 hex characters, like ocre 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 to cf deploy --secrets-file, and deletes it afterwards, even when the deploy fails: cf keeps a Worker’s existing secrets (including those of ocre secrets push) only when --secrets-file is passed; deployed without it, the new version has none (cf 1.0.0-beta.5; cf rejects an empty .env file, 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.
  7. 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.
  8. Build and upload. cf deploy, with OCRE_BUILD=--release. cf delegates the build to the app’s wrangler, which runs the build.command of wrangler.config.ts, worker-build --release (installing worker-build 0.8 with cargo install if needed): an optimized WebAssembly build passed through wasm-opt. Durable Objects (the CHANNELS namespace of realtime apps) are created by this step from the exports of cloudflare.config.ts; nothing else to provision.
  9. URL. The report shows the first https://...workers.dev address 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 build and the build step of cf deploy hand the build (and, for cf dev, the local server) to the app’s wrangler (node_modules/wrangler, 4.136 or newer), which reads wrangler.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 --e2e and ocre doctor’s migration check run node_modules/.bin/wrangler d1 ... DB --local with a config Ocre derives from cloudflare.config.ts (.wrangler/ocre-d1.json) and the same .wrangler/state as cf dev. Their --remote forms 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"}
KeyPresentMeaning
ok, commandalwaystrue, "deploy"
urlwhen cf printed a workers.dev URLThe deployed URL. Absent when the Worker is only on a custom domain (workersDev: false)
secret_createdonly when trueA SECRET_KEY_BASE was uploaded with this deploy. The secret itself is never printed
secret_savedonly when set".prod.vars": the generated secret was written there
provisionedonly when not emptyResources 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):

NameKindSet it withNeeded for
SECRET_KEY_BASEsecretocre deploy (first deploy)Sessions, flash, CSRF-protected forms, JWTs
MAIL_FROMvarMAIL_FROM: bindings.text(...), written by ocre new with a placeholderSending email: an address on your domain
MAIL_ADAPTERvarMAIL_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_KEYsecretocre secrets push RESEND_API_KEY --file .prod.varsMAIL_ADAPTER = "resend"
ALLOWED_ORIGINSvarALLOWED_ORIGINS: bindings.text("https://app.example.com")A frontend on another origin calling the app from the browser (CORS, CSRF)
ALLOWED_HOSTSvarALLOWED_HOSTS: bindings.text("example.com, .example.com")Answering only on your own host names (other hosts get 403)
SECRET_KEY_BASE_PREVIOUSsecretocre secrets push SECRET_KEY_BASE_PREVIOUS --file .prod.varsRotating SECRET_KEY_BASE without signing anyone out
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET (or GOOGLE_...)secretocre secrets push <NAME>... --file .prod.varsocre 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 deploy stops before cf 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 OcreChannel class of the first ocre 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 auth use 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 the workers.dev address: 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 in cloudflare.config.ts (without it, those routes answer 500 and the log names the entry to add), and limit and period (10 or 60 seconds) suit you;
  • your own routes that are expensive or send email call ocre::security::rate_limit with 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:

PrefixWritten 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:

  1. Create a token in the dashboard (My Profile > API Tokens) that can edit what ocre deploy touches: 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.
  2. Store it as the repository secret CLOUDFLARE_API_TOKEN, and your account id as CLOUDFLARE_ACCOUNT_ID (needed when the token sees several accounts) in Settings > Secrets and variables > Actions.
  3. Run the first deploy locally (or commit afterwards) so the KV id that ocre deploy writes into cloudflare.config.ts is committed. A CI deploy without it finds the namespace by title and links it again, in its own checkout.
  4. Commit package.json and package-lock.json; the deploy job installs the pinned cf and wrangler with npm 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

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 ocre CLI installed (cargo install --git https://github.com/tgeselle/ocre.rs ocre-cli).
  • The app in git with a clean worktree: cf migrate refuses 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:

  1. 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.

  2. 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.

  3. 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 as worker; the key is required).

  4. [[migrations]] (realtime apps). cf reports it as unsupported. Replace it with the export of the class, inside worker:

    exports: {
    	// ocre:exports
    	OcreChannel: exports.durableObject({ storage: "sqlite" }),
    },
    
  5. Markers. Ocre’s generators add entries after three marker comments. Put each on its own line: // ocre:env inside worker.env, // ocre:triggers inside worker.triggers (add triggers: [ ... ], if the app has none yet) and // ocre:exports inside worker.exports (add exports: { ... }, if needed). The import line must bring all four names: import { bindings, defineConfig, exports, triggers } from "cf/config";.

  6. Literals. Ocre reads the file without running it, so the values it needs (the worker name, DB’s name, 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

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: one CREATE TABLE per table, with the primary keys, unique and foreign keys that pg_dump adds afterwards (ALTER TABLE ... ADD CONSTRAINT) folded in, as SQLite requires, and the plain indexes;
  • db/import_from_postgres.sql: the rows of each COPY block as INSERTs, 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

PostgresSQLite (D1)ValuesNote
smallint, integer, bigint, serial, bigserialINTEGERas isa serial primary key becomes INTEGER PRIMARY KEY AUTOINCREMENT
real, double precisionREALas is
numeric, decimal, moneyTEXTthe exact digitsOcre’s decimal field type; a REAL would round them
booleanINTEGERt/f become 1/0models read them with ocre::bool_from_sql
uuidTEXTas isnew rows need an id from the app: gen_random_uuid() defaults are dropped
timestamp with time zoneTEXTconverted to UTC, YYYY-MM-DD HH:MM:SS (fractions kept)the format of datetime('now'), which Ocre’s created_at uses
timestamp, date, timeTEXTas is
json, jsonbTEXT + CHECK (json_valid(...))as isquery with json_extract(col, '$.key') or col ->> '$.key'; Ocre’s json field type
an ENUM typeTEXT + CHECK (col IN (...))as isOcre’s enum field type
arrays (text[]…)TEXTa JSON array of stringsread them with json_each
byteaBLOBhex literalsprefer R2 for files (File storage)
citextTEXT COLLATE NOCASEas iscase-insensitive for ASCII letters only
interval, tsvector, ranges, geometry…TEXTas isnoted: 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 model writes the model and a create_ 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 uuid keep their ids as text. For new rows, the app sets one (ocre::token::public_id() gives a random, URL-safe id); or move to id INTEGER PRIMARY KEY with a public_id:token column (Field types).
  • Times: Ecto’s utc_datetime and Rails’ datetime columns are timestamp without time zone holding 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 ocre CLI, installed with cargo 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, which ocre new runs): commands that touch Cloudflare or the dev server run the app’s Cloudflare cf CLI (node_modules/.bin/cf, pinned in package.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-unknown target for ocre dev and ocre deploy.
  • Except ocre new, ocre login, ocre secret, ocre push-keys, ocre version, ocre doctor and ocre help, commands run inside an Ocre app: the CLI walks up from the current directory to the nearest cloudflare.config.ts, and reads the name of its DB: bindings.d1({ name }) entry (see Configuration). A directory with only a wrangler.toml (an app made by an older Ocre) is refused with a hint pointing to Upgrading from wrangler.toml.
  • Commands with --remote, ocre login and ocre deploy need a Cloudflare account (free) and a login (ocre login) or the CLOUDFLARE_API_TOKEN environment variable that cf reads (plus CLOUDFLARE_ACCOUNT_ID when 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

CommandWhat 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 loginLogs in to Cloudflare in the browser, unless already logged in
ocre generate / ocre gGenerates code: see Generators; --pretend, --force, --skip
ocre destroy / ocre dUndoes a recorded generator run
ocre template SOURCEApplies an application template (a file or URL of ocre commands) to the app
ocre migrateApplies D1 migrations (local unless --remote); --status lists pending ones
ocre db createCreates the local database, or with --remote the D1 database on Cloudflare
ocre db prepareLocal, safe to repeat: applies pending migrations, seeds a new database
ocre db seedLoads db/fixtures, then runs db/seeds.sql (local unless --remote); --replant empties the tables first
ocre db resetLocal only: deletes the local database, applies every migration, loads fixtures and seeds
ocre db dropLocal only: deletes the local database
ocre db truncateLocal only: deletes every row, keeps tables and migrations
ocre db versionPrints the last applied migration
ocre db loadRuns the statements of a SQL file (local unless --remote)
ocre db import-postgresConverts a Postgres dump into a migration and a data file for D1
ocre db schemaWrites the database’s CREATE statements to db/schema.sql
ocre db dumpWrites table rows to fixture files db/fixtures/<table>.yml
ocre sql QUERYRuns SQL on D1 and prints the rows
ocre devApplies local migrations, then runs the app with cf dev
ocre testRuns cargo test, the wasm32 check and, with --e2e, the request tests, tests/e2e.sh and the browser tests against a local server
ocre deployCreates missing Cloudflare resources, applies remote migrations, then runs cf deploy
ocre logsStreams the deployed Worker’s live logs (wrangler tail)
ocre push-keysPrints a new VAPID key pair for web push
ocre secretPrints a new random value for SECRET_KEY_BASE
ocre secrets list / push / fetchLists 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 missingChecks the locale files; fails on missing keys or invalid files
ocre doctorChecks the tools and the app’s setup (production settings, .ocre/doctor/ checks); fails when a check fails
ocre ciRuns the CI steps locally (fmt, clippy, tests, wasm32 check, translations); --signoff runs gh signoff
ocre about, ocre versionVersions and the app’s configuration
ocre stats [DIRS]Lines of code per part of the app
ocre notesLists TODO, FIXME and OPTIMIZE comments
ocre time-zonesPrints the IANA time zone names
ocre helpHelp 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 new skips 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):

KeyTypeSet byMeaning
okbooleanevery commandtrue
commandstringevery commandThe 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)
createdstring[]new, generators, db dumpFiles created, relative to the app root (to the current directory for ocre new, so they start with the app name)
updatedstring[]generators, destroy, template, db schema, db dump --forceExisting files changed
skippedstring[]generators (--skip), destroyExisting files kept: by --skip, or left changed by destroy (Cargo.toml, cloudflare.config.ts, package.json, changed lines)
removedstring[]destroyFiles deleted, the generation record last
pretendtruegenerators, destroy--pretend: nothing was written
templatesobject[]generate override{"path", "overridden"} per generator template
aboutobjectversion, aboutcli, app, app_version, ocre, and for about also mode, rust_toolchain, compatibility_date, bindings, vars, features
checksobject[]doctor{"name", "status", "detail", "hint"}, status being ok, warn or fail
statsobjectstatsrows (name, files, lines, loc, functions), code_loc, test_loc
notesobject[]notes{"path", "line", "tag", "text"}
versionstring or nulldb versionLast applied migration file, null when none is
secretsobject[]secrets list{"name", "local", "deployed"}
schedulesobject[]schedules{"cron", "task"}
urlstringdev, deploy, new --deployhttp://localhost:<port>, or the https://....workers.dev URL found in cf deploy’s output
emailstringlogin, new --login, new --deployEmail of the Cloudflare login
pendingstring[]migrate --statusMigration files not applied yet
ranstring[]new, db seed, db reset, db dump, i18n missingSteps performed, in order
rowsarraysqlD1’s JSON: one object per statement, with results (the rows), success and meta
routesobject[]routes{"method", "path", "handler"}, sorted by path then method
remotetruemigrate --status --remote, db seed --remote, db dump --remote, sql --remoteThe command used the production database
secretstringsecret128 lowercase hex characters
secret_createdtruedeploy, new --deployThe deploy uploaded a new SECRET_KEY_BASE because the Worker had none
provisionedstring[]deployCloudflare resources created because they were missing, e.g. D1 database blog, queue blog-jobs
nextstring[]new, migrate --status, generators, destroy, db tasks, secrets listCommands 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

CodeWhenstdout with --json
0Success{"ok": true, ...}
1The command failed{"ok": false, "error", "hint"}
2Invalid arguments (unknown flag, missing required argument), detected by the argument parser before the command runsNothing: 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

ErrorHintCause
no cloudflare.config.ts found in this directory or its parentsrun 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.tsconvert 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 readthe 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 appNo Node.js on PATH
`cf <arguments>` failed (exit status: N) (or `wrangler <arguments>`)read the cf output above; the first error line names the causeThe 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 foundinstall Rust with rustup: https://rustup.rsNo Rust on PATH
invalid locale files: followed by one line per problemfix each line named above (quote values with "..." when in doubt); `ocre i18n missing` checks them againocre 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 flagDefault (flag mode)Effect
NAMErequired in flag modeApp 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
--apioffAPI-only app: JSON endpoints, no HTML templates, no askama; Ocre’s default features (html) are off
--full-stackonHTML pages with askama and htmx, plus JSON APIs when generated. --api and --full-stack override each other; the last one wins
--starter <STARTER>emptyempty: 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-loginno loginLog in to Cloudflare (cf auth login, opens a browser) if not logged in yet
--account-id <ACCOUNT_ID>noneCloudflare 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-gitno gitRun git init in the new app
--deploy / --no-deployno deployDeploy right after creating the app; implies --login
--no-installinstallSkip npm install in the new app (for offline use); run npm install in it before ocre dev. Cannot be combined with --deploy
-y, --yesoffNever prompt, even in a terminal
--ocre-path <OCRE_PATH>git dependencyUse 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>noneApplication 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:

  1. Checks the name and that ./NAME does not exist, resolves --ocre-path, and checks that git runs when --git is given; --api with --starter qa stops with the 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.
  2. With --login or --deploy: runs cf auth whoami, runs cf auth login if not logged in, and picks the account (--account-id must be one of the login’s accounts; with one account none is needed).
  3. Writes the app: Cargo.toml, cloudflare.config.ts (with accountId when 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 apps templates/layout.html, templates/home.html and templates/error.html. An API-only app’s Cargo.toml has ocre = { ..., default-features = false }, no askama, and [package.metadata.ocre] mode = "api", which generators read.
  4. Writes .dev.vars (git-ignored) with a new random SECRET_KEY_BASE and MAIL_ADAPTER=log, used by ocre dev only.
  5. With --starter blog: runs the equivalent of ocre g scaffold Post title:string body:text published:boolean (ocre g api in an API-only app). With --starter qa: runs the equivalents of 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 (recorded for ocre destroy), writes the room’s controllers, templates and request tests over the generated files, and removes the generated question pages; created lists the files that remain.
  6. With --template: runs the template’s lines in the new app, like ocre template; the files they create are added to created and the lines to ran.
  7. Unless --no-install: runs npm install in the app, which installs the pinned cf, wrangler and typescript into node_modules/ and writes package-lock.json (commit it). ran gets npm install (cf 1.0.0-beta.5, wrangler 4.144.0).
  8. With --git: runs git init --quiet.
  9. With --deploy: runs the same steps as ocre 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:

ErrorHint
missing app namerun `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 existschoose 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 installedinstall git, or create the app without `--git`
`git init` failed (<status>)none
Cloudflare login did not completerun `ocre login` and approve access in the browser, or set CLOUDFLARE_API_TOKEN
this Cloudflare login has several accountspass --account-id with one of: abc123 (Ada), def456 (Work) (the login’s accounts)
account `zzz` is not available to this Cloudflare loginuse one of: abc123 (Ada), def456 (Work)
npm not foundinstall 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 packagesdrop --no-install, or deploy later with `npm install && ocre deploy`
this Cloudflare login has no accountscreate 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.

FlagEffect
--pretendReport the files that would be created or updated; write nothing
--forceOverwrite files that already exist
--skipKeep 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 flagDefaultEffect
GENERATORrequiredThe generator as typed after ocre g (scaffold, controller, or an app generator’s name)
NAMElatest runThe name given to the generator (Post; BlogPost, blog_post and blog-post match each other). Without it, the latest run of that generator
--forceoffDelete the generated files even if they changed since; leave in place the added lines that changed
--pretendoffShow 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:

ErrorHint
no recorded `ocre g scaffold Temp` run to destroyrecorded 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:

ErrorHint
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.

FlagDefaultEffect
--remotelocalUse the production database on Cloudflare instead of the local one in .wrangler/state/v3/d1
--statusoffList 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.

FlagDefaultEffect
--remotelocalSeed the production database on Cloudflare (db/seeds.sql only; refused when there are fixtures to load)
--replantoffLocal 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/fixturesFixture 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.

FlagDefaultEffect
--tables <TABLES>every app tableComma-separated table names
--dir <DIR>db/fixturesDirectory of the fixture files, relative to the app root
--forceoffOverwrite existing fixture files
--remotelocalRead 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 flagDefaultEffect
QUERYrequiredSQL statements separated by ;
--remotelocalRun 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).

FlagDefaultEffect
--port <PORT>8787Port of the local server
--no-cache / --cacheneitherTurn ocre::cache off (CACHE_STORE=null in .dev.vars) or back on; kept for later runs

Steps:

  1. Checks the locale files (see ocre i18n missing); a file the Worker could not load stops here.
  2. Checks that rustc has the wasm32-unknown-unknown target.
  3. Checks that the app’s npm packages are installed (node_modules/.bin/cf and wrangler).
  4. Applies local migrations, like ocre migrate (through the app’s wrangler).
  5. Runs cf dev --port <PORT>. cf delegates the build and the local server to the app’s wrangler, which runs the build.command of wrangler.config.ts; ocre dev sets OCRE_BUILD=--dev, so worker-build makes an unoptimized build, much faster to compile than the --release build 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.vars provides 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:

  1. cargo test, the native unit tests, with the arguments after -- passed on (ocre test -- models runs the tests whose name contains models); request tests are #[ignore]d here;

  2. cargo check --target wasm32-unknown-unknown, the type check of the real build;

  3. with --e2e:

    • a fresh test database in .wrangler/test-state (migrations, then tests/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, with OCRE_TEST_URL, OCRE_TEST_STATE and OCRE_TEST_LOG set for ocre::testing,
    • sh tests/e2e.sh with BASE_URL, when the app has it,
    • node_modules/.bin/playwright test with BASE_URL, when tests/system/ has *.spec.ts files (ocre g system_test),

    then stops the server.

FlagDefaultEffect
--e2eoffAlso run the request tests, tests/e2e.sh and the browser tests against a local server (see Testing)
--port <PORT>8788Port 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:

  1. Checks the locale files, the wasm target and that node_modules has the app’s cf and wrangler (hint: npm install).
  2. Database: looks for the DB database with cf d1 list --name <database> and creates it with cf d1 create --name <database> when missing.
  3. Queues: lists the account’s queues (cf queues list) and creates, with cf queues create --queue-name <name>, each queue the config names (bindings.queue names, triggers.queue names and their deadLetterQueue) that is missing. A consumer of a missing queue would fail the deploy.
  4. R2 buckets: for each bindings.r2({ name }) (the STORAGE bucket added by the first attachment field), runs cf r2 buckets get <name> and creates the bucket (cf r2 buckets create) when Cloudflare answers that it does not exist (API code 10006).
  5. KV namespaces: for each bindings.kv() entry without an id (the CACHE binding added by ocre g cache), finds the namespace titled <worker>-<binding> (lowercase, _ as -, e.g. blog-cache) in cf kv namespaces list, creates it when missing, and rewrites the entry to CACHE: bindings.kv({ id: "<id>" }), in cloudflare.config.ts. Commit that change: later deploys reuse the namespace.
  6. SECRET_KEY_BASE: runs cf workers secrets list --worker <name>. When the Worker does not exist yet or has no SECRET_KEY_BASE, a new random value (or the one of .prod.vars) is uploaded with the deploy. Every deploy passes cf 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 of ocre secrets push) only when --secrets-file is 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.
  7. 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.
  8. Deploy: cf deploy, with OCRE_BUILD=--release; cf delegates the release build to the app’s wrangler (build.command of wrangler.config.ts).
  9. Reports the https://...workers.dev URL 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):

ErrorHint
`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
FlagEffect
--format pretty|jsonpretty (default), or one JSON object per event
--status ok|error|canceledKeep 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:

ErrorHint
name the secrets to uploade.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.varsadd `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).

ArgumentDefaultEffect
FILTERnoneKeep 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.

ArgumentDefaultEffect
TASKrequiredThe task, as in src/schedules/<task>.rs
--port8787The 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/ that src/lib.rs does 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, plus few/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:

  1. cargo fmt --check (hint on failure: run `cargo fmt`, then `ocre ci` again);
  2. cargo clippy --all-targets -- -D warnings (hint: fix the warnings above (`rustup component add clippy` if cargo has no clippy command));
  3. cargo test;
  4. cargo check --target wasm32-unknown-unknown (after checking that rustc has the target);
  5. ocre i18n missing, when src/lib.rs declares locales.
FlagEffect
--signoffAfter 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.

CheckFails or warns when
rustFails: rustc has no wasm32-unknown-unknown target
nodeFails: node --version does not run or is older than 22 (cf’s requirement)
cloudflare loginWarns: 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 packagesIn 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)
configIn 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 bindingOnly 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
migrationsWarns: local migrations are pending (wrangler d1 migrations list DB --local, through the app’s wrangler)
local secretsFails: .dev.vars has no SECRET_KEY_BASE
production secretsWhen 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 configSettings 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

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

Before you start

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

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

How generators behave

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

Generator flags

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

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

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

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

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

Running a controller generator again, keeping what exists:

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

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

Generation records and ocre destroy

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

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

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

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

Fields

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

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

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

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

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

Model names

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

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

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

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

ocre g model

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

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

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

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

Files:

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

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

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

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

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

ocre g scaffold

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Errors: the field and name errors; src/<plural>.rs already exists (or a template) when the pages exist; in an API-only app, --realtime updates HTML pages; this app is API-only with the hint run `ocre g scaffold` without --realtime; to push JSON to clients, see 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 flagDefaultMeaning
NAMErequiredSingular model name
FIELDSrequired, at least onename:type fields; attachments must be optional (file:attachment?)
--graphqloffAlso expose the resource on /graphql. Per the CLI’s help, it adds about 1.1 MB of WebAssembly and 20-60 ms of CPU when a Worker instance starts

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

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

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

Routes of src/products_api.rs:

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

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

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

ocre g resource

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

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

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

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

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

ocre g controller

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

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

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

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

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

Errors:

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

See Controllers, routing, views and htmx.

ocre g auth

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

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

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

In a new full-stack app:

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

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

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

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

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

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

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

Errors:

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

See Authentication.

ocre g migration

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

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

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

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

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

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

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

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

Index and rename migrations:

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

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

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

Errors:

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

ocre g mailer

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

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

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

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

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

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

ocre g mailbox

ocre g mailbox

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

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

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

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

ocre g webhook

ocre g webhook <name> [--standard]

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

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

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

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

ocre g external_job

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

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

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

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

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

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

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

ocre g seo

ocre g seo

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

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

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

ocre g job

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

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

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

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

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

Errors:

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

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

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

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

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

See Background jobs and schedules.

ocre g schedule

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

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

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

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

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

Errors:

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

See Background jobs and schedules.

ocre g cache

ocre g cache

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

ocre g cache
  update  cloudflare.config.ts

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

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

ocre g data

ocre g data <NAME>

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

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

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

ocre g system_test

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

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

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

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

ocre g ci

ocre g ci

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

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

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

  create  .github/workflows/ci.yml

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

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

ocre g push

ocre g push

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

ocre g pwa

ocre g pwa

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

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

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

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

ocre g locale

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

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

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

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

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

Errors:

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

See Translations.

ocre g override

ocre g override [PATHS]...

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

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

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

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

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

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

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

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

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

ocre g generator

ocre g generator <NAME>

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

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

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

App generators (ocre g <name>)

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

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

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

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

description = "Service object in src/services/"

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

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

Errors:

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

See also

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).
  • references fields need the referenced model to exist first (ocre g model Author name:string before author:references).
  • Everything below was checked by running the generators of the current ocre CLI in a new app and quoting the files they wrote.

Syntax

A field is name:type, optionally followed by modifiers:

WrittenMeaning
title:stringRequired: 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

TypeSQL columnRust typeHTML form inputJSONGraphQLGenerated validation
stringTEXTString<input>stringString“can’t be blank” when required
textTEXTString<textarea rows="5">stringString“can’t be blank” when required
rich_textTEXTString (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
integerINTEGERi64<input type="number" step="1">numberIntwithin ±ocre::MAX_SAFE_INTEGER
floatREALf64<input type="number" step="any">numberFloatnone (forms: “is not a number”)
booleanINTEGER NOT NULL DEFAULT 0bool<input type="checkbox" value="true">true / falseBooleannone; cannot be optional
dateTEXTString<input type="date">string YYYY-MM-DDString“is not a valid date”
datetimeTEXTString<input type="datetime-local">string YYYY-MM-DD HH:MM[:SS] (space or T)String“is not a valid date and time”
timeTEXTString<input type="time">string HH:MM[:SS]String“is not a valid time”
decimalTEXTString<input inputmode="decimal">string such as "19.99"String“is not a decimal number”
uuidTEXTString<input>stringString“is not a valid UUID”
referencesINTEGER REFERENCES <plural>(id) ON DELETE CASCADE, named <name>_id, indexedi64<input type="number" step="1">numberInt“must exist” in create/update
attachmentfour 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 columnsthe four columns (read only)size and content type (v.file); “can’t be blank” when required
jsonTEXT CHECK (json_valid(<name>))ocre::serde_json::Value<textarea rows="5" spellcheck="false" placeholder="{}">the JSON value itselfJSON scalarforms: “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 valuestring, one of the valuesnot supported (--graphql refuses it)forms: “is not included in the list”; cannot be unique
public_id:tokenTEXT NOT NULL with a unique indexString in the record (set by create), absent from New and Changes; the record’s id is not serializednonenot sentnot supported with --graphqlnone
lock_version:integerINTEGER NOT NULL DEFAULT 0i64 in the record, Option<i64> in Changes, absent from New<input type="hidden">numberInt (patch only)none; update answers 409 when stale
polymorphic:<a>,<b>...<name>_type TEXT CHECK (... IN ('a', 'b')) and <name>_id INTEGER, indexed togethera generated enum (CommentableType) and i64; an accessor returning Commentable<select> and <input type="number">string and numbernot supported“must exist” in create/update
attachmentsa child table <model>_<singular>a child model; attach_<name> / replace_<name> / purge_<name> on the parentthe show page’s <input type="file" multiple>POST/GET/DELETE /api/<plural>/{id}/<name>not supportedthe 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:

AliasSame asWhy
int, small_int, big_intintegerSQLite stores every integer in up to 8 bytes whatever the declared size; D1 returns them exactly within ±(2^53 - 1)
doublefloatSQLite REAL is a 64-bit float
boolboolean
date_timedatetime
jsonbjsonD1 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:

StructRoleRequired fieldOptional field (?)
PostA row read from D1, serialized as the JSON responseTOption<T>
NewPostInput of create (JSON body, GraphQL input, parsed form)TOption<T> with #[serde(default, deserialize_with = "ocre::optional")]
PostChangesInput of update: absent fields keep their valueOption<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 textString (bool for checkboxes)String, empty means None

The serde helpers come from the ocre crate:

HelperAcceptsResult
ocre::optionalmissing 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::patchsame as optionalmissing key = None (keep), null or blank string = Some(None) (clear), value = Some(Some(value))
ocre::patch_jsonany JSON valuemissing = None, null = Some(None), anything else (a JSON string stays a string) = Some(Some(value))
ocre::bool_from_sqltrue/false or the integers 0/1 that D1 returnsbool
ocre::json_from_sqlthe JSON text D1 returns (or an already parsed value)serde_json::Value
ocre::optional_json_from_sqlsame, or NULLOption<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>; the required attribute 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 as NULL.
  • No length limit is generated. Add v.max_length(..) in validate() 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 that ocre::security::sanitize cleaned 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.html loads 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 from ocre::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”) or v.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) through ocre::optional.

float

price REAL NOT NULL,
discount REAL,
  • Rust: f64 / Option<f64>. Form: <input type="number" step="any">, parsed with v.number (“is not a number”).
  • No generated validation: add v.range("price", self.price, 0.0..=1_000_000.0) or v.check(..) in validate() 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.
  • NewItem has #[serde(default)] pub active: bool (missing = false), ItemChanges pub active: Option<bool>.
  • Form: <input type="checkbox" name="active" value="true">. An unchecked box is not submitted, so the form struct defaults it to false.
  • A boolean cannot be optional: flag:boolean? fails with error: boolean field `flag` cannot be optional and hint: booleans are true or false (a checkbox); drop the `?` . GraphQL inputs default it to false (#[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 written YYYY-MM-DD (month lengths and leap years checked: 2024-02-29 passes, 2026-02-30 fails with “is not a valid date”).
  • Form: <input type="date">, which browsers submit as YYYY-MM-DD.

datetime

starts_at TEXT NOT NULL,
ends_at TEXT,
  • Rust String, stored exactly as sent. Validation: v.datetime(..) accepts YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS, with a space or T between 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 as 2026-09-29T18:30.
  • Values are not normalized: a record created with 2026-09-29T18:30 returns "starts_at":"2026-09-29T18:30", while created_at uses SQLite’s 2026-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) accepts HH:MM or HH:MM:SS from 00:00 to 23:59:59 (“is not a valid time”). No time zone.
  • Form: <input type="time">, which browsers submit as HH:MM.

decimal

price TEXT NOT NULL,
discount TEXT,
  • An exact number, for money: stored as its text ("19.99"), so no digit is lost. A float (REAL) would store 0.1 + 0.2 as 0.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 price sorts "10.00" before "9.50". Sort or sum with CAST(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() through worker::js_sys or 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 omits NOT NULL). Deleting an author deletes its items (ON DELETE CASCADE); files of the deleted items stay in R2. An optional reference is set to NULL instead (ON DELETE SET NULL).
  • create and update check 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 to src/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_id names the column writer_id (still pointing to authors); the association method is then item.writer(&ctx) and the form label “Writer”. The column must be snake_case and end in _id (invalid foreign key column `writer` for `author` with hint: 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_id and author:references:writer_id? are the same. Only references and enum take 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 an ocre::storage::Attachment (item.doc() returns Option<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 gets v.check("image", self.image.is_none(), "can't be blank") in NewItem::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, and GET /<plural>/{id}/<name> to download.
  • JSON APIs: attachments must be optional (ocre g api Document file:attachment fails with error: attachment `file` must be optional in a JSON API); files are sent with PUT /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 named edit, delete or new (they clash with scaffold routes), and <name>_key, _filename, _content_type, _size cannot 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::Value binds as its compact JSON text with params![value]; D1 returns the text, which json_from_sql parses 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: PATCH with "meta": "{}" stores the string "{}", not an object; null clears it.
  • Forms: a <textarea rows="5" spellcheck="false" placeholder="{}"> parsed with v.json(..); text that does not parse (including an empty required field) shows “is not valid JSON”. Optional fields use v.optional_json(..): empty means NULL.
  • GraphQL: the JSON scalar (async-graphql’s scalar for serde_json::Value).
  • Cannot be unique: data:json^ fails with hint: 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::ALL lists the values in order, status.as_str() and Display give the stored text, FromStr parses it back ("archived".parse::<Status>() is an Err), and ocre::IntoParam binds it in params![...]. The record, NewTask and TaskChanges hold Status (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 with v.one_of("status", ...) (“is not included in the list”), or v.optional_one_of(..).
  • JSON: the value as a string, "status": "doing". Another string is refused with a 400 by the JSON extractor (serde’s unknown variant) before validation.
  • GraphQL: not supported yet: ocre g api Ticket state:enum:open,closed --graphql fails with error: enum `state` is not supported with --graphql yet and hint: 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:enum gives enum `s` has no values (hint: list them after the type, e.g. `s:enum:draft,published` ); s:enum:A,b or s:enum:a,a give invalid 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^ gives enum `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>,
  • update adds lock_version = lock_version + 1 and WHERE ... AND (?N IS NULL OR lock_version = ?N); a stale version is Error::Conflict (409), None skips the check.
  • Forms: a hidden input on the edit page. JSON: returned with the record, sent back in PATCH bodies. GraphQL: lockVersion in 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 id for references, find, update and delete; JSON leaves it out.
  • ocre g scaffold, ocre g api and ocre g resource route /{plural}/{id} with the public id: an Id extractor in the controller looks it up (one D1 query) and gives handlers Id(id, key), the integer id and the public id. Links (paths::show(video.public_id)), redirects and the files of photos: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_id from the factory, which sets a random one.
  • On an existing table, ocre g migration add_public_id_to_<table> public_id:token adds the column, gives every row a random id (32 hex digits) and adds the unique index.
  • ocre g api ... --graphql refuses it for now (GraphQL nodes are keyed by the integer id).

Modifiers

ModifierSQLRustValidationNot allowed for
noneNOT NULLTthe type’s checks, “can’t be blank” for string/text/attachment
?nullable columnOption<T>, Option<Option<T>> in Changesthe type’s checks when a value is presentboolean
^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 indexOption<T>uniqueness checked only when a value is givenboolean, 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 '', numbers DEFAULT 0, json DEFAULT '{}', enum its 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 as slug:string?^ and fill it afterwards.
  • references and attachment must 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?` ), and error: `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 of create/update yourself (the command says so in its Next: 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

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 as ocre new and the generators write them; docs-app stands for your app name.
  • Its npm packages installed: ocre new runs npm install (skip it with --no-install, then run npm install yourself). The TypeScript files import their types from node_modules.
  • Setting production secrets needs a Cloudflare login (ocre login, which runs cf 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

FileWritten byCommittedPurpose
cloudflare.config.tsocre new, then generators (ocre g job, schedule, cache, auth, --realtime, attachments) and ocre deploy (KV ids)yesWorker 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.tsocre newyesThe build command (worker-build) and the static-assets directory
package.json, package-lock.jsonocre new and its npm installyesThe pinned cf, wrangler and typescript versions
tsconfig.jsonocre newyesType checking of the two .ts files, for your editor and npx tsc -p .
.dev.varsocre newno (.gitignore)Secrets and variable overrides for ocre dev only
Cargo.tomlocre new, then ocre g api --graphql and --realtime (features)yesRust dependencies, Ocre features, API-only mode
rust-toolchain.tomlocre newyesStable Rust with the wasm32-unknown-unknown target
rustfmt.tomlocre newyesThe 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:

    MarkerInsideEntries 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 OcreChannel Durable Object class (first --realtime scaffold)

    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>(...) and KEY: exports.<kind>(...) calls inside defineConfig({ worker: { ... } }) and parses their arguments as object literals (unquoted keys and trailing commas are fine; comments are skipped). Write the values Ocre needs (the worker name, DB’s name, 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 example cloudflare.config.ts defines `DB` in a form Ocre cannot read with the hint write `DB: bindings.d1({ name: "docs-app" }),`. Values Ocre does not read can be any TypeScript.

  • Checking. npx tsc -p . type-checks both .ts files against cf’s types (a wrong period: 30 on a rate limiter gives Type '30' is not assignable to type '10 | 60'). ocre doctor runs 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 byMarkerEntry
ocre new(written in place)DB: bindings.d1({ name: "docs-app" }),
ocre g cache// ocre:envCACHE: bindings.kv(),; ocre deploy rewrites it to CACHE: bindings.kv({ id: "<id>" }),
first ocre g job// ocre:envJOBS: bindings.queue({ name: "docs-app-jobs" }),
first ocre g job// ocre:triggerstriggers.queue({ name: "docs-app-jobs", deadLetterQueue: "docs-app-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }),
ocre g job <Name> --queue urgentbothJOBS_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:triggerstriggers.scheduled({ schedule: "0 3 * * *" }),, one per expression
first attachment field// ocre:envSTORAGE: bindings.r2({ name: "docs-app-storage" }),
first --realtime scaffold// ocre:envCHANNELS: bindings.durableObject({ worker: "docs-app", exportName: "OcreChannel" }),
first --realtime scaffold// ocre:exportsOcreChannel: exports.durableObject({ storage: "sqlite" }),
ocre g auth// ocre:envAUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "2964407", simple: { limit: 10, period: 60 } }),
you// ocre:envEMAIL: bindings.sendEmail(), (uncomment it for MAIL_ADAPTER "cloudflare")
you// ocre:envKEY: 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

KeyValueNotes
accountId (top level)a 32-character account idWritten 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.namethe app nameThe Worker name; the URL is https://<name>.<your-subdomain>.workers.dev. ocre deploy also names the KV namespaces after it (<name>-cache).
worker.compatibilityDate2026-09-01The workerd behavior the Worker runs with. Change it only on purpose.
worker.entrypointbuild/index.jsThe 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.assetsabsent{ 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 every ocre database command looks for it.
  • name is the database name. ocre deploy creates the database on the first deploy when cf d1 list --name does not find it; remote commands resolve the name to the database id the same way, so no id is needed.
  • Migrations are the numbered SQL files of migrations/ (cf’s default directory; there is no migrations_dir key).

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 }),
KeyValueMeaning
binding keyJOBSRequired name: ocre::jobs::enqueue looks it up (JOBS_<NAME> for a named queue)
name<app>-jobsThe same queue for the binding (producer) and the trigger (consumer): the app’s Worker sends and runs its own jobs
maxBatchSize10Messages per consumer run (Cloudflare allows up to 100)
maxBatchTimeout5Seconds to wait to fill a batch (up to 60)
maxRetries5Deliveries after the first before the message goes to the dead-letter queue
deadLetterQueue<app>-jobs-failedWhere 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 } }),
KeyValueMeaning
binding keyAUTH_RATE_LIMITERThe binding the generated throttle (in src/auth_api.rs) passes to ocre::security::rate_limit
namespacean integer as a string, derived from the app nameBindings with the same namespace share counters across the account’s Workers: keep it unique per app
simple.limit10Requests allowed per key and period; the next one gets 429 Too Many Requests
simple.period60The 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

Bindingcloudflare.config.ts entryUsed byAdded by
DBbindings.d1ctx.db(), models, ocre migrate, ocre sql, ocre db ...ocre new
STORAGEbindings.r2ocre::storage, attachment fieldsfirst generator with an attachment field
JOBSbindings.queue (plus triggers.queue)ocre::jobs::enqueue, enqueue_in, ocre::mail::deliver_laterfirst ocre g job
CACHEbindings.kvocre::cacheocre g cache
CHANNELSbindings.durableObject (export OcreChannel)ocre::realtimefirst --realtime scaffold
EMAILbindings.sendEmailocre::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:

CommandOCRE_BUILDBuild
ocre dev--devUnoptimized, fast to compile
ocre deploy and every other ocre command that builds--releaselto = true, opt-level = "z" (from Cargo.toml)
npx cf deploy run by handunset--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’s npm install writes package-lock.json: commit both, so every machine and CI run the same cf. ocre doctor warns when the installed cf differs from the version Ocre expects, or wrangler is older than 4.136.
  • cf runs dev, deploy and every Cloudflare API call; wrangler is what cf delegates the build to, and what Ocre uses for local D1 commands (see Why wrangler still appears); typescript checks the config files.
  • "type": "module" lets cf load cloudflare.config.ts without a module-type warning on every call.
  • Node.js 22 or newer is required (cf’s engines).

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=value per line (dotenv).
  • Only ocre dev (cf dev) reads it; it never reaches Cloudflare. A name in both .dev.vars and a bindings.text(...) entry takes the .dev.vars value locally, which is how MAIL_ADAPTER=log keeps development from sending real mail.
  • .gitignore lists .dev.vars, .dev.vars.*, .prod.vars, .env and .env.*. Each clone needs its own: copy the lines above with a value from ocre secret.
  • ocre dev prints what the Worker receives; secrets are hidden (output of an app named ftapp with 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
Wherecloudflare.config.ts, committedEncrypted on Cloudflare; never in the repository
Set in productionedit cloudflare.config.ts, then ocre deployocre secrets push NAME --file .prod.vars (values read from that git-ignored file, never from the command line)
Set for ocre devbindings.text(...), or .dev.vars to override.dev.vars
Use forSender address, adapter names, allowed origins and hostsSECRET_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 dev and ocre test --e2e copy the .dev.vars value of each such binding into the local store before starting (cf dev gives the binding the local store’s value and ignores .dev.vars), and print RESEND_API_KEY: .dev.vars value copied into the local Secrets Store. Without a .dev.vars value, ctx.secret fails with the fix.
  • SECRET_KEY_BASE and the R2_* settings stay Worker secrets: Ocre reads them without waiting (sessions, cookies, presigned URLs), and ocre secrets push --store refuses 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.vars or 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::jwt derives its HS256 signing key from it with a different label, so one secret covers cookies and tokens.
  • Format: at least 64 characters. ocre secret prints a new random value of 128 hex characters (--json: {"command":"secret","ok":true,"secret":"..."}).
  • Development: ocre new writes one to .dev.vars.
  • Production: ocre deploy checks cf workers secrets list; when the Worker has no SECRET_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.json readable 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 of ocre secrets push) only when --secrets-file is passed; deployed without it, the new version has none. A deploy that created the secret prints Created 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.vars as SECRET_KEY_BASE=..., then ocre 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 in SECRET_KEY_BASE_PREVIOUS first.
  • 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 dev with 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_BASE values (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_PREVIOUS together 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_PREVIOUS in the dashboard (Workers & Pages > your Worker > Settings > Variables and Secrets) once the longest session lifetime (two weeks for ocre 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 as http://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 other Host gets a plain-text 403 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.com allows example.com and www.example.com.
ALLOWED_HOSTS: bindings.text("example.com, .example.com"),
  • Default: unset or empty, every host is allowed.
  • Always allowed: localhost, 127.0.0.1 and [::1], with any port, so ocre dev keeps working.
  • Why on Workers: Cloudflare only routes your own host names to the Worker, but the same Worker also answers on <name>.<subdomain>.workers.dev and on preview URLs. Listing only your custom domain keeps visitors and search engines on it (list the workers.dev host 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_ID and GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (client_id_secret and client_secret_secret of ocre::oauth::GITHUB and GOOGLE).
  • 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 (and http://localhost:8787/auth/<provider>/callback for ocre dev, in a second OAuth app for GitHub).
  • Development: ocre g auth --oauth appends commented lines to .dev.vars; uncomment them and fill in the values.
  • Production: put both in .prod.vars and run ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars; ocre secrets list shows 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 logs the 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, needs RESEND_API_KEY), cloudflare (Email Service through the EMAIL binding). 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 new writes MAIL_ADAPTER=log to .dev.vars.
  • Production: uncomment MAIL_ADAPTER: bindings.text("resend"), (or "cloudflare") in worker.env, then ocre 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).

See Email.

MAIL_FROM

  • Kind: variable. Required to send mail, with every adapter.
  • Format: noreply@yourdomain.com or Name <noreply@yourdomain.com> (the name may be quoted). With Resend or Cloudflare, the domain must be verified with that provider.
  • Default: ocre new writes MAIL_FROM: bindings.text("<app> <noreply@example.com>"); replace example.com with 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> to https://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 from ocre dev, put RESEND_API_KEY=... and MAIL_ADAPTER=resend in .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_ID and R2_BUCKET are variables (bindings.text); R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are 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 by direct_upload: replacing it invalidates uploads in progress.
  • Production: the variables in worker.env, the secrets with ocre 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, not ocre 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.dev URL 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: on routes 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) stores ocre::cache values in the CACHE namespace; null turns caching off without code changes. ocre dev --no-cache writes CACHE_STORE=null into .dev.vars and --cache removes 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:8787 in .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, error or off (warning and fatal are accepted): the lowest level ctx.log() and Ocre write. Default: debug in ocre dev (debug build), info after ocre 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) or text (INFO message key=value ...). Default: text in ocre dev, json after ocre 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 optional SENTRY_RELEASE variable 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):

DevelopmentProduction
Builddebug: ocre dev, cargo test, ocre testrelease: ocre deploy
ocre::config::Environment::current()DevelopmentProduction
Settings.dev.vars overrides cloudflare.config.tscloudflare.config.ts variables and secrets
Logsdebug, textinfo, JSON
Errorsdevelopment error page, Server-Timinggeneric error page; details in logs and reporters
Databaselocal D1 in .wrangler/statethe 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:

NameRead byMeaning
OCRE_BUILDbuild.command of wrangler.config.ts--dev or --release; set by ocre for every build (see wrangler.config.ts)
CLOUDFLARE_API_TOKENcfAuthenticates without ocre login (CI); the CLI’s hints mention it when a Cloudflare call fails
CLOUDFLARE_ACCOUNT_IDcfPicks the account when the token or login sees several, instead of accountId in cloudflare.config.ts
CF_SEND_TELEMETRYcffalse 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

FeatureDefaultEnablesTurned on by
htmlyesaskama templates, render, HTML error pages, the Htmx extractoron in full-stack apps; off in API-only apps (default-features = false)
graphqlnoocre::graphql (async-graphql, GraphiQL)ocre g api <Model> ... --graphql, which also adds the async-graphql dependency
realtimenoocre::realtime and the OcreChannel Durable Object classthe 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

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 deploy on 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

LimitFree plan (September 2026)SourceWhat Ocre does
Requests100,000 a day per account; past it, error 1027 until midnight UTCWorkers limitsFiles 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 requestsfree and unlimited; 20,000 files per version, 25 MiB per fileStatic assets billing, limitsassetsDirectory: "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 time10 ms per invocation (HTTP request, Cron Trigger, queue consumer batch); occasional overruns are tolerated per isolate, frequent ones end in error 1102CPU timeWaiting 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.
Memory128 MB per isolate, shared by the concurrent requests it runsMemoryUploads 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 size100 MB (Cloudflare Free plan); larger bodies get 413 before the Worker runsRequest limitsKeep max_bytes of attachment Rules in the tens of MB.
Worker size64 MiB uncompressedWorker sizeRelease 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 time1 second to evaluate the global scopeStartup timeThe blog starter measured 4 ms. Translations are parsed on first use, not at startup.
Subrequests50 per invocation; 1,000 to Cloudflare servicesSubrequestsGenerated 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 secrets64 per Worker, 5 KB eachEnvironment variablesOcre needs at most four: SECRET_KEY_BASE, MAIL_FROM, MAIL_ADAPTER, RESEND_API_KEY (plus ALLOWED_ORIGINS when used).
Cron Triggers5 per accountAccount plan limitsocre g schedule warns when the app has more than 5; run several tasks from one cron.
Workers Logs200,000 log events a day, kept 3 daysWorkers Logs pricingOcre 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)

LimitFree plan (September 2026)SourceWhat Ocre does
Rows read5 million a day (rows scanned, not rows returned)D1 pricingGenerated 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 written100,000 a day; each index on a written column adds one row writtenD1 pricingSessions live in an encrypted cookie (no rows); API keys update last_used_at at most once an hour.
Storage5 GB per account; 500 MB per database; 10 databases per accountD1 limitsOne database per app (database_name = app name). Files go to R2, not D1.
Queries per invocation50D1 limitsfind_many instead of find in a loop.
Bound parameters100 per queryD1 limitsfind_many splits ids into chunks of 100.
Row or string size2,000,000 bytes (2 MB)D1 limitsKeep large text and files in R2 (attachment fields).
SQL statement length100 KBD1 limitsQueries use ?N placeholders, never inlined values.
NumbersD1 returns numbers as JavaScript numbers (exact up to 2^53 - 1)ocre::MAX_SAFE_INTEGERGenerated integer validations reject values beyond ±9,007,199,254,740,991.

Queues (background jobs, binding JOBS)

LimitFree plan (September 2026)SourceWhat Ocre does
Operations10,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 operationQueues pricingOne message per job: about 3,300 jobs a day without retries.
Retention24 hours, not configurableQueues pricingRetries (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 size128 KB (1 KB = 1,000 bytes, including about 100 bytes of metadata)Queues limitsenqueue 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.
Delay24 hours, on send and on retryDelay messagesenqueue_in refuses longer delays and names the fix (a scheduled task, or a due time in D1).
Batchesup to 100 messages, 60 s waitQueues limitsmax_batch_size = 10, max_batch_timeout = 5: each consumer run handles up to 10 jobs within one invocation’s 10 ms of CPU.
Retriesup to 100Queues limitsmax_retries = 5.
Queues10,000 per accountQueues limitsTwo per app: <app>-jobs and <app>-jobs-failed, created by ocre deploy.

R2 (files, binding STORAGE)

LimitFree plan (September 2026, per month)SourceWhat Ocre does
Storage10 GB-month (Standard storage class)R2 pricingFiles of deleted and replaced records are deleted; files of rows removed by ON DELETE CASCADE are not.
Class A operations1 million a month (PutObject, ListObjects, …)R2 pricingOne per upload.
Class B operations10 million a month (GetObject, HeadObject, …)R2 pricingOne per download or 304.
Deletes, egressfreeR2 pricing
ActivationR2 must be enabled once in the dashboard, which asks for a payment method even for the free tierR2 pricingocre 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 (ffmpeg included) 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)

LimitFree plan (September 2026)SourceWhat Ocre does
Reads100,000 a dayKV limitsocre::cache::fetch reads once per call.
Writes1,000 a day (to different keys); 1 per second to the same keyKV limitsThe 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, lists1,000 a day eachWorkers pricing, KVocre::cache::delete is one delete; Ocre never lists.
Storage1 GB per account and per namespaceKV limitsValues are JSON; keep them small.
Key size512 bytesKV limitsLonger or empty keys are refused with an error naming the fix.
Value size25 MiBKV limits
Minimum TTL60 seconds (expirationTtl)Write key-value pairsfetch and write refuse shorter TTLs (ocre::cache::MIN_TTL).
Consistencywrites can take up to 60 seconds to be visible elsewhereWrite key-value pairsNot for data that must be read back immediately.

Durable Objects (realtime, binding CHANNELS)

LimitFree plan (September 2026)SourceWhat Ocre does
Requests100,000 a day; incoming WebSocket messages count 1 per 20; outgoing messages are freeDurable Objects pricingOne request per connection (and reconnection) and one per broadcast. Browsers only listen, so they send no messages.
Duration13,000 GB-s a day (at 128 MB, about 28 hours awake)Durable Objects pricingOcreChannel uses the WebSocket Hibernation API: objects are only billed while handling a connection or a broadcast.
Storage backendSQLite-backed classes onlyDurable Objects pricingnew_sqlite_classes = ["OcreChannel"]; the channel stores nothing.
Classes, storage100 classes per account, 5 GB storage per accountDurable Objects limitsOne class, no storage.
Received WebSocket message size32 MiBDurable Objects limitsClient messages are ignored.

Email

LimitFree plan (September 2026)SourceWhat Ocre does
Email Routing (receiving)free and unlimited; 200 routing rules per domain, 200 destination addresses per account, 25 MiB per incoming messageEmail Service pricing, limitsocre 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 recipientsEmail Service pricing, limitsFine 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 domainsResend quotasdeliver_later retries provider failures through the jobs queue.

What happens at a limit

ResourceBehavior past the limitOcre’s handling
Worker requestsError 1027 page (or the request bypasses the Worker if its route fails open) until 00:00 UTCnone possible inside the app
CPUError 1102 “Worker exceeded resource limits” when overruns become frequentDesign: I/O in handlers, heavy work in jobs
D1 rowsQueries failErrors become 500 responses, logged with [ocre]
KVOperations of that type fail until 00:00 UTCfetch falls back to computing the value
QueuesSends failenqueue returns the error; the handler decides
Durable ObjectsRequests failBroadcast failures are logged ([ocre realtime] broadcast to <channel> failed: ...) and the request still succeeds

See also

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, the json! macro) for json fields, without adding serde_json to the app. pub use serde_json
  • ocre::locales (macro): Declares the app’s locales as a Locales static, compiling locales/<code>.yml into the binary. macro_rules! locales
  • ocre::params (macro): Builds the parameter list of a Db query: params![title, id]. macro_rules! params
  • ocre::bulk (mod): Many rows in one D1 statement (Rails’ insert_all, upsert_all, and an update_all with a value per row).
  • ocre::cache (mod): Caching: read-through values in Workers KV, HTTP Cache-Control/ETag and 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’s Rails.event): named facts about what the app did, with a payload, for analytics, audit trails or a data warehouse.
  • ocre::filters (mod, feature html): askama filters for Ocre’s helpers (feature html): {{ post.price|number_to_currency("$") }}.
  • ocre::graphql (mod, feature graphql): GraphQL support (feature graphql).
  • 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.logger and 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, feature push): Web push notifications: messages a browser shows even when the app’s page is closed (the Push API, with the service worker of ocre g pwa).
  • ocre::realtime (mod, feature realtime): Realtime updates: WebSocket channels on a Durable Object, HTML broadcasts for htmx (feature realtime).
  • 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 what serve always does: policies, rate limits, safe redirects, HTML cleaning.
  • ocre::seo (mod): Search engines and language models: JSON-LD, sitemaps and llms.txt.
  • ocre::sse (mod): Server-Sent Events: a response that sends events as they happen (Rails’ ActionController::Live with SSE).
  • ocre::storage (mod): File storage in Cloudflare R2: multipart uploads, attachments, streamed downloads.
  • ocre::testing (mod, feature testing): Test helpers for Ocre apps, like Rails’ ActionDispatch::IntegrationTest and ActiveSupport::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) into bool. 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) -> String
  • ocre::error_page (fn, feature html): Renders error responses with the app’s own template (Rails’ public/404.html and 500.html). pub fn error_page(response: Response, render: impl FnOnce(&ErrorPage) -> Result<Html<String>>) -> Response
  • ocre::escape_like (fn): Escapes %, _ and \ so text matches literally in a LIKE ... ESCAPE '\' pattern (Rails’ sanitize_sql_like). pub fn escape_like(text: &str) -> String
  • ocre::json_from_sql (fn): Deserializes a JSON column (the JSON text D1 returns) into serde_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() -> i64
  • ocre::optional (fn): Deserializes an optional field where null, a missing value or an empty string is None. pub fn optional<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error> where D: Deserializer<'de>, T: Deserialize<'de> + FromStr, T::Err: Display
  • ocre::optional_json_from_sql (fn): Like json_from_sql for a NULL-able JSON column: NULL is None. 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: Display
  • ocre::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 to fallback (Rails’ redirect_back_or_to). pub fn redirect_back(headers: &HeaderMap, fallback: &str) -> Redirect
  • ocre::remote_ip (fn): The client’s IP address from the CF-Connecting-IP header, for code that has the headers but no extractor. pub fn remote_ip(headers: &HeaderMap) -> Option<IpAddr>
  • ocre::render (fn, feature html): 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 Worker fetch entry point. pub async fn serve(routes: Router<Ctx>, req: HttpRequest, env: Env) -> worker::Result<web_sys::Response>
  • ocre::sleep (fn): Waits duration without using CPU (JavaScript’s setTimeout): pacing for sse streams and polling. pub fn sleep(duration: std::time::Duration) -> impl Future<Output = ()> + Send
  • ocre::ApiError (struct): Handler error for JSON endpoints: an Error rendered as a JSON body. pub struct ApiError(pub Error)
    • Implements: Debug, From, IntoResponse
  • ocre::Batches (struct): Batches of rows by increasing id, from Query::batches: Rails’ find_in_batches without holding a cursor open. pub struct Batches<T>
    • ocre::Batches::resume_after (fn): Starts after id (Rails’ start:, exclusive), or from the first row with None. pub fn resume_after(mut self, id: Option<i64>) -> Self
    • ocre::Batches::after (fn): The last id read so far (None before 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) -> bool
    • ocre::Batches::statement (fn): The SELECT of the next batch. pub fn statement(&self) -> Statement
    • ocre::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, or None once 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 cookie name. pub fn get(&self, name: &str) -> Option<String>
    • ocre::Cookies::signed (fn): The value of the signed cookie name; None when absent or changed by the client. pub fn signed(&self, name: &str) -> Result<Option<String>>
    • ocre::Cookies::encrypted (fn): The value of the encrypted cookie name; None when absent or changed by the client. pub fn encrypted(&self, name: &str) -> Result<Option<String>>
    • ocre::Cookies::set (fn): Sets a plain cookie; max_age None makes 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 as None. 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 cookie name from the browser. pub fn remove(&self, name: &str)
    • Implements: Clone, FromRequestParts
  • ocre::Created (struct): 201 Created response with a JSON body, for create endpoints. pub struct Created<T>(pub T)
    • Implements: IntoResponse
  • ocre::Ctx (struct): Per-request application context: the Worker environment with typed access to its bindings. pub struct Ctx
    • ocre::Ctx::log (fn): The logger of this request, job batch or cron run (see ocre::log). pub fn log(&self) -> &Logger
    • ocre::Ctx::errors (fn): The error reporter of this request, job batch or cron run (see ocre::errors). pub fn errors(&self) -> &Reporter
    • ocre::Ctx::events (fn): Structured events of this request or job (Rails’ Rails.event), see ocre::events. pub fn events(&self) -> &Events
    • ocre::Ctx::config (fn): The app’s settings, read from Worker variables and secrets into T (see ocre::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) -> &Env
    • ocre::Ctx::secret (fn): The secret name, wherever it is kept: a Worker secret (ocre secrets push), a .dev.vars value in ocre dev, or a secret of the account’s Secrets Store bound to the Worker in cloudflare.config.ts (NAME: bindings.secretsStoreSecret({ storeId, secretName }), which ocre secrets push NAME --store writes). pub fn secret(&self, name: &str) -> impl Future<Output = Result<String>> + Send + use<>
    • ocre::Ctx::db (fn): The application database: the D1 binding DB. 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, from Ctx::db. pub struct Db
    • ocre::Db::uncached (fn): This handle without the per-request query cache: every query goes to D1. pub fn uncached(self) -> Self
    • ocre::Db::all (fn): Runs a query and returns every row, deserialized into T. 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 into T. 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, feature html): What an HTML error page may show: the status, a message safe for users, and validation errors. pub struct ErrorPage
    • ocre::ErrorPage::status (field): HTTP status of the response, e.g. 404: {{ error.status.as_u16() }} in a template. pub status: StatusCode
    • ocre::ErrorPage::message (field): Message for the user: Not found, Validation failed, a bad request’s own message… pub message: String
    • ocre::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 FieldError
    • ocre::FieldError::field (field): Field name as in the form or JSON body ("title", "author_id"). pub field: String
    • ocre::FieldError::message (field): Message without the field name ("can't be blank"). pub message: String
    • ocre::FieldError::new (fn): Builds an error for field with message (without the field name). pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self
    • ocre::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 given kind, if the previous request set one. pub fn get(&self, kind: &str) -> Option<&str>
    • ocre::Flash::notice (fn): The notice message (success), same as flash.get("notice"). pub fn notice(&self) -> Option<&str>
    • ocre::Flash::alert (fn): The alert message (failure), same as flash.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) -> bool
    • ocre::Flash::now (fn): These messages plus message of kind, 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, feature html): Extractor telling whether htmx sent the request: Htmx(true) when it has HX-Request: true. pub struct Htmx(pub bool)
    • Implements: Clone, Copy, Debug, FromRequestParts
  • ocre::HxRedirect (struct, feature html): Response telling htmx to load another page: 200 OK with the HX-Redirect header. pub struct HxRedirect(pub String)
    • Implements: Clone, Debug, Eq, IntoResponse, PartialEq
  • 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
  • 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
  • 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 an application/x-www-form-urlencoded string (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 Page
    • ocre::Page::limit (field): Rows to return, 1..=100. pub limit: i64
    • ocre::Page::offset (field): Rows to skip, 0 or more. pub offset: i64
    • ocre::Page::DEFAULT_LIMIT (const): limit when the query string has none: 50. pub const DEFAULT_LIMIT: i64 = 50
    • ocre::Page::MAX_LIMIT (const): Largest accepted limit: 100. pub const MAX_LIMIT: i64 = 100
    • ocre::Page::new (fn): Builds a page after checking the bounds: 1..=100 for limit, 0.. for offset. pub fn new(limit: i64, offset: i64) -> Result<Self, Error>
    • ocre::Page::next (fn): The page after this one, or None when returned (the rows this page got) is less than limit. pub fn next(&self, returned: usize) -> Option<Self>
    • ocre::Page::previous (fn): The page before this one, or None on the first page. pub fn previous(&self) -> Option<Self>
    • ocre::Page::query (fn): The query string of this page, without ?: offset=50, with limit= first when it is not the default. pub fn query(&self) -> String
    • ocre::Page::links (fn): Link header (RFC 8288) pointing JSON clients at the next, prev and first pages of path. pub fn links(&self, path: &str, returned: usize) -> PageLinks
    • Implements: Clone, Copy, Debug, FromRequestParts, PartialEq
  • ocre::PageLinks (struct): The Link header built by Page::links, as a response part (nothing when there is no other page). pub struct PageLinks(/* private fields */)
    • Implements: Clone, Debug, IntoResponseParts
  • 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: i64
    • ocre::Paginated::limit (field): Rows per page. pub limit: i64
    • ocre::Paginated::offset (field): Rows skipped before this page. pub offset: i64
    • ocre::Paginated::current_page (fn): 1-based number of this page. pub fn current_page(&self) -> i64
    • ocre::Paginated::total_pages (fn): Number of pages (at least 1, even with no row). pub fn total_pages(&self) -> i64
    • ocre::Paginated::has_next (fn): Whether rows follow this page. pub fn has_next(&self) -> bool
    • ocre::Paginated::has_previous (fn): Whether rows come before this page. pub fn has_previous(&self) -> bool
    • ocre::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 ?N placeholder of a D1 query. pub struct Param(/* private fields */)
    • Implements: Clone, Debug, IntoParam, PartialEq
  • ocre::Query (struct): A SELECT on one table, built step by step, whose values are always bound parameters. pub struct Query<T>
    • ocre::Query::table (fn): Starts a query on table: SELECT * FROM <table>. pub fn table(table: &'static str) -> Self
    • ocre::Query::scope (fn): Applies a scope: f(self). pub fn scope(self, f: impl FnOnce(Self) -> Self) -> Self
    • ocre::Query::select (fn): Selects columns (an SQL select list) instead of *; the rows become U. 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) -> Self
    • ocre::Query::join (fn): Adds a join clause, written in full: JOIN ... or LEFT JOIN .... pub fn join(mut self, clause: &'static str) -> Self
    • ocre::Query::eq (fn): column = value. pub fn eq(mut self, column: &'static str, value: impl IntoParam) -> Self
    • ocre::Query::ne (fn): column != value. pub fn ne(mut self, column: &'static str, value: impl IntoParam) -> Self
    • ocre::Query::gt (fn): column > value. pub fn gt(mut self, column: &'static str, value: impl IntoParam) -> Self
    • ocre::Query::gte (fn): column >= value. pub fn gte(mut self, column: &'static str, value: impl IntoParam) -> Self
    • ocre::Query::lt (fn): column < value. pub fn lt(mut self, column: &'static str, value: impl IntoParam) -> Self
    • ocre::Query::lte (fn): column <= value. pub fn lte(mut self, column: &'static str, value: impl IntoParam) -> Self
    • ocre::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) -> Self
    • ocre::Query::is_in (fn): column IN (?, ?, ...). pub fn is_in<V: IntoParam>(mut self, column: &'static str, values: impl IntoIterator<Item = V>) -> Self
    • ocre::Query::not_in (fn): column NOT IN (?, ?, ...). pub fn not_in<V: IntoParam>(mut self, column: &'static str, values: impl IntoIterator<Item = V>) -> Self
    • ocre::Query::is_null (fn): column IS NULL. pub fn is_null(mut self, column: &'static str) -> Self
    • ocre::Query::is_not_null (fn): column IS NOT NULL. pub fn is_not_null(mut self, column: &'static str) -> Self
    • ocre::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) -> Self
    • ocre::Query::not_like (fn): column NOT LIKE pattern (wildcards as given, see like). pub fn not_like(mut self, column: &'static str, pattern: impl IntoParam) -> Self
    • ocre::Query::contains (fn): column contains text (case-insensitive for ASCII), with %, _ and \ in text matched literally (see escape_like). pub fn contains(mut self, column: &'static str, text: &str) -> Self
    • ocre::Query::starts_with (fn): column starts with text (wildcards escaped, see contains). pub fn starts_with(mut self, column: &'static str, text: &str) -> Self
    • ocre::Query::ends_with (fn): column ends with text (wildcards escaped, see contains). pub fn ends_with(mut self, column: &'static str, text: &str) -> Self
    • ocre::Query::where_sql (fn): Adds a raw SQL condition with bare ? placeholders, bound to params in order. pub fn where_sql(mut self, fragment: &'static str, params: Vec<Param>) -> Self
    • ocre::Query::where_associated (fn): Keeps rows with at least one row in table pointing to them through foreign_key (Rails’ where.associated on a has-many side). pub fn where_associated(mut self, table: &'static str, foreign_key: &'static str) -> Self
    • ocre::Query::where_missing (fn): Keeps rows that no row of table points to through foreign_key (Rails’ where.missing on a has-many side): posts without comments. pub fn where_missing(mut self, table: &'static str, foreign_key: &'static str) -> Self
    • ocre::Query::date_range (fn): Filters column between two optional bounds, like Loco’s DateRangeBuilder. pub fn date_range<V: IntoParam>(self, column: &'static str, from: Option<V>, to: Option<V>) -> Self
    • ocre::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) -> Self
    • ocre::Query::unscope_limit (fn): Removes the limit and offset set so far (Rails’ unscope(:limit, :offset)). pub fn unscope_limit(mut self) -> Self
    • ocre::Query::reverse_order (fn): Reverses the order (Rails’ reverse_order): ASC terms become DESC and back; terms without a direction (order_in, a bare order_sql) get DESC. pub fn reverse_order(mut self) -> Self
    • ocre::Query::any (fn): Matches rows meeting at least one of the conditions f adds: (a OR b ...). pub fn any(mut self, f: impl FnOnce(Self) -> Self) -> Self
    • ocre::Query::not (fn): Matches rows that do not meet all the conditions f adds: NOT (a AND b ...). pub fn not(mut self, f: impl FnOnce(Self) -> Self) -> Self
    • ocre::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) -> Self
    • ocre::Query::group_by (fn): GROUP BY columns; combine with select for the aggregates and having to filter groups. pub fn group_by(mut self, columns: &'static str) -> Self
    • ocre::Query::having (fn): Adds a HAVING condition (raw SQL with bare ? placeholders) on the groups. pub fn having(mut self, fragment: &'static str, params: Vec<Param>) -> Self
    • ocre::Query::order_asc (fn): ORDER BY column ASC, after any order already set. pub fn order_asc(self, column: &'static str) -> Self
    • ocre::Query::order_desc (fn): ORDER BY column DESC, after any order already set. pub fn order_desc(self, column: &'static str) -> Self
    • ocre::Query::order_by (fn): ORDER BY column <direction>, after any order already set. pub fn order_by(mut self, column: &'static str, direction: Direction) -> Self
    • ocre::Query::order_in (fn): Orders column by 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>) -> Self
    • ocre::Query::order_sql (fn): Adds a raw ORDER BY term, e.g. lower(title) or published_at DESC NULLS LAST. pub fn order_sql(mut self, term: &'static str) -> Self
    • ocre::Query::reorder (fn): Removes the order set so far (Rails’ reorder when followed by a new order). pub fn reorder(mut self) -> Self
    • ocre::Query::limit (fn): Returns at most limit rows. pub fn limit(mut self, limit: i64) -> Self
    • ocre::Query::offset (fn): Skips the first offset rows. pub fn offset(mut self, offset: i64) -> Self
    • ocre::Query::page (fn): Limit and offset from a Page (?limit=&offset=). pub fn page(self, page: Page) -> Self
    • ocre::Query::explain_statement (fn): EXPLAIN QUERY PLAN of the SELECT (Rails’ explain): how SQLite finds the rows, e.g. SEARCH posts USING INDEX index_posts_on_author_id (author_id=?) or a full SCAN posts. pub fn explain_statement(&self) -> Statement
    • ocre::Query::batches (fn): Walks the matching rows in batches of size, ordered by id (Rails’ find_in_batches / in_batches, and find_each with a loop over each batch). pub fn batches(self, size: i64, id: fn(&T) -> i64) -> Batches<T>
    • ocre::Query::to_statement (fn): The SELECT statement, with placeholders numbered ?1, ?2.... pub fn to_statement(&self) -> Statement
    • ocre::Query::count_statement (fn): SELECT COUNT(*) AS count of the matching rows, ignoring order, limit and offset. pub fn count_statement(&self) -> Statement
    • ocre::Query::exists_statement (fn): SELECT 1 ... LIMIT 1: whether any row matches, stopping at the first. pub fn exists_statement(&self) -> Statement
    • ocre::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) -> Statement
    • ocre::Query::aggregate_statement (fn): SELECT <aggregate> AS value over the matching rows, without order or limits. pub fn aggregate_statement(&self, expression: &'static str) -> Statement
    • ocre::Query::update_statement (fn): UPDATE <table> SET ... WHERE ... on the matching rows (Rails’ update_all): no validation, no updated_at change unless listed. pub fn update_statement(&self, sets: Vec<(&'static str, Param)>) -> Statement
    • ocre::Query::delete_statement (fn): DELETE FROM <table> WHERE ... on the matching rows (Rails’ delete_all). pub fn delete_statement(&self) -> Statement
    • ocre::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 with LIMIT 1 and returns the first row, if any (Rails’ first, take and find_by). pub async fn first(&self, db: &Db) -> Result<Option<T>>
    • ocre::Query::paginate (fn): Runs the query and its count_statement for page: 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 runs create when there is none (Rails’ find_or_create_by; with a New... 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): Runs create first 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): SEARCH means an index is used, SCAN a 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’s CF-Connecting-IP header. pub struct RemoteIp(pub Option<IpAddr>)
    • Implements: Clone, Copy, Debug, Eq, FromRequestParts, PartialEq
  • 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
  • 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 expire seconds from 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 for seconds, 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, if expire_in or remember_for set it. pub fn expires_at(&self) -> Result<Option<i64>>
    • ocre::Session::get (fn): Returns the value stored under key, or None if absent or not deserializable as T. pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>>
    • ocre::Session::insert (fn): Stores value under key, replacing any previous value. pub fn insert(&self, key: &str, value: impl Serialize) -> Result<()>
    • ocre::Session::remove (fn): Removes key and 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): Stores message under kind to 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): Keeps flash for 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, for Db::batch. pub struct Statement
    • ocre::Statement::sql (field): SQL text with ?1, ?2... placeholders. pub sql: String
    • ocre::Statement::params (field): Values for the placeholders, in order (build with params!). pub params: Vec<Param>
    • ocre::Statement::new (fn): Pairs sql with its params. 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; finish fails with all of them. pub struct Validator
    • ocre::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 Self
    • ocre::Validator::file (fn): Checks an uploaded file against rules: its size and its content type. pub fn file(&mut self, field: &str, upload: &Upload, rules: &Rules) -> &mut Self
    • ocre::Validator::new (fn): Creates a validator with no errors. pub fn new() -> Self
    • ocre::Validator::check (fn): Adds message for field when failed is true: the building block for custom rules. pub fn check(&mut self, field: &str, failed: bool, message: impl Into<String>) -> &mut Self
    • ocre::Validator::required (fn): Checks that value is not empty after trimming whitespace (“can’t be blank”). pub fn required(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::max_length (fn): Checks that value has at most max characters (Unicode scalar values, not bytes). pub fn max_length(&mut self, field: &str, value: &str, max: usize) -> &mut Self
    • ocre::Validator::min_length (fn): Checks that value has at least min characters (Unicode scalar values, not bytes). pub fn min_length(&mut self, field: &str, value: &str, min: usize) -> &mut Self
    • ocre::Validator::range (fn): Checks that value is within range, bounds included. pub fn range<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, range: RangeInclusive<T>) -> &mut Self
    • ocre::Validator::safe_integer (fn): Checks that value is an integer D1 can store and return exactly (±2^53 - 1). pub fn safe_integer(&mut self, field: &str, value: i64) -> &mut Self
    • ocre::Validator::inclusion (fn): Checks that value is one of allowed (“is not included in the list”). pub fn inclusion(&mut self, field: &str, value: &str, allowed: &[&str]) -> &mut Self
    • ocre::Validator::exclusion (fn): Checks that value is not one of forbidden (“is reserved”), like Rails’ exclusion. pub fn exclusion(&mut self, field: &str, value: &str, forbidden: &[&str]) -> &mut Self
    • ocre::Validator::length (fn): Checks that value has exactly length characters (Unicode scalar values): “is the wrong length (should be N characters)”. pub fn length(&mut self, field: &str, value: &str, length: usize) -> &mut Self
    • ocre::Validator::greater_than (fn): Checks that value > 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 Self
    • ocre::Validator::greater_than_or_equal_to (fn): Checks that value >= 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 Self
    • ocre::Validator::less_than (fn): Checks that value < than (“must be less than N”). pub fn less_than<T: PartialOrd + fmt::Display>(&mut self, field: &str, value: T, than: T) -> &mut Self
    • ocre::Validator::less_than_or_equal_to (fn): Checks that value <= 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 Self
    • ocre::Validator::other_than (fn): Checks that value != other (“must be other than N”). pub fn other_than<T: PartialEq + fmt::Display>(&mut self, field: &str, value: T, other: T) -> &mut Self
    • ocre::Validator::confirmation (fn): Checks that confirmation equals value, 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 Self
    • ocre::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 Self
    • ocre::Validator::absence (fn): Checks that value is blank: empty or only whitespace (“must be blank”), like Rails’ absence. pub fn absence(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::format (fn): Checks that every character of value passes allowed (“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 Self
    • ocre::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 Self
    • ocre::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 that value looks like an e-mail address (“is invalid”). pub fn email(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::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 is None without 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, like number: None plus “is not included in the list” when T::from_str refuses it. pub fn one_of<T: FromStr>(&mut self, field: &str, text: &str) -> Option<T>
    • ocre::Validator::optional_one_of (fn): Like one_of for an optional field: blank text is None without 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 is None without an error. pub fn optional_json(&mut self, field: &str, text: &str) -> Option<serde_json::Value>
    • ocre::Validator::date (fn): Checks that value is a real calendar date written YYYY-MM-DD (“is not a valid date”). pub fn date(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::datetime (fn): Checks that value is YYYY-MM-DD HH:MM[:SS], with a space or T (HTML datetime-local). pub fn datetime(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::time (fn): Checks that value is a time of day written HH:MM or HH:MM:SS (HTML <input type="time">). pub fn time(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::uuid (fn): Checks that value is a UUID in its hyphenated form, any case (“is not a valid UUID”). pub fn uuid(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::decimal (fn): Checks that value is an exact decimal number such as -12.50 (“is not a decimal number”). pub fn decimal(&mut self, field: &str, value: &str) -> &mut Self
    • ocre::Validator::merge (fn): Adds the errors collected by other, e.g. a model’s validate() after parsing a form. pub fn merge(&mut self, mut other: Validator) -> &mut Self
    • ocre::Validator::is_valid (fn): Whether no error has been collected so far. pub fn is_valid(&self) -> bool
    • ocre::Validator::finish (fn): Returns Ok(()) 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 for Query::order_by, read from a query string as asc or desc. pub enum Direction
    • ocre::Direction::Asc (variant): Smallest first (ASC). Asc
    • ocre::Direction::Desc (variant): Largest first (DESC). Desc
    • ocre::Direction::as_sql (fn): ASC or DESC. 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 Error
    • ocre::Error::NotFound (variant): 404 Not Found: the record or route does not exist. NotFound
    • ocre::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). Unauthorized
    • ocre::Error::Forbidden (variant): 403 Forbidden: signed in, but not allowed to do this. Forbidden
    • ocre::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 a lock_version column), 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. TooManyRequests
    • ocre::Error::Internal (variant): 500 Internal Server Error; the message goes to the Worker logs only. Internal(String)
    • ocre::Error::bad_request (fn): Builds a 400 Error::BadRequest whose message is shown to the user. pub fn bad_request(message: impl Into<String>) -> Self
    • ocre::Error::internal (fn): Builds a 500 Error::Internal whose message is logged, never shown to the user. pub fn internal(message: impl Into<String>) -> Self
    • ocre::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’s UNIQUE constraint failed (two requests raced past the check), or a Conflict. pub fn is_taken(&self) -> bool
    • Implements: Debug, Display, Error, From, IntoResponse
  • ocre::Format (enum): Response format a client asks for in its Accept header, for actions that answer HTML or JSON (Rails’ respond_to). pub enum Format
    • ocre::Format::Html (variant): text/html: a page. Html
    • ocre::Format::Json (variant): application/json. Json
    • ocre::Format::Xml (variant): application/xml or text/xml, e.g. an RSS or Atom feed. Xml
    • ocre::Format::Text (variant): text/plain. Text
    • ocre::Format::Markdown (variant): text/markdown, e.g. for LLM clients (Rails’ format.md); answer with Markdown. Markdown
    • ocre::Format::Other (variant): Only types Ocre does not know, e.g. application/pdf. Other
    • ocre::Format::from_headers (fn): Reads the Accept header; see Format for 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 IntoParam
    • ocre::IntoParam::into_param (fn): Converts the value into a bound parameter. fn into_param(self) -> Param
  • ocre::OptionExt (trait): Extension for Option: option.or_404()? turns a missing record into a 404 response. pub trait OptionExt<T>
    • ocre::OptionExt::or_404 (fn): The value, or Error::NotFound (404) when None. fn or_404(self) -> Result<T>
  • ocre::ApiResult (type): Result for JSON handlers: errors become ApiError JSON responses. pub type ApiResult<T> = Result<T, ApiError>
  • ocre::Result (type): Result with Error as 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) - 1
  • ocre::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 previous SECRET_KEY_BASE values, 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 whose key already 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 to limit cached values whose key starts with prefix (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): Removes key, 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 under key in KV, or else the result of compute. 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, feature html): Fragment caching, like Rails’ <% cache post do %>: the HTML stored under key, or else the template build returns, 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() -> T
  • ocre::cache::fragments (fn, feature html): Collection caching, like Rails’ render collection:, cached: true: one Fragment per 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) -> T
  • ocre::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)]) -> String
  • ocre::cache::read (fn): The value under key, or None when it is absent, expired, unreadable as T, 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): Stores value as JSON under key for ttl, 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): A Cache-Control response header, built from one of four policies. pub struct CacheControl
    • ocre::cache::CacheControl::no_store (fn): A no-store policy: never keep a copy (pages with secrets, one-time tokens). pub fn no_store() -> Self
    • ocre::cache::CacheControl::no_cache (fn): A private, no-cache policy: the browser keeps a copy but asks every time. pub fn no_cache() -> Self
    • ocre::cache::CacheControl::private (fn): A private, max-age=N policy: the browser reuses its copy for max_age without asking. pub fn private(max_age: Duration) -> Self
    • ocre::cache::CacheControl::public (fn): A public, max-age=N policy: browsers and Cloudflare may reuse it for every visitor. pub fn public(max_age: Duration) -> Self
    • ocre::cache::CacheControl::stale_while_revalidate (fn): Adds stale-while-revalidate, serving the old copy for up to window while 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): What clear deleted. pub struct Cleared
    • ocre::cache::Cleared::deleted (field): How many values were deleted. pub deleted: usize
    • ocre::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’s If-None-Match, to answer 304 Not Modified without rendering. pub struct Conditional
    • ocre::cache::Conditional::is_fresh (fn): Whether the client already has the version etag. pub fn is_fresh(&self, etag: &ETag) -> bool
    • ocre::cache::Conditional::fresh_when (fn): Answers 304 Not Modified when 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]>) -> Self
    • ocre::cache::ETag::strong (fn): A strong tag ("<hash>", Rails’ strong_etag:) for a version string you build. pub fn strong(version: impl AsRef<[u8]>) -> Self
    • ocre::cache::ETag::is_strong (fn): Whether the tag is strong (built with strong). pub fn is_strong(&self) -> bool
    • ocre::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, from fragment or fragments. pub struct Fragment(/* private fields */)
    • ocre::cache::Fragment::as_str (fn): The HTML. pub fn as_str(&self) -> &str
    • ocre::cache::Fragment::into_string (fn): The HTML as a String, e.g. for a realtime broadcast. 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 holding fragments: 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): Reads T from the given variables: what Ctx::config does 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 Environment
    • ocre::config::Environment::Development (variant): Debug build: ocre dev, cargo test, ocre test. Development
    • ocre::config::Environment::Production (variant): Release build: ocre deploy. Production
    • ocre::config::Environment::current (fn): The environment of this build. pub fn current() -> Self
    • ocre::config::Environment::as_str (fn): development or production, as sent to error reporters. pub fn as_str(self) -> &'static str
    • ocre::config::Environment::is_development (fn): Whether this is Development. pub fn is_development(self) -> bool
    • Implements: Clone, Copy, Debug, Eq, PartialEq

ocre::encryption

  • ocre::encryption::install (fn): Makes encryptor the one Encrypted and Deterministic use. pub fn install(encryptor: Encryptor)
  • ocre::encryption::installed (fn): The installed encryptor. pub fn installed() -> Result<Encryptor>
  • ocre::encryption::is_encrypted (fn): Whether text looks like an encrypted value (starts with v1:). pub fn is_encrypted(text: &str) -> bool
  • ocre::encryption::Deterministic (struct): A column encrypted deterministically: equal values give equal stored texts, so query().eq("email", Deterministic::from(email)) finds the row and a UNIQUE index 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 from SECRET_KEY_BASE. pub struct Encryptor
    • ocre::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): Encrypts plaintext with a random nonce: v1: plus URL-safe base64. pub fn encrypt(&self, plaintext: &str) -> String
    • ocre::encryption::Encryptor::encrypt_deterministic (fn): Encrypts plaintext so that equal values give equal texts (see Deterministic). pub fn encrypt_deterministic(&self, plaintext: &str) -> String
    • ocre::encryption::Encryptor::deterministic_candidates (fn): The deterministic texts of plaintext under every key, current first: look rows up with Query::is_in during a key rotation. pub fn deterministic_candidates(&self, plaintext: &str) -> Vec<String>
    • ocre::encryption::Encryptor::decrypt (fn): Decrypts a value from encrypt or encrypt_deterministic, with the current key or a previous one. pub fn decrypt(&self, ciphertext: &str) -> Result<String>
    • ocre::encryption::Encryptor::decrypt_or_plaintext (fn): Like decrypt, but a value without the v1: 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 HTTP POST a Subscriber asks Ocre to send for a report. pub struct Delivery
    • ocre::errors::Delivery::url (field): Where to POST. pub url: String
    • ocre::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 Options
    • ocre::errors::Options::new (fn): The defaults: handled, warning, no context, source application. pub fn new() -> Self
    • ocre::errors::Options::handled (fn): Whether the app recovered from the error (false for errors that failed the request or job). pub fn handled(mut self, handled: bool) -> Self
    • ocre::errors::Options::severity (fn): The report’s Severity. pub fn severity(mut self, severity: Severity) -> Self
    • ocre::errors::Options::context (fn): Adds a context entry, merged over the request’s Reporter::set_context entries. pub fn context(mut self, key: &str, value: impl Serialize) -> Self
    • ocre::errors::Options::source (fn): Where the error comes from, e.g. billing (Rails’ source:). pub fn source(mut self, source: &str) -> Self
    • ocre::errors::Options::except (fn): Does not send this report to the subscriber named name (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 Report
    • ocre::errors::Report::class (field): The error’s type, e.g. ocre::error::Error (Sentry’s exception type). pub class: String
    • ocre::errors::Report::message (field): The error’s text (Display). pub message: String
    • ocre::errors::Report::handled (field): Whether the app recovered from it. pub handled: bool
    • ocre::errors::Report::severity (field): How bad it is. pub severity: Severity
    • ocre::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: String
    • ocre::errors::Report::timestamp (field): When it was reported, in Unix seconds. pub timestamp: i64
    • ocre::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 Reporter
    • ocre::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 of result, 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 of result, if any, as unhandled, and returns result unchanged (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
  • ocre::errors::Severity (enum): How bad a report is (Rails’ severity:). pub enum Severity
    • ocre::errors::Severity::Error (variant): A failure: the default of Reporter::record and of Ocre’s own reports. Error
    • ocre::errors::Severity::Warning (variant): Handled, worth looking at: the default of Reporter::report and Reporter::handle. Warning
    • ocre::errors::Severity::Info (variant): For information. Info
    • ocre::errors::Severity::as_str (fn): error, warning or info, 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 a Report into the request to send. pub trait Subscriber: Send + Sync
    • ocre::errors::Subscriber::name (fn): A short name, for Options::except and failure logs: sentry. fn name(&self) -> &'static str
    • ocre::errors::Subscriber::deliver (fn): The request to send for report, 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 HTTP POST a Subscriber asks Ocre to send for a report. pub struct Delivery
    • ocre::events::Delivery::url (field): Where to POST. pub url: String
    • ocre::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, from Events::notify. pub struct Event
    • ocre::events::Event::name (field): What happened, e.g. order.placed. pub name: String
    • ocre::events::Event::payload (field): Its data; a payload that is not a JSON object is kept under value. pub payload: Map<String, Value>
    • ocre::events::Event::tags (field): Tags of the Events that 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 Events
    • ocre::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) -> Self
    • ocre::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 an Event into the request to send. pub trait Subscriber: Send + Sync
    • ocre::events::Subscriber::name (fn): A short name, for failure logs: analytics. fn name(&self) -> &'static str
    • ocre::events::Subscriber::emit (fn): The request to send for event, 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_graphql
  • ocre::graphql::graphiql (fn): Renders GraphiQL, the in-browser query editor, pointed at /graphql. pub fn graphiql() -> Html<String>
  • ocre::graphql::respond (fn): Executes a POST /graphql JSON body against schema and 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 + 'static
  • ocre::graphql::routes (fn): Returns a router serving GraphiQL on GET /graphql and queries on POST /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)]) -> String
  • ocre::helpers::current_page (fn): Whether url names the page being shown (Rails’ current_page?), for “you are here” links. pub fn current_page(current: &str, url: &str) -> bool
  • ocre::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) -> String
  • ocre::helpers::excerpt (fn): The first match of phrase (case-insensitive) with radius characters around it, ... where cut (Rails’ excerpt). pub fn excerpt(value: impl Display, phrase: &str, radius: usize) -> String
  • ocre::helpers::highlight (fn): The text, HTML-escaped, with each match of phrase (case-insensitive) in <mark> (Rails’ highlight). pub fn highlight(value: impl Display, phrase: &str) -> String
  • ocre::helpers::number_to_currency (fn): Two decimals, thousands grouped, unit first: 1234.5 with "$" becomes $1,234.50, -3 becomes -$3.00 (Rails’ number_to_currency). pub fn number_to_currency(value: impl Display, unit: &str) -> String
  • ocre::helpers::number_to_human (fn): Three significant digits and a word, Thousand to Quadrillion: 1234567 becomes 1.23 Million (Rails’ number_to_human). pub fn number_to_human(value: impl Display) -> String
  • ocre::helpers::number_to_human_size (fn): A byte count in 1024 steps: 1536 becomes 1.5 KB (Rails’ number_to_human_size, as storage::human_size). pub fn number_to_human_size(value: impl Display) -> String
  • ocre::helpers::number_to_percentage (fn): Rounds to precision decimals and adds %: 12.345 with 1 becomes 12.3% (Rails’ number_to_percentage). pub fn number_to_percentage(value: impl Display, precision: usize) -> String
  • ocre::helpers::number_with_delimiter (fn): Groups the integer part by thousands: 1234567.891 becomes 1,234,567.891 (Rails’ number_with_delimiter). pub fn number_with_delimiter(value: impl Display) -> String
  • ocre::helpers::number_with_precision (fn): Rounds to precision decimals: 3.14159 with 2 becomes 3.14 (Rails’ number_with_precision). pub fn number_with_precision(value: impl Display, precision: usize) -> String
  • ocre::helpers::progress_bar (fn): A progress bar with the element id id: a <progress> at percent (clamped to 0-100) and a label (escaped), as HTML. pub fn progress_bar(id: &str, percent: i64, label: &str) -> String
  • ocre::helpers::strftime (fn): Formats a time in UTC with strftime directives: %b %-d, %Y gives Sep 29, 2026. pub fn strftime(value: impl Display, format: &str) -> String
  • ocre::helpers::time_ago_in_words (fn): The time from value to now, in words: about 3 hours (Rails’ time_ago_in_words). pub fn time_ago_in_words(value: impl Display) -> String
  • ocre::helpers::time_zone_options (fn): The <option>s of every TIME_ZONES name, selected marked (Rails’ time_zone_select): write them in a <select>, and store the IANA name the form sends. pub fn time_zone_options(selected: &str) -> String
  • ocre::helpers::word_wrap (fn): Breaks lines longer than width characters at spaces (Rails’ word_wrap). pub fn word_wrap(value: impl Display, width: usize) -> String
  • ocre::helpers::TIME_ZONES (const): IANA time zone names, as browsers list them (Intl.supportedValuesOf("timeZone")): what time_zone_options offers and ocre time-zones prints. 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 the I18n extractor, 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 Catalog
    • ocre::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)]) -> Self
    • ocre::i18n::Catalog::errors (fn): Parse errors from load, 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 to locales!. pub fn default_locale(&self) -> &'static str
    • ocre::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 locale code, for code outside requests such as mailers and jobs. pub fn locale(&'static self, code: &str) -> I18n
    • Implements: Debug
  • ocre::i18n::HtmlTranslation (struct): A Translation written as HTML, from Translation::html: the text as is, every value escaped. pub struct HtmlTranslation<'a>(/* private fields */)
    • Implements: Clone, Debug, Display, HtmlSafe
  • ocre::i18n::I18n (struct): Axum extractor giving translations in the request’s locale, like Rails’ I18n.t with a per-request locale. pub struct I18n
    • ocre::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) -> String
    • ocre::i18n::I18n::number (fn): Groups thousands and writes the decimal separator of the locale: 1 234 567,5 in French. pub fn number(&self, value: impl fmt::Display) -> String
    • ocre::i18n::I18n::number_with_precision (fn): Rounds to precision decimals with the locale’s decimal separator: 3,14 in French. pub fn number_with_precision(&self, value: impl fmt::Display, precision: usize) -> String
    • ocre::i18n::I18n::currency (fn): Formats an amount with two decimals and unit placed as the locale does: 1 234,50 € in French. pub fn currency(&self, value: impl fmt::Display, unit: &str) -> String
    • ocre::i18n::I18n::time_ago_in_words (fn): The time from value to now in words, in the locale: environ 3 heures. pub fn time_ago_in_words(&self, value: impl fmt::Display) -> String
    • ocre::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) -> String
    • ocre::i18n::I18n::model_name (fn): The name of a model for count items, from models.<model> (Rails’ Post.model_name.human(count:)). pub fn model_name(&self, model: &str, count: impl Count) -> String
    • ocre::i18n::I18n::attribute (fn): The name of a model’s field, from attributes.<model>.<field>, then attributes.<field> (Rails’ human_attribute_name). pub fn attribute(&self, model: &str, field: &str) -> String
    • ocre::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) -> String
    • ocre::i18n::I18n::full_message (fn): The translated field name and message of a validation error, as errors.format lays them out (Rails’ full_message). pub fn full_message(&self, model: &str, error: &FieldError) -> String
    • ocre::i18n::I18n::locale (fn): Returns the locale code, e.g. for <html lang="{{ i18n.locale() }}">. pub fn locale(&self) -> &'static str
    • ocre::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> + 'static
    • ocre::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 with scope as the prefix of keys starting with . (Rails’ lazy lookup). pub fn scope(&self, scope: &'static str) -> Self
    • ocre::i18n::I18n::in_locale (fn): Returns the same translations in locale code (Rails’ locale: option), keeping the scope. pub fn in_locale(&self, code: &str) -> Self
    • ocre::i18n::I18n::exists (fn): Whether key has a translation: in the locale, the default locale or the built-in translations. pub fn exists(&self, key: &str) -> bool
    • ocre::i18n::I18n::namespace (fn): Returns every translation under prefix, by key relative to it (Rails’ namespace lookup). pub fn namespace(&self, prefix: &str) -> BTreeMap<&'static str, &'static str>
    • ocre::i18n::I18n::path (fn): Prefixes path with the locale, for routes nested under /{locale} (Rails’ default_url_options). pub fn path(&self, path: &str) -> String
    • ocre::i18n::I18n::alternates (fn): The absolute URL of path in 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), one alternate per locale with its hreflang, and x-default (the default locale’s URL). pub fn alternate_links(&self, base_url: &str, path: &str) -> String
    • ocre::i18n::I18n::cookie (fn): Returns a Set-Cookie value remembering this locale for a year. pub fn cookie(&self) -> String
    • Implements: Clone, Copy, Debug, FromRequestParts
  • ocre::i18n::Translation (struct): A translation being built by I18n::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) -> Self
    • ocre::i18n::Translation::count (fn): Sets the plural count: picks the form for n and fills %{count}. pub fn count(mut self, n: impl Count) -> Self
    • ocre::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) -> Self
    • ocre::i18n::Translation::or (fn): Uses text when neither the key nor its alternatives have a translation (Rails’ default: "text"). pub fn or(mut self, text: impl Into<Cow<'a, str>>) -> Self
    • ocre::i18n::Translation::html (fn): Marks the translation as HTML: its text is written as is, the values of arg escaped. pub fn html(self) -> HtmlTranslation<'a>
    • Implements: Clone, Debug, Display
  • ocre::i18n::Problem (enum): A problem in the locale files, found by check. pub enum Problem
    • ocre::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 from locale. Missing { locale: String, key: String, }
    • Implements: Clone, Debug, Display, PartialEq
  • ocre::i18n::Count (trait): A number Translation::count accepts: every integer type, and references to them. pub trait Count
    • ocre::i18n::Count::to_count (fn): Returns the number as i64, or i64::MAX when it does not fit. fn to_count(&self) -> i64
  • ocre::i18n::Locales (type): The app’s translations: a Catalog parsed on first use, declared once as a static. 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 parameter I18n reads the locale from. pub const LOCALE_PARAM: &str = "locale"

ocre::jobs

  • ocre::jobs::consume (fn): Runs a batch of queue messages through the app’s perform; the Worker’s queue entry 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’s scheduled entry 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 by ocre dev only, for tests (Rails’ assert_enqueued_with and assert_performed_jobs). pub fn dev_routes<S: Clone + Send + Sync + 'static>() -> axum::Router<S>
  • ocre::jobs::enqueue (fn): Sends job to the JOBS queue, to run in the background through consume. pub fn enqueue<J: Serialize>(ctx: &Ctx, job: &J) -> impl Future<Output = Result<()>> + Send + use<J>
  • ocre::jobs::enqueue_all (fn): Sends every job of jobs to the JOBS queue 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): Sends job to the JOBS queue like enqueue, to run after delay. 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 lock key for owner until ttl seconds from now; false when 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) -> Queue
  • ocre::jobs::run_steps (fn): Runs step from cursor while budget covers its cost (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 lock key if owner holds 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 Budget
    • ocre::jobs::Budget::FREE_D1_QUERIES (const): D1 queries per invocation on the free plan: 50. pub const FREE_D1_QUERIES: u32 = 50
    • ocre::jobs::Budget::FREE_SUBREQUESTS (const): Subrequests (fetch) per invocation on the free plan: 50. pub const FREE_SUBREQUESTS: u32 = 50
    • ocre::jobs::Budget::new (fn): A budget of calls. pub fn new(calls: u32) -> Self
    • ocre::jobs::Budget::take (fn): Takes calls from the budget when that many are left; false (and nothing taken) otherwise. pub fn take(&self, calls: u32) -> bool
    • ocre::jobs::Budget::left (fn): The calls not taken yet. pub fn left(&self) -> u32
    • Implements: Debug
  • ocre::jobs::Queue (struct): A job queue, from queue: enqueue on it like on the default queue. pub struct Queue
    • ocre::jobs::Queue::enqueue (fn): Sends job to this queue, like enqueue does to default. pub fn enqueue<J: Serialize>(&self, job: &J) -> impl Future<Output = Result<()>> + Send + use<J>
    • ocre::jobs::Queue::enqueue_in (fn): Sends job to this queue, to run after delay (24 hours at most), like enqueue_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 of jobs to this queue in batches, like enqueue_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 of run_steps did: 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 queue enqueue and enqueue_in use: default, bound as QUEUE_BINDING. pub const DEFAULT_QUEUE: &str = "default"
  • ocre::jobs::LOCKS_TABLE_SQL (const): The job_locks table of lock and unlock, created by the migration of ocre 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, for enqueue_in and 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 from SECRET_KEY_BASE and returns its claims. pub fn decode(ctx: &Ctx, token: &str) -> Result<Claims>
  • ocre::jwt::decode_with (fn): Verifies token with key at Unix time now (seconds) and returns its claims. pub fn decode_with(key: &Key, token: &str, now: i64) -> Result<Claims>
  • ocre::jwt::encode (fn): Signs claims with the HS256 key derived from the SECRET_KEY_BASE Worker secret. pub fn encode(ctx: &Ctx, claims: &Claims) -> Result<String>
  • ocre::jwt::encode_with (fn): Signs claims with key and returns the compact token header.payload.signature. pub fn encode_with(key: &Key, claims: &Claims) -> String
  • ocre::jwt::token_from (fn): The token of the first locations entry 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 Claims
    • ocre::jwt::Claims::sub (field): Subject: the user id, as a string (the JWT standard’s type). pub sub: String
    • ocre::jwt::Claims::iat (field): Issued at, in Unix seconds. pub iat: i64
    • ocre::jwt::Claims::exp (field): Expires at, in Unix seconds; the token is rejected from this second on. pub exp: i64
    • ocre::jwt::Claims::new (fn): Claims for sub, issued now (crate::now) and valid for ttl_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 from SECRET_KEY_BASE. pub struct Key(/* private fields */)
    • ocre::jwt::Key::from_secret_key_base (fn): Derives the key from a SECRET_KEY_BASE value: HMAC-SHA256 of a fixed label. pub fn from_secret_key_base(secret: &str) -> Self
  • ocre::jwt::Location (enum): Where token_from looks for a token (Loco’s auth.jwt.location). pub enum Location
    • ocre::jwt::Location::Bearer (variant): Authorization: Bearer <token>, the default for API clients. Bearer
    • ocre::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 Logger
    • ocre::log::Logger::new (fn): A logger without fields. pub fn new() -> Self
    • ocre::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) -> Self
    • ocre::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 of level are written, under the current LOG_LEVEL. pub fn enabled(&self, level: Level) -> bool
    • ocre::log::Logger::debug (fn): Writes a debug line. pub fn debug(&self, message: impl Display)
    • ocre::log::Logger::info (fn): Writes an info line. pub fn info(&self, message: impl Display)
    • ocre::log::Logger::warn (fn): Writes a warn line. pub fn warn(&self, message: impl Display)
    • ocre::log::Logger::error (fn): Writes an error line. pub fn error(&self, message: impl Display)
    • ocre::log::Logger::log (fn): Writes a line of level when it is enabled; the message is formatted only then. pub fn log(&self, level: Level, message: &dyn Display)
    • ocre::log::Logger::line (fn): The line log writes, 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 Format
    • ocre::log::Format::Json (variant): One JavaScript object per line, indexed by Workers Logs. Json
    • ocre::log::Format::Text (variant): LEVEL message key=value ..., for the terminal. Text
    • ocre::log::Format::parse (fn): Reads a format name: json, or text (pretty and compact accepted, 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 Level
    • ocre::log::Level::Debug (variant): Details for development: SQL statements, request summaries. Debug
    • ocre::log::Level::Info (variant): Normal events worth keeping in production. Info
    • ocre::log::Level::Warn (variant): Something unexpected that the app handled. Warn
    • ocre::log::Level::Error (variant): A failure: internal errors, failed jobs. Error
    • ocre::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: json or text. pub const LOG_FORMAT: &str = "LOG_FORMAT"
  • ocre::log::LOG_LEVEL (const): Worker variable setting the lowest level logged: debug, info, warn, error or off. pub const LOG_LEVEL: &str = "LOG_LEVEL"

ocre::mail

  • ocre::mail::address_with_name (fn): Formats Name <address>, quoting the name when needed, like Rails’ email_address_with_name. pub fn address_with_name(name: &str, address: &str) -> String
  • ocre::mail::deliver_in (fn): Checks email now and sends it from the jobs queue after delay, 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): Checks email now 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 by ocre dev only, like Rails’ /rails/mailers and 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 mailbox handler for an email from Cloudflare Email Routing; the Worker’s email entry 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): Sends email now, from MAIL_FROM, with the adapter named by MAIL_ADAPTER. pub fn send(ctx: &Ctx, email: Email) -> impl Future<Output = Result<()>> + Send + use<>
  • ocre::mail::url (fn): An absolute URL for path on the app’s public address, the APP_URL variable (Rails’ _url helpers in mailers). pub fn url(ctx: &Ctx, path: &str) -> Result<String>
  • ocre::mail::Attachment (struct): A file attached to an Email, or an inline image shown by its HTML. pub struct Attachment
    • ocre::mail::Attachment::filename (field): File name shown by mail clients, invoice.pdf. pub filename: String
    • ocre::mail::Attachment::content_type (field): MIME type, application/pdf or image/png. pub content_type: String
    • ocre::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 as cid:<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 Email
    • ocre::mail::Email::from (field): Sender, when not MAIL_FROM: billing@example.com or Billing <billing@example.com>, on a domain verified with the provider. pub from: Option<String>
    • ocre::mail::Email::to (field): To recipients, ada@example.com or Ada <ada@example.com>; an invalid one makes sending a 400. pub to: Vec<String>
    • ocre::mail::Email::cc (field): Cc recipients, checked like to. pub cc: Vec<String>
    • ocre::mail::Email::bcc (field): Bcc recipients, checked like to; 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: String
    • ocre::mail::Email::text (field): Plain-text body; always sent, so every mail client can read it. pub text: String
    • ocre::mail::Email::html (field): Optional HTML body, shown instead of text by clients that render HTML. pub html: Option<String>
    • ocre::mail::Email::reply_to (field): Where replies go, when not to the sender; checked like to. 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 with cid:. pub attachments: Vec<Attachment>
    • ocre::mail::Email::delivery_method (field): Adapter for this email instead of MAIL_ADAPTER (resend or cloudflare), set by delivery_method; ignored while MAIL_ADAPTER is log. 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>) -> Self
    • ocre::mail::Email::also_to (fn): Adds another To recipient. pub fn also_to(mut self, address: impl Into<String>) -> Self
    • ocre::mail::Email::cc (fn): Adds a Cc recipient. pub fn cc(mut self, address: impl Into<String>) -> Self
    • ocre::mail::Email::bcc (fn): Adds a Bcc recipient: it gets the email, the other recipients do not see it. pub fn bcc(mut self, address: impl Into<String>) -> Self
    • ocre::mail::Email::from (fn): Sends from address instead of the MAIL_FROM variable. pub fn from(mut self, address: impl Into<String>) -> Self
    • ocre::mail::Email::delivery_method (fn): Sends this email with another adapter than MAIL_ADAPTER: "resend" or "cloudflare" (Rails’ delivery_method). pub fn delivery_method(mut self, adapter: impl Into<String>) -> Self
    • ocre::mail::Email::html (fn): Adds the HTML version of the body, replacing any previous one. pub fn html(mut self, html: impl Into<String>) -> Self
    • ocre::mail::Email::reply_to (fn): Sets the Reply-To address, so replies go there instead of to the sender. pub fn reply_to(mut self, address: impl Into<String>) -> Self
    • ocre::mail::Email::header (fn): Adds a header, e.g. In-Reply-To and References to thread a reply, or List-Unsubscribe. pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self
    • ocre::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>) -> Self
    • ocre::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 InboundEmail
    • ocre::mail::InboundEmail::from (fn): Returns the envelope sender (SMTP MAIL FROM), checked by Cloudflare. pub fn from(&self) -> &str
    • ocre::mail::InboundEmail::to (fn): Returns the envelope recipient: the address of this app that received the email. pub fn to(&self) -> &str
    • ocre::mail::InboundEmail::subject (fn): Returns the decoded Subject header, or "" when it is missing. pub fn subject(&self) -> &str
    • ocre::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 first text/plain part, decoded to UTF-8, or None when 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 first text/html part, decoded to UTF-8, or None when 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 with reason. pub fn reject(&self, reason: &str)
    • ocre::mail::InboundEmail::forward (fn): Forwards the email unchanged to to, 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/mailers in ocre dev, rendered with sample data, like Rails’ mailer previews. pub struct Preview
    • ocre::mail::Preview::name (field): mailer/action, e.g. user/welcome; the page’s URL is /ocre/dev/mailers/preview/<name>. pub name: &'static str
    • ocre::mail::Preview::build (field): Builds the email with sample data. pub build: fn() -> Result<Email>
    • ocre::mail::Preview::new (fn): A preview named mailer/action, built by build. 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 the bindings.sendEmail() binding used when MAIL_ADAPTER = "cloudflare". pub const EMAIL_BINDING: &str = "EMAIL"
  • ocre::mail::LOG_PREFIX (const): Prefix of every line Ocre logs about mail, e.g. in ocre dev output. pub const LOG_PREFIX: &str = "[ocre mail]"
  • ocre::mail::MAIL_ADAPTER (const): Name of the Worker variable that chooses the adapter: log, resend or cloudflare. 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 when MAIL_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) -> String
  • ocre::oauth::exchange_code (fn): Trades the authorization code from 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’s Profile with 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 called name ("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 Pkce
    • ocre::oauth::Pkce::verifier (field): The secret, sent with the token request (exchange_code). pub verifier: String
    • ocre::oauth::Pkce::challenge (field): BASE64URL(SHA-256(verifier)), sent in the authorization URL. pub challenge: String
    • ocre::oauth::Pkce::new (fn): A new random verifier (32 bytes) and its challenge. pub fn new() -> Self
    • ocre::oauth::Pkce::challenge_for (fn): The S256 challenge of verifier. 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 Profile
    • ocre::oauth::Profile::provider (field): The provider’s Provider::name. pub provider: &'static str
    • ocre::oauth::Profile::uid (field): The user’s stable id at the provider (GitHub’s numeric id, Google’s sub). pub uid: String
    • ocre::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 Provider
    • ocre::oauth::Provider::name (field): Lowercase name, used in routes (/auth/github) and stored with identities. pub name: &'static str
    • ocre::oauth::Provider::authorize_url (field): Where the browser is sent to sign in and approve the app. pub authorize_url: &'static str
    • ocre::oauth::Provider::token_url (field): Where the Worker trades the code for an access token. pub token_url: &'static str
    • ocre::oauth::Provider::userinfo_url (field): Where the Worker reads the signed-in user. pub userinfo_url: &'static str
    • ocre::oauth::Provider::scopes (field): Space-separated scopes: the user’s identity and email address only. pub scopes: &'static str
    • ocre::oauth::Provider::client_id_secret (field): Name of the Worker secret holding the OAuth app’s client id. pub client_id_secret: &'static str
    • ocre::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): scopes read:user user:email. pub const GITHUB: Provider = Provider
  • ocre::oauth::GOOGLE (const): Google (OpenID Connect): scopes openid email profile. pub const GOOGLE: Provider = Provider
  • ocre::oauth::PROVIDERS (const): Every provider Ocre knows: ocre g auth --oauth accepts these names. pub const PROVIDERS: [&Provider; 2] = [&GITHUB, &GOOGLE]

ocre::password

  • ocre::password::hash (fn): Hashes password with 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 in digest, or None when it is not in Ocre’s format. pub fn iterations(digest: &str) -> Option<u32>
  • ocre::password::verify (fn): Whether password matches digest (made by hash), 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): Encrypts payload for a subscription (aes128gcm content 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 of ocre g pwa shows: {"title", "options": {"body", "data": {"path"}}}; clicking the notification opens path. pub fn message(title: &str, body: &str, path: &str) -> serde_json::Value
  • ocre::push::send (fn): Encrypts message (JSON, e.g. message) for subscription and 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): The Authorization header of a push to endpoint (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’s PushSubscription.toJSON() gives: where and how to push to it. pub struct Subscription
    • ocre::push::Subscription::endpoint (field): The push service URL for this browser. pub endpoint: String
    • ocre::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 SubscriptionKeys
    • ocre::push::SubscriptionKeys::p256dh (field): P-256 public key (65 bytes, uncompressed). pub p256dh: String
    • ocre::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 VapidKeys
    • ocre::push::VapidKeys::public_key (field): The public key (65 bytes, uncompressed point): VAPID_PUBLIC_KEY, given to browsers. pub public_key: String
    • ocre::push::VapidKeys::private_key (field): The private key (32 bytes): VAPID_PRIVATE_KEY, a secret. pub private_key: String
    • ocre::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 Sent
    • ocre::push::Sent::Delivered (variant): Accepted (201): the browser gets it when it is online, within the TTL. Delivered
    • ocre::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 - 1
  • ocre::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.com or an https: URL. pub const VAPID_SUBJECT: &str = "VAPID_SUBJECT"

ocre::realtime (feature realtime)

  • ocre::realtime::append (fn): Builds a message that inserts html at the end of the element with id target (htmx beforeend). pub fn append(target: &str, html: &str) -> String
  • ocre::realtime::broadcast (fn): Sends message (an HTML fragment or JSON text) to every browser connected to channel. 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 by ocre dev only, for tests (Rails’ assert_broadcasts). pub fn dev_routes<S: Clone + Send + Sync + 'static>() -> Router<S>
  • ocre::realtime::prepend (fn): Builds a message that inserts html at the start of the element with id target (htmx afterbegin). pub fn prepend(target: &str, html: &str) -> String
  • ocre::realtime::remove (fn): Builds a message that removes the element with id id from the page (htmx delete). pub fn remove(id: &str) -> String
  • ocre::realtime::update (fn): Builds a message that replaces the contents of the element with id target (htmx innerHTML). pub fn update(target: &str, html: &str) -> String
  • ocre::realtime::OcreChannel (struct): The Durable Object class behind each realtime channel, exported by Ocre under the name OcreChannel. pub struct OcreChannel
    • Implements: DurableObject, From
  • ocre::realtime::WebSocketUpgrade (struct): Axum extractor for a WebSocket handshake (Upgrade: websocket), finished with connect. pub struct WebSocketUpgrade
    • ocre::realtime::WebSocketUpgrade::identified_by (fn): Names who is connecting, like Action Cable’s identified_by :current_user. pub fn identified_by(mut self, identity: impl Into<String>) -> Self
    • ocre::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) -> Self
    • ocre::realtime::WebSocketUpgrade::connect (fn): Connects the browser to channel, returning the 101 Switching Protocols response 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. in ocre dev output. pub const LOG_PREFIX: &str = "[ocre realtime]"
  • ocre::realtime::MAX_CHANNEL_LEN (const): Longest channel name, in bytes. pub const MAX_CHANNEL_LEN: usize = 128
  • ocre::realtime::MAX_IDENTITY_LEN (const): Longest identity given to WebSocketUpgrade::identified_by, in bytes. pub const MAX_IDENTITY_LEN: usize = 256
  • ocre::realtime::MAX_REBROADCAST_BYTES (const): Longest client message WebSocketUpgrade::rebroadcast relays, 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 to on: 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) -> String
  • ocre::security::filter_json (fn): A JSON value with sensitive values replaced by "[FILTERED]", at any depth. pub fn filter_json(value: &Value) -> Value
  • ocre::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) -> String
  • ocre::security::json_escape (fn): Escapes a JSON string for a <script> element (Rails’ json_escape). pub fn json_escape(json: &str) -> String
  • ocre::security::rate_limit (fn): Counts one request for key against the Workers Rate Limiting binding binding; 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 to SANITIZE_TAGS and SANITIZE_ATTRIBUTES (Rails’ sanitize). pub fn sanitize(html: &str) -> String
  • ocre::security::sanitize_with (fn): Like sanitize, with your own allowed tags and attributes (Rails’ sanitize(html, tags:, attributes:)). pub fn sanitize_with(html: &str, tags: &[&str], attributes: &[&str]) -> String
  • ocre::security::strip_tags (fn): Removes every tag and comment and keeps the text, escaped (Rails’ strip_tags). pub fn strip_tags(html: &str) -> String
  • ocre::security::url_from (fn): The URL to redirect to when candidate points inside this app, else None (Rails’ url_from). pub fn url_from(uri: &Uri, candidate: &str) -> Option<String>
  • ocre::security::AllowBrowser (struct): Tower layer that answers 406 Not Acceptable to browsers older than the versions it allows (Rails’ allow_browser). pub struct AllowBrowser
    • ocre::security::AllowBrowser::new (fn): A policy that allows every browser; add limits with minimum and deny. pub fn new() -> Self
    • ocre::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() -> Self
    • ocre::security::AllowBrowser::minimum (fn): Allows browser from version major.minor on; replaces an earlier rule for the same browser. pub fn minimum(self, browser: Browser, major: u32, minor: u32) -> Self
    • ocre::security::AllowBrowser::deny (fn): Refuses every version of browser (Rails’ ie: false). pub fn deny(self, browser: Browser) -> Self
    • ocre::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>) -> Self
    • ocre::security::AllowBrowser::allows (fn): Whether a request with this User-Agent passes: 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 the AllowBrowser layer. pub struct AllowBrowserService<S>
    • Implements: Clone, Debug, Service
  • ocre::security::BasicAuth (struct): HTTP Basic credentials from Authorization: Basic ..., as an extractor (Rails’ http_basic_authenticate_with). pub struct BasicAuth
    • ocre::security::BasicAuth::username (field): The user name the client sent. pub username: String
    • ocre::security::BasicAuth::password (field): The password the client sent. pub password: String
    • ocre::security::BasicAuth::matches (fn): Whether the credentials are username and password, compared in constant time. pub fn matches(&self, username: &str, password: &str) -> bool
    • ocre::security::BasicAuth::challenge (fn): 401 Unauthorized with WWW-Authenticate: Basic realm="Application": the browser asks again. pub fn challenge() -> Response
    • Implements: Clone, Debug, Eq, FromRequestParts, PartialEq
  • ocre::security::ContentSecurityPolicy (struct): A Content-Security-Policy header, built directive by directive, and the layer that sends it. pub struct ContentSecurityPolicy
    • ocre::security::ContentSecurityPolicy::new (fn): An empty policy: add directives with the builder methods. pub fn new() -> Self
    • ocre::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]) -> Self
    • ocre::security::ContentSecurityPolicy::default_src (fn): default-src: the fallback for every fetch directive not set. pub fn default_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::script_src (fn): script-src: where scripts may come from. pub fn script_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::style_src (fn): style-src: where stylesheets and inline styles may come from. pub fn style_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::img_src (fn): img-src: images and favicons. pub fn img_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::font_src (fn): font-src: web fonts. pub fn font_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::connect_src (fn): connect-src: fetch, XHR (htmx requests), WebSockets and EventSource. pub fn connect_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::media_src (fn): media-src: <audio> and <video>. pub fn media_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::object_src (fn): object-src: <object> and <embed>; set it to NONE. pub fn object_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::frame_src (fn): frame-src: pages this app may put in an <iframe>. pub fn frame_src(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::frame_ancestors (fn): frame-ancestors: sites that may put this app in a frame (the modern X-Frame-Options). pub fn frame_ancestors(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::form_action (fn): form-action: where forms may be submitted. pub fn form_action(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::base_uri (fn): base-uri: allowed <base href> values. pub fn base_uri(self, sources: &[&str]) -> Self
    • ocre::security::ContentSecurityPolicy::upgrade_insecure_requests (fn): upgrade-insecure-requests: browsers load http: resources over HTTPS. pub fn upgrade_insecure_requests(self) -> Self
    • ocre::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) -> Self
    • ocre::security::ContentSecurityPolicy::report_to (fn): report-to: the Reporting-Endpoints group violation reports go to. pub fn report_to(self, group: &str) -> Self
    • ocre::security::ContentSecurityPolicy::report_only (fn): Sends Content-Security-Policy-Report-Only: browsers report violations but block nothing. pub fn report_only(mut self) -> Self
    • ocre::security::ContentSecurityPolicy::header_name (fn): content-security-policy, or content-security-policy-report-only after report_only. pub fn header_name(&self) -> HeaderName
    • ocre::security::ContentSecurityPolicy::header_value (fn): The header value, with NONCE replaced by 'nonce-<nonce>' (and dropped when nonce is None). 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): A Permissions-Policy header, built feature by feature, and the layer that sends it. pub struct PermissionsPolicy
    • ocre::security::PermissionsPolicy::new (fn): An empty policy: add features with allow and deny. pub fn new() -> Self
    • ocre::security::PermissionsPolicy::allow (fn): Allows feature for the listed origins only; replaces a previous rule for it. pub fn allow(mut self, feature: &str, allowlist: &[&str]) -> Self
    • ocre::security::PermissionsPolicy::deny (fn): Turns features off everywhere: camera=(). pub fn deny(self, features: &[&str]) -> Self
    • ocre::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 a ContentSecurityPolicy or PermissionsPolicy layer wraps routes in. pub struct PolicyService<S, P>
    • Implements: Clone, Service
  • ocre::security::Browser (enum): A browser family that AllowBrowser recognizes in the User-Agent header. pub enum Browser
    • ocre::security::Browser::Chrome (variant): Google Chrome and Chromium (Chrome/, CriOS/ on iOS). Chrome
    • ocre::security::Browser::Edge (variant): Microsoft Edge (Edg/, EdgA/, EdgiOS/). Edge
    • ocre::security::Browser::Firefox (variant): Mozilla Firefox (Firefox/). Firefox
    • ocre::security::Browser::InternetExplorer (variant): Internet Explorer (MSIE, Trident/). InternetExplorer
    • ocre::security::Browser::Opera (variant): Opera (OPR/). Opera
    • ocre::security::Browser::Safari (variant): Apple Safari (Version/... Safari/). Safari
    • ocre::security::Browser::detect (fn): The browser and its (major, minor) version named by a User-Agent, or None for 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 values filter_parameters and filter_json hide. pub const FILTERED_PARAMETERS: &[&str] = &["passw", "email", "secret", "token", "_key", "crypt", "salt", "certificate", "otp", "ssn", "cvv", "cvc"]
  • ocre::security::HTTPS (const): Any https: 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 a ContentSecurityPolicy source list. pub const NONE: &str = "'none'"
  • ocre::security::SANITIZE_ATTRIBUTES (const): Attributes sanitize keeps 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): Tags sanitize keeps: 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 a ContentSecurityPolicy or PermissionsPolicy source 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': allows eval and new Function (htmx’s hx-on and js: need it). pub const UNSAFE_EVAL: &str = "'unsafe-eval'"
  • ocre::security::UNSAFE_INLINE (const): 'unsafe-inline': allows inline <style>/<script> and style= attributes. pub const UNSAFE_INLINE: &str = "'unsafe-inline'"

ocre::seo

  • ocre::seo::json_ld (fn): data as a JSON-LD <script> for a page’s <head>. pub fn json_ld(data: &impl Serialize) -> String
  • ocre::seo::LlmsTxt (struct): /llms.txt: a site’s summary and links in Markdown, for language models (https://llmstxt.org). pub struct LlmsTxt
    • ocre::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>) -> Self
    • ocre::seo::LlmsTxt::details (fn): Free text after the summary. pub fn details(mut self, text: impl Into<String>) -> Self
    • ocre::seo::LlmsTxt::link (fn): A link in the section list (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 Sitemap
    • ocre::seo::Sitemap::MAX_URLS (const): Most URLs in one sitemap file. pub const MAX_URLS: usize = 50_000
    • ocre::seo::Sitemap::new (fn): An empty sitemap. pub fn new() -> Self
    • ocre::seo::Sitemap::add (fn): Adds a page. pub fn add(&mut self, url: SitemapUrl) -> &mut Self
    • ocre::seo::Sitemap::add_localized (fn): Adds a page in every language: urls is (hreflang, absolute URL) for each locale, the first being the default (also x-default). pub fn add_localized(&mut self, urls: &[(&str, &str)], lastmod: Option<&str>) -> &mut Self
    • ocre::seo::Sitemap::len (fn): The URLs added so far. pub fn len(&self) -> usize
    • ocre::seo::Sitemap::is_empty (fn): Whether no URL was added. pub fn is_empty(&self) -> bool
    • ocre::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 a Sitemap: its absolute URL, last change and other languages. pub struct SitemapUrl
    • ocre::seo::SitemapUrl::loc (field): Absolute URL (https://example.com/fr/pricing). pub loc: String
    • ocre::seo::SitemapUrl::lastmod (field): Last change, YYYY-MM-DD or a full W3C date-time; omitted when None. 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::Event
  • ocre::sse::Sse (re-export) pub use axum::response::sse::Sse
  • ocre::sse::stream (fn): An event stream: step(state) gives the next event and the next state, or None to 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’s analyze). pub fn analyze(bytes: &[u8]) -> Analysis
  • ocre::storage::attach_direct_upload (fn): Finishes a direct upload (or a multipart_uploads one): checks the object behind signed_key against rules and returns its Attachment. 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 against rules and lays it out in parts, as multipart_uploads does 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 an UPDATE of one attachment’s columns: a change flag, then the four columns. 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 at key. pub fn delete(ctx: &Ctx, key: &str) -> impl Future<Output = Result<()>> + Send + use<>
  • ocre::storage::delete_attachments (fn): Deletes the objects of every Some attachment 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 against rules and presigns a PUT of a new key under prefix. pub fn direct_upload(ctx: &Ctx, prefix: &str, field: &str, request: &DirectUploadRequest, rules: &Rules) -> Result<DirectUpload>
  • ocre::storage::direct_upload_script (fn): Serves DIRECT_UPLOAD_JS at GET /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 at key (Active Storage’s exist?); a head without the details. pub fn exists(ctx: &Ctx, key: &str) -> impl Future<Output = Result<bool>> + Send + use<>
  • ocre::storage::head (fn): Describes the object at key without reading it: size, content type, ETag, upload time; None when 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) -> String
  • ocre::storage::list (fn): Lists one page of the objects whose key starts with prefix, 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 the field of 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 cut size bytes into parts no larger than max_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 a GET of an attachment on R2’s S3 API, valid expires_in seconds (at most 7 days). pub fn presign_get(ctx: &Ctx, attachment: &Attachment, disposition: Disposition, expires_in: u64) -> Result<String>
  • ocre::storage::presign_parts (fn): Presigned PUT URLs of parts of the upload upload_id of key, valid expires_in seconds. 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 a PUT of exactly size bytes of type content_type at key, valid expires_in seconds. 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 of key in a public bucket: <STORAGE_PUBLIC_URL>/<key> (Active Storage’s public: true). pub fn public_url(ctx: &Ctx, key: &str) -> Result<String>
  • ocre::storage::purge_unattached (fn): Deletes the objects under prefix older than max_age seconds that no table.column row 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 returns None when key does 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 first length bytes of an object (all of it when shorter), or None when key does 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 .ics file. pub fn send_data(data: impl Into<Bytes>, filename: &str, content_type: &str, disposition: Disposition) -> Response
  • ocre::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): Answers 302 Found to a presigned GET of 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 an Upload in R2 under a new random key starting with prefix, and returns the Attachment to save. pub fn store(ctx: &Ctx, prefix: &str, upload: Upload) -> impl Future<Output = Result<Attachment>> + Send + use<>
  • ocre::storage::store_body (fn): Streams body (exactly size bytes) into R2 without holding it in memory, and returns its Attachment. 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) like store. 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): What analyze found in a file’s bytes. pub struct Analysis
    • ocre::storage::Analysis::content_type (field): The type told by the file’s signature (magic bytes), or None for 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, like width. 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 Attachment
    • ocre::storage::Attachment::key (field): Object key in the R2 bucket: <prefix>/<22 random URL-safe characters>. pub key: String
    • ocre::storage::Attachment::filename (field): The uploader’s file name, without directories or control characters. pub filename: String
    • ocre::storage::Attachment::content_type (field): Content type, lowercase and without parameters (image/png). pub content_type: String
    • ocre::storage::Attachment::size (field): Size in bytes. pub size: i64
    • ocre::storage::Attachment::human_size (fn): Formats the size for people with human_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 the ETag it answered. pub struct CompletedPart
    • ocre::storage::CompletedPart::part_number (field): From 1. pub part_number: u16
    • ocre::storage::CompletedPart::etag (field): The part’s ETag, quoted or not. pub etag: String
    • Implements: Clone, Debug, Deserialize, Eq, PartialEq, Serialize
  • ocre::storage::DirectUpload (struct): A direct upload the browser may perform: PUT the file to url with headers, then submit signed_key. pub struct DirectUpload
    • ocre::storage::DirectUpload::signed_key (field): The object key and its signature (<prefix>/<22 characters>.<43 characters>); the form submits it. pub signed_key: String
    • ocre::storage::DirectUpload::url (field): Presigned PUT URL on R2’s S3 API. pub url: String
    • ocre::storage::DirectUpload::headers (field): Headers the PUT must carry, exactly (Content-Type); the browser adds Content-Length itself. pub headers: BTreeMap<String, String>
    • ocre::storage::DirectUpload::sign (fn): Checks request against rules and presigns a PUT of a new key under prefix, valid expires_in seconds. 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 DirectUploadRequest
    • ocre::storage::DirectUploadRequest::filename (field): File name, as the browser gives it (cleaned up when attached). pub filename: String
    • ocre::storage::DirectUploadRequest::content_type (field): Declared content type; signed into the upload URL, so R2 stores this one. pub content_type: String
    • ocre::storage::DirectUploadRequest::size (field): Declared size in bytes; signed into the upload URL as Content-Length. pub size: u64
    • Implements: Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize
  • ocre::storage::FinishRequest (struct): The browser finishes (parts set) or abandons an upload. pub struct FinishRequest
    • ocre::storage::FinishRequest::signed_key (field): From the MultipartUpload. pub signed_key: String
    • ocre::storage::FinishRequest::upload_id (field): From the MultipartUpload. pub upload_id: String
    • ocre::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 of list: the objects, and the cursor of the next page. pub struct Listing
    • ocre::storage::Listing::objects (field): Objects in key order. pub objects: Vec<StoredObject>
    • ocre::storage::Listing::cursor (field): Pass it to the next list call; None on the last page. pub cursor: Option<String>
    • Implements: Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize
  • ocre::storage::Multipart (struct): Extractor for a multipart/form-data body of at most LIMIT bytes. pub struct Multipart<const LIMIT: usize>(pub MultipartForm)
    • Implements: Debug, FromRequest
  • ocre::storage::MultipartForm (struct): The parsed parts of a multipart body: text fields and files, by field name. pub struct MultipartForm
    • ocre::storage::MultipartForm::form (fn): Deserializes the text fields into T, exactly like axum’s Form. pub fn form<T: DeserializeOwned>(&self) -> Result<T>
    • ocre::storage::MultipartForm::text (fn): Returns the first text field named name, or None when it was not sent. pub fn text(&self, name: &str) -> Option<&str>
    • ocre::storage::MultipartForm::file (fn): Takes the first file sent as name, leaving the others. pub fn file(&mut self, name: &str) -> Option<Upload>
    • ocre::storage::MultipartForm::files (fn): Takes every file sent as name or name[] (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 MultipartUpload
    • ocre::storage::MultipartUpload::signed_key (field): The object key and its signature, as for a direct upload: the form submits it. pub signed_key: String
    • ocre::storage::MultipartUpload::upload_id (field): R2’s id of the upload. pub upload_id: String
    • ocre::storage::MultipartUpload::part_size (field): Bytes per part; the last part holds the rest. pub part_size: u64
    • ocre::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 to PUT each part: {"urls": {"1": "https://..."}}. pub struct PartUrls
    • ocre::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 PartsRequest
    • ocre::storage::PartsRequest::signed_key (field): From the MultipartUpload. pub signed_key: String
    • ocre::storage::PartsRequest::upload_id (field): From the MultipartUpload. pub upload_id: String
    • ocre::storage::PartsRequest::parts (field): Part numbers, from 1; at most MAX_PARTS_PER_REQUEST. pub parts: Vec<u16>
    • Implements: Clone, Debug, Deserialize, Eq, PartialEq
  • ocre::storage::Purged (struct): What one purge_unattached call did: the keys it deleted, and where the next call resumes. pub struct Purged
    • ocre::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; None once 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 Rules
    • ocre::storage::Rules::max_bytes (field): Largest accepted file, in bytes (u64: multipart uploads take files over 4 GB, more than a usize holds in WebAssembly). pub max_bytes: u64
    • ocre::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 whether content_type is 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 S3Endpoint
    • ocre::storage::S3Endpoint::host (field): Host name of the S3 API, without scheme (<account_id>.r2.cloudflarestorage.com). pub host: String
    • ocre::storage::S3Endpoint::region (field): Signing region: auto for R2. pub region: String
    • ocre::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: String
    • ocre::storage::S3Endpoint::access_key_id (field): Access key ID of the credentials. pub access_key_id: String
    • ocre::storage::S3Endpoint::secret_access_key (field): Secret access key of the credentials. pub secret_access_key: String
    • ocre::storage::S3Endpoint::r2 (fn): The S3 endpoint of an R2 bucket: host <account_id>.r2.cloudflarestorage.com, region auto, path-style URLs. pub fn r2(account_id: &str, bucket: &str, access_key_id: &str, secret_access_key: &str) -> Self
    • ocre::storage::S3Endpoint::presign (fn): Presigns a request on key (AWS SigV4, query-string form) and returns its https:// 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, as head and list describe it (without its bytes). pub struct StoredObject
    • ocre::storage::StoredObject::key (field): Object key. pub key: String
    • ocre::storage::StoredObject::size (field): Size in bytes. pub size: u64
    • ocre::storage::StoredObject::content_type (field): Content type recorded with the object (application/octet-stream when none was). pub content_type: String
    • ocre::storage::StoredObject::etag (field): R2’s entity tag, unquoted (the MD5 of the content for single-part uploads). pub etag: String
    • ocre::storage::StoredObject::uploaded_at (field): Upload time, in Unix seconds (compare with crate::now). pub uploaded_at: i64
    • ocre::storage::StoredObject::filename (field): File name recorded by store and friends; None for objects uploaded directly. pub filename: Option<String>
    • ocre::storage::StoredObject::attachment (fn): The Attachment of this object under filename (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 a multipart/form-data request, before it is stored. pub struct Upload
    • ocre::storage::Upload::filename (field): File name sent by the browser, cleaned up. pub filename: String
    • ocre::storage::Upload::content_type (field): Content type sent by the browser, lowercase without parameters. pub content_type: String
    • ocre::storage::Upload::bytes (field): The file’s bytes. pub bytes: axum::body::Bytes
    • ocre::storage::Upload::new (fn): An upload made by the app (Active Storage’s attach(io:, filename:, content_type:)), cleaned up like a browser’s. pub fn new(filename: &str, content_type: &str, bytes: impl Into<Bytes>) -> Self
    • ocre::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 Variant
    • ocre::storage::Variant::new (fn): A variant with no resizing: only format=auto. pub const fn new() -> Self
    • ocre::storage::Variant::width (fn): Sets the largest width, in pixels. pub const fn width(mut self, pixels: u32) -> Self
    • ocre::storage::Variant::height (fn): Sets the largest height, in pixels. pub const fn height(mut self, pixels: u32) -> Self
    • ocre::storage::Variant::fit (fn): Sets how the image fits the width × height box; without it, Cloudflare scales the image down to fit. pub const fn fit(mut self, fit: Fit) -> Self
    • ocre::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) -> Self
    • ocre::storage::Variant::path (fn): The same-origin path of this variant of source: /cdn-cgi/image/<options>/<source>. pub fn path(&self, source: &str) -> String
    • Implements: Clone, Copy, Debug, Default, Eq, PartialEq
  • ocre::storage::Disposition (enum): Tells serve whether the browser shows a file or downloads it. pub enum Disposition
    • ocre::storage::Disposition::Inline (variant): Shows the file in the page or tab when its type is safe to display, and downloads anything else. Inline
    • ocre::storage::Disposition::Download (variant): Always downloads the file, with its original name. Download
    • Implements: Clone, Copy, Debug, Eq, PartialEq
  • ocre::storage::Fit (enum): How a Variant fits the image in its width × height box (Cloudflare’s fit option). pub enum Fit
    • ocre::storage::Fit::ScaleDown (variant): Like Contain, but never enlarges a smaller image (Active Storage’s resize_to_limit). ScaleDown
    • ocre::storage::Fit::Contain (variant): Fits inside the box, keeping the aspect ratio (resize_to_fit). Contain
    • ocre::storage::Fit::Cover (variant): Fills the box, cropping what overflows (resize_to_fill). Cover
    • ocre::storage::Fit::Crop (variant): Like Cover, but never enlarges a smaller image. Crop
    • ocre::storage::Fit::Pad (variant): Like Contain, then pads to the exact box size (resize_and_pad). Pad
    • Implements: Clone, Copy, Debug, Eq, PartialEq
  • ocre::storage::CACHE_CONTROL (const): Cache-Control of files sent by serve: 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’s activestorage.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_800
  • ocre::storage::MAX_PART (const): Largest part R2 accepts: 5 GiB. pub const MAX_PART: u64 = 5 * 1024 * MIB
  • ocre::storage::MAX_PARTS (const): Most parts in one upload. pub const MAX_PARTS: u64 = 10_000
  • ocre::storage::MAX_PARTS_PER_REQUEST (const): Most part URLs answered at once. pub const MAX_PARTS_PER_REQUEST: usize = 1_000
  • ocre::storage::MAX_WORKER_PART (const): Largest part sent through the Worker (its request limit is 100 MB). pub const MAX_WORKER_PART: u64 = 95 * MIB
  • ocre::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 * MIB
  • ocre::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, for public_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: async handlers, helpers and ocre::password in plain unit tests (no async runtime runs outside workerd). pub use pollster::block_on
  • ocre::testing::assert_changes (fn): Asserts that block changes what expression returns (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 that block changes the number expression returns by difference (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) -> R
  • ocre::testing::assert_no_changes (fn): Asserts that block leaves what expression returns 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 that block leaves the number expression returns unchanged (Rails’ assert_no_difference). pub fn assert_no_difference<R>(expression: impl FnMut() -> i64, block: impl FnOnce() -> R) -> R
  • ocre::testing::count (fn): Number of rows of table in the test database, for assert_difference. pub fn count(table: &str) -> i64
  • ocre::testing::eventually (fn): Retries check every 100 ms for up to 30 seconds until it returns Some, for effects that happen later: a queued job, a cron, a broadcast. pub fn eventually<T>(check: impl FnMut() -> Option<T>) -> T
  • ocre::testing::fixture (fn): The row of table loaded from the fixture label (Rails’ posts(:first)). pub fn fixture(table: &str, label: &str) -> Map<String, Value>
  • ocre::testing::fixture_id (fn): The id of the fixture labelled label (Rails’ users(:david).id): the loader of tests/fixtures/*.yml gives a record without an explicit id the CRC-32 of its label modulo 2^30 - 1, like Rails. pub fn fixture_id(label: &str) -> i64
  • ocre::testing::freeze_time (fn): Stops crate::now at the current second (Rails’ freeze_time); returns it. pub fn freeze_time() -> i64
  • ocre::testing::insert (fn): Inserts a row into table of the test database and returns its id: what generated factories (tests/factories/) call. pub fn insert(table: &str, values: &[(&str, Value)]) -> i64
  • ocre::testing::quote (fn): value as a SQL literal: strings quoted (' doubled), numbers as is, booleans as 1/0, null as NULL, arrays and objects as JSON text. pub fn quote(value: impl Into<Value>) -> String
  • ocre::testing::redact (fn): Replaces values that change on every run by placeholders, so a snapshot or an assert_eq! on a whole page or JSON body is stable (Loco’s cleanup_* 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) -> String
  • ocre::testing::sequence (fn): A unique number per call in the test process, for unique test data (FactoryBot’s sequence): 1, 2, 3… pub fn sequence() -> u64
  • ocre::testing::sql (fn): Rows returned by query, run on the test database (Rails’ ActiveRecord::Base.connection.select_all). pub fn sql(query: &str) -> Vec<Map<String, Value>>
  • ocre::testing::travel (fn): Moves crate::now by seconds from its current value and freezes it there (Rails’ travel 1.day). pub fn travel(seconds: i64)
  • ocre::testing::travel_back (fn): Returns crate::now to the real clock (Rails’ travel_back). pub fn travel_back()
  • ocre::testing::travel_to (fn): Makes crate::now return unix on this thread until travel_back (Rails’ travel_to, frozen). pub fn travel_to(unix: i64)
  • ocre::testing::var (fn): A variable of the app under test: the environment variable name, else its value in .dev.vars (the file ocre dev loads), as the Worker sees it. pub fn var(name: &str) -> Option<String>
  • ocre::testing::Broadcast (struct): A message broadcast to a realtime channel, from Client::broadcasts. pub struct Broadcast
    • ocre::testing::Broadcast::id (field): Position in the capture, from 1. pub id: u64
    • ocre::testing::Broadcast::channel (field): The channel it went to (posts). pub channel: String
    • ocre::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 Client
    • ocre::testing::Client::new (fn): A client for the server ocre test --e2e started (TEST_URL). pub fn new() -> Self
    • ocre::testing::Client::with_base_url (fn): A client for the app at base (http://localhost:8787), e.g. a running ocre dev. pub fn with_base_url(base: &str) -> Self
    • ocre::testing::Client::header (fn): Sends name: value with every request (Rails’ headers:); replaces an earlier value of the same header. pub fn header(mut self, name: &str, value: &str) -> Self
    • ocre::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) -> Self
    • ocre::testing::Client::htmx (fn): Requests as htmx does (HX-Request: true), Rails’ xhr: true. pub fn htmx(self) -> Self
    • ocre::testing::Client::get (fn): GET path. pub fn get(&mut self, path: &str) -> Response
    • ocre::testing::Client::post (fn): POST path with an application/x-www-form-urlencoded body, as an HTML form sends it. pub fn post(&mut self, path: &str, form: &impl Serialize) -> Response
    • ocre::testing::Client::post_json (fn): POST path with value as a JSON body. pub fn post_json(&mut self, path: &str, value: &impl Serialize) -> Response
    • ocre::testing::Client::patch_json (fn): PATCH path with value as a JSON body. pub fn patch_json(&mut self, path: &str, value: &impl Serialize) -> Response
    • ocre::testing::Client::put_json (fn): PUT path with value as a JSON body. pub fn put_json(&mut self, path: &str, value: &impl Serialize) -> Response
    • ocre::testing::Client::delete (fn): DELETE path. pub fn delete(&mut self, path: &str) -> Response
    • ocre::testing::Client::request (fn): Sends method path with 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>)>) -> Response
    • ocre::testing::Client::follow_redirect (fn): Follows the redirect response answered: GET of its Location (Rails’ follow_redirect!). pub fn follow_redirect(&mut self, response: &Response) -> Response
    • ocre::testing::Client::cookie (fn): The cookie name as 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’ session in tests), decrypted with the app’s SECRET_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 of kind (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 with MAIL_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) -> Jobs
    • ocre::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’s email event. 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, from Client::jobs. pub struct EnqueuedJob
    • ocre::testing::EnqueuedJob::id (field): Position in the capture, from 1 (shared with runs). pub id: u64
    • ocre::testing::EnqueuedJob::queue (field): The queue: default, or the name given to ocre::jobs::queue. pub queue: String
    • ocre::testing::EnqueuedJob::job (field): The job as the app serialized it: {"send_welcome": {"user_id": 7}}. pub job: Value
    • ocre::testing::EnqueuedJob::name (fn): The job’s name: the key of its JSON object (send_welcome), as PerformedJob::job names it. pub fn name(&self) -> Option<&str>
    • Implements: Clone, Debug, Deserialize, PartialEq
  • ocre::testing::Jobs (struct): Jobs the app enqueued and ran, from Client::jobs. pub struct Jobs
    • ocre::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 dev output: console.log, job and cron lines, errors), read from the position of Log::mark on. pub struct Log
    • ocre::testing::Log::mark (fn): The log from now on (TEST_LOG, else .wrangler/test-state/dev.log). pub fn mark() -> Self
    • ocre::testing::Log::text (fn): What was logged since the mark. pub fn text(&self) -> String
    • ocre::testing::Log::wait_for (fn): Waits up to 30 seconds for a line containing needle and returns it. pub fn wait_for(&self, needle: &str) -> String
    • Implements: Clone, Debug
  • ocre::testing::PerformedJob (struct): A job run by the queue consumer, from Client::jobs. pub struct PerformedJob
    • ocre::testing::PerformedJob::id (field): Position in the capture, from 1 (shared with enqueued jobs). pub id: u64
    • ocre::testing::PerformedJob::job (field): The job’s name (send_welcome), or mail for deliver_later emails. pub job: String
    • ocre::testing::PerformedJob::outcome (field): done, discarded (an error a retry cannot fix) or retried. 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 Response
    • ocre::testing::Response::status (field): HTTP status code. pub status: u16
    • ocre::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 for HEAD, 204 and 304. pub body: String
    • ocre::testing::Response::header (fn): The first value of the header name (any case). pub fn header(&self, name: &str) -> Option<&str>
    • ocre::testing::Response::location (fn): The Location header 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) -> T
    • ocre::testing::Response::assert_status (fn): Asserts the status code (Rails’ assert_response 201). pub fn assert_status(&self, status: u16) -> &Self
    • ocre::testing::Response::assert_success (fn): Asserts a 2xx status (Rails’ assert_response :success). pub fn assert_success(&self) -> &Self
    • ocre::testing::Response::assert_redirect_to (fn): Asserts a 3xx redirect to location (Rails’ assert_redirected_to). pub fn assert_redirect_to(&self, location: &str) -> &Self
    • ocre::testing::Response::assert_contains (fn): Asserts the body contains text (HTML-escaped as templates escape it: ' is &#39;). pub fn assert_contains(&self, text: &str) -> &Self
    • ocre::testing::Response::assert_not_contains (fn): Asserts the body does not contain text. pub fn assert_not_contains(&self, text: &str) -> &Self
    • ocre::testing::Response::assert_header (fn): Asserts the header name has value. 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 server ocre test --e2e started. pub const TEST_URL: &str = "OCRE_TEST_URL"

ocre::token

  • ocre::token::constant_time_eq (fn): Whether a equals b, compared in constant time for equal lengths. pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool
  • ocre::token::digest (fn): SHA-256 of token as 64 lowercase hex characters: the value to store and look up. pub fn digest(token: &str) -> String
  • ocre::token::generate (fn): A new random token: TOKEN_BYTES secure random bytes as URL-safe base64 without padding (43 characters). pub fn generate() -> String
  • ocre::token::public_id (fn): A random id for URLs: 22 URL-safe characters (128 bits), the value of a public_id:token column. pub fn public_id() -> String
  • ocre::token::TOKEN_BYTES (const): Random bytes in a token generated by generate: 32 (256 bits). pub const TOKEN_BYTES: usize = 32

ocre::webhooks

  • ocre::webhooks::once (fn): Runs effect for the event event_id of source unless 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): POSTs body as JSON to url, signed: the HMAC-SHA256 of the body with secret in X-Signature (lowercase hex, see sign), and Authorization: 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 of message with secret, as lowercase hex: the signature to send in a header (X-Signature) of an outgoing call. pub fn sign(secret: &[u8], message: &[u8]) -> String
  • ocre::webhooks::sign_standard (fn): The webhook-signature header value (v1,<base64>) of a Standard Webhooks delivery of body with this id and timestamp (Unix seconds): what verify_standard checks. pub fn sign_standard(secret: &str, id: &str, timestamp: i64, body: &[u8]) -> Result<String>
  • ocre::webhooks::verify (fn): Checks that signature is the HMAC-SHA256 of message with secret, 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-signature holds one or more space-separated v1,<base64> signatures of {id}.{timestamp}.{body} keyed with the base64 part of the whsec_... secret, and the webhook-timestamp must be within tolerance seconds of now (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 of post_signed: status and body. pub struct Answer
    • ocre::webhooks::Answer::status (field): HTTP status. pub status: u16
    • ocre::webhooks::Answer::text (field): Body, as text. pub text: String
    • Implements: Clone, Debug, Eq, PartialEq
  • ocre::webhooks::Delivery (enum): What once did 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 left processing this long (seconds) is taken over by the next one: the invocation that claimed it died before finishing. pub const STALE_AFTER: i64 = 300
  • ocre::webhooks::TABLE_SQL (const): The webhook_events table once records deliveries in, created by the migration of ocre 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

PieceWhat it isWhere
Your appA 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 ityour repository
ocreThe framework crate: serve, Ctx, Db, Error, sessions, validations, and one module per Cloudflare product (jobs, storage, cache, mail, realtime…)crates/ocre
workerCloudflare’s workers-rs 0.8: Rust bindings to the Workers JavaScript runtime, the #[event(...)] entry-point macros, and the axum integrationdependency of both
worker-buildCompiles the crate to wasm32-unknown-unknown, runs wasm-bindgen (and wasm-opt in release), and writes build/index.js plus build/index_bg.wasmrun by the build.command of wrangler.config.ts
cfCloudflare’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 wranglerthe app’s package.json (npm install)
wranglerCloudflare’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 CLIGenerators, 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]
  1. Static files first. wrangler.config.ts declares assetsDirectory: "public". Cloudflare serves a request that matches a file in public/ (/robots.txt, images, CSS) from Workers Static Assets, without invoking the Worker: no Worker request counted, no CPU used.

  2. The Worker’s fetch event. Every other request runs the entry point ocre new writes into src/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
    }
  3. ocre::serve. It builds a Ctx from the Worker’s environment and gives it to the router as axum state, reads the SECRET_KEY_BASE secret and the ALLOWED_ORIGINS variable, and wraps the router in the middleware every Ocre app runs, outermost first:

    LayerDoesCosts
    Security headersAdds 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
    CORSOnly when ALLOWED_ORIGINS lists origins: answers preflights and adds CORS headers for themnothing
    CSRFRefuses with 403 an unsafe request (or a WebSocket handshake) that a browser sends from another site, judged by Sec-Fetch-Site, else Origin against Hostnothing
    SessionDecrypts the _ocre_session cookie on first use, re-encrypts it into Set-Cookie when a handler changed itno D1 row, no KV operation

    A missing or short SECRET_KEY_BASE does 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.

  4. 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 binding DB; 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 through ApiError); internal details are logged, never sent.

  5. The response. serve turns the axum response into a JavaScript Response with worker::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.

FeatureCloudflare productBinding or configOcre APIAdded by
Static filesWorkers Static Assets[assets] directory = "public"noneocre new
Models, migrationsD1 (SQLite)DB: bindings.d1({ name })ctx.db(), Db::all / first / execute / batch, params!ocre new
Sessions, flashnone: an encrypted cookieSECRET_KEY_BASE secretSession, Flashocre new (.dev.vars), ocre deploy
Background jobsQueuesJOBS: bindings.queue({ name }), triggers.queue(...), a dead-letter queueocre::jobs::enqueue, enqueue_in; entry point consumeocre g job
Scheduled tasksCron Triggers[triggers] cronsentry point ocre::jobs::cronocre g schedule
FilesR2STORAGE: bindings.r2({ name })ocre::storagefirst attachment field
RealtimeDurable Objects (WebSocket Hibernation)CHANNELS: bindings.durableObject(...), OcreChannel: exports.durableObject(...)ocre::realtime, the OcreChannel class (feature realtime)ocre g scaffold ... --realtime
CacheWorkers KVCACHE: bindings.kv()ocre::cacheocre g cache
Sending emailResend’s HTTP API, or Email ServiceMAIL_ADAPTER, MAIL_FROM; RESEND_API_KEY or EMAIL: bindings.sendEmail()ocre::mail::send, deliver_laterocre new (bindings.text), ocre g mailer
Receiving emailEmail Routinga routing rule in the dashboardentry point ocre::mail::receiveocre 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 with default-features = false, whose WebAssembly build calls fetch; 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’s Date.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.getRandomValues through getrandom’s js feature. Password hashing calls crypto.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.
  • Send without threads. axum requires handler futures to be Send, but JavaScript handles (the environment, a D1 database, a stream) are not. Ocre wraps each one in worker::send::SendWrapper (and futures in SendFuture), which is sound because a Worker runs your code on a single thread. As a result every Ocre type is Send, 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 = 1 and opt-level = "z", and worker-build runs wasm-opt; symbols are kept because stripping breaks wasm-bindgen. The blog starter’s index_bg.wasm is 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 featureDefaultEnables
htmlyesaskama templates (render), HTML error pages, the Htmx extractor. API-only apps (ocre new --api) turn it off
graphqlnoThe graphql module (async-graphql); ocre g api ... --graphql turns it on
realtimenoThe realtime module and the exported OcreChannel Durable Object class; ocre g scaffold ... --realtime turns it on

See also

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.rs sees 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" src finds every query on a table; ocre routes lists 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_many and preload_<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 (what post::query().eq("published", true).order_desc("id") builds) only assembles one SQL string and its parameters, and to_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:

MarkerFileReceives
// ocre:modulessrc/lib.rsmod <name>; for each new module
// ocre:routessrc/lib.rs, in routes().merge(<module>::routes())
// ocre:modelssrc/models/mod.rspub mod <model>;
// ocre:associationssrc/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-dispatchsrc/jobs/mod.rsthe job’s module, its Job variant and its perform arm
// ocre:schedules, // ocre:schedule-dispatchsrc/schedules/mod.rsthe task’s module and its cron arm
// ocre:mailerssrc/mailers/mod.rspub mod <mailer>;
// ocre:channelssrc/realtime.rsa channel name anyone may open
// ocre:graphql-queries, // ocre:graphql-mutationssrc/graphql.rsthe 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 update or regeneration. To see what changed, generate the same resource in a scratch app (ocre new scratch --yes, then the same ocre 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 generated AGENTS.md says 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 (the created and updated lists 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: Error and its HTML/JSON rendering, Validator, Json, Page, multipart parsing, the translation file parser, ETag and Cache-Control handling.

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

RailsLocoOcre
ModelA class inheriting ApplicationRecord: columns come from the database schema at run time; queries are built by Active RecordSeaORM entities generated from the database schema into src/models/_entities/ (regenerated by cargo loco db entities), plus a model file for your codeOne Rust file: struct, inputs, validations, callbacks and queries, never regenerated
QueriesBuilt at run time by Active RecordBuilt by SeaORMocre::Query chains in the model (one table, bound values), or SQL written out
After a schema changeNothing to update in the modelRegenerate the entitiesEdit 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

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

ThreatWhat Ocre doesWhereYour part
Reading or forging the sessionCookie encrypted and authenticated with AES-256-GCMocre::serveKeep SECRET_KEY_BASE secret
Cross-site request forgeryBrowsers’ cross-site unsafe requests get 403, without tokensocre::serveNever change data in a GET handler
Cross-origin reads by other sitesNo CORS headers unless ALLOWED_ORIGINS lists the originocre::serveList only origins you control
Clickjacking, MIME sniffingX-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff and moreocre::servenone
Requests for other host names403 for hosts not in ALLOWED_HOSTS, when setocre::serveList your domains
XSS in pagesaskama escapes {{ value }}; generated full-stack apps send a Content-Security-Policy without inline scriptstemplates, src/lib.rsNever mark user input |safe unless it went through ocre::security::sanitize
SQL injectionValues bind to ?1, ?2 placeholders through params![...]; Query takes column names as &'static strDb, QueryNever build SQL with format! from input
Stolen database: passwordsPBKDF2-HMAC-SHA256 digests, 100,000 iterationsocre::passwordnone
Stolen database: links and keysOnly SHA-256 digests of tokens are storedocre::token, auth modelsKeep it that way for your own tokens
JWT algorithm confusionOnly HS256 is accepted, none included in the refusalocre::jwtKeep TTLs short
Uploaded HTML or SVG running as the appServed as application/octet-stream downloadsocre::storage::serveCheck who may download
Cross-site WebSocket hijackingHandshakes checked like formsocre::serve, src/realtime.rsAuthorize channels in connect
Leaking internals in errorsInternal messages are logged, clients see Internal server errorocre::ErrorUse Error::internal for unexpected failures
Open redirect after loginOnly local paths are remembered and followed (ocre::security::url_from)src/auth.rsUse url_from for your own redirects
Account enumerationSame answers whether an email has an account; a password hash runs either wayauth controllers, user.rsnone
Brute force, credential stuffing10 attempts a minute per IP address and action on every route that checks a password or sends an emailsrc/auth_api.rs (throttle), ocre::security::rate_limitKeep 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/hs256 keyed 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) and remember_for store an expiry inside the encrypted cookie, checked on every request, so a copy stops working after it; ocre g auth signs users in for two weeks. To end one session, or all of a user’s sessions, before that, ocre g auth --db-sessions keeps 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_in empties the session before storing user_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:

  1. GET, HEAD and OPTIONS pass, except WebSocket handshakes (Upgrade: websocket), which are checked like forms.
  2. An Origin listed in ALLOWED_ORIGINS passes.
  3. Sec-Fetch-Site: same-origin or none (typed in the address bar, a bookmark) passes; any other value, same-site included, gets 403 Forbidden: cross-site request. Add the origin to ALLOWED_ORIGINS to allow it.
  4. Without Sec-Fetch-Site (older browsers): when both Origin and Host are present, the host in Origin must equal Host, else 403 Forbidden: cross-origin request. Add the origin to ALLOWED_ORIGINS to allow it.
  5. 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 GET handler must never change data: it is not checked.
  • Subdomains are other sites here: a form on admin.example.com posting to example.com is same-site, not same-origin, and gets 403 unless listed in ALLOWED_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:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsSAMEORIGIN
Referrer-Policystrict-origin-when-cross-origin
X-XSS-Protection0 (turns off the obsolete browser filter)
X-Permitted-Cross-Domain-Policiesnone
Strict-Transport-Securitymax-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.

  • ocre::token::generate() returns 32 random bytes (256 bits) as 43 URL-safe characters.
  • The auth_tokens table keeps only ocre::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 (ASCII filename= plus UTF-8 filename*=, 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::serve shows inline only types that cannot run scripts (raster images, PDF, plain text, audio, video). HTML, SVG, XML and JavaScript are sent as application/octet-stream attachments, and every other type as an attachment, so an uploaded file never runs in the app’s origin. Responses carry Cache-Control: private, no-cache and nosniff.
  • 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 when SECRET_KEY_BASE changes.
  • 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

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 scaffold for pages, ocre g job for jobs, ocre g cache for KV, --realtime for WebSockets.
  • Measured numbers come from the Ocre README (production wrangler tail on the free plan, and wrangler dev on an Apple M5 Max). Everything else is computed from Cloudflare’s published rules and marked as an estimate.

The daily budgets

BudgetFree plan (September 2026)Spent by
Worker requests100,000 a day per accountEvery HTTP request that is not a static file, every WebSocket connection
CPU10 ms per invocationRust code running in WebAssembly, JSON and HTML rendering, crypto
D1 rows read5,000,000 a dayRows scanned by queries
D1 rows written100,000 a dayInserted, updated and deleted rows, plus one per index touched
KV reads / writes100,000 / 1,000 a dayocre::cache
Queues operations10,000 a dayBackground jobs: 3 per job
Durable Object requests100,000 a dayRealtime connections and broadcasts
R210 GB, 1M uploads, 10M downloads a monthattachment 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:

TrafficWorker requestsWhy
A file in public/ (CSS, images, robots.txt)0Workers Static Assets serves it before the Worker runs; asset requests are free and unlimited (billing)
An HTML page, a JSON call, GET /up1
A form submission in a scaffold2POST /posts answers with a redirect, and the browser then loads GET /posts/{id}
A 304 Not Modified from Conditional::fresh_when1The Worker still runs (and queries D1); it only skips rendering
A response served by Workers Cache ([cache] enabled = true)1Cache 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 WebSocket1 per connection or reconnectionMessages over an open WebSocket are not Worker requests (pricing)
A queue consumer batch, a cron run, an incoming email1 invocation eachEach 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”):

RouteCPU medianCPU maxWall median
GET / (list, 1 D1 query)2 ms23 ms (1 of 40, likely a new isolate)18 ms
GET /posts/:id2 ms4 ms16.5 ms
POST /posts (insert)3 ms6 ms28 ms

Other measured costs:

WorkCPUWhere measured
Worker startup (blog starter, 420 KB of WebAssembly)4 msREADME “Measured”
One password hash (PBKDF2-HMAC-SHA256, 100,000 iterations, WebCrypto)5.5 mswrangler dev, 100 hashes in 550 ms
A login request (one hash) against GET /up9.5 ms against 3.5 mswrangler dev
GraphQL schema at each new Worker instance (--graphql)20 to 60 mswrangler tail
Splitting a 10 MB multipart upload / copying it to R21.2 ms / 0.15 msV8, 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 --graphql is 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):

FunctionSQLRows (estimate from D1’s rules)
all(ctx, page)SELECT * FROM posts ORDER BY id DESC LIMIT ?1 OFFSET ?2The 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 postsScans the table: grows with the table. Scaffold pages do not call it.
find(ctx, id)SELECT * FROM posts WHERE id = ?11 read
find_many(ctx, &ids)SELECT * FROM posts WHERE id IN (?1, ...), 100 ids per query1 read per id found, one query per 100 ids
createINSERT ... RETURNING *, after one SELECT 1 ... LIMIT 1 per unique field and per reference1 row written plus 1 per index on the table; 1 read per check (indexed)
updateUPDATE ... RETURNING * with the same checks1 row written plus 1 per index whose column is written
deleteDELETE ... 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 columnthe 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 compute and is one write;
  • every ocre::cache::delete (after changing the data behind a key) is one delete, and every write one 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):

EventOperations
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 firstthe 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:

EventWorker requestsDurable Object requests
A browser opens (or reopens) the page’s WebSocket11
ocre::realtime::broadcast to a channel0 (a subrequest of the request that broadcasts)1, whatever the number of browsers
A message to each browser00 (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):

ActivityWorker requestsD1 readsD1 writesOther
1,000 views of the post list (50 per page)1,00050,000CSS and images from public/: free
2,000 views of a post (find)2,0002,000
100 comments posted (POST + redirect; reference check; insert into comments, which has the post_id index)200200200
20 sign-ups (one password hash each; users has a unique email)402040your code queues 20 welcome emails with deliver_later
30 password resets (the generated src/passwords.rs sends the email directly with mail::send)60606030 emails on Resend
20 welcome-email jobs in consumer batchesup to 202060 Queues operations; 20 emails on Resend
1 nightly cron1a few
Home page statistic in KV, one-hour TTL, on the list page24 recomputations1,000 KV reads, 24 KV writes
300 realtime connections, 100 broadcasts300400 Durable Object requests
Totalabout 3,600 of 100,000about 52,300 of 5,000,000about 300 of 100,000KV 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):

ResourceFreePaid (included, then pay as you go)
Worker requests100,000 a day10 million a month, then $0.30 per million
CPU10 ms per invocation30 million CPU-ms a month, then $0.02 per million; 30 s per invocation by default, up to 5 minutes
Subrequests50 per invocation10,000 per invocation
D15 million reads, 100,000 writes a day, 5 GB25 billion reads, 50 million writes a month, 5 GB; then usage pricing
KV100,000 reads, 1,000 writes a day, 1 GB10 million reads, 1 million writes a month, 1 GB; then usage pricing
Queues10,000 operations a day, 24-hour retention1 million operations a month, then $0.40 per million; retention 4 days by default, up to 14
Durable Objects100,000 requests, 13,000 GB-s a day1 million requests, 400,000 GB-s a month; then usage pricing
Cron Triggers5 per account250 per account
Email Service sendingverified addresses onlyany 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] ... failed lines 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