Repository navigation
Rust off Rails 0.7.2
What's new
cargo offrails verifyruns the Definition of Done in parallel in one command.--fast(after every change) runs fmt, clippy and the SQLite and Postgres suites at the same time;--full(once, at the end) adds coverage,cargo deny, gitleaks and the other checks. A bare app's loop after a one-line change went from 140 s to about 6 s.- Generators:
cargo offrails generate <kind>(g) writes migrations, models, CRUD resources, controllers, pages, APIs, jobs, mailers, channels and tests that follow the guides and come with their tests.cargo offrails destroy(d) removes exactly what was generated and is still unchanged. cargo offrails update-partbrings a newer version of an installed part into the app with a three-way merge: clean changes apply, overlapping edits get conflict markers, and the app's own changes are never silently overwritten.- Faster Postgres tests: the suite migrates once into a template database and reuses locked slot databases reset before each test, instead of creating and migrating a database per test.
- A browser smoke for boosted navigation catches page JavaScript that works on a fresh load but is dead after a boosted link. Page scripts now load from the layout and bind by delegation.
- Template speed-ups: one CI test job instead of two, a cached Docker dependency layer, and a cached development CSS check.
Breaking changes
Nothing is required to keep an existing app working. Before using the new tools:
- Generators need the
// samples:anchors in the shared files; a generator that lacks one refuses and names it. - The route-sweep tests (
smoke_test.rs,security_test.rs) must send each route's own method before the firstcargo offrails g api. - Parts updated to this release load their page scripts at a new
samples: app headanchor in the layout. split-debuginfoleft[profile.dev]in the template; an app built on Windows must remove it too.
Upgrading
Follow the ordered list below; each step's Detect/Converge/Prove is in the entry it names under Full list of changes, and upgrade is the whole procedure. $FW is the new framework.
- Parts first, before re-vendoring:
update-partmerges against the part as installed in.offrails/, so run it while.offrails/is still the old release:cargo offrails update-part <each installed part> --framework "$FW" --dry-run, then without--dry-run. Resolve any<<<<<<<markers, thencargo fmt --allandcargo run -q -- db migrate. (An app on 0.7.1 or older has noupdate-partyet: run the new release's,cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" -- update-part <part>… --framework "$FW" --app ..) - Re-vendor:
offrails-gen vendor --framework "$FW".cargo offrails verify,generate,destroyandupdate-partnow work. - Generator anchors (Generators → App migration): add every missing
// samples:anchor. A generator that needs a missing one refuses and names it, so this can wait until the firstgenerate. - Route sweeps send each route's own method (same entry): required before the first
cargo offrails g api. - Optional speed-ups (Fast loop → App migration): Postgres test slots, the CSS check cache, the
split-debuginfomove (needed for Windows builds), and the one-job CI. - Prove it:
cargo offrails verify --fullis green on SQLite and Postgres,sh .offrails/tools/offrails-checkreports 0 errors, anddocs/decisions.mdhasYYYY-MM-DD: upgraded to framework 0.7.2plus the generators line.
Full list of changes
-
A fast loop:
cargo offrails verify(workflow). A human, after a 41-minute agent build and 4+ minute CI runs: "we need CI to run in 10 seconds or less, can't we parallelize or something". The Definition of Done ran sequentially (fmt, clippy, SQLite suite, Postgres suite, coverage, deny, gitleaks, browser smokes), and the docs had agents run all of it after every change. Now one cross-platform command (inoffrails-gen, socargo offrails verifyworks in every template app) runs it in parallel:-
--fast(default; after every slice):cargo fmt --check, the CSS build (htmx), clippy-D warningsin its own target dir (target/clippy, so it compiles while the tests build), the test binaries once, then the SQLite and Postgres suites at the same time (nextest; Postgres runs the integration tests, since unit tests never readTEST_DATABASE_URL). One line per step with its time and test counts, the tail of each failure, logs intarget/verify/, non-zero exit on any failure. Postgres comes fromTEST_DATABASE_URL(environment or.env) or the compose URL whenlocalhost:${DB_PORT:-5432}answers; otherwise it says skipped. -
--full(once, at the end): alsooffrails-check, coverage (it is the SQLite run, instrumented withcargo llvm-cov nextest --fail-under-lines 80),cargo deny, gitleaks, the no-unwrap/expectreview,routesvsdocs/routes.md, the TS checks when there is aweb/, and the browser smoke whenBASEnames the running app. Missing tools are reported asskipwith the install command, never as passed. -
The guidance changed with it: workflow (the loop's VERIFY step, the Definition of Done, "iterate fast, verify fully once": one test with
cargo nextest run <name>while red,verifyafter each slice, coverage/deny/gitleaks/full browser smokes only in--full), CHEATSHEET,/offrails:check, the appAGENTS.mdblueprint, new step 5,offrails-gen new's next steps,tools/ci/test-template.sh(now runsverify, still failing on any flaky test outside its known list). -
Measured on generated apps (Apple M2, 8 cores, macOS; other agents were compiling on the same machine the whole time, so each row gives the load average, and old and new were run back to back under the same load). "Old" is 0.7.1's template and its sequential Definition of Done; "new" is this release.
Step Bare, old Bare, new Every part, old Every part, new Cold build ( cargo build, then the test targets)217 s + 38 s (load 22–45) same dependencies, same 180 s + 59 s (load 31–53) same Cold build, a second app on the machine 293 s (load 121) 128 s with sccache (opt-in; load 127) n/m n/m No-op rebuild of the tests 0.5–0.7 s same 0.65 s same One-line change, test build 3.7–5.4 s (load 40–65) 3.2–3.8 s inside verify (load 11) 12.3 s (load 36) 12.2–15.9 s inside verify (load 9–11) Clippy after a one-line change 1.9 s 1.1–2.3 s, beside the build 8.1 s 7.7–9.5 s, beside the build SQLite suite 1.3 s (121 tests) 1.4 s 8.0–10.4 s (652 tests) 8.5–9.5 s Postgres suite 8.0 / 11.9 s (load 15–18) 1.7 / 1.8 s 149 / 46 s (load 12–37) 21 / 14 s (load 17–20) The fast loop: fmt, clippy, test build, SQLite, Postgres after a one-line change 140 s, sequential (load 19–33) 5.5–6.7 s ( verify, load 11)76–179 s, sequential (sum of the rows above) 33.5–36.5 s ( verify, load 9–11)The whole Definition of Done after a one-line change (+ offrails-check, coverage, deny, gitleaks) 157.7 s (load 19–33) 20.1 s ( verify --full, load 14–18)not run (the instrumented build needs another ~5 GB; the disk was full) — Where an all-parts app's 34 s go: the test build (~12 s, the app crate compiled as lib and as lib-test in parallel, then the test binary), then the Postgres suite (~20 s beside SQLite, ~14 s alone; 461 integration tests, the longest single tests 4–5 s of real timeouts in the parts' own tests). The 10 s target holds for the bare app, not for one with every part.
-
-
Postgres tests: no
CREATE DATABASEand no migrations per test (testing). Each test used to createtest_<crate>_<uuid>, migrate it and drop it.CREATE DATABASEcopies and fsyncs a template (measured on this machine under load: 1.2–3.8 s for one empty database), so the Postgres run degraded badly when the machine was busy (a 541-test blog: 42.5 s quiet, 335 s at load ~68). Nowsupport.rsmigrates once intotest_<crate>_<tag>_tpl(<tag>: a hash of the migration names and files, so an edited migration builds a new one; built under an advisory lock and renamed into place, so concurrent first tests wait and a killed build leaves nothing half-migrated), each concurrent test locks a slottest_<crate>_<tag>_<n>(pg_try_advisory_lock), cloned from the template once and reused across tests and runs, withsynchronous_commit = off, andoffrails_test.reset()puts the slot back to the just-migrated state before every test: tables with rows truncated, the migrations' seed rows and every sequence restored. A schema fingerprint makes the reset refuse a slot a test changed (rolled a migration back, created a table), and the harness re-clones it. Still every test on both databases, isolated. Also evaluated: a rollback transaction per test (rejected: the app's own transactions, the job worker and realtime use other connections) andCREATE DATABASE … TEMPLATEper test (keeps the fsync cost). SQLite stays in-memory and migrated per test: the migrations are ~5 ms of a ~40 ms median test. The new SQL is inkernel::db(database_exists,create_database_from,rename_database,advisory_lock/try_advisory_lock/advisory_unlock,skip_commit_flush,install_test_reset/reset_test_database,drop_idle_stale_test_databases), with a kernel test (the_postgres_test_reset_empties_tables_and_refuses_a_changed_schema). -
The development CSS check is cached (views).
kernel::css::css_warningread every template and the built CSS on every development page (~85 ms in a grown app; 97% of the CPU under load). It now recomputes only when a template,assets/app.cssorstatic/app.csschanges (size and mtime), with a unit test. -
Template CI: one test job (testing). The
sqliteandpostgresjobs each compiled the tests; the newtestjob runscargo offrails verify --fastagainst a Postgres service on tmpfs, with rust-cache keepingtargetandtarget/clippy, after the migration guard,offrails-checkand acargo metadata --lockedcheck. The Docker job'sDockerfilebuilds the dependencies in a layer of their own (a placeholdersrc/), which theghalayer cache keeps; the old--mount=type=cachetarget was never in that cache, so every run compiled every dependency in release mode. Locally (Docker on the M2, bare app, load 18→77): the first image build 352 s, a rebuild after a one-line code change 156 s with the dependency layer reused (the app crate's release compile and the CSS), and the image boots (/up200). Not measured on GitHub (no runner available to this change): expected, from the local split, that a code-only push skips the release dependency build (most of the Docker job) and compiles the tests once instead of twice. -
A dev-dashboard test failed for good after ~50 runs in one working copy (
run_history_is_capped_redacted_and_reopened). It saved a run dated 2027 into the realstorage/dashboard/ai-runs/and never removed it; onceMAX_HISTORYof them piled up they outranked and pruned the run the test makes. It now deletes its file. A local change totemplate/parts/dev-dashboard(the samples repo needs the same one-line fix;tools/sync-parts.shwould undo it). Its other flake (another test process pruning the shared history) remains intest-template.sh's known list. -
Build settings (testing).
split-debuginfo = "unpacked"left[profile.dev]: Windows MSVC refuses it, and it is macOS's default anyway; Linux gets it from.cargo/config.toml([target.'cfg(target_os = "linux")']). Faster linkers stay opt-in per machine (~/.cargo/config.toml; x86_64 Linux already uses rust-lld since Rust 1.90). sccache is documented as an optional shared cache for a second app on one machine; a sharedCARGO_TARGET_DIRis not recommended (one build lock for every app and agent).App migration (optional; every item makes an existing app faster, none is required):
- The verify command. Detect:
cargo offrails help | grep -c verifyis 0, or.offrails/tools/offrails-gen/src/verify.rsis missing. Converge: re-vendor (offrails-gen vendor --framework "$FW", the upgrade does this); the alias in.cargo/config.tomlalready runs the vendoredoffrails-gen. Apps without the alias add it from.offrails/template/app/.cargo/config.toml. Prove it:cargo offrails verifyprints a line per step andgreen. - Postgres test slots. Detect:
grep -c offrails_test src/kernel/db.rsis 0, ortests/app/support.rsstill callscreate_databaseper test. Converge: copy from.offrails/template/app/src/kernel/db.rsthe functions fromdrop_idle_stale_test_databasesthroughTEST_RESET_SQL(keep the app's own changes to the rest of the file), and replace intests/app/support.rseverything frompub struct DbGuardtoimpl TestApp {with the template's (DbGuard,PgSlot,test_database,checkout_postgres_slot,test_db_prefix,fnv1a,migration_tag,database_url,lock_slot,ensure_template).TestAppkeeps itsdb_guardfield andon_sqlite(). If the app'ssupport.rscreates databases elsewhere (a second pool, a fixture), route it throughtest_database(). Copy the kernel test from the template'stests/app/kernel_test.rs. Thenapp task test_db_cleanup <admin url>once to drop the oldtest_<crate>_<hex>leftovers. Prove it: the suite on Postgres twice (the second run creates no database:psql -Atc "select count(*) from pg_database where datname like 'test_%'"is unchanged), a test that runsMigrator::downstill passes, and the kernel test passes on both databases. - CSS check cache. Detect:
grep -c inputs_key src/kernel/css.rsis 0. Converge: portcss_warning,cached_warning,inputs_key,html_files_underand the test from the template'ssrc/kernel/css.rs. Prove it:cargo nextest run the_css_warning_is_recomputed_only_when. - Profiles. Detect:
grep -n split-debuginfo Cargo.toml. Converge: remove that line from[profile.dev]and add the Linux[target.'cfg(target_os = "linux")']block from the template's.cargo/config.toml. Prove it:cargo build(and on Windows, a build that no longer stops at-Csplit-debuginfo). - CI. Detect:
.github/workflows/ci.ymlhas separatesqliteandpostgresjobs. Converge: replace them with the template'stestjob (keep the app's own extra steps, such as the TS steps, afterverify), and take theDockerfile's dependency layer (renameoffrails_appto the crate). Prove it: the workflow is green on the next push and itstestjob shows one compile.
- The verify command. Detect:
-
Generators you run, not recipes you copy:
cargo offrails generate <kind> …(g) andcargo offrails destroy <kind> <name>(d) (generators). The human: "can we add actual code generators similar to rails? so the agent can invoke those instead of just copying? like db migrations, new controller, new api route, new page, and especially the parts… i hope this would make things even faster." Kinds:migration(create_x/add_y_to_x/remove_y_from_x/ blank, each with a workingdown),model(migration + entity +Params→validate()→New+ scoped queries + factory + model tests),resource(aliasscaffold: the model plus htmx CRUD with the seven actions, forms that re-render with errors and the input, flash, nav link, request tests),controller,page,api(/api/v1with bearer tokens and scopes,ApiJson,{"data": …}+ page info, OpenAPI via utoipa with a drift test, and the API kernel on first use),job,mailer(HTML + text, previews),channel(a realtime topic with authorization) andtest. Field types:string text integer bigint boolean decimal{p.s} money datetime date uuid json references{model}with?/:optional,:unique,:index, each mapped to its column, Rust type, form control, validation and factory value. Generated code follows the guides exactly: 64-bit ids and FKs (offrails-check [ids]), per-user scoping when the app has accounts (find_for, rawfindmodel-private, another user's id is 404, unique-per-owner indexes, references checked against the actor),Accessrows,paths::helpers, docs/routes.md,#[instrument(skip_all)]on every action and model fn, nounwrap/expectoutside tests, templates in the design tokens with everydark:partner, and request tests for happy paths, 422s, sign-in, tenancy (guard_test.rs+ an anchoredtenancy_test.rs), escaping and the N+1 guard, on SQLite and Postgres. Without accounts,model/resourcerefuse until you add them or pass--public.- Safe to run: everything is rendered in memory first (a missing anchor is a refusal naming the file and spot; nothing written),
--dry-runprints the plan, re-running is a no-op,--forceregenerates only over files nobody changed, anddocs/generated.tomlrecords each generation (arguments, choices, formatted-file hashes) sodestroyremoves exactly the unchanged files and wiring and reports what it kept.rustfmtruns afterwards; next steps are printed (migrate, Tailwind,cargo offrails verify --fast, what to customize: the// APP:lines). - Parts are first-class in the same CLI:
cargo offrails parts(alias oflist-parts),cargo offrails add <part>(alias ofadd-part), and newcargo offrails update-part <part>… --framework <newer>: a three-way merge per file (the part as installed in.offrails/, the newer part, the app's copy): clean hunks apply, overlapping edits get<<<<<<</|||||||/=======/>>>>>>>markers and exit 1, changed wiring is swapped where the old text stands; an app change is never silently overwritten.add-partandnewnow refresh docs/routes.md (the firstoffrails-checkno longer warns[routes]). - Measured (macOS, Apple silicon, 8 cores, load average ~20 from other agents, cold target dir, warm crates.io cache): a blog-equivalent built only with generators (
new --parts auth-core,auth-magic-link,jobs, theng resource posts title:string slug:string:unique body:text 'published_at:datetime?',g page about,g mailer post_mailer published,g job notify_post_published post_id:bigint --queue mailers):new8 s, the four generators 4 s, then green on SQLite after 200 s (the first compile; 357 tests in 4.8 s) and on Postgres 9 s later (7.7 s),offrails-check0 errors: 221 s from nothing to green on both databases. The generators wrote 1,865 lines in 38 files (migration, model with validation and scoped queries, controller, views, 5 templates, routes, factory, model/request/tenancy/guard tests, page, mailer with previews, job), with 24// APP:decisions marked. The 0.7.1 blog's hand-written equivalent of those files was ~2,100 lines; what an agent still writes for that blog is the domain on top (public reading by slug, publish/unpublish, slug from the title, excerpt and reading time, Markdown, the feed, the mail's words, enqueueing the job in the publish transaction, the spec's own tests): roughly 700–900 lines, by the blog's own counts. - App migration: existing apps get the generators by re-vendoring (
.offrails/tools/offrails-gen);cargo offrails g --helpworks at once. Then:- Detect the anchors generators need:
grep -n "samples: \(model mods\|model aliases\|controller mods\|view mods\|routes\|paths\|routes table\|test mods\|factories\|migration mods\|migrations\)" src/models/mod.rs src/controllers/mod.rs src/views/mod.rs src/routes.rs tests/app/main.rs tests/app/factories.rs migration/src/lib.rs(andnav linksintemplates/shared/_nav.html,sitemap pathsinsrc/controllers/meta.rs,test supportintests/app/support.rs). Converge: add each missing one on a line of its own where new.md#anchors says; a generator that lacks one refuses and names it.migration/src/lib.rsrewritten bysea-orm-cliis healed automatically; from now on generate migrations withcargo offrails g migration, neversea-orm-cli migrate generate. - Detect
tests/app/smoke_test.rs/security_test.rssweepingROUTESwithapp.post_formfor every non-GET row. Converge (needed beforegenerate api, whose routes usePATCH/DELETE): copyTestApp::request_methodandsend_jsonfrom.offrails/template/app/tests/app/support.rsintosupport.rs, and send each row's own method (other => app.request_method(other, &path).await) as the template's two tests now do. (generate apiinsertssend_jsonitself when it's missing.) - Detect a hand-written
tests/app/tenancy_test.rswithout// samples: tenant fill. Converge: nothing required;generate resourcethen prints theTenant/filllines to add for each new resource (or replace the file with the generated one, keeping yourEXPECTEDrows). - Agents: build features with generators from now on (AGENTS.md prime directive 2; workflow loop step 3). Log in
docs/decisions.md:YYYY-MM-DD: generators adopted (framework 0.7.2): new features start from cargo offrails generate.
- Detect the anchors generators need:
- Changed docs: generators/README (the command table, field types, what generators decide), new migration, controller (and pages), api recipes; scaffold, job, mailer (sends from a job with
state.mailer.deliver, no nonexistentdeliver_later), channel start with the command; new (build features with generators after the parts; generator anchors), upgrade (step 6:update-partbefore re-vendoring), AGENTS.md (prime directive 2: generate before hand-writing), CHEATSHEET, workflow (PLAN/GENERATE), RAILS_MAP (rails generate↔cargo offrails g,rails destroy), cli, structure, template, thegenerateandscaffoldskills. - Template:
TestApp::request_method(the route sweeps send each row's own method) andTestApp::send_json(JSON with headers, no cookies). - CI:
tools/ci/test-template.sh generatorsgenerates every kind into a bare app (a--publicreferences chain, a resource with every field type, page, controller, migrations) and into an app with every part (scoped resources, api, job, mailer, channel, model, test), runs fmt, clippy, the suite on SQLite and Postgres and offrails-check, re-runs every generator (no-op), destroys everything in reverse and checks the app is byte-for-byte the baseline;update-partmerges a synthetic newer part cleanly and then reports a conflict with markers.offrails-genhas golden files for every generator (tools/offrails-gen/testdata/golden/,UPDATE_GOLDEN=1 cargo test golden).
- Safe to run: everything is rendered in memory first (a missing anchor is a refusal naming the file and spot; nothing written),
-
A browser smoke for boosted navigation: every page must work after a boosted link, not only on a fresh load (frontend-htmx, testing). The layout boosts every link, and a boosted click swaps
<body>and runs nothing from the next page's<head>;DOMContentLoadednever fires again. The sign-in page's password Show button worked on a direct load and was dead after clicking through to the page:auth.jsloaded in the page's{% block head %}. Request tests and screenshots can't see it..offrails/tools/browser-smoke/smoke-boosted.mjs: for each GET route indocs/routes.mdwithout a parameter that is a boosted HTML page (kernel pages, JSON and streams are skipped and listed; a route that redirects smokes the page it lands on), it clicks the page's live elements after a full load and records which react (DOM or attribute change, print, copy, dialog), then loads/, follows a boosted link to the page and clicks them again. It fails on an element that is dead after the boosted link, on a script or stylesheet the page loads in its own<head>, and on any console error, uncaught exception or CSP violation on either path. With password sign-in it runs signed out, then signed in as the demo account. Zero dependencies: Node ≥ 18 and the installed Chrome, Chromium or Edge (CHROME_PATH, else the usual places on macOS, Linux and Windows), driven over the DevTools Protocol on a pipe; it never downloads a browser. Without one it printsskip: no Chromeand exits 77.- The live-element convention: every element page JS reacts to on click is a
<button type="button">or carriesdata-js(ordata-toggle,role="button"), and the JS binds by delegation ondocument. cargo offrails verify --fullwithBASEset runs it asbrowser smoke (boosted)beside the kernel-page smoke; exit 77 is reported asskip, never passed (Step::skip_on, with a test).run.shis POSIX sh now and hasrun.sh boosted.- Template: the layout has a
samples: app headanchor (a part's scripts load on every page),static/app.jsstates the rule, andtests/app/smoke_test.rshas two static guards:app_pages_load_no_scripts_or_stylesheets_in_their_head_blockandpage_scripts_never_bind_only_on_dom_content_loaded. - Parts: the parts synced from samples ee71024 load their page scripts at
samples: app head(auth-core'sauth.js, realtime'schannels.jsandlive_room.js, the dev-dashboard's browser beacon) instead of page head blocks; that wiring needs the anchor above. - CI:
tools/ci/test-template.shserves the bare and the all-parts apps (development, their own SQLite file, the demo account seeded) and runs the smoke against them; without Chrome or Node the summary saysbrowser smoke SKIPPED. The framework's CI runstools/browser-smoke/selftest-boosted.mjs, which serves a tiny htmx site and proves the smoke passes a delegated Show button and fails a head-block script binding onDOMContentLoaded, a layout script binding onDOMContentLoaded, and a console error. - Measured (M2, load 13–25): the all-parts app, 18 page visits signed out and in, every Show and Copy button on the auth pages clicked on both paths, 25–28 s; the bare app (one page,
/) 4.7 s; the selftest's four cases 25 s. Reintroducing the bug in an all-parts app (auth.js back insessions/new.html's head block) fails it with/session/new (signed out): loads /static/auth.js in its <head>…andbutton[data-reveal="signin_password"] "Show password" reacts on a full load and is DEAD after a boosted link; putting it back passes. - App migration:
- Page scripts. Detect:
grep -rln "{% block head %}" templates | xargs grep -l "<script\|stylesheet", andgrep -rln DOMContentLoaded static --exclude-dir=vendor. Converge: move each page's head-block<script>/stylesheet intotemplates/layouts/application.html(afterapp.js; add{# samples: app head #}on a line of its own there if missing), digested throughctxlikeapp_js; rewrite a script that binds elements onDOMContentLoadedas delegated listeners ondocument(e.target.closest("[data-…]")), or also start it onhtmx:load. For the auth and realtime parts,cargo offrails update-part auth-core(andauth-password,auth-totp,realtime) brings the samples' fix. Prove it: copy the two tests from the end of.offrails/template/app/tests/app/smoke_test.rs(withfiles_with_extension) intotests/app/smoke_test.rs; both pass. - The smoke. Re-vendor (the upgrade does). Prove it: with
bin/devrunning,BASE=<its URL> node .offrails/tools/browser-smoke/smoke-boosted.mjsends with0 failure(s)(seed the demo account first withcargo run -- task auth_demowhen the app has password sign-in). LogYYYY-MM-DD: page JS loads from the layout and binds by delegation (framework 0.7.2, boosted navigation)indocs/decisions.md.
- Page scripts. Detect: