Repository navigation
Rust off Rails 0.6.2
What's new
offrails-checkcatches 32-bit ids ([ids]), which pass on SQLite and break every Postgres write, and test suites that never run on Postgres ([postgres]).bin/devmanages 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 withkill "$(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.rsandkernel/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-checkfails on the new[ids]and[postgres]rules: an app with 32-bit ids adds a widening migration (never edits the applied ones), andtests/app/support.rsmust readTEST_DATABASE_URL.bin/devno longer usestrap '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
/tmpjust to type-check their own part. Now: the coordinator builds the skeleton alone, up to the first greencargo checkand 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.rsfiles,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'sAGENTS.mdor 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'sINTEGERis 64-bit, so the suite was green; on Postgres the columns wereINT4and every write that returned an id was a 500. Its tests had never run on Postgres:support.rshard-codedsqlite::memory:. Two newoffrails-checkrules, with fixtures:[ids]fails anid/*_idcolumn defined withinteger/small_integer/tiny_integer(helper orColumnDefchain, across lines,Alias::new("x_id")too) orpk_auto, and ani32/i16id field insrc/;[postgres]fails atests/app/support.rsthat never readsTEST_DATABASE_URL. A migration repaired by a later one carries// offrails-check: ids widened in m<…>(migrations, with thewiden_idstemplate, its SQL verified on Postgres 18). App migration: Detect:sh .offrails/tools/offrails-checkprints[ids]or[postgres]. Converge:[postgres]:TestApp::newper testing first, then run the suite on Postgres and fix what it finds.[ids]: never edit the applied migrations; addwiden_ids(Postgres only;ALTER SEQUENCE … AS bigintforpk_autotables), put the waiver comment in each repaired file, change entity fields toi64, thendb rollback --steps 1 && db migrateand the suite on both databases. - Seeds retry after a half-failed run. The same app's
seed_if_emptyreturned early when any team existed, so a seed that failed after the first team never ran again.tasks/seed.rsnow runs in onekernel::db::begintransaction 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.rsgating the whole seed with an early return. Converge: one transaction, find-or-create per record; prove it by runningapp db seedtwice (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/devby its own process; the NEVER list and the hook saidlsof … | xargs killon the listener, which leaves cargo watch and Tailwind running. A peer's agent (dev-92, opencode) also broke the name rule withpkill -fand killed the human's demo: the hook that blockspkillexists only in Claude Code. Nowbin/devwritesstorage/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 plainserveis started assh -c 'echo $$ > storage/pids/serve-<port>.pid; exec cargo run -q -- serve'(cargo runexecs the app, so the PID is the server's; verified). Stop either withkill "$(cat storage/pids/…)"(cli; the NEVER line, CHEATSHEET 14 and the hook message match). Also fixed: 0.6.1'strap 'kill 0' EXITsignals the whole process group, and abin/devstarted 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, orgrep -c 'storage/pids' bin/devis 0. Converge: copy the top ofbin/devfrom dev-tools (PID file, port check,cleanuptrap) and the app'sAGENTS.mdNEVER line fromblueprints/AGENTS.md. Prove it from a non-interactive shell: startbin/devin the background,kill "$(cat storage/pids/dev-$PORT.pid)", thenlsof -nP -iTCP:$PORT -sTCP:LISTENprints nothing, nocargo watch/tailwindcssof 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--minifyto that line. kernel/tls.rsandkernel/introspect.rsare whole files in the guides, not sketches pointing at "the reference app's file", which an upgrading agent can't see. deployment has the fullHttpToHttpsacceptor with its four tests; dev-tools has the fullintrospect.rs(with// APP:marking the realtime part), the two handlers, theapp introspectarm andkernel::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.PubSubgainsstreams()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'sacquire_timeout,AppError::Unavailable) and 11b (only apps with live regions:X-Live-Refreshand 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 isrun.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.shonly 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_CLASSESallowlist, 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: eachHOOK_CLASSESentry. Converge:grep -rn '<class>' static/*.js web/src testsfinds no use → remove the entry and the class from the template; updatetheme_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::upgradesets.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.rsfinds nothing. Converge: add the five calls beforeon_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 aHashSetof 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.datais the snapshot and itsseqwithoutsince, orseq+ 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/loadgenspec 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.