Skip to content

Rust off Rails 0.6.2

Choose a tag to compare

@danielcj danielcj released this 14 Oct 01:18
· 111 commits to main since this release

What's new

  • offrails-check catches 32-bit ids ([ids]), which pass on SQLite and break every Postgres write, and test suites that never run on Postgres ([postgres]).
  • bin/dev manages its own process: it writes a PID file per server, refuses to start twice on one port, and on exit stops only its own process tree. Agents stop a server with kill "$(cat storage/pids/…)".
  • Seeds can be re-run after a half-failed run: one transaction, each record found or created by a natural key.
  • WebSockets sized for many sockets: smaller per-socket buffers took 10,000 sockets from 1.37 GB to 160–225 MB.
  • Whole files in the guides for kernel/tls.rs and kernel/introspect.rs, and a pattern for live boards over a channel (snapshot, patches, reducer).
  • Parallel helpers only after the skeleton compiles, each with its own files.

Breaking changes

  • offrails-check fails on the new [ids] and [postgres] rules: an app with 32-bit ids adds a widening migration (never edits the applied ones), and tests/app/support.rs must read TEST_DATABASE_URL.
  • bin/dev no longer uses trap 'kill 0' (it could kill the caller's shell); copy the new top of the script.

Upgrading

Each entry's App migration note below, then upgrade. This release also corrects the 0.6.1 steps in place.

Full list of changes

  • Parallel helpers only after the skeleton compiles (workflow). A real migration (Fizzy → Rust off Rails, run in opencode) had three helpers writing the jobs executor, the jobs dashboard and the dev mailbox into one uncompiled crate at once. Each tripped over the others' half-written files, and two built stub crates in /tmp just to type-check their own part. Now: the coordinator builds the skeleton alone, up to the first green cargo check and the baseline commit. After that it runs at most one helper per vertical slice, with each helper's files listed. Shared registries (routes.rs, mod.rs files, migration/src/lib.rs, Cargo.toml) stay with the coordinator, and a helper blocked by another's file stops and reports. App migration: docs only. Detect: the app's AGENTS.md or any decision line telling agents to fan out helpers on code before the tree compiles. Converge: drop it and follow the workflow rule.
  • Fixed (P0): 32-bit ids passed on SQLite and broke every Postgres write. A peer's Kanban (0.3.0 → 0.6.1) used .integer() for ids and foreign keys. SQLite's INTEGER is 64-bit, so the suite was green; on Postgres the columns were INT4 and every write that returned an id was a 500. Its tests had never run on Postgres: support.rs hard-coded sqlite::memory:. Two new offrails-check rules, with fixtures: [ids] fails an id/*_id column defined with integer/small_integer/tiny_integer (helper or ColumnDef chain, across lines, Alias::new("x_id") too) or pk_auto, and an i32/i16 id field in src/; [postgres] fails a tests/app/support.rs that never reads TEST_DATABASE_URL. A migration repaired by a later one carries // offrails-check: ids widened in m<…> (migrations, with the widen_ids template, its SQL verified on Postgres 18). App migration: Detect: sh .offrails/tools/offrails-check prints [ids] or [postgres]. Converge: [postgres]: TestApp::new per testing first, then run the suite on Postgres and fix what it finds. [ids]: never edit the applied migrations; add widen_ids (Postgres only; ALTER SEQUENCE … AS bigint for pk_auto tables), put the waiver comment in each repaired file, change entity fields to i64, then db rollback --steps 1 && db migrate and the suite on both databases.
  • Seeds retry after a half-failed run. The same app's seed_if_empty returned early when any team existed, so a seed that failed after the first team never ran again. tasks/seed.rs now runs in one kernel::db::begin transaction and finds-or-creates each record by a natural key (cli; scaffold and new updated). App migration: Detect: grep -nE 'count|is_empty|exists' src/tasks/seed.rs gating the whole seed with an early return. Converge: one transaction, find-or-create per record; prove it by running app db seed twice (no change) and after deleting one seeded row (it comes back).
  • One PID file per server; cli.md and the NEVER list now agree on how to stop one. cli.md said to stop bin/dev by its own process; the NEVER list and the hook said lsof … | xargs kill on the listener, which leaves cargo watch and Tailwind running. A peer's agent (dev-92, opencode) also broke the name rule with pkill -f and killed the human's demo: the hook that blocks pkill exists only in Claude Code. Now bin/dev writes storage/pids/dev-<port>.pid, refuses to start when that PID is alive or the port is taken, and on exit stops only its own process tree (leaves first) and removes the file. A plain serve is started as sh -c 'echo $$ > storage/pids/serve-<port>.pid; exec cargo run -q -- serve' (cargo run execs the app, so the PID is the server's; verified). Stop either with kill "$(cat storage/pids/…)" (cli; the NEVER line, CHEATSHEET 14 and the hook message match). Also fixed: 0.6.1's trap 'kill 0' EXIT signals the whole process group, and a bin/dev started from an agent's shell isn't a group leader, so stopping it also killed the caller's shell (reproduced with a mock tree). App migration: Detect: grep -n "kill 0" bin/dev, or grep -c 'storage/pids' bin/dev is 0. Converge: copy the top of bin/dev from dev-tools (PID file, port check, cleanup trap) and the app's AGENTS.md NEVER line from blueprints/AGENTS.md. Prove it from a non-interactive shell: start bin/dev in the background, kill "$(cat storage/pids/dev-$PORT.pid)", then lsof -nP -iTCP:$PORT -sTCP:LISTEN prints nothing, no cargo watch/tailwindcss of this app is left, and the shell that ran it is still alive.
  • Fixed: bin/dev's Tailwind watcher overwrote the minified CSS with an unminified build. The watcher now runs with --minify, like the production build (verified with Tailwind 4.3.3: rebuilds after a template edit stay minified) (dev-tools). App migration: Detect: grep -n 'tailwindcss.*--watch' bin/dev | grep -v -- --minify. Converge: add --minify to that line.
  • kernel/tls.rs and kernel/introspect.rs are whole files in the guides, not sketches pointing at "the reference app's file", which an upgrading agent can't see. deployment has the full HttpToHttps acceptor with its four tests; dev-tools has the full introspect.rs (with // APP: marking the realtime part), the two handlers, the app introspect arm and kernel::css::css_warning() for the banner. Both come from an app that implemented them from the 0.6.1 spec (axum-server 0.8.0, sea-orm 2.0.4), generalized. PubSub gains streams() for the introspection's realtime part. App migration: Detect: an app that wrote either file from the 0.6.1 sketch. Converge: diff it against the guide's file; adopt the guide's tests if yours has fewer. Nothing to do if they match.
  • Upgrade notes that fit apps without every feature (from the website's 0.6.0 → 0.6.1 upgrade: no accounts, no live pages, no pending-migrations page, no CSS banner). The 0.6.1 App migration steps below are corrected in place: step 11 is split into 11 (every app: 503 + Retry-After, the pool's acquire_timeout, AppError::Unavailable) and 11b (only apps with live regions: X-Live-Refresh and the gate), since its old detect matched every app; step 5 also detects an app with no CSS banner and says how to add one; step 6 exempts the dev routes from the pending-migrations guard only if the app has one; step 2's proof for apps without accounts is run.sh a11y. upgrade gains the rule behind these (a step applies only where its Detect matches; otherwise it's n/a, with the reason, in the plan) and a preconditions paragraph. App migration: none; upgrades from 0.6.0 follow the corrected steps.
  • The browser smokes need Node ≥ 22.12, and now say so everywhere they're a proof (upgrade, the 0.6.1 steps, dev-tools). Fixed: run.sh only checked the major version, so Node 22.0–22.11 passed the gate and failed inside puppeteer; it now compares major and minor. App migration: none (.offrails/ is re-vendored).
  • The stale-CSS guard says to delete dead classes. It lists template classes with no CSS rule, and the guide only offered the HOOK_CLASSES allowlist, so dead classes got allowlisted. Now, per class: delete it if nothing uses it, rebuild the CSS for a real utility, allowlist only JS/test hooks with a reason (test-standards §11; the template's failure message says the same). App migration: Detect: each HOOK_CLASSES entry. Converge: grep -rn '<class>' static/*.js web/src tests finds no use → remove the entry and the class from the template; update theme_test.rs's failure message.
  • WebSocket buffers sized for many sockets (from a peer's 10,000-socket run on 0.6.1). kernel::channels::upgrade sets .read_buffer_size(4 * 1024).write_buffer_size(0).max_write_buffer_size(256 * 1024) (plus message and frame size = max_frame); axum's defaults are 128 KiB each way per socket. Measured at 10,000 sockets: 1.37 GB → 160–225 MB, failed handshakes 17 → 0, fan-out p50 320–590 → 58–88 ms (channels; the methods checked against axum 0.8.9). App migration (apps with channels): Detect: grep -n 'read_buffer_size' src/kernel/channels.rs finds nothing. Converge: add the five calls before on_upgrade; the wire tests must stay green (an oversized frame still closes the socket).
  • Fixed: forwarders replayed history after a restart. A row forwarder with a look-back window (outboxes, board events, chat rows written over HTTP) deduped with an in-memory set that starts empty, so its first poll after boot re-sent the whole window. Seed the set from the window at start (channels, with the test: 3 events of history, a restart, a 4th event → exactly the 4th is delivered). The pub/sub poller was never affected (it seeks to the end). App migration: Detect: a task that reads rows newer than now - <window> (or the last K ids) and keeps a HashSet of sent ids, built empty. Converge: fill the set from the window in its constructor, before the first poll; add the restart test.
  • Live boards over a channel: snapshot, patches, reducer (channels). The pattern a peer measured at 10,000 watchers (p50 58–88 ms, zero re-fetches): the join's ok.data is the snapshot and its seq without since, or seq + replayed patches with it; patches carry the final list order and go through one pure, idempotent reducer; actions stay HTTP POSTs, optimistic and confirmed by the channel; a JSON snapshot is polled while the socket is down. App migration: none (a pattern for new live boards); an existing board that re-fetches HTML per signal keeps working.
  • Load testing: the per-user cap explains "closed" counts. At 128 connections per user, N connections need ⌈N/128⌉ users; the tools/loadgen spec now says so and requires the generator to print the cap as the reason for refused or closed connections (realtime, channels). App migration: none.
  • README: "~50 Markdown files", not ~30 (27 guides, 15 root docs, 8 generators, 4 blueprints). App migration: none.