ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
# __APP_NAME__

Ocre app: Rust compiled to WebAssembly, running on Cloudflare Workers (free
plan) with a D1 (SQLite) database. Full-stack apps render HTML with askama and
htmx; API-only apps (`mode = "api"` in Cargo.toml) serve JSON only.

## Documentation

Ocre's documentation is written for agents: fetch
`__DOCS_URL__/llms.txt` (every page with a one-line description) and then
the pages you need as Markdown by adding `.md` to their URL, for example
`__DOCS_URL__/guides/models.md`, `__DOCS_URL__/reference/generators.md`,
`__DOCS_URL__/reference/limits.md`. `__DOCS_URL__/llms-full.txt` is every
page in one file; `__DOCS_URL__/api-index.md` lists every public item of the
`ocre` crate. Pages are self-contained and their examples compile. This file
wins when they disagree about this app.

## Commands

Run from the app root. Add `--json` to any `ocre` command for one JSON object
on stdout (`"ok": true|false`, plus `error` and `hint` on failure).

| Task | Command |
|---|---|
| Model only: table, validations, queries, associations | `ocre g model Author name:string^ bio:text?` |
| CRUD resource (HTML pages; JSON in API-only apps) | `ocre g scaffold Post title:string body:text published:boolean author:references` |
| Same, index page updated live in every open browser (WebSockets) | `ocre g scaffold Post title:string --realtime` |
| Resource with an uploaded file (R2; adds the `STORAGE` binding) | `ocre g scaffold Photo title:string image:attachment notes:attachment?` |
| Many files per record (child model, show page and API routes) | `ocre g scaffold Album title:string photos:attachments` |
| Belongs to one of several models | `ocre g scaffold Comment body:text commentable:polymorphic:post,photo` |
| JSON REST resource, `/api/posts` | `ocre g api Post title:string body:text` |
| Same, also on `/graphql` (costs CPU, see below) | `ocre g api Post title:string --graphql` |
| Model + `index`/`show` actions to fill in (lighter than scaffold) | `ocre g resource Tag name:string^ color:enum:red,green,blue` |
| Pages or JSON endpoints without a model (GET actions) | `ocre g controller Pages about contact` (`--api` for JSON, `--auth` for signed-in users) |
| Add columns (SQL inferred from the name) | `ocre g migration add_slug_to_posts slug:string?` |
| Remove a column | `ocre g migration remove_slug_from_posts` |
| Add / remove an index (column names, in order) | `ocre g migration add_index_to_posts author_id created_at` (`add_unique_index_to_posts slug`, `remove_index_from_posts author_id created_at`) |
| Rename a column / a table; drop a table | `ocre g migration rename_body_to_content_in_posts`, `rename_posts_to_articles`, `drop_posts` |
| Change a column's type, NOT NULL, DEFAULT or CHECK (SQLite table rebuild) | `ocre db schema`, then `ocre g migration rebuild_posts` and edit its CREATE TABLE |
| Empty migration (data changes, custom SQL) | `ocre g migration backfill_slugs` |
| Authentication (users, login, magic link, password reset, JWT, API keys; once) | `ocre g auth` (`--db-sessions` to list/revoke devices, `--oauth github,google`) |
| Emails to send (one function per email; previews at `/ocre/dev/mailers` in `ocre dev`) | `ocre g mailer User welcome password_reset` |
| Receive email (Email Routing) | `ocre g mailbox` |
| Push notifications to browsers (after `ocre g pwa`) | `ocre g push` |
| Sitemap, llms.txt; JSON-LD with `ocre::seo::json_ld`; hreflang with `i18n.alternate_links` | `ocre g seo` |
| Signed webhook from a service (payments, GPU jobs), each event once | `ocre g webhook payments` (`--standard` for Standard Webhooks) |
| Work a Worker cannot do (ffmpeg, GPU, AI): submit to a service, track its state | `ocre g external_job upscale video_id:integer` |
| Unguessable ids in URLs (`/videos/Xq3v9L...`, not `/videos/42`) | add `public_id:token` to the scaffold/api/resource fields |
| Job in steps, one run per key at a time | `ocre g job ProcessVideo video_id:integer --steps fetch,split,notify --lock video_id` |
| Background job (Cloudflare Queues) | `ocre g job SendWelcome user_id:integer` |
| Job on its own queue (never waits behind others) | `ocre g job SendCode user_id:integer --queue urgent` |
| Scheduled task (Cron Trigger, UTC; English or cron) | `ocre g schedule nightly_cleanup "every day at 3am"` |
| List scheduled tasks; run one now (`ocre dev` running) | `ocre schedules`, `ocre schedules run nightly_cleanup` |
| Cache values in Workers KV (adds the `CACHE` binding) | `ocre g cache` |
| Read-only JSON data compiled into the Worker | `ocre g data countries` (`crate::data::countries::all()`) |
| Browser test (Playwright, run by `ocre test --e2e`) | `ocre g system_test signing_up` |
| Turn the cache off / back on in `ocre dev` (`CACHE_STORE=null` in `.dev.vars`) | `ocre dev --no-cache` / `ocre dev --cache` |
| GitHub Actions: checks on every push, `ocre deploy` on main (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` secrets) | `ocre g ci` |
| Installable app: web manifest, service worker, icon | `ocre g pwa` |
| Custom domain (then `ocre deploy`) | `ocre domains add www.example.com` (`ocre domains`, `ocre domains remove`) |
| Translations: set up (first code = default), add a locale | `ocre g locale en fr`, then `ocre g locale de` |
| Preview a generator without writing; overwrite / keep existing files | `ocre g scaffold Post title:string --pretend` (`--force`, `--skip`) |
| Undo a generator run (from `.ocre/generated/`; commit that directory) | `ocre destroy scaffold Post` (`--pretend`, `--force`) |
| Customize generated code: copy templates to `.ocre/templates/` | `ocre g override controller` (no argument: list) |
| App's own generator in `.ocre/generators/<name>/`, then run it | `ocre g generator service`, then `ocre g service Billing amount:integer` |
| Apply a template of ocre commands (one per line) | `ocre template setup.ocre` |
| Keys missing from a locale (fails if any) | `ocre i18n missing` |
| Apply migrations locally | `ocre migrate` |
| Pending migrations | `ocre migrate --status` |
| Load seed data (`db/fixtures/<table>.yml` named rows, local only, then `db/seeds.sql`); empty the tables first | `ocre db seed` (`ocre db seed --replant`, `--from test/fixtures`) |
| Save table rows as fixture files (never overwrites without `--force`) | `ocre db dump` (`--tables posts,users`, `--dir <dir>`) |
| Recreate local database (migrations + fixtures + seeds) | `ocre db reset` |
| Set up or update the local database (safe to repeat) | `ocre db prepare` |
| Empty every local table / delete the local database | `ocre db truncate` / `ocre db drop` |
| Last applied migration | `ocre db version` |
| Current schema of every table into `db/schema.sql` | `ocre db schema` |
| Query the database (JSON rows with `--json`) | `ocre sql "SELECT * FROM posts LIMIT 5"` |
| Run locally (http://localhost:8787) | `ocre dev` |
| List routes (method, path, handler) | `ocre routes` (or `ocre routes posts`) |
| New secret value | `ocre secret` |
| Live production logs | `ocre logs` (`--status error`, `--search text`) |
| Secret names locally and deployed; upload production values; print one local value | `ocre secrets list`, `ocre secrets push GITHUB_CLIENT_SECRET --file .prod.vars`, `ocre secrets fetch NAME --file .prod.vars` |
| Check tools, bindings, migrations, secrets, production settings, `.ocre/doctor/` scripts (fails on a problem) | `ocre doctor` |
| Everything CI checks, locally, before pushing (fmt, clippy, tests, wasm32, translations) | `ocre ci` |
| Versions and configuration; code size; TODO/FIXME comments | `ocre about`, `ocre stats`, `ocre notes` |
| Cloudflare login (browser, `cf auth login`; once) | `ocre login` |
| Deploy: creates missing resources, remote migrations, then `cf deploy` | `ocre deploy` |
| Check cloudflare.config.ts after editing it | `npx tsc -p .` |
| Find a Cloudflare command `ocre` does not wrap | `npx cf cli search "list worker versions"`, then `npx cf <command> --help` |
| Type-check | `cargo check --target wasm32-unknown-unknown` |
| Unit tests + type-check (`--e2e`: also the request tests of `tests/*.rs`, `tests/e2e.sh` and `tests/system/` against a fresh test database) | `ocre test` |

Field types: `string`, `text`, `rich_text` (HTML from the Trix editor,
sanitized on save; show it with `{{ x|rich_text }}`), `integer`, `float`, `decimal` (exact, as
text), `boolean`, `date` (`YYYY-MM-DD`), `time`, `datetime`, `uuid`,
`enum:draft,published` (a Rust enum in the model, stored as text with a
`CHECK`; cannot be unique, not with `--graphql`), `references`
(`author:references` adds `author_id`, a foreign key deleted with its parent;
`author:references?` sets it to NULL instead; `user:references^` is a
has-one; a model with only references, `Tagging post:references
tag:references`, is a join model giving `post.tags(ctx, page)`),
`attachment` (a file in R2 stored as `<name>_key`, `_filename`,
`_content_type`, `_size` columns; must be optional `?` in JSON APIs), `json`
(any JSON value as `ocre::serde_json::Value`, stored as JSON text; build
values with `ocre::serde_json::json!`; cannot be unique `^`). Suffix `?`
makes a field optional (NULL allowed), `^` unique. Integers must stay within
±`ocre::MAX_SAFE_INTEGER` (2^53 - 1): D1 returns numbers as JavaScript
numbers; generated validations already reject larger values.
`lock_version:integer` turns on optimistic locking: `update` answers
`Error::Conflict` (409) when `<Model>Changes.lock_version` is stale.

## Layout

```
src/lib.rs          entry point and router; keep the `// ocre:` marker comments
src/models/<model>.rs  the model: struct, New<Model>/<Model>Changes, validate(), query(),
                    all/count/find/find_many/create/update/delete, preload_<x>/for_<x>,
                    associations, before_/after_ create/update/delete callbacks
src/<plural>.rs     HTML resource: form parsing, handlers, routes (calls the model)
src/<plural>_api.rs JSON resource: REST handlers and GraphQL resolvers (call the model)
src/graphql.rs      GraphQL schema (when used); keep the `// ocre:graphql-*` markers
src/auth.rs         after `ocre g auth`: CurrentUser/OptionalUser extractors, sign_in/sign_out (HTML apps)
src/auth_api.rs     after `ocre g auth`: BearerUser extractor, /api/auth/* (token, me, keys)
src/mailers/<name>.rs  functions building `ocre::mail::Email`; templates in templates/mailers/<name>/<action>.{txt,html}, extending templates/mailers/layout.{txt,html}
src/mailers/mod.rs  `defaults(email)` applied to every mailer's email; `PREVIEWS` (keep `// ocre:mailer-previews`)
src/mailbox.rs      incoming email handler, called by the `email` event in src/lib.rs
src/jobs/<name>.rs  a job: serde struct of arguments + `perform(self, ctx)`
src/jobs/mod.rs     `Job` enum and `perform` match (dispatch); keep the `// ocre:job*` markers
src/schedules/<name>.rs  a scheduled task: `run(ctx)`
src/schedules/mod.rs  `run` matches the cron expression to a task; keep the `// ocre:schedule*` markers
src/realtime.rs     after `--realtime`: `GET /realtime/{channel}` and which channels may be opened; keep `// ocre:channels`
templates/<plural>/_row.html  after `--realtime`: one row with `id="<singular>_<id>"`, used by the index and broadcasts
templates/          askama templates, compiled into the binary
locales/<code>.yml  translations (after `ocre g locale`), declared in `ocre::locales!(..)` in src/lib.rs
public/             static files (CSS, images, robots.txt), served by Cloudflare before the Worker runs
migrations/         numbered D1 SQL migrations, applied in order
cloudflare.config.ts  Cloudflare config (bindings, triggers, exports); the D1 binding must be DB;
                    variables are `KEY: bindings.text("value")` in worker.env (MAIL_FROM);
                    generators add entries after `// ocre:env`, `// ocre:triggers`, `// ocre:exports`
                    (JOBS / JOBS_<NAME> queues, crons, CACHE, STORAGE, CHANNELS, AUTH_RATE_LIMITER);
                    `ocre deploy` writes the CACHE KV id into it: commit it
wrangler.config.ts  build command (worker-build) and assets directory, read by cf through the app's wrangler
package.json        pinned cf, wrangler, typescript (commit package-lock.json); tsconfig.json checks the .ts files
.dev.vars           local secrets and overrides for `ocre dev` (SECRET_KEY_BASE, MAIL_ADAPTER=log); never commit it
.prod.vars          production secret values for `ocre secrets push` (git-ignored); never commit it
```

## Rules

- Cloudflare: use `ocre` commands first, then Cloudflare's `cf` CLI
  (`npx cf ...`, the version pinned in package.json); never run `wrangler`
  directly (Ocre runs the app's wrangler itself where cf cannot yet). Find a
  command with `npx cf cli search "<what you want to do>"` and read its
  `--help` (or `npx cf schema <command>`). Production secrets: put
  `NAME=value` lines in `.prod.vars`, then `ocre secrets push NAME --file .prod.vars`
  (`--store`: the account's Secrets Store, shared by the account's Workers);
  never pass secret values on the command line. Read one in code with
  `ctx.secret("NAME").await?` (Worker secret, .dev.vars or Secrets Store alike).
- cloudflare.config.ts: edit only its literal entries, in the canonical forms
  the generators write (`JOBS: bindings.queue({ name: "__APP_NAME__-jobs" }),`,
  `triggers.scheduled({ schedule: "0 3 * * *" }),`); Ocre reads the file
  without running it, so no variables, template strings or spreads in the
  values it reads (names, queues, buckets, crons, KV ids). Keep the
  `// ocre:env`, `// ocre:triggers` and `// ocre:exports` markers. Check with
  `npx tsc -p .` (or `ocre doctor`).
- Handlers are plain axum handlers taking `State(ctx): State<Ctx>`. Ocre types
  are `Send`; add `#[worker::send]` only to a handler that awaits another
  crate's JavaScript-backed future (reqwest, `worker::Fetch`): without it the
  route fails with "`Handler<_, _>` is not satisfied".
- Outgoing HTTP: `reqwest` with `default-features = false` (it uses `fetch` in
  WebAssembly) or `worker::Fetch`; each call is a subrequest.
- Queries: start from the model's `query()` (an `ocre::Query<T>`):
  `post::query().eq("published", true).order_desc("id").page(page).all(&ctx.db()?)`;
  `.first(&db)` (find_by), `.count`, `.exists`, `.pluck(&db, "id")`,
  `.aggregate(&db, "SUM(price)")`, `.paginate(&db, page)` (`Paginated<T>`),
  `.update_all(&db, vec![("col", v.into_param())])`, `.delete_all(&db)`.
  Conditions: `eq ne gt gte lt lte between is_in not_in is_null like contains
  starts_with ends_with`, `any(|q| ..)` for OR, `not(|q| ..)`,
  `where_sql("x > ?", params![..])`. Scopes are plain
  `fn published(q: Query<Post>) -> Query<Post>` applied with `.scope(published)`.
  Column names are `&'static str`: map user input to a fixed column with a
  `match` (sort by `Direction`, which deserializes from `asc`/`desc`).
- Raw SQL goes through `ctx.db()?` with `?1, ?2` placeholders and
  `params![...]`; never build SQL with `format!` from user input (`LIKE`
  patterns: `ocre::escape_like`). `db.all::<T>`, `db.first::<T>`
  (`INSERT ... RETURNING *` to get the new row), `db.execute` (rows changed).
- Transactions: D1 has no `BEGIN`; statements that must all apply or none go
  in one `ctx.db()?.batch(vec![Statement::new(sql, params![..]), query.update_statement(..)])`.
  Put conditions in the write (`UPDATE ... WHERE stock >= ?1`) and check the
  rows changed instead of read-then-write.
- Booleans are INTEGER 0/1 in SQLite: read them with
  `#[serde(deserialize_with = "ocre::bool_from_sql")]`. JSON columns hold
  JSON text: bind a `serde_json::Value` with `params![value]` and read it
  with `deserialize_with = "ocre::json_from_sql"` (`optional_json_from_sql`
  for `Option`); HTML forms parse the text with `v.json(field, &text)`.
- Missing record: `.or_404()?`. Bad input: `Error::bad_request("...")`.
  Unexpected failure: `Error::internal("...")` (logged, not shown to users).
- Validation: collect every problem with `ocre::Validator` (`required`,
  `min_length`/`max_length`/`length`, `range`, `greater_than`..., `email`,
  `inclusion`/`exclusion`, `format(field, v, |c| ..)`, `confirmation`,
  `acceptance`, `date`/`time`/`datetime`, `one_of::<Enum>`, `check`,
  `.message("..")` to replace the last message) and end with `v.finish()?`,
  which returns `Error::Invalid` (422). JSON answers
  `{"error": {"fields": {"title": ["can't be blank"]}}}`; generated HTML forms
  re-render with the messages and the typed values.
- Data rules live in the model (`src/models/<model>.rs`): `validate()` for
  checks without the database, `create`/`update` for uniqueness and foreign
  keys, the `before_*`/`after_*` callbacks at the end of the file for
  normalizing input and side effects (a `before_*` `Err` stops the write; an
  `after_*` `Err` does not undo it). Handlers and GraphQL resolvers only call
  model functions. After a migration that changes columns, update the model
  struct, `New<Model>`, `<Model>Changes`, `validate()` and the SQL in
  `create`/`update`.
- Load associations for many rows at once: `comment::preload_posts(ctx, &comments)`
  (HashMap by id), `comment::for_posts(ctx, &post_ids)`, `find_many(ctx, &ids)`;
  never an association or `find` in a loop. Other query helpers:
  `where_missing("comments", "post_id")`, `create_or_first(&db, || async { .. })`
  with a UNIQUE index, `batches(100, |r| r.id)` + `next(&db)` for big tables
  (from jobs: 50 queries per invocation), `explain(&db)`.
- Migrations are forward-only (no down/rollback): fix mistakes with a new
  migration. Encrypted columns: `ocre::encryption::Encrypted` (or
  `Deterministic` to look up with `eq`) as the row field type. Another D1
  database: `ctx.db_named("BINDING")?`.
- Templates escape `{{ value }}` by default; never mark user input `|safe`.
- Change the schema only with a new migration file; never edit an applied one.
- New module: generators register it in `src/lib.rs`. By hand, add
  `mod name;` under `// ocre:modules` and `.merge(name::routes())` under
  `// ocre:routes`. Keep `// ocre:models` and `// ocre:associations` too.
- URLs: each HTML controller has `pub mod paths` (`paths::index()`,
  `paths::new()`, `paths::show(id)`, `paths::edit(id)`, `paths::delete(id)`);
  redirect with `Redirect::to(&paths::show(id))`, link in its templates with
  `{{ paths::show(post.id) }}` and elsewhere `crate::posts::paths::show(id)`.
  When you change a route, change `paths` too. Nested routes use
  `Path((post_id, id)): Path<(i64, i64)>`; namespaces `.nest("/admin", admin::routes())`.
- Forms with lists or nested records (`tag_ids[]`, `lines[0][qty]`,
  `order[note]`): extract `ocre::NestedForm<T>` instead of axum's `Form`.
  Unicode route paths: `.route(&ocre::encode_path("/café"), ..)`. Streaming
  (Server-Sent Events): return `ocre::sse::stream(state, step)`, pace with
  `ocre::sleep`; links in emails: `ocre::mail::url(&ctx, path)` (`APP_URL`).
- Errors render `templates/error.html` through `error_page` in src/lib.rs;
  keep `.fallback(not_found)` and `.layer(map_response(error_page))` last in
  `routes()` (before the i18n layer).
- View helpers: `use ocre::filters;` in the controller, then
  `{{ price|number_to_currency("$") }}`, `{{ post.created_at|time_ago_in_words }} ago`,
  `{{ post.created_at|strftime("%b %-d, %Y") }}`, `number_with_delimiter`,
  `number_to_human_size`, `excerpt`, `highlight`; askama's `truncate`,
  `linebreaksbr`, `pluralize`. The same functions are in `ocre::helpers`.
- One action serving HTML and JSON: take `format: ocre::Format` (Accept
  header) and `match` it. Client IP: `ocre::RemoteIp`; request id:
  `ocre::RequestId`; back to the previous page: `ocre::redirect_back(&headers, "/")`;
  generated files: `ocre::storage::send_data(bytes, "name.csv", "text/csv", Disposition::Download)`.
- htmx: the layout boosts links and forms (`hx-boost`). For partial updates,
  answer a fragment when `Htmx(true)` and a redirect otherwise; `ocre::HxRedirect`
  for a full navigation from an htmx request. Pagination links:
  `page.previous()` / `page.next(rows.len())` and `?{{ p.query() }}`. No inline
  `<script>` or `on*=` attributes: scripts go in `public/`.
- JSON handlers return `ApiResult<T>` (errors become `{"error": {"status",
  "message"}}`), take bodies with `ocre::Json<T>` and lists with `Page`.
  Optional fields use `#[serde(default, deserialize_with = "ocre::optional")]`
  (empty or `null` = `None`) and, in `<Model>Changes`,
  `deserialize_with = "ocre::patch"` (missing keeps the value, `null` clears it);
  optional `json` fields use `#[serde(default)]` and `ocre::patch_json`, so a
  JSON string stays a string.
- Sessions: take `session: ocre::Session` in a handler; `session.insert("user_id", id)?`,
  `session.get::<i64>("user_id")?`, `session.remove(..)?`, `session.clear()?`. The
  session is an encrypted cookie (4 KB max): store ids, never records or secrets.
- Flash: `session.flash("notice", "Post was successfully created.")?` before a
  redirect; the next page takes `flash: ocre::Flash` and its template shows
  `flash.notice()` / `flash.alert()`.
- CSRF protection is automatic: browsers' cross-site POST/PUT/PATCH/DELETE get
  403. Forms and `fetch()` calls need no token. Another site (a separate
  frontend) that must call the app: list its origin in
  `ALLOWED_ORIGINS: bindings.text("https://app.example.com")` in worker.env of
  cloudflare.config.ts (comma-separated); that also enables CORS for it.
  `ALLOWED_HOSTS: bindings.text("example.com, .example.com")` answers only on those hosts.
- Security headers (nosniff, SAMEORIGIN framing, referrer policy, HSTS on
  HTTPS) are added to every response; a handler that sets one keeps its value.
  Full-stack apps also set a Content-Security-Policy in src/lib.rs: no inline
  scripts or `onclick=`; use `ocre::security::CspNonce` for an inline script.
- Request data: read forms and JSON into structs that list only the fields a
  visitor may set (never `user_id` or roles: take them from `CurrentUser`).
  SQL: values only through `?1` + `params![..]` or `Query` methods, never
  `format!`; map a user-chosen sort onto a fixed column name. User HTML:
  `ocre::security::sanitize` before `|safe`. Redirect targets from the
  request: `ocre::security::url_from`.
- Rate limiting: `ocre::security::rate_limit(&ctx, "BINDING", &key).await?`
  (429 when over) with a `BINDING: bindings.rateLimit({ namespace: "<unique integer>", simple: { limit: 10, period: 60 } }),`
  entry in cloudflare.config.ts; key by
  `ocre::remote_ip(&headers)` or user id.
- Logging: `ctx.log().info(...)` (levels trace/debug/info/warn/error; add fields with
  `ctx.log().with("key", value)`), never `println!`. Lines carry the request id and
  go to Workers Logs as JSON in production. `LOG_LEVEL` (var) sets the level.
- Errors: return `Error::internal("...")` for unexpected failures (logged with the
  request's details and sent to `ocre::errors` subscribers, e.g. Sentry with the
  `SENTRY_DSN` secret); `ctx.errors().report(...)` for errors you handle yourself.
  `ocre dev` shows a development error page with the D1 statements the request ran.
- `GET /up` is the health check; keep it cheap (no database).
- Email: build it in a mailer (`src/mailers/`), send it from the handler with
  `ocre::mail::send(&ctx, mailers::user::welcome(&address)?).await?`. Validate
  user-typed addresses first with `v.email(..)`. `MAIL_ADAPTER` picks the
  delivery: `log` (`ocre dev`: the email is printed in the dev output between
  `[ocre mail]` lines, links included; read them there), `resend` (secret
  `RESEND_API_KEY`; free plan: any recipient, 100/day) or `cloudflare`
  (`EMAIL: bindings.sendEmail()` binding; free plan: only verified addresses of the
  account). Unset in production = `send` fails with an error naming the fix.
  Put data in the template structs; `.txt` templates are not HTML-escaped.
  More on an `Email`: `.cc(..)`, `.bcc(..)`, `.also_to(..)`, `.reply_to(..)`,
  `.header(..)`, `.attach(filename, content_type, bytes)`, `.inline(cid, ..)`
  (HTML `<img src="cid:..">`). In `ocre dev`: previews and the emails sent at
  `http://localhost:8787/ocre/dev/mailers`, as JSON at
  `/ocre/dev/mailers/sent.json` (read emailed links there in scripts).
- Background jobs: anything slow, retryable or not needed for the response
  (emails, calls to other APIs, bulk updates) goes in a job. Enqueue with
  `SendWelcome { user_id }.perform_later(&ctx).await?`
  (`use crate::jobs::SendWelcome;`), or `ocre::jobs::enqueue_in(&ctx,
  &Job::SendWelcome(..), Duration::from_secs(n))` (24 h max), or many at once
  with `ocre::jobs::enqueue_all(&ctx, &jobs)` (never a loop of enqueues). Job
  fields are the arguments: pass ids
  and small values (128 KB max), load records in `perform`. `perform` returns
  `Err(Error::NotFound)` or another 4xx error to drop the job (logged, not
  retried), any other `Err` to retry (30 s, 1 min, 3 min, 9 min, 27 min, then the
  `<app>-jobs-failed` dead-letter queue); jobs may run twice, so make them
  safe to repeat. Never rename a `Job` variant or change its fields while
  messages may be queued: they would be dropped (`[ocre jobs] dropped message`).
  In `ocre dev` jobs run within ~5 s; read the `[ocre jobs]` lines. Queues have
  no priorities: urgent jobs get their own queue (`--queue urgent`).
- Email from a request: prefer `ocre::mail::deliver_later(&ctx, email).await?`
  (sent by the jobs queue, retried; `deliver_in` to delay it) once the app has
  a job; `send` otherwise.
- Scheduled tasks: `ocre g schedule <name> "<when>"` ("every 15 minutes",
  "every weekday at 6pm", or a cron expression); the task `run(ctx)` must stay within
  10 ms CPU: query ids, then enqueue jobs (`enqueue_all`). Crons are UTC; a failed
  run is logged (`[ocre cron]`), not retried. Cron Triggers never fire in
  `ocre dev`: `ocre schedules run <name>` fires one.
- Incoming email: `src/mailbox.rs` `receive(ctx, email)`; `email.to()`,
  `subject()`, `text()`, `header(..)`; `email.reject("reason")` bounces,
  `email.forward("verified@address").await?` forwards; an `Err` bounces.
  Test locally with the form at `http://localhost:8787/ocre/dev/mailbox`, or
  `curl 'http://localhost:8787/cdn-cgi/local/email?from=a@example.com&to=b@example.com' --data-binary @message.eml`
  (the message needs a `Message-ID` header).

- Authentication (after `ocre g auth`):
  - Protect an HTML page: take `CurrentUser(user): CurrentUser` (from
    `crate::auth`); visitors are redirected to /login and come back after.
    Optional: `OptionalUser(user): OptionalUser` gives `Option<User>`.
  - Protect a JSON route: take `BearerUser(user): BearerUser` (from
    `crate::auth_api`); it accepts `Authorization: Bearer <JWT or API key>`
    and answers 401 JSON otherwise. Put it before `Json(..)` in the arguments.
  - Ownership: filter queries by `user.id` (`WHERE user_id = ?1`) and return
    `Error::NotFound` (or `Error::Forbidden`) for other users' records.
  - Sign in only through `auth::sign_in(&ctx, &session, &headers, &user, remember).await?`
    (it resets the session and returns the page to go to) and out with
    `auth::sign_out(&ctx, &session).await?`; never store more than the user id
    in the session.
  - Passwords: `ocre::password::hash` / `verify` only (PBKDF2 via WebCrypto,
    about 5 ms CPU per call: one per request at most). Never log or return
    passwords, tokens or digests; `User` never serializes `password_digest`.
  - Secret tokens (links, keys): `ocre::token::generate()`, store only
    `ocre::token::digest(&token)`, look rows up by digest.
  - JWT: `POST /api/auth/token` with `{"email", "password"}`; issue others
    with `ocre::jwt::encode(&ctx, &Claims::new(user.id.to_string(), ttl))?`.
    The key comes from `SECRET_KEY_BASE`; no other secret is needed.
  - API keys: `POST /api/auth/keys` `{"name"}` with a Bearer token returns
    `{"key", "api_key"}`: the key is shown once. List with `GET`, revoke with
    `DELETE /api/auth/keys/{id}`. In code: `models::api_key::create(&ctx, user.id, NewApiKey { name })`.
  - Magic-link and reset emails print in the `ocre dev` output
    (`[ocre mail]`); open the link from there.
  - Every route that checks a password or sends an email calls
    `auth_api::throttle(&ctx, &headers, "<action>").await?` (binding
    `AUTH_RATE_LIMITER`, 10 a minute per IP): do the same in new ones.
  - `--db-sessions` apps: sessions are D1 rows (`/account/sessions` revokes
    them). `--oauth` apps: secrets `<PROVIDER>_CLIENT_ID` and
    `<PROVIDER>_CLIENT_SECRET` in .dev.vars and, for production, `ocre secrets push ... --file .prod.vars`.

- Files (`attachment` fields, `ocre::storage`, R2 binding `STORAGE`):
  - Rules live in the model: `pub const IMAGE: Rules { max_bytes, content_types }`
    (exact types; no `image/*`, it would admit SVG). `validate()` checks
    uploads with `v.file("image", &upload, &IMAGE)`; forms add `max_bytes`
    to `FORM_LIMIT` (the `Multipart<FORM_LIMIT>` extractor answers 413 above it).
  - Set a file through the model only: `NewPhoto { image: Some(upload), .. }`,
    `PhotoChanges { image: Some(upload), .. }` (optional files:
    `Some(Some(upload))` replaces, `Some(None)` removes). `create`/`update`/`delete`
    store and delete R2 objects; never write the `*_key` columns by hand.
  - In a handler: `Multipart(mut form): Multipart<LIMIT>`, `form.form::<T>()?`
    for text fields, `form.file("image")` for an `Upload` (`None` when no file was chosen).
  - Serve with `storage::serve(&ctx, &photo.image(), &headers, Disposition::Inline).await`
    (streams R2 without CPU; ETag/304, Range, safe `Content-Disposition`).
    Check the user may see the record first. `Disposition::Download` forces a download.
  - JSON: `PUT /api/<plural>/{id}/<name>` with `curl -X PUT -F <name>=@file`,
    `DELETE` removes it; JSON bodies cannot carry files.
  - Other code: `storage::store(&ctx, "prefix", upload)`, `store_bytes`,
    `store_body` (raw body with Content-Length), `read`, `delete`;
    `Upload::new(name, type, bytes)` for app-made files; `head`/`exists`
    (class B), `list(prefix, cursor, limit)` (class A per page: never per request).
  - Content check: `v.file_content("image", &upload)` refuses bytes that do not
    match the declared type; `storage::analyze(&bytes)` gives type, width, height.
  - Big files: `storage::direct_upload` (presigned PUT, needs `R2_ACCOUNT_ID`,
    `R2_BUCKET` vars, `R2_ACCESS_KEY_ID`/`R2_SECRET_ACCESS_KEY` secrets and a
    bucket CORS rule) then `attach_direct_upload(signed_key)`; purge abandoned
    ones from a schedule with `purge_unattached`. `serve_redirect` sends a 302
    to a presigned GET. Presigned URLs hit the real bucket, not `ocre dev`'s.
  - Resized images: `Variant::new().width(300).path("/photos/1/image")`
    (Cloudflare Image Transformations; custom domain only, not workers.dev).
  - Files of rows deleted by `ON DELETE CASCADE` stay in R2: delete them in
    the parent's `delete` if that matters.

- Realtime (after `ocre g scaffold ... --realtime`):
  - Send to every open page: `ocre::realtime::broadcast(&ctx, "posts", &html).await`
    from any handler or job. Build `html` with `realtime::prepend("posts", &row)`
    (top of `<tbody id="posts">`), `append`, `update(id, html)` (contents),
    `remove("post_12")`, or send an element with an `id` to replace it.
    Broadcast after the database write succeeded.
  - Pages subscribe with `<div hx-ext="ws" ws-connect="/realtime/posts">` and
    the htmx ws script (see `templates/posts/index.html`); no custom JavaScript.
  - New channel: add `"name" => {}` under `// ocre:channels` in
    `src/realtime.rs`; unknown channels are 404. Names: letters, digits,
    `_ - . :` (e.g. `post:12`). Private channel: check the user in `connect`
    (take `CurrentUser`) and return `Err(Error::Forbidden)`; use one channel
    per user or record, never a shared channel for private data.
  - Messages are rendered HTML: use askama templates (escaping) as for pages.
    Client messages are ignored; use forms/htmx requests to send data. For
    chat-like relays between JavaScript clients, `upgrade.identified_by(user.id.to_string()).rebroadcast()`
    in `connect` sends what a client sends to the others as `{"from","data"}` JSON.
  - Tests: in `ocre dev`, GET `/ocre/dev/realtime/sent.json` lists recent broadcasts.
  - Needs `features = ["realtime"]` on `ocre` in Cargo.toml and the
    `CHANNELS` binding + `OcreChannel` export in cloudflare.config.ts (the
    generator adds them; `ocre deploy` creates the namespace).

- Caching (after `ocre g cache`):
  - Slow or costly results (aggregates, external APIs):
    `ocre::cache::fetch(&ctx, "stats:v1", Duration::from_secs(3600), || async { compute(&ctx).await }).await?`.
    The value is JSON (`Serialize + Deserialize`); bump the key's `:v1` when
    its type changes. `ocre::cache::delete(&ctx, key).await?` after changing
    the data behind it; `read`/`write` for explicit use.
  - Never cache per-user data under a shared key; put the user id in the key.
  - Costly HTML: `ocre::cache::fragment(&ctx, &ocre::cache::key(&[&"posts", &post.id, &post.updated_at, &"card-v1"]), ttl, || CardView { post: &post }).await?`
    returns a `Fragment`; put it in the page's view struct and write `{{ card }}` (no `|safe`).
    Lists: `ocre::cache::fragments(&ctx, &posts, ttl, |p| key.., |p| RowView { post: p })`.
    Bump the key's version when the template changes; never cache HTML holding a CSRF token or nonce.
  - Repeated identical SELECTs in one request are cached automatically (any write empties it);
    `ctx.db()?.uncached()` bypasses it. `CACHE_STORE=null` in `.dev.vars` turns KV caching off.
  - TTL is at least 60 s. KV is eventually consistent (up to 60 s).
  - Pages: take `conditional: ocre::cache::Conditional` and return
    `conditional.fresh_when(ETag::of(&(&post, i18n.locale()))?, CacheControl::no_cache(), || render(&view))`:
    304 without rendering when the browser's copy is current. Put everything
    the page shows in the ETag (records, locale, user id, flash).
  - `CacheControl::public(..)` only for responses identical for every
    visitor (no session data, locale in the path).
- Translations (after `ocre g locale en ...`):
  - Strings go in `locales/<code>.yml` under the root key `<code>:`, as nested
    keys indented by 2 spaces (no lists, no `{}`/`|`). Quote values starting with `%`
    or containing `: `. `%{name}` is filled by `.arg("name", value)`; plural
    keys have `one:`/`other:` children (Russian/Polish also `few:`/`many:`)
    and are picked by `.count(n)`, which also fills `%{count}`.
  - Handlers take `i18n: ocre::i18n::I18n` and pass it to the template struct:
    `{{ i18n.t("posts.title") }}`, `<html lang="{{ i18n.locale() }}">`. In
    handlers: `i18n.t("posts.created").to_string()`. Outside requests:
    `LOCALES.locale("fr")`.
  - Locale: `{locale}` path segment (`.nest("/{locale}", routes)`), else the
    `locale` cookie (`i18n.cookie()` is its `Set-Cookie` value), else
    `Accept-Language`, else the default (first code in `ocre::locales!`).
  - Defaults: `i18n.t("key").or_key("other.key").or("Text")`. Scoped keys:
    `i18n.scope("posts.index")` in the handler, then `{{ i18n.t(".title") }}`.
    Markup in a translation: `{{ i18n.t("terms").arg("name", n).html() }}`
    (text raw, values escaped); never `|safe`. Links keeping the locale:
    `{{ i18n.path("/posts") }}` (routes under `.nest("/{locale}", ..)`).
  - Localized formats: `i18n.l(date, "long")`, `i18n.number(n)`,
    `i18n.currency(amount, "€")`, `i18n.time_ago_in_words(at)`. Names:
    `i18n.model_name("post", n)` (`models.post.one/other`),
    `i18n.attribute("post", "title")` (`attributes.post.title`). Validation
    errors: `i18n.full_message("post", &error)` (keys `errors.messages.blank`...,
    built in for en, fr, de, es, it, pt, nl).
  - After adding keys, run `ocre i18n missing` and translate what it lists.
    `ocre dev` shows `translation missing: fr.key` in pages; production falls
    back to the default locale.
  - Keep `.layer(ocre::i18n::layer(&LOCALES))` last in `routes()`.

## Free-plan limits (design for them)

- 10 ms CPU per request. Awaiting D1 or `fetch` does not count. No CPU-heavy
  work in handlers: no argon2/bcrypt (use WebCrypto PBKDF2), no large parsing.
  Put static files in `public/`: they cost no Worker request or CPU.
- D1: daily read/write row quotas. Avoid N+1 queries: one query with `JOIN` or
  `WHERE id IN (...)` instead of a query per row.
- 50 D1 queries and 50 subrequests (`fetch`) per invocation (request, queue
  batch, cron run). Write many rows with one statement:
  `ocre::bulk::{insert, upsert, update}` (then `db.execute(&s.sql, s.params)`).
  Long jobs: `ocre::jobs::run_steps(&Budget::new(49), cost_per_step, cursor, step)`
  returns the cursor to re-enqueue the job with when the budget runs out.
- 100,000 requests per day.
- Queues: 10,000 operations per day (a job costs 3; each retry 1 more), so
  about 3,000 jobs per day; messages expire after 24 hours. Each consumer
  batch (up to 10 jobs) is one Worker request with 10 ms CPU. Cron Triggers:
  5 per account.
- GraphQL adds ~1.1 MB of WebAssembly and 20-60 ms of CPU when a Worker
  instance starts. Only add `--graphql` when a client needs it.
- Realtime: every WebSocket connection and every broadcast is one Durable
  Object request (100,000 a day, shared with other Durable Object use).
  Broadcast once per change, not per row in a loop; messages to browsers are free.
- KV (`ocre::cache`): 100,000 reads and 1,000 writes per day, 1 GB. Every
  `fetch` is a read; each miss and each `write`/`delete` is a write: a key
  refreshed every `ttl` seconds costs up to 86,400 / ttl writes per day. Use
  TTLs of an hour or more and few keys; past the limit `fetch` still works
  (computes every time) but gets slower.
- R2 (`ocre::storage`): 10 GB stored, 1M uploads (class A) and 10M downloads
  or 304s (class B) per month; deletes are free. The whole upload is in memory
  (128 MB per Worker; Cloudflare refuses bodies over 100 MB): keep `max_bytes`
  in the tens of MB. R2 must be enabled once in the dashboard (it asks for a
  payment method even for the free tier).

## Runtime constraints

- Target is `wasm32-unknown-unknown`: no tokio, no threads, no filesystem, no
  raw sockets. Crates that need them (sqlx, reqwest with native TLS, tokio)
  do not compile. Use `worker::Fetch` for outgoing HTTP.
- Rust must come from rustup with the wasm32 target (Homebrew `rust` lacks it).
- Node.js 22 or newer, with the app's npm packages installed (`npm install`).