Skip to content

Rust off Rails 0.7.2

Choose a tag to compare

@danielcj danielcj released this 18 Oct 08:29
· 55 commits to main since this release

What's new

  • cargo offrails verify runs 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-part brings 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 first cargo offrails g api.
  • Parts updated to this release load their page scripts at a new samples: app head anchor in the layout.
  • split-debuginfo left [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.

  1. Parts first, before re-vendoring: update-part merges 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, then cargo fmt --all and cargo run -q -- db migrate. (An app on 0.7.1 or older has no update-part yet: run the new release's, cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" -- update-part <part>… --framework "$FW" --app ..)
  2. Re-vendor: offrails-gen vendor --framework "$FW". cargo offrails verify, generate, destroy and update-part now work.
  3. 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 first generate.
  4. Route sweeps send each route's own method (same entry): required before the first cargo offrails g api.
  5. Optional speed-ups (Fast loop → App migration): Postgres test slots, the CSS check cache, the split-debuginfo move (needed for Windows builds), and the one-job CI.
  6. Prove it: cargo offrails verify --full is green on SQLite and Postgres, sh .offrails/tools/offrails-check reports 0 errors, and docs/decisions.md has YYYY-MM-DD: upgraded to framework 0.7.2 plus 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 (in offrails-gen, so cargo offrails verify works in every template app) runs it in parallel:

    • --fast (default; after every slice): cargo fmt --check, the CSS build (htmx), clippy -D warnings in 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 read TEST_DATABASE_URL). One line per step with its time and test counts, the tail of each failure, logs in target/verify/, non-zero exit on any failure. Postgres comes from TEST_DATABASE_URL (environment or .env) or the compose URL when localhost:${DB_PORT:-5432} answers; otherwise it says skipped.

    • --full (once, at the end): also offrails-check, coverage (it is the SQLite run, instrumented with cargo llvm-cov nextest --fail-under-lines 80), cargo deny, gitleaks, the no-unwrap/expect review, routes vs docs/routes.md, the TS checks when there is a web/, and the browser smoke when BASE names the running app. Missing tools are reported as skip with 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, verify after each slice, coverage/deny/gitleaks/full browser smokes only in --full), CHEATSHEET, /offrails:check, the app AGENTS.md blueprint, new step 5, offrails-gen new's next steps, tools/ci/test-template.sh (now runs verify, 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 DATABASE and no migrations per test (testing). Each test used to create test_<crate>_<uuid>, migrate it and drop it. CREATE DATABASE copies 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). Now support.rs migrates once into test_<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 slot test_<crate>_<tag>_<n> (pg_try_advisory_lock), cloned from the template once and reused across tests and runs, with synchronous_commit = off, and offrails_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) and CREATE DATABASE … TEMPLATE per 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 in kernel::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_warning read 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.css or static/app.css changes (size and mtime), with a unit test.

  • Template CI: one test job (testing). The sqlite and postgres jobs each compiled the tests; the new test job runs cargo offrails verify --fast against a Postgres service on tmpfs, with rust-cache keeping target and target/clippy, after the migration guard, offrails-check and a cargo metadata --locked check. The Docker job's Dockerfile builds the dependencies in a layer of their own (a placeholder src/), which the gha layer cache keeps; the old --mount=type=cache target 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 (/up 200). 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 real storage/dashboard/ai-runs/ and never removed it; once MAX_HISTORY of them piled up they outranked and pruned the run the test makes. It now deletes its file. A local change to template/parts/dev-dashboard (the samples repo needs the same one-line fix; tools/sync-parts.sh would undo it). Its other flake (another test process pruning the shared history) remains in test-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 shared CARGO_TARGET_DIR is 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 verify is 0, or .offrails/tools/offrails-gen/src/verify.rs is missing. Converge: re-vendor (offrails-gen vendor --framework "$FW", the upgrade does this); the alias in .cargo/config.toml already runs the vendored offrails-gen. Apps without the alias add it from .offrails/template/app/.cargo/config.toml. Prove it: cargo offrails verify prints a line per step and green.
    • Postgres test slots. Detect: grep -c offrails_test src/kernel/db.rs is 0, or tests/app/support.rs still calls create_database per test. Converge: copy from .offrails/template/app/src/kernel/db.rs the functions from drop_idle_stale_test_databases through TEST_RESET_SQL (keep the app's own changes to the rest of the file), and replace in tests/app/support.rs everything from pub struct DbGuard to impl TestApp { with the template's (DbGuard, PgSlot, test_database, checkout_postgres_slot, test_db_prefix, fnv1a, migration_tag, database_url, lock_slot, ensure_template). TestApp keeps its db_guard field and on_sqlite(). If the app's support.rs creates databases elsewhere (a second pool, a fixture), route it through test_database(). Copy the kernel test from the template's tests/app/kernel_test.rs. Then app task test_db_cleanup <admin url> once to drop the old test_<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 runs Migrator::down still passes, and the kernel test passes on both databases.
    • CSS check cache. Detect: grep -c inputs_key src/kernel/css.rs is 0. Converge: port css_warning, cached_warning, inputs_key, html_files_under and the test from the template's src/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.yml has separate sqlite and postgres jobs. Converge: replace them with the template's test job (keep the app's own extra steps, such as the TS steps, after verify), and take the Dockerfile's dependency layer (rename offrails_app to the crate). Prove it: the workflow is green on the next push and its test job shows one compile.
  • Generators you run, not recipes you copy: cargo offrails generate <kind> … (g) and cargo 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 working down), model (migration + entity + Params → validate() → New + scoped queries + factory + model tests), resource (alias scaffold: 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/v1 with 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) and test. 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, raw find model-private, another user's id is 404, unique-per-owner indexes, references checked against the actor), Access rows, paths:: helpers, docs/routes.md, #[instrument(skip_all)] on every action and model fn, no unwrap/expect outside tests, templates in the design tokens with every dark: partner, and request tests for happy paths, 422s, sign-in, tenancy (guard_test.rs + an anchored tenancy_test.rs), escaping and the N+1 guard, on SQLite and Postgres. Without accounts, model/resource refuse 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-run prints the plan, re-running is a no-op, --force regenerates only over files nobody changed, and docs/generated.toml records each generation (arguments, choices, formatted-file hashes) so destroy removes exactly the unchanged files and wiring and reports what it kept. rustfmt runs 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 of list-parts), cargo offrails add <part> (alias of add-part), and new cargo 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-part and new now refresh docs/routes.md (the first offrails-check no 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, then g 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): new 8 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-check 0 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 --help works 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 (and nav links in templates/shared/_nav.html, sitemap paths in src/controllers/meta.rs, test support in tests/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.rs rewritten by sea-orm-cli is healed automatically; from now on generate migrations with cargo offrails g migration, never sea-orm-cli migrate generate.
      • Detect tests/app/smoke_test.rs / security_test.rs sweeping ROUTES with app.post_form for every non-GET row. Converge (needed before generate api, whose routes use PATCH/DELETE): copy TestApp::request_method and send_json from .offrails/template/app/tests/app/support.rs into support.rs, and send each row's own method (other => app.request_method(other, &path).await) as the template's two tests now do. (generate api inserts send_json itself when it's missing.)
      • Detect a hand-written tests/app/tenancy_test.rs without // samples: tenant fill. Converge: nothing required; generate resource then prints the Tenant/fill lines to add for each new resource (or replace the file with the generated one, keeping your EXPECTED rows).
      • 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.
    • 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 nonexistent deliver_later), channel start with the command; new (build features with generators after the parts; generator anchors), upgrade (step 6: update-part before 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, the generate and scaffold skills.
    • Template: TestApp::request_method (the route sweeps send each row's own method) and TestApp::send_json (JSON with headers, no cookies).
    • CI: tools/ci/test-template.sh generators generates every kind into a bare app (a --public references 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-part merges a synthetic newer part cleanly and then reports a conflict with markers. offrails-gen has golden files for every generator (tools/offrails-gen/testdata/golden/, UPDATE_GOLDEN=1 cargo test golden).
  • 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>; DOMContentLoaded never 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.js loaded 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 in docs/routes.md without 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 prints skip: no Chrome and exits 77.
    • The live-element convention: every element page JS reacts to on click is a <button type="button"> or carries data-js (or data-toggle, role="button"), and the JS binds by delegation on document.
    • cargo offrails verify --full with BASE set runs it as browser smoke (boosted) beside the kernel-page smoke; exit 77 is reported as skip, never passed (Step::skip_on, with a test). run.sh is POSIX sh now and has run.sh boosted.
    • Template: the layout has a samples: app head anchor (a part's scripts load on every page), static/app.js states the rule, and tests/app/smoke_test.rs has two static guards: app_pages_load_no_scripts_or_stylesheets_in_their_head_block and page_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's auth.js, realtime's channels.js and live_room.js, the dev-dashboard's browser beacon) instead of page head blocks; that wiring needs the anchor above.
    • CI: tools/ci/test-template.sh serves 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 says browser smoke SKIPPED. The framework's CI runs tools/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 on DOMContentLoaded, a layout script binding on DOMContentLoaded, 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 in sessions/new.html's head block) fails it with /session/new (signed out): loads /static/auth.js in its <head>… and button[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", and grep -rln DOMContentLoaded static --exclude-dir=vendor. Converge: move each page's head-block <script>/stylesheet into templates/layouts/application.html (after app.js; add {# samples: app head #} on a line of its own there if missing), digested through ctx like app_js; rewrite a script that binds elements on DOMContentLoaded as delegated listeners on document (e.target.closest("[data-…]")), or also start it on htmx:load. For the auth and realtime parts, cargo offrails update-part auth-core (and auth-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 (with files_with_extension) into tests/app/smoke_test.rs; both pass.
      • The smoke. Re-vendor (the upgrade does). Prove it: with bin/dev running, BASE=<its URL> node .offrails/tools/browser-smoke/smoke-boosted.mjs ends with 0 failure(s) (seed the demo account first with cargo run -- task auth_demo when the app has password sign-in). Log YYYY-MM-DD: page JS loads from the layout and binds by delegation (framework 0.7.2, boosted navigation) in docs/decisions.md.