# Ocre documentation (all pages) > Every page of the Ocre documentation in reading order. Each page starts with its URL; links point to the Markdown version of pages. --- URL: https://ocre.rs/introduction.md # 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](https://docs.rs/askama) templates and [htmx](https://htmx.org), 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. ```sh 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..workers.dev ``` Status: early. The APIs described here are the ones in the repository's `main` branch; there is no stable release yet. ## How these docs are organized | Part | Read it when | Pages | |---|---|---| | Getting started | You are new: install the tools, then build and deploy a live Q&A app step by step | [Installation](https://ocre.rs/getting-started/installation.md), [Tutorial](https://ocre.rs/getting-started/tutorial.md) | | Guides | You need to do one thing (add a model, send email, run a job...) | One page per domain, from [Models](https://ocre.rs/guides/models.md) to [Deployment](https://ocre.rs/guides/deployment.md) | | Reference | You need exact facts: every command and flag, field types, configuration keys, limits | [CLI](https://ocre.rs/reference/cli.md), [Generators](https://ocre.rs/reference/generators.md), [Field types](https://ocre.rs/reference/field-types.md), [Configuration](https://ocre.rs/reference/configuration.md), [Limits](https://ocre.rs/reference/limits.md), [API index](https://ocre.rs/api-index.md) | | Explanations | You want to know why Ocre works the way it does | [Architecture](https://ocre.rs/explanations/architecture.md), [Generated code](https://ocre.rs/explanations/generated-code.md), [Security model](https://ocre.rs/explanations/security-model.md), [Cost model](https://ocre.rs/explanations/cost-model.md) | The Rust API of the `ocre` crate is documented item by item in the [rustdoc reference](https://ocre.rs/api/ocre/), and on one page in the [API index](https://ocre.rs/api-index.md). ## For AI agents - Every page is also plain Markdown: add `.md` to its URL (`/guides/models` is `/guides/models.md`). - [`/llms.txt`](https://ocre.rs/llms.txt) lists every page with a one-line description; [`/llms-full.txt`](https://ocre.rs/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](https://ocre.rs/reference/cli.md)). --- URL: https://ocre.rs/getting-started/installation.md # 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](https://dash.cloudflare.com/sign-up) 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](https://rustup.rs): ```sh 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: ```toml [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](https://ocre.rs/getting-started/installation.md#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](https://www.npmjs.com/package/cf) for local development, deploys and every Cloudflare API call. Install [Node.js](https://nodejs.org) 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](https://ocre.rs/guides/upgrading.md). ## Install the ocre CLI Install the `ocre` binary from the Git repository: ```sh 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: ```sh 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 ```sh ocre --version ``` ```text ocre 0.2.0 ``` ```sh ocre --help ``` ```text Ocre: Rails-like Rust web framework for Cloudflare Workers, free plan first. Usage: ocre [OPTIONS] Commands: new Create a new app in ./. 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 --help` describes each command and its flags; the [CLI reference](https://ocre.rs/reference/cli.md) documents them all. ## Create an app with the guided setup In a terminal, `ocre new` asks for everything its flags did not answer: ```sh ocre new ``` The questions, in order (each one is skipped when the matching flag was given): | Question | Answers | Flag that skips it | |---|---|---| | What is your app called? | A name, checked as you type: lowercase letters, digits and dashes, starting with a letter, at most 63 characters, and no existing directory of that name | `ocre new ` | | What are you building? | Full-stack app (HTML pages with askama and htmx) or API only (JSON endpoints, no HTML) | `--full-stack`, `--api` | | Pick a starter | Empty (a home page, or a status endpoint in API mode), Live Q&A (hosts create events, the audience asks and votes, live on every screen; full-stack apps only) or Blog (posts with title, body and published, full CRUD) | `--starter empty`, `--starter qa`, `--starter blog` | | Connect your Cloudflare account? | Log in now (`cf auth login`, opens your browser) or Later (run `ocre login` when you are ready). Asked only when `cf auth whoami` finds no login; otherwise the setup prints `Logged in to Cloudflare as ` | `--login`, `--no-login` | | Which Cloudflare account should host it? | One of the accounts of your login, when it has several | `--account-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 `, `ocre dev`, `ocre deploy`, plus `ocre login` when you skipped the login) and either `Your app is live at ` or `Happy building!`. Esc or Ctrl-C cancels with `error: cancelled`. ## Create an app with flags (agents and scripts) The guided setup only runs when stdin and stdout are a terminal and neither `--yes` nor `--json` was given. Otherwise `ocre new` never prompts: flags decide, and anything not given takes an opt-in default (full-stack, empty starter, no Cloudflare login, no git, no deploy; the npm install still runs). | Flag | Effect | Default without prompts | |---|---|---| | `` | App name and directory (`./`) | required | | `--api` / `--full-stack` | API only (JSON, no templates, Ocre's `html` feature off), or HTML pages | full-stack | | `--starter empty\|blog` | `blog` adds a `Post` resource (title, body, published) | `empty` | | `--login` / `--no-login` | Log in to Cloudflare if needed (opens a browser) | no login | | `--account-id ` | Account to deploy to; required when the login has several | none | | `--git` / `--no-git` | Run `git init` | no git | | `--deploy` / `--no-deploy` | Deploy right away (implies `--login`) | no deploy | | `--yes`, `-y` | Never prompt, even in a terminal | | | `--ocre-path ` | Depend on a local `crates/ocre` checkout instead of the Git repository | Git dependency | | `--no-install` | Skip `npm install` in the new app (offline); run it yourself before `ocre dev`. `ocre doctor` reports it until you do | install | | `--json` | Print one JSON object on stdout; tool output goes to stderr | | ```sh ocre new blog --yes ``` ```text 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: ```sh ocre new other --json --yes ``` ```json {"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](https://ocre.rs/getting-started/tutorial.md): it runs `ocre g auth`, `ocre g scaffold Event name:string public_id:token user:references` and `ocre g scaffold Question event:references body:text votes:integer answered:boolean --realtime`, then writes the room over the generated files; `--api --starter qa` stops with ``the qa starter has HTML pages; it cannot be API-only``. `--api` writes an API-only `src/lib.rs`, no `templates/`, and adds `[package.metadata.ocre] mode = "api"` to `Cargo.toml`. ## What ocre new creates | File | Contents | |---|---| | `Cargo.toml` | The app crate (`cdylib`), depending on `ocre`, `worker`, `axum`, `askama` (full-stack only) and `serde`; a standalone `[workspace]`; release profile tuned for size | | `cloudflare.config.ts` | The Worker's Cloudflare configuration: `name`, logs, the `DB` D1 database and `MAIL_FROM` in `env`, and the `// ocre:env`, `// ocre:triggers`, `// ocre:exports` markers where generators add entries (see [Configuration](https://ocre.rs/reference/configuration.md#cloudflareconfigts)) | | `wrangler.config.ts` | The build command (installs `worker-build` and compiles to WebAssembly) and `public/` as static assets | | `package.json`, `package-lock.json` | The pinned `cf`, `wrangler` and `typescript`, installed in `node_modules/` by `npm install`; commit both files | | `tsconfig.json` | Type checking of the two `.ts` files, for your editor and `npx tsc -p .` | | `rust-toolchain.toml` | Stable Rust with the `wasm32-unknown-unknown` target | | `.gitignore` | Build output, `node_modules/`, `.wrangler/` (local database), `.cloudflare/`, `.dev.vars`, `.prod.vars` and `.env*` | | `AGENTS.md` | Conventions, commands and free-plan limits for AI agents working on the app | | `migrations/` | D1 SQL migrations, applied in order (empty for now) | | `public/robots.txt` | Static files, served by Cloudflare before the Worker runs | | `src/lib.rs` | The Worker entry point and router: `GET /` (home page) and `GET /up` (health check) | | `templates/layout.html`, `templates/home.html` | askama templates: the page layout (with htmx) and the home page | | `.dev.vars` | Local-only settings for `ocre dev`: a random `SECRET_KEY_BASE` and `MAIL_ADAPTER=log`. Never commit it | The app has no Cloudflare resources yet: the first `ocre deploy` creates the D1 database and the `SECRET_KEY_BASE` secret. The [tutorial](https://ocre.rs/getting-started/tutorial.md) 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`): ```text 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: ```text error: rustc not found hint: install Rust with rustup: https://rustup.rs ``` No Node.js (`ocre new` without `--no-install`): ```text 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): ```text 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: ```text 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 ` ``` `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): ```text error: invalid app name `Blog` hint: use lowercase letters, digits and dashes, starting with a letter (max 63), e.g. `my-blog` ``` ```text error: missing app name hint: run `ocre new `, or run `ocre new` in a terminal for the guided setup ``` ```text error: `/path/to/blog` already exists hint: choose another name or remove the directory ``` ## See also - [Tutorial: a live Q&A app](https://ocre.rs/getting-started/tutorial.md): build, run and deploy a first app. - [CLI commands](https://ocre.rs/reference/cli.md#ocre-new): every command and flag, including `ocre new`. - [Deployment](https://ocre.rs/guides/deployment.md): what `ocre deploy` creates on Cloudflare. - [Configuration](https://ocre.rs/reference/configuration.md): `cloudflare.config.ts`, `.dev.vars`, variables and secrets. - [Upgrading from wrangler.toml](https://ocre.rs/guides/upgrading.md): converting an app made by an earlier Ocre. --- URL: https://ocre.rs/getting-started/tutorial.md # 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](https://ocre.rs/getting-started/installation.md): 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](https://ocre.rs/getting-started/tutorial.md#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](https://ocre.rs/getting-started/tutorial.md#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 ```sh ocre new qa --yes cd qa ``` ```text 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](https://ocre.rs/getting-started/installation.md#create-an-app-with-the-guided-setup)). 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 ``, so links and forms swap the page body instead of reloading it, and every page still works without JavaScript (see [htmx](https://ocre.rs/guides/htmx.md)). `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: ```sh ocre g auth ``` ```text 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](https://ocre.rs/guides/authentication.md) 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: ```sh ocre g scaffold Event name:string public_id:token user:references ``` ```text 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. ```sh ocre g scaffold Question event:references body:text votes:integer answered:boolean --realtime ``` ```text create migrations/0005_create_questions.sql create src/models/question.rs create tests/factories/question.rs create src/questions.rs create templates/questions/index.html create templates/questions/show.html create templates/questions/new.html create templates/questions/edit.html create templates/questions/_form.html create templates/questions/_row.html create tests/questions.rs create src/realtime.rs update src/models/event.rs update src/models/mod.rs update tests/factories/mod.rs update src/lib.rs update Cargo.toml update cloudflare.config.ts update templates/layout.html Next: ocre migrate ocre dev open http://localhost:8787/questions open http://localhost:8787/questions in a second window, then create a question ``` The first `--realtime` scaffold sets up the pieces every live page needs: | File | What changed | |---|---| | `Cargo.toml` | Ocre's `realtime` feature | | `cloudflare.config.ts` | The `CHANNELS` Durable Object binding and the `OcreChannel` export: one Durable Object per channel holds the browsers' WebSockets, and hibernates between messages so idle connections cost nothing | | `src/realtime.rs` | `GET /realtime/{channel}`, where browsers connect, and `connect`, which decides who may listen to which channel | | `templates/layout.html` | htmx's WebSocket extension, after htmx | | `src/questions.rs` | After each create, update and delete, the handler broadcasts the changed row on the `questions` channel | Apply the five migrations and start the app: ```sh 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: ```sh 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 ``` ```text 303 http://localhost:8787/ 303 http://localhost:8787/events/1SPmf4grPih32uDXg8GP-g 303 http://localhost:8787/questions/1 [{"id":1,"channel":"questions","message":"1Will there be pizza?0falseShow Edit"}] ``` `/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: ```rust /// 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 { let new = NewEvent { name: self.name.clone(), user_id }; new.validate().finish()?; Ok(new) } fn to_changes(&self) -> Result { 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: ```html {% if !errors.is_empty() %}
    {% for error in errors %}
  • {{ error.full_message() }}
  • {% endfor %}
{% endif %} ``` The handlers that create or change an event take `CurrentUser`, and a small helper refuses other users with a 403: ```rust /// The event `id`, when `user` hosts it: other users get a 403. async fn hosted(ctx: &Ctx, user: &User, id: i64) -> Result { 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, CurrentUser(user): CurrentUser, flash: Flash) -> Result> { render(&IndexView { flash, events: event::for_users(&ctx, &[user.id]).await? }) } async fn new(CurrentUser(_): CurrentUser) -> Result> { render(&NewView { form: EventForm::default(), errors: vec![] }) } async fn create( State(ctx): State, CurrentUser(user): CurrentUser, session: Session, Form(form): Form, ) -> Result { 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: ```html {% extends "layout.html" %} {% block title %}Your events{% endblock %} {% block content %}

Your events

{% if let Some(notice) = flash.notice() %}

{{ notice }}

{% endif %} {% if let Some(alert) = flash.alert() %}

{{ alert }}

{% endif %}

New event

{% if events.is_empty() %}

No events yet: create one, then share its link with your audience.

{% else %}
    {% for event in events %}
  • {{ event.name }} created {{ event.created_at }}
  • {% endfor %}
{% 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: ```sh 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`: ```rust /// 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> { 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> { 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: ```rust //! 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 { 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, session: Session, flash: Flash, OptionalUser(user): OptionalUser, uri: Uri, Path(key): Path, Form(form): Form, ) -> Result { 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, session: Session, Htmx(is_htmx): Htmx, Path(id): Path) -> Result { let record = question::find(&ctx, id).await?.or_404()?; let event = record.event(&ctx).await?.or_404()?; let mut voted: Vec = 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, CurrentUser(user): CurrentUser, Htmx(is_htmx): Htmx, Path(id): Path, ) -> Result { 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, CurrentUser(user): CurrentUser, Htmx(is_htmx): Htmx, Path(id): Path, ) -> Result { 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:` and `host:`. - **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}`): ```rust /// 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, form: QuestionForm, errors: Vec, } 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, flash: Flash, OptionalUser(user): OptionalUser, uri: Uri, Id(id, ..): Id, ) -> Result> { 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, form: QuestionForm, errors: Vec, ) -> Result> { 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`: ```html {% extends "layout.html" %} {% block title %}{{ event.name }}{% endblock %} {% block content %} {% if let Some(notice) = flash.notice() %}

{{ notice }}

{% endif %} {% if let Some(alert) = flash.alert() %}

{{ alert }}

{% endif %}

Live Q&A

{{ event.name }}

{% if host %}

You host this event. Rename · Your events

{% endif %}
{% if !errors.is_empty() %}
    {% for error in errors %}
  • {{ error.full_message() }}
  • {% endfor %}
{% endif %}
{# 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). #}
{% include "questions/_list.html" %}
{% endblock %} ``` And the list both the page and the broadcasts use, `templates/questions/_list.html`: ```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() %}

No questions yet. Be the first to ask.

{% else %}
    {% for question in questions %} {% let vote = crate::questions::paths::vote(question.id) %}
  1. {{ question.body }}

    {% if question.answered %}Answered{% endif %} {% if host %} {% let answer = crate::questions::paths::answer(question.id) %} {% let delete = crate::questions::paths::delete(question.id) %}
    {% endif %}
  2. {% endfor %}
{% 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;`): ```rust /// Opens a WebSocket on `channel` for whoever may listen to it: /// `event:`, an event's room, is open to everyone with the link; /// `host:`, 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, Path(channel): Path, OptionalUser(user): OptionalUser, upgrade: WebSocketUpgrade, ) -> Result { 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: ```sh 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' ``` ```text 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: ```sh 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 ``` ```text 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: ```sh ocre sql "SELECT id, event_id, body, votes, answered FROM questions" ``` ```text 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: ```rust 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: ```sh curl -s -c guest.txt -b guest.txt http://localhost:8787/events/$EVENT/questions -d 'body=hi' -w "%{http_code}\n" | grep -E "
  • Body|^4" ``` ```text
  • Body is too short (minimum is 3 characters)
  • 422 ``` See [Validations](https://ocre.rs/guides/validations.md) 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: ```rust 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: ```sh 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: ```text 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](https://ocre.rs/guides/testing.md) 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: ```sh ocre routes ``` ```text 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 ```sh 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 `. 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](https://resend.com/docs/knowledge-base/account-quotas-and-limits) 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 "),`. 2. Store the API key as a secret: put `RESEND_API_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](https://ocre.rs/guides/email.md) for the `cloudflare` adapter and [Configuration](https://ocre.rs/reference/configuration.md#mail_adapter) for every variable. ### ocre deploy ```sh 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: ```text 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..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..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](https://developers.cloudflare.com/workers/platform/limits/), [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/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](https://ocre.rs/reference/limits.md) lists the rest. ## Next steps - [Realtime](https://ocre.rs/guides/realtime.md): channels, broadcasts from jobs, presence, relaying client messages. - [Models and migrations](https://ocre.rs/guides/models.md): queries, associations, changing columns with `ocre g migration`. - [Authentication](https://ocre.rs/guides/authentication.md): `CurrentUser`, ownership checks, OAuth, JWTs and API keys. - [Controllers and routing](https://ocre.rs/guides/controllers.md), [Views, helpers and forms](https://ocre.rs/guides/views.md) and [htmx](https://ocre.rs/guides/htmx.md): handlers, templates, partial updates with htmx. - [Background jobs and schedules](https://ocre.rs/guides/jobs.md): send a summary to the host after the event, clean up old events every night. - [Deployment](https://ocre.rs/guides/deployment.md): environments, secrets, custom domains. - [CLI commands](https://ocre.rs/reference/cli.md) and [Generators](https://ocre.rs/reference/generators.md): every command and flag. --- URL: https://ocre.rs/guides/models.md # Models and migrations A model is a generated Rust file, `src/models/.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](https://ocre.rs/getting-started/installation.md)). 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](https://developers.cloudflare.com/d1/platform/pricing/)): 5 million rows read and 100,000 rows written a day, 5 GB of storage in total; [D1 limits](https://developers.cloudflare.com/d1/platform/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 field:type...` writes the migration that creates the table and the model file, and registers the module in `src/models/mod.rs`: ```sh ocre g model Author name:string^ bio:text? ``` ```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:,...`; the suffix `?` makes a field optional (`NULL` allowed) and `^` unique, and a `lock_version:integer` field turns on [optimistic locking](https://ocre.rs/guides/models.md#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](https://ocre.rs/reference/field-types.md#public_id)). [Field types](https://ocre.rs/reference/field-types.md) 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](https://ocre.rs/reference/generators.md#ocre-g-model)). The suffixes change what a `references` field generates (see [Associations](https://ocre.rs/guides/models.md#associations)): `author:references?` is an optional parent (`ON DELETE SET NULL`), `user:references^` a one-to-one link (has one). The migration: ```sql 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 ```rust /// A row of the `authors` table. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Author { pub id: i64, pub name: String, pub bio: Option, 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: ```rust #[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 ```rust /// Values for a new author. #[derive(Debug, Clone, Deserialize)] pub struct NewAuthor { pub name: String, #[serde(default, deserialize_with = "ocre::optional")] pub bio: Option, } /// 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, #[serde(default, deserialize_with = "ocre::patch")] pub bio: Option>, } ``` `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>`: `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](https://ocre.rs/guides/json-apis.md#optional-fields-and-partial-updates)). `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() ```rust 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](https://ocre.rs/guides/validations.md) lists every check. ### Queries | Function | SQL | Returns | |---|---|---| | `query()` | `SELECT * FROM authors`, as an `ocre::Query` to refine (see [The query builder](https://ocre.rs/guides/models.md#the-query-builder)) | `Query` | | `all(ctx, page)` | `SELECT * FROM authors ORDER BY id DESC LIMIT ?1 OFFSET ?2` | `Vec`, newest first | | `count(ctx)` | `SELECT COUNT(*) AS count FROM authors` | `i64` | | `find(ctx, id)` | `SELECT * FROM authors WHERE id = ?1 LIMIT ?2` | `Option` | | `find_many(ctx, &ids)` | `SELECT * FROM authors WHERE id IN (?1, ?2, ...)`, 100 ids per query | `Vec`, in no particular order | | `create(ctx, new)` | `before_create`, `validate()`, database checks, `INSERT ... RETURNING *`, `after_create` | the new `Author`, or `Error::Invalid` (422) | | `update(ctx, id, changes)` | `before_update`, `validate()`, database checks, `UPDATE ... RETURNING *`, `after_update` | `Some(Author)`, `None` when the id does not exist, or `Error::Invalid` | | `delete(ctx, id)` | `before_delete`, `DELETE FROM authors WHERE id = ?1 RETURNING *`, `after_delete` | `true`, or `false` when the id does not exist | `all`, `count`, `find` and `find_many` are one line each on top of `query()`: ```rust pub fn query() -> Query { Query::table("authors") } pub async fn find(ctx: &Ctx, id: i64) -> Result> { 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: ```rust pub async fn create(ctx: &Ctx, mut new: NewAuthor) -> Result { 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`: ```rust let updated: Option = db .first( "UPDATE authors SET name = CASE WHEN ?1 THEN ?2 ELSE name END, bio = CASE WHEN ?3 THEN ?4 ELSE bio END, updated_at = datetime('now') WHERE id = ?5 RETURNING *", params![changes.name.is_some(), changes.name, changes.bio.is_some(), changes.bio.flatten(), id], ) .await?; ``` ### Callbacks The end of every model file holds six private functions that `create`, `update` and `delete` call, like Rails' callbacks. They do nothing until you fill them in: | Function | Called | Typical use | |---|---|---| | `before_create(ctx, &mut new)` | before `validate()` and the `INSERT` | normalize input (trim, lowercase an email), fill in defaults | | `after_create(ctx, &record)` | after the `INSERT` | send an email, enqueue a job | | `before_update(ctx, id, &mut changes)` | before `validate()` and the `UPDATE` | normalize the changed fields | | `after_update(ctx, &record)` | after a successful `UPDATE` (not when the id does not exist) | refresh a cache, broadcast | | `before_delete(ctx, id)` | before the `DELETE` | refuse to delete (return an error) | | `after_delete(ctx, &record)` | after the `DELETE`, with the deleted row (`RETURNING *`) | delete files, clean up other tables | Every controller, JSON API and GraphQL resolver goes through `create`/`update`/`delete`, so a callback runs for all of them. An `Err` returned by a `before_*` callback stops the operation before anything is written; for example, keeping authors that still have tasks (`Task` from `ocre g model Task title:string status:enum:open,done author:references?`): ```rust /// 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`](https://ocre.rs/guides/models.md#transactions). Bulk operations (`update_all`, `delete_all`, hand-written SQL) and `ON DELETE CASCADE` run no callback. The rest of Rails' callback API maps to plain code in these functions: | Rails | In Ocre | |---|---| | `before_validation`, `after_validation` | `before_create` / `before_update` run before `validate()`; code after `v.finish()?` in `create` / `update` runs after it | | `before_save`, `after_save` (create and update) | a private function both `before_create` and `before_update` (or both `after_*`) call; `around_*`: code before and after in the same function | | `after_commit`, `after_create_commit`, `after_update_commit`, `after_destroy_commit` | the `after_*` functions: each generated write is committed when it returns (D1 auto-commits every statement) | | `after_rollback` | the `Err` of the write or of `db.batch`: nothing was applied | | `if:` / `unless:` conditions, `on:` | an `if` in the function; `changes.changed()` tells which fields an update sets | | `throw :abort` | return an `Err` from a `before_*` function | | callback objects, shared callbacks | a function in a module of your own, called from several models' callbacks | | `dependent: :destroy` running the children's callbacks | in `before_delete`, delete the children through their model's `delete` (each runs its callbacks and deletes its files) instead of relying on `ON DELETE CASCADE` | | association callbacks (`before_add`, `after_remove`) | the join or child model's `before_create` / `after_delete` | | skipping callbacks (`update_column`, `update_all`, `delete`, `insert_all`) | `query().update_all(..)`, `delete_all`, `db.execute`, `db.batch`: they run no model code and no validation | | `after_initialize`, `after_find`, `after_touch`, `Model.suppress` | none: rows are plain values deserialized by serde (derive values in a method), and nothing saves implicitly | ### Associations A `references` field connects two models. With the blog starter: ```sh 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](https://ocre.rs/guides/files.md)). The generator writes both sides: ```rust // src/models/comment.rs: belongs to impl Comment { /// The post this comment belongs to. pub async fn post(&self, ctx: &Ctx) -> Result> { crate::models::post::find(ctx, self.post_id).await } // ocre:associations } ``` ```rust // 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> { ctx.db()? .all( "SELECT * FROM comments WHERE post_id = ?1 ORDER BY id DESC LIMIT ?2 OFFSET ?3", params![self.id, page.limit, page.offset], ) .await } } ``` In a handler: `let comments = post.comments(&ctx, page).await?;` and `let post = comment.post(&ctx).await?;`. Keep the `// ocre:associations` and `// ocre:models` markers: later generators insert code after them. The suffixes and the shape of the model choose the kind of association: | Fields | Migration | Generated | |---|---|---| | `post:references` | `post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE`, index | belongs to: `comment.post(ctx)`; has many: `post.comments(ctx, page)` | | `author:references?` | `author_id INTEGER REFERENCES authors(id) ON DELETE SET NULL`, index | the same, with `task.author(ctx)` returning `None` when `author_id` is `NULL`; deleting the author keeps its tasks and clears their `author_id` | | `user:references^` (in `ocre g model Profile bio:text user:references^`) | `NOT NULL ... ON DELETE CASCADE` plus a unique index | has one: `user.profile(ctx)` returns `Option` instead of a list | | two references or more and no other field, e.g. `ocre g model Tagging post:references tag:references` | the references, plus a unique index on the pair | a join model: has many through, `post.tags(ctx, page)` and `tag.posts(ctx, page)` (a `JOIN` on `taggings`), and a "has already been taken" check on the pair in `create` | | `author:references:writer_id` (in `ocre g model Book title:string author:references:writer_id`) | `writer_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE`, index | belongs to named after the column, `book.writer(ctx)`; has many `author.books(ctx, page)` | | `employee:references:manager_id?` in `ocre g model Employee name:string employee:references:manager_id?` | `manager_id INTEGER REFERENCES employees(id) ON DELETE SET NULL`, index | a self join: belongs to `employee.manager(ctx)`, has many `employee.employees(ctx, page)` (rename it `reports` in the file if you like) | With `Tagging` from the command above, the blog starter's `Post` gets: ```rust /// Tags of this post, through taggings, most recently linked first. pub async fn tags(&self, ctx: &Ctx, page: ocre::Page) -> Result> { 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](https://ocre.rs/guides/models.md#avoid-n1-queries)): | Function | In | Returns | |---|---|---| | `preload_(ctx, &records)` | `comment::preload_posts(&ctx, &comments)` | `HashMap`: the parent of every record, by id | | `for_(ctx, &parent_ids)` | `comment::for_posts(&ctx, &post_ids)` | `Vec`: every child of these parents, newest first (not paginated: keep the id list short) | #### Association options, the Ocre way Rails configures associations with options; in Ocre each one is a line of code in the model, where you can read it: | Rails option | In Ocre | |---|---| | `dependent: :destroy / :nullify / :restrict_with_error` | `ON DELETE CASCADE` (required reference), `ON DELETE SET NULL` (optional), or an error from `before_delete` (see [Callbacks](https://ocre.rs/guides/models.md#callbacks)) | | `class_name:`, `foreign_key:` | `author:references:writer_id` names the column; the target is the model before `:references` | | `counter_cache: true` | a `comments_count INTEGER NOT NULL DEFAULT 0` column on the parent, updated by the child's `after_create` / `after_delete` (below), or a `COUNT(*)` on the indexed foreign key when the list is short | | `touch: true` | `crate::models::post::touch(ctx, comment.post_id).await?` in the child's `after_create` / `after_update` / `after_delete`: every model has `touch(ctx, id)`, which sets `updated_at` (and bumps `lock_version`) without validation or callbacks | | `inverse_of`, association caching, `reload_` | not needed: an association is an `async` function returning plain values; call it again to reload | | `validate: true` (`validates_associated`) | call the other model's `validate()` and `v.merge(..)` it in `validate()` or `create` | | `autosave: true`, nested attributes | call the other model's `create`/`update` from the handler or a callback; several writes that must succeed together go into one [`db.batch`](https://ocre.rs/guides/models.md#transactions) | | scopes on an association (`-> { where(...) }`) | `crate::models::comment::query().eq("post_id", post.id).eq("approved", true)`, or a scope function in the child model | | `polymorphic: true` | `commentable:polymorphic:post,photo` (see [Polymorphic references](https://ocre.rs/guides/models.md#polymorphic-references)) | | `has_many_attached` | `photos:attachments` (see [Files](https://ocre.rs/guides/files.md#many-files-per-record)) | 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`): ```rust /// 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: ```sh ocre g scaffold Comment body:text commentable:polymorphic:post,photo ``` It becomes two fields: `commentable_type`, an [enum](https://ocre.rs/guides/models.md#enum-fields) 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: ```rust,ignore // src/models/comment.rs pub enum Commentable { Post(crate::models::post::Post), Photo(crate::models::photo::Photo), } let parent = comment.commentable(&ctx).await?; // Option: 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: ```sql 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: ```rust #[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](https://ocre.rs/guides/validations.md#every-check)). 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](https://ocre.rs/guides/models.md#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](https://trix-editor.org) editor, the one Rails uses, and kept as HTML in a `TEXT` column of the record. ```sh 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 (`

    `) 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 ``; 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_.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: ```sh ocre sql "SELECT id, name FROM d1_migrations" ``` ```text 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 [arguments...]` infers the SQL from the name, as Rails does. The first names take fields (`name:type`), the others column names or nothing: | Name | SQL | Arguments | |---|---|---| | `create_` | `CREATE TABLE` with `id`, the fields, `created_at`, `updated_at`, plus indexes | fields | | `add__to_
    ` | `ALTER TABLE
    ADD COLUMN ...` per column, plus indexes | fields, required | | `remove__from_
    ` | `DROP INDEX IF EXISTS index_
    _on_` (SQLite refuses to drop an indexed column), then `ALTER TABLE
    DROP COLUMN ` | optional fields: when given, one `DROP COLUMN` per field instead | | `add_index_to_
    ` | `CREATE INDEX index_
    _on__and_ ON
    (a, b)` | column names, in index order, required | | `add_unique_index_to_
    ` | the same with `CREATE UNIQUE INDEX` | column names, required | | `remove_index_from_
    ` | `DROP INDEX IF EXISTS index_
    _on__and_` (the name the two above give) | column names, required | | `rename__to__in_
    ` | `ALTER TABLE
    RENAME COLUMN TO ` | none | | `rename_
    _to_` | `ALTER TABLE
    RENAME TO ` | none | | `drop_
    ` | `DROP TABLE
    ` | none | | `rebuild_
    ` | 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](https://ocre.rs/guides/models.md#change-a-column-rebuild-the-table)) | none | | anything else | empty file to fill in (data changes, custom SQL) | none allowed | Index, rename and drop migrations need no schema file: ```sh ocre g migration add_index_to_posts published created_at ocre g migration rename_body_to_content_in_posts ``` ```sql -- 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); ``` ```sql -- 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` ``. ```sh ocre g migration add_slug_to_posts slug:string? ``` ```text create migrations/0004_add_slug_to_posts.sql Next: ocre migrate update the model in src/models/ to match the new columns ``` ```sql -- 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: ```sh ocre g migration add_author_to_posts author:references ``` ```text 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 `NULL`s), fill it, then enforce presence in `validate()`. Other names give an empty migration, with only the two comment lines; write the SQL yourself: ```sh ocre g migration backfill_slugs ``` ```sql -- 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: ```text error: cannot tell which table `fix_things` changes hint: name it `create_
    `, `add__to_
    ` or `remove__from_
    ` ``` ### Apply migrations Check what is pending, then apply it. Here after `ocre g migration add_views_to_posts views:integer`: ```sh ocre migrate --status ``` ```text ⛅️ 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`: ```sh ocre migrate --status --json ``` ```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](https://ocre.rs/guides/deployment.md#why-wrangler-still-appears)) and shows its output: ```sh ocre migrate ``` ```text ... ? 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: ```text ✘ [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](https://ocre.rs/guides/deployment.md)), 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: ```sh ocre migrate && ocre db schema ``` ```text create db/schema.sql ``` ```sql -- Schema of the local D1 database, written by `ocre db schema` from sqlite_master. -- A snapshot for reading: migrations/ are the source of truth. Run `ocre db schema` again after `ocre migrate`. CREATE TABLE authors ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, bio TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX index_authors_on_name ON authors (name); ... ``` It is the quickest way for a person or an agent to see every table at once. Nothing loads it: a new database is always built by replaying `migrations/` (Rails' `schema.rb`/`structure.sql` in one SQL format). Commit it if you want schema changes visible in code review, and regenerate it after each `ocre migrate`. ### Column options, constraints and comments Migrations are SQL, so Rails' column modifiers and table options are SQLite syntax in the file (edit a generated migration before applying it, or write one with a name the generator does not read): | Rails | SQLite, in the migration | |---|---| | `default:`, `null: false` | `views INTEGER NOT NULL DEFAULT 0`, `status TEXT NOT NULL DEFAULT 'draft'` | | `limit:`, `precision:`, `scale:` | a `CHECK (length(code) <= 8)`; SQLite ignores declared sizes (`VARCHAR(8)` is `TEXT`); exact numbers are the `decimal` field type | | `collation:` | `email TEXT NOT NULL COLLATE NOCASE` (case-insensitive comparisons and unique index) | | `add_check_constraint` | `CHECK (price >= 0)` on the column or the table; to add one to an existing table, rebuild it (below) | | `comment:` | an SQL comment inside `CREATE TABLE`: `price REAL NOT NULL, -- in euros`; SQLite keeps the statement's text, so `ocre db schema` shows it in `db/schema.sql` | | `if_not_exists:`, `force:`, `id: false`, `primary_key:` | `CREATE TABLE IF NOT EXISTS`, `DROP TABLE IF EXISTS` first, any primary key you declare (`PRIMARY KEY (a, b)`, `id TEXT PRIMARY KEY` for UUIDs); generated models expect an `INTEGER` `id`, so such tables get a hand-written model module | | `change_table` (several changes at once) | several `ALTER TABLE` statements in one migration file, applied together | | `change_column`, `change_column_null`, `change_column_default` | a table rebuild (below) | ### Change a column: rebuild the table SQLite's `ALTER TABLE` can add, drop and rename columns, but not change a column's type, `NOT NULL`, `DEFAULT`, `CHECK` or `REFERENCES`. For those, SQLite's documented answer is to rebuild the table. `ocre g migration rebuild_
    ` writes that migration from the table's definition in `db/schema.sql` (run `ocre db schema` first): ```sh ocre g migration rebuild_authors ``` ```sql -- 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: ```text error: `comments` references `posts`: rebuilding it would delete or clear their rows hint: D1 enforces foreign keys, so dropping `posts` runs ON DELETE on `comments`; add a new column and backfill it instead ``` Without `db/schema.sql` it answers ``hint: run `ocre migrate` then `ocre db schema`: the rebuild copies the table's current definition``. ### Undo a migration D1 migrations only go forward: D1 (and cf) have `d1 migrations apply` and `list`, and no command that runs a migration backwards, so Ocre has no `down` migrations, no `rollback` and no `redo`. Undo a change the way it was made, with a new migration: | Mistake | Fix | |---|---| | A column should not exist | `ocre g migration remove__from_
    ` | | A column has the wrong name | `ocre g migration rename__to__in_
    ` | | A column has the wrong type or constraint | `ocre g migration rebuild_
    `, then edit it | | A table should not exist | `ocre g migration drop_
    ` | | An index is missing or wrong | `add_index_to_
    ` / `remove_index_from_
    ` | | A migration changed data wrongly | a migration with `UPDATE` statements that repair it | Locally, when the migration was never deployed, the shortcut is to fix the file and start over: `ocre db reset` deletes the local database, applies every migration and loads the seeds. In production, when a migration or a bad `UPDATE`/`DELETE` destroyed data, [D1 Time Travel](https://developers.cloudflare.com/d1/reference/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: ```sh npx cf d1 list --name # its uuid npx cf d1 time-travel get-bookmark --timestamp 2026-09-29T10:00:00+00:00 npx cf d1 time-travel restore --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 --bookmark `). `` 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](https://ocre.rs/guides/jobs.md)), 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): ```sql -- 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'); ``` ```sh 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" ``` ```text 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`: ```json {"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](https://ocre.rs/reference/cli.md#ocre-migrate) 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](https://ocre.rs/reference/cli.md). #### 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. ```yaml # 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 ` 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/
    .yml`, one row per label `
    _` with its explicit id, readable by `ocre db seed`. It never overwrites a file without `--force`; `--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. ```rust,check // 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> = 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](https://ocre.rs/guides/jobs.md) 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): ```rust // 1. The record struct: one field per column. pub struct Post { // ... pub slug: Option, } // 2. NewPost: optional input. pub struct NewPost { // ... #[serde(default, deserialize_with = "ocre::optional")] pub slug: Option, } // 3. PostChanges: keep / clear / set. pub struct PostChanges { // ... #[serde(default, deserialize_with = "ocre::patch")] pub slug: Option>, } ``` ```rust // 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/_api.rs`. Check with: ```sh 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` 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::::table("posts")`), so terminal methods return `Post`s. 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 and a search Scopes are plain functions taking and returning a `Query`; `scope(f)` applies one, and they take arguments like any function. Put them in the model file, below `query()`: ```rust,check // 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) -> Query { query.eq("published", true) } /// Titles containing `term`; `%` and `_` in it match literally. pub fn titled(query: Query, term: &str) -> Query { 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> { 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: ```sql 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` 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`) 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](https://ocre.rs/guides/controllers.md)). `ocre::Direction` deserializes from `asc` or `desc` (any case; `asc` by default), so a handler takes it straight from the query string: ```rust,check // 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 { 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, page: Page, Query(search): Query) -> ApiResult>> { let mut query = post::query().eq("published", true); let term = search.q.trim(); if !term.is_empty() { query = query.any(|q| q.contains("title", term).contains("body", term)); } let column = if search.sort == "title" { "title" } else { "created_at" }; Ok(Json(query.order_by(column, search.direction).paginate(&ctx.db()?, page).await?)) } ``` (`axum::extract::Query` reads the query string; `ocre::Query` is the builder. Import only one of them under that name.) ### Conditions Conditions combine with `AND`, in the order they are added. The column argument may be qualified (`comments.author`) after a join. | Method | SQL | |---|---| | `eq(col, v)`, `ne(col, v)` | `col = ?`, `col != ?` (`eq(col, None)` matches nothing: use `is_null`) | | `gt`, `gte`, `lt`, `lte` | `col > ?`, `>=`, `<`, `<=` | | `between(col, low, high)` | `col BETWEEN ? AND ?` (inclusive) | | `is_in(col, values)`, `not_in(col, values)` | `col IN (?, ?...)`, `col NOT IN (...)`; an empty `is_in` matches nothing (D1 binds 100 parameters at most per query) | | `is_null(col)`, `is_not_null(col)` | `col IS NULL`, `col IS NOT NULL` | | `like(col, pattern)`, `not_like(col, pattern)` | `col LIKE ?` with your pattern (`%` and `_` are wildcards) | | `contains(col, text)`, `starts_with`, `ends_with` | `col LIKE ? ESCAPE '\'` with `%text%`, `text%`, `%text`, the text escaped | | `where_sql(fragment, params![..])` | `(fragment)`, with bare `?` placeholders bound in order | | `any(\|q\| q.a(..).b(..))` | `(a OR b)` | | `not(\|q\| q.a(..).b(..))` | `NOT (a AND b)` | | `none()` | `WHERE 0`: no row, and no row read (Rails' `none`) | | `where_associated(table, fk)`, `where_missing(table, fk)` | `EXISTS (SELECT 1 FROM table WHERE table.fk = .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: ```rust post::query() .any(|q| q.is_null("slug").eq("slug", "")) .not(|q| q.eq("published", false)) .where_sql("created_at >= datetime('now', ?)", params!["-7 days"]) // SELECT * FROM posts WHERE (slug IS NULL OR slug = ?1) AND NOT (published = ?2) AND (created_at >= datetime('now', ?3)) ``` ### Order, limits, columns, joins, groups | Method | SQL | |---|---| | `order_asc(col)`, `order_desc(col)`, `order_by(col, direction)` | `ORDER BY col ASC/DESC`, appended after any order already set | | `order_in(col, values)` | `ORDER BY CASE col WHEN ? THEN 0 ... ELSE n END` (Rails' `in_order_of`; unlisted values last) | | `order_sql(term)` | a raw term, e.g. `lower(title) ASC` | | `reorder()` | removes the order set so far (a scope's default order) | | `reverse_order()` | flips every `ASC`/`DESC` (Rails' `reverse_order`); `ORDER BY
    .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::(columns)` | `SELECT columns` instead of `*`; the rows become `U`, a struct whose fields match the column names (`AS` for expressions) | | `distinct()` | `SELECT DISTINCT` | | `join(clause)` | the clause as written: `JOIN ...` or `LEFT JOIN ...` | | `group_by(columns)`, `having(fragment, params![..])` | `GROUP BY columns HAVING (fragment)` | ### Running a query Terminal methods take `&Db` (`&ctx.db()?`, or `&ctx.db_named("...")?` for another database) and return `ocre::Result`: | Method | Runs | Returns | |---|---|---| | `all(&db)` | the query | `Vec`, every row in memory: bound it with `limit` or `page` | | `first(&db)` | the query with `LIMIT 1` | `Option` (Rails' `find_by`/`first`; add an order for "first") | | `paginate(&db, page)` | the rows and `COUNT(*)` | `Paginated` | | `count(&db)` | `SELECT COUNT(*)`, ignoring order and limits | `i64` | | `exists(&db)` | `SELECT 1 ... LIMIT 1` | `bool` (Rails' `exists?`) | | `pluck::(&db, expr)` | `SELECT expr AS value`, keeping order and limits | `Vec` (Rails' `pluck`, `ids`) | | `aggregate::(&db, expr)` | `SELECT SUM(price) AS value ...`, without order or limits | `Option`: `None` when SQL gives `NULL` (the `SUM` of no row) | | `update_all(&db, vec![(col, value.into_param())])` | `UPDATE table SET col = ? WHERE ...` | rows changed | | `delete_all(&db)` | `DELETE FROM table WHERE ...` | rows deleted | | `first_or_create(&db, \|\| async { .. })` | the query with `LIMIT 1`, then your `create` when nothing matched | `T` (Rails' `find_or_create_by`; two racing requests can both create) | | `create_or_first(&db, \|\| async { .. })` | your `create`, then the query when it failed with "has already been taken" or `UNIQUE constraint failed` | `T` (Rails' `create_or_find_by`: safe under races, needs a `UNIQUE` index) | | `batches(size, \|row\| row.id)`, then `next(&db)` | `WHERE id > ORDER BY id LIMIT size`, one query per call | `Option>`, `None` when done (Rails' `find_in_batches`; loop over each batch for `find_each`) | | `explain(&db)` | `EXPLAIN QUERY PLAN SELECT ...` | `Vec`, 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](https://ocre.rs/guides/models.md#transactions), 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: ```rust,check // 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> { comment::query() .select::("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> { post::query() .select::("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> { product::query().aggregate(&ctx.db()?, "AVG(price)").await } /// Ids of the latest drafts. pub async fn draft_ids(ctx: &Ctx) -> Result> { 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 { 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 { 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 { 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> { 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 { 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::(); } 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 { 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_
    `. 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
    ` reads every row: ```sh ocre sql "EXPLAIN QUERY PLAN SELECT * FROM comments WHERE post_id = 1" ``` ```text id | parent | notused | detail ---+--------+---------+-------------------------------------------------------------- 3 | 0 | 61 | SEARCH comments USING INDEX index_comments_on_post_id (post_id=?) (1 row) ``` ## Custom queries When the builder does not fit (a subquery, `UNION`, `INSERT ... ON CONFLICT`, a window function, a report across tables), write the SQL. Every query goes through `ctx.db()?`, the `DB` binding of `cloudflare.config.ts`, with `?1, ?2...` placeholders and `params![...]`. Never build SQL with `format!` from user input: values bound as parameters cannot change the statement. | Method | Use for | Returns | |---|---|---| | `db.all::(sql, params)` | `SELECT` returning rows | `Vec` (all rows in memory: always `LIMIT`) | | `db.first::(sql, params)` | one row, `INSERT/UPDATE ... RETURNING *` | `Option` | | `db.execute(sql, params)` | `INSERT`, `UPDATE`, `DELETE` without rows | rows changed (`usize`) | | `db.exists(sql, params)` | `SELECT 1 ... LIMIT 1` | `bool` | | `db.batch(statements)` | several `ocre::Statement`s in one transaction | rows changed per statement | `params!` accepts strings, integers, `f64`, `bool` (bound as 1/0), `Option` (`None` binds `NULL`) and `serde_json::Value` (bound as JSON text), mixed freely. A failed query is `Error::Internal`: the client gets a 500 "Internal server error", and the Worker log gets the D1 message and the SQL. The full API is in the [rustdoc of `Db`](https://ocre.rs/api/ocre/struct.Db.html). 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`): ```rust,check // 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> { 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> { 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 { 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: ```json [{"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_` (belongs to), `for_` (has many) or `find_many`, which use `WHERE id IN (...)` (100 ids per query): ```rust,check // 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, } /// 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> { 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, } /// 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> { let posts = post::all(ctx, page).await?; let ids: Vec = 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`: ```json [{"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` keyed by id (rows come in no particular order, and missing ids are skipped). `for_posts` is not paginated: use it for a page of parents, not for a whole table. For aggregates (counts, sums), prefer one `JOIN ... GROUP BY` query as in `busiest_posts` and `posts_with_comment_counts` above. ## Many rows in one query A free-plan invocation runs 50 D1 queries, and D1 binds at most 100 parameters per statement, so writing rows one by one does not scale. `ocre::bulk` writes many rows in one statement: the rows go as one JSON array parameter, unpacked by SQLite's `json_each`. | Builder | SQL | Rails | |---|---|---| | `bulk::insert(table, &columns, &rows)` | `INSERT INTO t (a, b) SELECT value ->> '$.a', value ->> '$.b' FROM json_each(?1)` | `insert_all` | | `bulk::upsert(table, key, &columns, &rows)` | the same, `ON CONFLICT (key) DO UPDATE SET` the other columns | `upsert_all` | | `bulk::update(table, key, &columns, &rows, touch)` | `UPDATE t SET a = row.value ->> '$.a' ... FROM json_each(?1) AS row WHERE t.key = row.value ->> '$.key'`, plus `updated_at` when `touch` | `update_all` with a value per row | ```rust,ignore #[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. ```rust,check // 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 { 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 { 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](https://ocre.rs/guides/models.md#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: ```sh 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`, the version the change was made from. `update` bumps and checks it in the same statement: ```sql 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 `` 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](https://ocre.rs/guides/models.md#transactions). ## Encrypted columns `ocre::encryption` encrypts a column's value in Rust before it reaches D1 and decrypts it when the row is read, like Rails' `encrypts`. Someone who reads the database (a leaked export, the D1 console) sees only ciphertext. The cipher is AES-256-GCM, with keys derived from `SECRET_KEY_BASE` (HKDF-SHA256, separate from the cookie key); a changed value fails to decrypt instead of giving a wrong result. Two field types go in the row struct: | Type | Stored text | Can be searched | |---|---|---| | `ocre::encryption::Encrypted` | a new random nonce each write: the same value never gives the same text | no: only read and written | | `ocre::encryption::Deterministic` | the nonce comes from the value (HMAC-SHA256): equal values give equal texts | yes, with `eq`/`is_in` and a `UNIQUE` index; the price is that anyone can see which rows share a value | Both deserialize by decrypting, bind as a parameter by encrypting (`IntoParam`), serialize to JSON as the plain value, and print `Encrypted(..)` in `Debug` output and logs. Declare the column `TEXT` (a value takes about 4/3 of its length plus 42 characters) and write the model by hand or edit a generated one: the generators have no encrypted field type. For a `contacts` table made with `ocre g migration create_contacts email:string^ phone:string?`: ```rust,check // 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, pub created_at: String, pub updated_at: String, } pub fn query() -> Query { 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) -> Result { 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> { 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> { 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 { 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](https://ocre.rs/guides/security.md#rotating-secret_key_base-without-signing-everyone-out)): 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: ```ts 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: ```rust,check // 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 { 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 { 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: ```sh 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 --dir migrations/analytics # production ``` Locally, cf 1.0.0-beta.5 cannot migrate a local database yet (see [Why wrangler still appears](https://ocre.rs/guides/deployment.md#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`: ```json {"name": "blog", "compatibility_date": "2026-09-01", "d1_databases": [{"binding": "ANALYTICS", "database_name": "blog-analytics", "migrations_dir": "../migrations/analytics"}]} ``` ```sh 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](https://developers.cloudflare.com/d1/best-practices/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): ```ts 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_` (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`](https://ocre.rs/api/ocre/replicas/index.html). ## 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: ```rust,check // 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` (or `Json`), calls `form.validate().finish()?`, then does the work: send the email, or call two models' `create` in turn. How each Active Model module maps: | Active Model | In Ocre | |---|---| | `API`, `Model`, `AttributeAssignment`, `Attributes` | a struct deriving `Deserialize` (mass assignment from a form, JSON or query string), with typed fields and `#[serde(default)]` defaults | | `Validations`, `validates_with`, `validates_each`, custom validators | `validate() -> Validator`; a reusable rule is a function `fn slug(v: &mut Validator, field: &str, value: &str)`; `v.merge(other.validate())` combines validators (see [Validations](https://ocre.rs/guides/validations.md)) | | `Callbacks`, `before_validation` | plain code before `validate()` (the generated `before_create` / `before_update` run before validation) | | `Conversion` (`to_param`, `to_key`), `Naming` (`model_name`, `route_key`) | the generated `paths` module of each controller (`paths::show(post.id)`) and the model file's name; no reflection | | `Dirty` | `Changes` and its `changed()` (see [New and Changes](https://ocre.rs/guides/models.md#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](https://ocre.rs/guides/authentication.md)) | | `Translation` (`human_attribute_name`) | `FieldError::full_message` humanizes field names; translated names come from locale files (see [I18n](https://ocre.rs/guides/i18n.md)) | | `Lint::Tests` | not needed: the compiler checks what a view or handler uses | ## What is not supported Ocre models are generated Rust over SQL, not an ORM, and D1 is SQLite behind an HTTP API. These Rails features have no equivalent; the second column is what to do instead: | Not supported | Instead | |---|---| | `down` migrations, `db:rollback`, `db:migrate:redo`, reversible `change` | D1 migrations are forward-only: a new migration, or D1 Time Travel (see [Undo a migration](https://ocre.rs/guides/models.md#undo-a-migration)) | | `Model.transaction do ... end`, savepoints, `after_commit` | `db.batch` (see [Transactions](https://ocre.rs/guides/models.md#transactions)); a job enqueued after the write | | Lazy loading, `strict_loading` | associations are explicit `async` functions; `includes` / `preload` / `eager_load` are `preload_`, `for_`, `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](https://ocre.rs/guides/models.md#optimistic-locking) | | Composite primary keys, single-table inheritance, delegated types | a plain `id` key and a unique index on the pair; a `kind` enum column (one table), or one table per type pointed to by a [polymorphic reference](https://ocre.rs/guides/models.md#polymorphic-references) | | Tables without `id`/`created_at`/`updated_at` from the generators | an empty migration with a name the generator does not read (`ocre g migration events_table`), your own `CREATE TABLE`, and a hand-written module | | `ActiveModel` modules on plain structs | serde and `Validator` (see [Form objects and plain structs](https://ocre.rs/guides/models.md#form-objects-and-plain-structs)) | | Fixtures' `created_at`/`updated_at` filled automatically, ERB in fixture files | write the values in `db/fixtures/*.yml`; YAML anchors and `<<` share them | | `readonly` records | records are plain values: nothing saves them except the model's `update` | | Enum fields with `--graphql` | a `string` field with `v.inclusion(..)` | | Joins across databases | two queries, one per database | ## See also - [Validations](https://ocre.rs/guides/validations.md): every `Validator` check, and how errors reach forms and JSON - [Controllers and routing](https://ocre.rs/guides/controllers.md): calling the model from handlers - [JSON APIs and GraphQL](https://ocre.rs/guides/json-apis.md): the model behind `/api/` and `/graphql` - [Generators](https://ocre.rs/reference/generators.md#ocre-g-model), [Field types](https://ocre.rs/reference/field-types.md), [CLI commands](https://ocre.rs/reference/cli.md#ocre-migrate) - [Free-plan limits](https://ocre.rs/reference/limits.md) - [API index](https://ocre.rs/api-index.md) and the [rustdoc](https://ocre.rs/api/ocre/index.html) --- URL: https://ocre.rs/guides/validations.md # 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](https://ocre.rs/guides/models.md)). 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 ```rust,check // 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](https://ocre.rs/guides/i18n.md#validation-messages). ## Every check Messages are the exact strings Ocre adds (Rails' wording). | Method | Passes when | Message | |---|---|---| | `v.required("title", &title)` | not empty after trimming whitespace (Rails' `presence`) | `can't be blank` | | `v.absence("nickname", &nickname)` | empty or only whitespace | `must be blank` | | `v.max_length("title", &title, 100)` | at most 100 characters (Unicode characters, not bytes) | `is too long (maximum is 100 characters)` | | `v.min_length("code", &code, 6)` | at least 6 characters | `is too short (minimum is 6 characters)` | | `v.length("zip", &zip, 5)` | exactly 5 characters | `is the wrong length (should be 5 characters)` | | `v.range("guests", guests, 1..=12)` | within the inclusive range; any `PartialOrd + Display` type (`i64`, `f64`...) | `must be greater than or equal to 1` / `must be less than or equal to 12` | | `v.greater_than("quantity", quantity, 0)` | `value > 0` | `must be greater than 0` | | `v.greater_than_or_equal_to("age", age, 18)` | `value >= 18` | `must be greater than or equal to 18` | | `v.less_than("discount", discount, 100)` | `value < 100` | `must be less than 100` | | `v.less_than_or_equal_to("guests", guests, 12)` | `value <= 12` | `must be less than or equal to 12` | | `v.other_than("floor", floor, 13)` | `value != 13` | `must be other than 13` | | `v.safe_integer("stock", stock)` | within ±`ocre::MAX_SAFE_INTEGER` (2^53 - 1), what D1 returns exactly | `must be less than or equal to 9007199254740991` (or greater than or equal to the negative bound) | | `v.inclusion("slot", &slot, &["lunch", "dinner"])` | one of the listed values | `is not included in the list` | | `v.exclusion("username", &username, &["admin", "root"])` | none of the listed values | `is reserved` | | `v.format("slug", &slug, \|c\| c.is_ascii_lowercase() \|\| c == '-')` | every character passes the function (no regular expressions: no regex engine in the WebAssembly binary) | `is invalid` | | `v.email("email", &email)` | one `@`, text on both sides, a dot in the domain, no spaces or `<>,` | `is invalid` | | `v.confirmation("password", &password, &password_confirmation)` | both texts are equal; the error is on `password_confirmation` | `doesn't match Password` | | `v.acceptance("terms_of_service", accepted)` | the checkbox `bool` is true | `must be accepted` | | `v.date("date", &date)` | `YYYY-MM-DD`, a real calendar date (month lengths, leap years) | `is not a valid date` | | `v.time("opens_at", &opens_at)` | `HH:MM` or `HH:MM:SS` (what `` sends) | `is not a valid time` | | `v.datetime("at", &at)` | `YYYY-MM-DD HH:MM[:SS]` with a space or `T` (what `` sends) | `is not a valid date and time` | | `v.decimal("price", &price)` | an optional sign, digits, optionally a dot and digits (`19.99`, `-3`); no exponent | `is not a decimal number` | | `v.uuid("token", &token)` | hyphenated UUID, any case | `is not a valid UUID` | | `v.check("guests", failed, "message")` | `failed` is false: any rule of your own | your message | | `v.number::("price", &text)` | the text parses as the target type; returns `Option` | `is not a number` | | `v.optional_number::("stock", &text)` | blank, or parses; blank returns `None` without an error | `is not a number` | | `v.one_of::("status", &text)` | the text parses with `FromStr` (a generated enum); returns `Option` | `is not included in the list` | | `v.optional_one_of::("status", &text)` | blank, or parses; blank returns `None` | `is not included in the list` | | `v.json("data", &text)` | the text is valid JSON; returns `Option` | `is not valid JSON` | | `v.optional_json("data", &text)` | blank, or valid JSON; blank returns `None` | `is not valid JSON` | | `v.file("image", &upload, &IMAGE)` | the upload is within `Rules::max_bytes` and has an allowed content type | `is too large (maximum is 10 MB)` / `has an unsupported type (allowed: ...)` | The comparisons take any `PartialOrd + Display` value, so they also compare `YYYY-MM-DD` dates as text (`v.greater_than("ends_on", &ends_on, &starts_on)`). For a length range, chain `min_length` and `max_length`. `.message("...")` right after a check replaces its message when it failed (Rails' `message:`): `v.required("body", &body).message("write something first")`. It only changes the check just before it. The other methods: `Validator::new()`, `v.merge(other)` (adds the errors another validator collected), `v.is_valid()` (no error so far), `v.errors()` (the `FieldError`s so far, in order) and `v.finish()`. File rules and `v.file` are covered in [File storage](https://ocre.rs/guides/files.md). The API reference is in the [rustdoc of `Validator`](https://ocre.rs/api/ocre/struct.Validator.html). A complete set of rules, with a custom one, behind a JSON endpoint: ```rust,check // src/reservations.rs use axum::{Router, routing::post}; use ocre::{ApiResult, Created, Ctx, Json, Validator}; use serde::{Deserialize, Serialize}; pub fn routes() -> Router { 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, #[serde(default)] pub promo_code: Option, #[serde(default)] pub deposit_cents: Option, } 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) -> ApiResult> { 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: ```sh 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}' ``` ```json {"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: ```sh 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"}' ``` ```json {"error":{"fields":{"slot":["is not included in the list"]},"message":"Validation failed","status":422}} ``` With a 101-character name and `"guests": 0`: ```json {"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`: ```sh 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"}' ``` ```json {"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](https://ocre.rs/guides/json-apis.md#errors)). ### 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?`): ```rust,check // 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::("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> { let mut v = Validator::new(); let status = v.optional_one_of::("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()`): ```text 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/.rs`, so the HTML pages, the JSON API and GraphQL enforce the same ones: | Rule | Where | Generated for | |---|---|---| | Checks on the values alone (presence, length, format, ranges, custom rules) | `New::validate()` and `Changes::validate()` | `required` (non-optional `string`/`text`), `date`, `time`, `datetime`, `decimal`, `uuid`, `safe_integer` (`integer`), `v.file` (`attachment`); `enum` fields need none (serde refuses unknown values, the `CHECK` backs it) | | Uniqueness: `has already been taken` | `create` and `update`, with `db.exists(..)` | fields marked `^`, and the pair of references of a join model | | Reference: `must exist` | `create` and `update`, with `db.exists(..)` | `references` fields (optional ones only when set) | | Normalizing input before the checks (trim, lowercase) | `before_create` / `before_update` callbacks | none: empty functions to fill in (see [Callbacks](https://ocre.rs/guides/models.md#callbacks)) | A generated `create`, for `Comment` (`post:references`): ```rust pub async fn create(ctx: &Ctx, mut new: NewComment) -> Result { 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: ```sh curl -s -X POST http://localhost:8787/api/products -H 'Content-Type: application/json' \ -d '{"name": "Teapot", "price": 3, "stock": 9007199254740992}' ``` ```json {"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/.rs`) match `Error::Invalid` and render the form again with the errors and the values as typed: ```rust Err(Error::Invalid(errors)) => { Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&NewView { form, errors })?).into_response()) } ``` `templates//_form.html` lists them with `full_message()`: ```html {% if !errors.is_empty() %}
      {% for error in errors %}
    • {{ error.full_message() }}
    • {% endfor %}
    {% endif %} ``` ```sh curl -s -X POST http://localhost:8787/comments -d 'author=Ada&body=Nice&post_id=99' ``` ```html ...
    • Post must exist
    ... ``` 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: ```rust,check // 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)> { 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: ```json {"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: ```html

    422

    Validation failed

    • Title can't be blank
    • Contact email is invalid
    ``` ### 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](https://ocre.rs/guides/json-apis.md#errors). ### 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`: ```sh curl -s -X POST http://localhost:8787/graphql -H 'Content-Type: application/json' \ -d '{"query": "mutation { createProduct(input: {name: \"\", price: 30}) { id } }"}' ``` ```json {"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: ```rust,check // src/models/event_rules.rs use ocre::{Ctx, Result, Validator, params}; /// `starts_on` and `ends_on` are `YYYY-MM-DD`, so text order is date order. pub fn check_dates(v: &mut Validator, starts_on: &str, ends_on: &str) { let mut dates = Validator::new(); dates.date("starts_on", starts_on).date("ends_on", ends_on); let both_valid = dates.is_valid(); v.merge(dates); v.check("ends_on", both_valid && ends_on < starts_on, "must be on or after the start date"); } /// Database rule: at most 20 comments per post. pub async fn check_comment_quota(ctx: &Ctx, post_id: i64) -> Result<()> { let full = ctx .db()? .exists("SELECT 1 FROM comments WHERE post_id = ?1 LIMIT 1 OFFSET 19", params![post_id]) .await?; let mut v = Validator::new(); v.check("post_id", full, "has too many comments"); v.finish() } ``` The separate `dates` validator tells whether both dates parsed (`is_valid()`), so a malformed date reports one error, not two; `merge` then adds its errors to `v`. Call such functions from the model's `validate()` (checks without the database) or `create`/`update` (database checks), before `finish()`. ### Rails' validation options, the Ocre way Rails declares validations with options; in Ocre a rule is a line of Rust in `validate()`, so the options are ordinary code: | Rails | In Ocre | |---|---| | `on: :create` / `on: :update` | `New::validate()` runs for `create`, `Changes::validate()` for `update` | | custom contexts (`valid?(:publish)`, `on: :publish`) | another function: `fn validate_for_publish(&self) -> Validator`, called where that context applies | | `if:` / `unless:`, `with_options` | an `if` around the checks: `if self.paid { v.required("card_number", &self.card_number); }` | | `allow_nil:` / `allow_blank:` | optional fields are `Option`: checks run inside `if let Some(value)`; for blank text, `if !value.trim().is_empty()` | | `validates_each`, `validates_with`, `ActiveModel::Validator` / `EachValidator` classes | a function taking `&mut Validator` (`check_dates` above), reused from any model | | `validates_associated` | `v.merge(other.validate())` for a nested value | | `numericality` (`only_integer`, `in:`, `odd`, `even`) | `v.number::(..)` / `v.number::(..)` for form text, the comparison checks and `range`, and `v.check(field, n % 2 == 0, "must be odd")` | | `uniqueness` with `scope:`, `case_sensitive: false`, `conditions:` | the generated `db.exists(..)` check with the SQL you need: `WHERE lower(email) = lower(?1) AND account_id = ?2 AND deleted_at IS NULL`, plus a matching `UNIQUE` index (`CREATE UNIQUE INDEX ... ON users (account_id, lower(email))`) | | `strict: true` | return an error directly (`Err(Error::internal(..))` for a programmer error) instead of adding it to the validator | | `save(validate: false)`, `update_column(s)`, `update_all`, `insert_all` | `query().update_all(..)`, `db.execute(..)`, `db.batch(..)`: no validation runs | | `validators`, `validators_on(:attr)` | read `validate()`: rules are code, not declarations to list | ## See also - [Models and migrations](https://ocre.rs/guides/models.md): the generated `validate()`, `create` and `update` - [Controllers and routing](https://ocre.rs/guides/controllers.md) and [Views, helpers and forms](https://ocre.rs/guides/views.md#forms): the scaffold's form handling - [JSON APIs and GraphQL](https://ocre.rs/guides/json-apis.md): error JSON, 400 versus 422 - [File storage](https://ocre.rs/guides/files.md): `Rules` and `v.file` for uploads - [Field types](https://ocre.rs/reference/field-types.md): which checks each type gets - [API index](https://ocre.rs/api-index.md) and the [rustdoc of `Validator`](https://ocre.rs/api/ocre/struct.Validator.html) --- URL: https://ocre.rs/guides/controllers.md # 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](https://ocre.rs/guides/views.md), interactivity in [htmx](https://ocre.rs/guides/htmx.md), CSS and JavaScript in [Assets](https://ocre.rs/guides/assets.md). ## Before you start - A full-stack Ocre app from `ocre new` (not `--api`: API-only apps have no templates; see [JSON APIs](https://ocre.rs/guides/json-apis.md)). 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](https://developers.cloudflare.com/workers/platform/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 ```sh ocre g scaffold Comment author:string body:text post:references ``` ```text 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](https://ocre.rs/guides/models.md); this page covers `src/comments.rs` (the controller); the templates are described in [Views](https://ocre.rs/guides/views.md). 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`): ```rust pub fn routes() -> Router { Router::new() .route("/posts", get(index).post(create)) .route("/posts/new", get(new)) .route("/posts/{id}", get(show).post(update)) .route("/posts/{id}/edit", get(edit)) .route("/posts/{id}/delete", post(delete)) } ``` | Method | Path | Handler | Does | |---|---|---|---| | GET | `/posts` | `index` | List, newest first (`?limit=&offset=`, 50 by default), with Previous / Next links | | GET | `/posts/new` | `new` | Empty form | | POST | `/posts` | `create` | Create, then redirect to `/posts/{id}` with a flash; 422 and the form again when invalid | | GET | `/posts/{id}` | `show` | One record, or 404 | | GET | `/posts/{id}/edit` | `edit` | Form filled with the record | | POST | `/posts/{id}` | `update` | Update, then redirect; 422 and the form again when invalid | | POST | `/posts/{id}/delete` | `delete` | Delete, then redirect to `/posts` | Update and delete use `POST` because HTML forms can only send `GET` and `POST`. Rails simulates `PATCH`/`DELETE` with a hidden `_method` field; Ocre keeps the routes the browser actually sends, so the pages work without JavaScript and without method overriding. JSON APIs use real `PATCH` and `DELETE` (see [JSON APIs](https://ocre.rs/guides/json-apis.md#routes)). 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](https://ocre.rs/guides/controllers.md#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)`): ```rust 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 `
    `, 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): ```sh ocre routes comments ``` ```text 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("", ...)` calls, follows `.merge(::routes())` and `.nest("", ::routes())` into `src/.rs` (or `src//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`: ```json {"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: ```rust // ocre:modules mod comments; mod posts; mod models; #[event(fetch)] async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> worker::Result { ocre::serve(routes(), req, env).await } fn routes() -> Router { 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 ;` under `// ocre:modules` and `.merge(::routes())` under `// ocre:routes`. `ocre::serve` wraps the router with sessions, CSRF protection, CORS and security headers (see [Sessions, flash and security](https://ocre.rs/guides/security.md)), 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](https://ocre.rs/guides/controllers.md#error-pages)), followed by the Content-Security-Policy and Permissions-Policy layers (see [Security](https://ocre.rs/guides/security.md)); 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: ```rust,check // 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 { 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| 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 { 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, Path((post_id, id)): Path<(i64, i64)>) -> Result { 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 { match session.get::("user_id")? { Some(id) => Ok(format!("user {id}").into_response()), None => Ok(Redirect::to("/login").into_response()), } } async fn preview(State(ctx): State, Path(id): Path) -> Result { Ok(post::find(&ctx, id).await?.or_404()?.title) } async fn drafts() -> &'static str { "drafts" } async fn page(Path(path): Path) -> 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` answers `400 Bad Request` to `/posts/abc`, with the app's error page. Everything else: | Rails | Ocre (axum) | |---|---| | `resources :posts` (seven actions, helpers) | `ocre g scaffold`: the routes, handlers and `paths` | | `resources :photos, :books` | one module per resource, each merged | | `get "x", to: "c#a"`, `match via:` | `.route("/x", get(a).post(b))`; unmatched methods answer 405 | | Optional segments `(/:page)` | two routes to the same handler, or a query parameter | | `defaults: { format: "json" }` | `#[serde(default)]` on the `Query` struct | | `as:` named routes, `direct`, `resolve` | functions in `paths` | | `scope "/:account_id"` | `.nest("/{account_id}", accounts::routes())`; handlers read `Path` | | `scope module:` / `path:` | the Rust module is independent of the prefix you nest at | | `concern :commentable` | a function returning a `Router`, merged or nested in each resource | | `shallow: true` | declare the member routes without the parent prefix | | Subdomain / request constraints | check `Host` or headers in a middleware or the handler (below) | | `.:format` segments | the `Accept` header with `ocre::Format` (below), or a `.json` route of its own | | `mount` a Rack app | `.merge(other_router)` or `.nest_service("/x", service)`; `ocre::graphql::routes` is one | | `draw` split route files | one `routes()` per module | | Unicode paths | wrap the path in `ocre::encode_path`: `.route(&ocre::encode_path("/café/{id}"), ..)`. axum matches the raw path, which browsers send percent-encoded (`/caf%C3%A9`), so a bare `"/café"` route never matches; `Path` parameters arrive decoded (`café`) | | Translated path segments | one route per language, pointing to the same handler (see [Translations](https://ocre.rs/guides/i18n.md)) | | `rails routes` | `ocre routes` | ## Handlers and extractors A handler is an `async fn` whose arguments are axum extractors and whose return type implements `IntoResponse`. Every Ocre type is `Send`, so handlers need no annotation unless they await a future from another crate that is not (see [Futures that are not Send](https://ocre.rs/guides/controllers.md#futures-that-are-not-send)). Params are typed per source instead of Rails' merged `params` hash, and a form or JSON struct lists exactly the fields it accepts, which replaces strong parameters. | Extractor | From | Gives | On failure | |---|---|---|---| | `State(ctx): State` | the router state | `Ctx`: `ctx.db()?`, `ctx.env()` and the bindings | never fails | | `Path(id): Path` | `{id}` in the path | the parsed value; a tuple or struct for several | 400 `Invalid URL: Cannot parse ...` | | `Query(q): Query` | the query string, into a serde struct | `T` | 400 | | `Form(form): Form` | a URL-encoded form body | `T` | 415 without `Content-Type: application/x-www-form-urlencoded`; 422 when it does not deserialize (e.g. `published=maybe` for a `bool`) | | `NestedForm(order): ocre::NestedForm` | 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 `Vec`s (see [Views: lists and nested records](https://ocre.rs/guides/views.md#lists-and-nested-records-in-one-form)) | 400 naming the field | | `ocre::Json(body): ocre::Json` | a JSON body | `T` | JSON 400 (see [JSON APIs](https://ocre.rs/guides/json-apis.md#errors)) | | `page: ocre::Page` | `?limit=&offset=` | `page.limit` (1-100, default 50), `page.offset` | JSON 400 `limit must be between 1 and 100` | | `session: ocre::Session` | the encrypted session cookie | `get`, `insert`, `remove`, `clear`, `flash` | its methods fail with a 500 (logged) when `SECRET_KEY_BASE` is missing | | `cookies: ocre::Cookies` | the request's cookies | `get`/`set`, `signed`/`set_signed`, `encrypted`/`set_encrypted`, `remove` (see [Security: cookies](https://ocre.rs/guides/security.md#cookies)) | never fails; the signed and encrypted methods fail with a 500 when `SECRET_KEY_BASE` is missing | | `flash: ocre::Flash` | messages set by the previous request (read once, then removed) | `flash.notice()`, `flash.alert()`, `flash.get(kind)` | 500 when `SECRET_KEY_BASE` is missing | | `format: ocre::Format` | the `Accept` header | `Format::Html`, `Json`, `Xml`, `Text` or `Other` | never fails | | `Htmx(is_htmx): ocre::Htmx` | the `HX-Request: true` header | `bool` | never fails | | `RemoteIp(ip): ocre::RemoteIp` | Cloudflare's `CF-Connecting-IP` | `Option` | never fails | | `RequestId(id): ocre::RequestId` | Cloudflare's `CF-Ray`, else `X-Request-Id`, else random | `String` | never fails | | `headers: HeaderMap` | all request headers | `headers.get("user-agent")`, cookies... | never fails | | `OriginalUri(uri)`, `method: Method` | the request line | the full URI (path and query), the method | never fails | | `ocre::storage::Multipart` | a `multipart/form-data` body | text fields and files | 413 above `LIMIT` bytes (see [File storage](https://ocre.rs/guides/files.md)) | | `i18n: ocre::i18n::I18n` | the request's locale | translations (see [Translations](https://ocre.rs/guides/i18n.md)) | | 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](https://ocre.rs/guides/authentication.md)); 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: ```text error[E0277]: the trait bound `fn() -> impl Future> {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](https://docs.rs/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`. ```rust,ignore use axum::Json; use ocre::{Error, Result}; #[worker::send] // reqwest's futures hold JavaScript values async fn rates() -> Result> { 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](https://ocre.rs/guides/jobs.md#long-jobs-continue-in-steps)). Cache answers that change slowly with `ocre::cache::fetch`, and put secrets (API keys) in Worker secrets read with `ctx.config()`. ## Responses | Return | Response | |---|---| | `Result>` 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](https://ocre.rs/guides/htmx.md#redirecting-from-an-htmx-request)) | | `(StatusCode::UNPROCESSABLE_ENTITY, render(&view)?)` | the page with another status | | `StatusCode::NO_CONTENT` | a status without a body (Rails' `head`) | | `([(header::CACHE_CONTROL, "no-store")], body)` | extra headers before the body | | `"text"` / `String`, `Html(..)` | `text/plain`, `text/html` | | `ocre::storage::send_data(..)`, `ocre::storage::serve(..)` | a file download (below) | | `Result` with `.into_response()` on each branch | handlers that answer differently per case | | `Err(ocre::Error)` | the error page with the error's status | `ocre::Error` and its statuses: | Variant | Status | Page shows | |---|---|---| | `Error::NotFound` | 404 | `Not found` | | `Error::BadRequest(msg)`, built with `Error::bad_request("...")` | 400 | the message | | `Error::Unauthorized` | 401 | `Unauthorized` | | `Error::Forbidden` | 403 | `Forbidden` | | `Error::Invalid(fields)` | 422 | `Validation failed` and each field error | | `Error::PayloadTooLarge(msg)` | 413 | the message | | `Error::Internal(msg)`, built with `Error::internal("...")` | 500 | `Internal server error`; the message goes to the Worker log only | `?` converts `worker::Error` and askama errors into `Error::Internal`. For a missing record, `option.or_404()?` (trait `ocre::OptionExt`) turns `None` into `Error::NotFound`. Mapping errors to responses (Rails' `rescue_from`) is a `match` on the `Error` in the handler, as the scaffold does for `Error::Invalid`, or a `map_response` layer for the whole app, as for error pages. ### Formats (respond_to) `ocre::Format` reads the `Accept` header, so one action can serve a page to browsers and JSON to API clients (Rails' `respond_to`, Loco's `RespondTo`): ```rust,check 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 = "

    {{ post.title }}

    {{ post.body }}

    ", ext = "html")] struct PostPage { post: Post, } /// GET /posts/{id}: HTML for browsers, JSON for `Accept: application/json`. async fn show(State(ctx): State, format: Format, Path(id): Path) -> Result { 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(), }) } ``` ```sh curl -s http://localhost:8787/posts/1 -H 'Accept: application/json' ``` ```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:`): ```rust,check 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!("

    {title}

    {body}

    ")).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: ```rust,check 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) -> Result { 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](https://ocre.rs/guides/files.md)). 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: ```rust,check use std::time::Duration; use axum::{Router, extract::Path, response::IntoResponse, routing::get}; use ocre::{Ctx, sse::{self, Event}}; pub fn routes() -> Router { Router::new().route("/imports/{id}/progress", get(progress)) } /// `data: 25`, `data: 50`... one second apart, then `event: done`. async fn progress(Path(id): Path) -> impl IntoResponse { sse::stream(Some(0u32), move |percent: Option| 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)), }) }) } ``` ```html ``` 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](https://ocre.rs/guides/realtime.md)). ## 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): ```rust /// 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`: ```html {% extends "layout.html" %} {% block title %}{{ error.message }}{% endblock %} {% block content %}

    {{ error.message }}

    {% if !error.fields.is_empty() %}
      {% for field in error.fields %}
    • {{ field.full_message() }}
    • {% endfor %}
    {% endif %}

    Error {{ error.status.as_u16() }}. Back to the home page

    {% endblock %} ``` ```sh curl -s -w '\n%{http_code}\n' http://localhost:8787/posts/999 ``` ```text ...

    Not found

    ... 404 ``` A 500 shows `Internal server error`; the cause goes to the Worker logs only. If the template itself fails, the plain page (`

    500

    Internal server error

    `) 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: ```rust #[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](https://ocre.rs/guides/validations.md#html-forms-re-render)). One template struct per page, each bound to a file in `templates/posts/`: ```rust #[derive(Template)] #[template(path = "posts/index.html")] struct IndexView { flash: Flash, page: Page, posts: Vec, } ``` Reading handlers call the model and render: ```rust async fn index(State(ctx): State, flash: Flash, page: Page) -> Result> { render(&IndexView { flash, page, posts: post::all(&ctx, page).await? }) } async fn show(State(ctx): State, flash: Flash, Path(id): Path) -> Result> { 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): ```html ``` 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: ```rust async fn create(State(ctx): State, session: Session, Form(form): Form) -> Result { 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), } } ``` ```sh curl -si -X POST http://localhost:8787/posts -d 'title=Third&body=Hello+again' ``` ```text 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 `

    Post was successfully created.

    `, 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: ```rust,check // 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 { 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 %}

    Drafts

    {% if let Some(notice) = flash.notice() %}

    {{ notice }}

    {% endif %}
    {% endblock %}"#, ext = "html" )] struct IndexView { flash: Flash, q: String, posts: Vec, } #[derive(Template)] #[template( source = r#"{% extends "layout.html" %} {% block title %}{{ post.title }}{% endblock %} {% block content %}

    {{ post.title }}

    {{ post.body }}

    {% endblock %}"#, ext = "html" )] struct ShowView { post: Post, } async fn index( State(ctx): State, flash: Flash, page: Page, Query(search): Query, ) -> Result> { 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, Path(id): Path) -> Result> { 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": ```sh curl -s 'http://localhost:8787/drafts?q=Dra' ``` ```html ...

    Drafts

    ... ``` `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](https://ocre.rs/guides/models.md#custom-queries)). ## 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](https://ocre.rs/guides/security.md)). 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`: ```rust,check 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 { 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, 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. ```rust,check use axum::{Router, routing::get}; use ocre::{Ctx, security::{AllowBrowser, Browser}}; pub fn routes() -> Router { 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](https://ocre.rs/guides/security.md)). Loco's and Rails' other middleware map to the platform: | Middleware | On Ocre | |---|---| | Request logging (Loco's `logger`, Rails' request log) | Workers Logs records every request (method, URL, status, CPU time, `console` lines): `observability: { enabled: true }`, in the generated `cloudflare.config.ts`. Free plan: 200,000 events a day, kept 3 days | | `request_id` | `ocre::RequestId`: Cloudflare's `CF-Ray`, shown next to each request in Workers Logs | | `remote_ip` | `ocre::RemoteIp`, from `CF-Connecting-IP`, which Cloudflare sets and clients cannot forge | | `compression` | Cloudflare compresses responses at the edge (Brotli, gzip) | | `etag` / conditional GET | `ocre::cache::Conditional` and `ETag` (see [Caching](https://ocre.rs/guides/caching.md)) | | `limit_payload` | axum's 2 MB `DefaultBodyLimit`, per route with `.layer(DefaultBodyLimit::max(n))` | | `cors` | the `ALLOWED_ORIGINS` variable, or tower-http's `CorsLayer` on a router | | `secure_headers` | set by `ocre::serve`; a handler's own value wins | | `catch_panic` | not possible: a panic aborts the WebAssembly instance; return `Err` | | `timeout_request` | Cloudflare ends requests over the CPU limit (10 ms on the free plan); subrequests have their own timeouts | | `fallback` | `.fallback(not_found)` in `src/lib.rs` | | `powered_by` / `X-Powered-By` | not sent; add it with a layer if you want it | | Server timing, `benchmark` | the Workers clock only moves on I/O, so in-Worker timings are meaningless; Workers Logs reports CPU and wall time per request | | Config-driven stack, `insert_before`, `delete` | the `.layer(..)` calls in `routes()`, in code | ## Static files and the health check Files in `public/` (CSS, images, `robots.txt`, `favicon.ico`) are served by Workers Static Assets before the Worker runs: they cost no Worker request and no CPU. See [Assets](https://ocre.rs/guides/assets.md) 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: ```sh curl -si http://localhost:8787/up ``` ```text 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](https://ocre.rs/guides/views.md): templates, layouts, partials, view helpers and forms - [htmx](https://ocre.rs/guides/htmx.md): boosted navigation, partial responses, inline editing - [Assets](https://ocre.rs/guides/assets.md): CSS, JavaScript and images - [Models and migrations](https://ocre.rs/guides/models.md): the functions controllers call - [Validations](https://ocre.rs/guides/validations.md): errors and form re-rendering - [JSON APIs and GraphQL](https://ocre.rs/guides/json-apis.md): `ocre g api`, `ApiResult`, `Json` - [Sessions, flash and security](https://ocre.rs/guides/security.md): sessions, flash, CSRF, CORS, headers - [Authentication](https://ocre.rs/guides/authentication.md): `CurrentUser` and protected pages - [Realtime](https://ocre.rs/guides/realtime.md): live updates with htmx and WebSockets - [Generators](https://ocre.rs/reference/generators.md#ocre-g-scaffold), [CLI commands](https://ocre.rs/reference/cli.md#ocre-routes) - [API index](https://ocre.rs/api-index.md) and the [rustdoc](https://ocre.rs/api/ocre/index.html) --- URL: https://ocre.rs/guides/views.md # Views, helpers and forms Views in Ocre are [askama](https://askama.readthedocs.io/) 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](https://ocre.rs/guides/htmx.md). ## 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>`: ```rust #[derive(Template)] #[template(path = "posts/show.html")] struct ShowView { flash: Flash, post: Post, } async fn show(State(ctx): State, flash: Flash, Path(id): Path) -> Result> { render(&ShowView { flash, post: post::find(&ctx, id).await?.or_404()? }) } ``` `path = "posts/show.html"` is a file under `templates/`. Short templates can live in the Rust file with `source = "..."` and `ext = "html"` (the extension decides escaping: `html` escapes, `txt` does not). Rails' conventions map to askama as follows: | Rails | Ocre (askama) | |---|---| | ERB `<%= %>` / `<% %>` | `{{ expression }}` / `{% statement %}`; values are HTML-escaped | | Implicit rendering of `show.html.erb` | explicit: `render(&ShowView { .. })`; the struct names the file | | `render "other_action"`, `render template:` | render another view struct | | `render inline:` | `#[template(source = "...", ext = "html")]` | | `render plain:` / `html:` / `body:` | return a `&str`/`String`, `Html(..)`, or `([(header::CONTENT_TYPE, "...")], body)` | | `render json:` | `ocre::Json(value)` (see [JSON APIs](https://ocre.rs/guides/json-apis.md)) | | `render xml:`, Builder templates, `atom_feed` | a template with `ext = "xml"` (below) | | `render status: :unprocessable_entity` | `(StatusCode::UNPROCESSABLE_ENTITY, render(&view)?)` | | `head :no_content` | return `StatusCode::NO_CONTENT` | | `DoubleRenderError` | impossible: a handler returns one response | | `local_assigns`, strict locals | struct fields; optional ones are `Option` (`{% if let Some(x) = x %}`) | | Template compilation and caching | done by `cargo build`: templates are Rust code in the Worker | | `render file:` | not available (no filesystem on Workers): put the file in `public/` or R2 | ## Layouts `templates/layout.html` is the application layout. Pages extend it and fill its blocks, the equivalent of `yield` and `content_for`: ```html {% extends "layout.html" %} {% block title %}Post {{ post.id }}{% endblock %} {% block content %}

    {{ post.title }}

    {% 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: ```html {# templates/admin/layout.html #} {% extends "layout.html" %} {% block content %} {% 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](https://ocre.rs/guides/controllers.md#error-pages). ## 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 %}`: ```html {% for comment in post_comments %} {% let item = comment %} {% include "comments/_comment.html" %} {% endfor %} ``` Macros are components with arguments (Rails' partials with locals, view components): ```html {% macro field(name, label, value, errors) %} {% for error in errors %}{% if error.field == name %}{{ error.message }}{% endif %}{% endfor %} {% endmacro %} {% call field("title", "Title", form.title, errors) %}{% endcall %} ``` `{% call %}` needs its `{% endcall %}`; anything between the two is the caller body, which the macro prints with `{{ caller() }}` (a block component, like a Rails partial rendered with a block). Macros defined in another file are imported with `{% import "forms.html" as forms %}` and called with `{% call forms::field(...) %}{% endcall %}`. ## Collections | Rails | askama | |---|---| | `render @posts` / `collection:` | `{% for post in posts %}...{% endfor %}` | | Empty-collection fallback | `{% for %}...{% else %}No posts yet.{% endfor %}` | | `post_counter`, `post_iteration.first?`/`last?` | `loop.index` (from 1), `loop.index0`, `loop.first`, `loop.last` | | `spacer_template:` | `{% if !loop.last %}
    {% endif %}` | | Heterogeneous collections | an `enum` and `{% match item %}{% when Item::Post with (post) %}...{% endmatch %}` | | Partial layouts | wrap the loop body in a macro | ```rust,check 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 %}
      {% for post in posts %}
    1. {{ post.title|truncate(60) }} · {{ post.body|wordcount }} words · {{ post.created_at|time_ago_in_words }} ago
    2. {% if !loop.last %}
      {% endif %} {% else %}
    3. No posts yet.
    4. {% endfor %}
    {% endblock %}"#, ext = "html" )] struct IndexView { posts: Vec, } async fn index(State(ctx): State, page: Page) -> Result> { render(&IndexView { posts: post::all(&ctx, page).await? }) } ``` ## View helpers `ocre::helpers` has Rails' formatting helpers as plain functions, and `ocre::filters` exposes them to templates as askama filters. askama finds custom filters in a module named `filters` where the template struct is defined, so a controller brings them in with `use ocre::filters;` (as the example above does): | Filter | Output | Rails | |---|---|---| | `{{ views\|number_with_delimiter }}` | `1,234,567` | `number_with_delimiter` | | `{{ ratio\|number_with_precision(2) }}` | `3.14` | `number_with_precision` | | `{{ price\|number_to_currency("$") }}` | `$1,234.50` | `number_to_currency` | | `{{ rate\|number_to_percentage(1) }}` | `12.3%` | `number_to_percentage` | | `{{ size\|number_to_human_size }}` | `1.5 KB` | `number_to_human_size` | | `{{ visits\|number_to_human }}` | `1.23 Million` | `number_to_human` | | `{{ post.created_at\|time_ago_in_words }} ago` | `about 3 hours ago` | `time_ago_in_words` | | `{{ from\|distance_of_time_in_words(to) }}` | `2 days` | `distance_of_time_in_words` | | `{{ post.created_at\|strftime("%b %-d, %Y") }}` | `Sep 29, 2026` | `l` / `to_fs` | | `{{ post.body\|excerpt(q, 40) }}` | `...text around q...` | `excerpt` | | `{{ post.body\|highlight(q) }}` | the text with each match in `` | `highlight` | | `{{ post.body\|word_wrap(72) }}` | lines of at most 72 characters | `word_wrap` | | `{{ ocre::helpers::class_names([("active", current)]) }}` | `active` when `current` | `class_names` / `token_list` | | `{% if ocre::helpers::current_page(current, "/posts") %}` | `true` when `current` (the request's `Uri` as text, set by the handler) is `/posts`, whatever its query | `current_page?` | | `` | an `