.offrails/RAILS_MAP.md
Rails → Off Rails Map
When a human (or your training data) says "the Rails way", look it up here.
Frameworks
| Rails | Off Rails | Guide |
|---|---|---|
| Active Record | SeaORM entities + query fns in src/models/ | models |
database.yml / adapters | DATABASE_URL: SQLite or Postgres, same code | database |
| Active Record Migrations | migration/ crate (sea-orm-migration DSL), embedded in the binary | migrations |
| Active Model validations | <Model>Params::validate() -> Result<New<Model>, Errors> | models |
| Action Controller | axum handlers in src/controllers/ | controllers |
Routing (routes.rb) | src/routes.rs (one file) | routing |
| Action View / ERB | Askama templates + view structs | views |
| Hotwire (Turbo + Stimulus) | htmx mode: htmx 2.x + htmx-ext-sse (+ static/app.js for small behavior) | frontend-htmx |
| Propshaft / importmap | static/ + kernel/assets.rs digests, vendored htmx | frontend-htmx |
| tailwindcss-rails | Tailwind v4 standalone CLI pinned in bin/setup-tailwind (htmx, islands) / @tailwindcss/vite (inertia, spa) | frontend-htmx |
rails new --api | FRONTEND_MODE: api: no UI, REST /api/v1 and/or GraphQL, bearer tokens | api |
| graphql-ruby (+ graphiql-rails, graphql-batch) | async-graphql: src/graphql/, /dev/graphiql, a DataLoader per association | graphql |
| rswag / OpenAPI docs | utoipa annotations → /api/openapi.json, committed docs/openapi.json, Scalar at /dev/api-docs | api |
has_secure_token API tokens / Doorkeeper | api_tokens (SHA-256 digests, scopes, expiry), ApiUser extractor | api |
| rack-cors | tower-http CorsLayer from APP_CORS_ORIGINS (exact origins) | api |
| Puma | The binary itself: axum on hyper (HTTP/1.1 + h2); optional direct TLS via APP_TLS_* | deployment |
docker compose up (Rails 7.1+ devcontainer) | compose.yaml from new: the production image + Postgres, profiles worker, mail | deployment |
favicon.ico, meta-tags gem | brand.toml + cargo offrails brand + Ctx meta (title, description, canonical, OG, Twitter) from new | views |
ViewComponent, a UI kit, layout "admin" | {% import "components/ui.html" as ui %} macros; {% extends "layouts/sidebar.html" %} (application, sidebar, marketing, auth, docs) | views |
| Capybara screenshots, a Percy-style visual review | BASE=… cargo offrails prove → docs/proof/PROOF.md | testing |
| inertia-rails | inertia mode: generated kernel/inertia.rs (Inertia v3 protocol) + @inertiajs/react | frontend-inertia |
| jsbundling / vite_rails | Vite 8 in web/ (inertia, spa, islands), manifest read by kernel/assets.rs | frontend-inertia |
| Jbuilder / serializers | #[derive(Serialize, TS)] structs in src/views/ | frontend-react |
| Active Job + Solid Queue | kernel/jobs/: Oban-style DB-backed queue, app worker | jobs |
| Mission Control – Jobs | /admin/jobs dashboard + app jobs … | jobs-dashboard |
| Action Mailer | lettre + Askama, delivered via jobs | mailers |
| letter_opener(_web), mailer previews | /dev/mailbox (Mailer::Local), with mailer previews in its Previews tab | mailers, dev-tools |
| Action Mailbox | Not included. Webhook controller if a spec needs it | |
| Action Cable + Solid Cable | SSE + broadcasts table (kernel/pubsub.rs) | realtime |
| Action Cable channels (two-way) | One WebSocket, topics, room actors (kernel/hub.rs, kernel/channels.rs) | channels |
| Active Storage | object_store + attachments table | storage |
| Action Text | Markdown column → pulldown-cmark → ammonia | views |
Solid Cache / Rails.cache | kernel/cache.rs: moka L1 + cache_entries table | caching |
| Active Support | std + chrono. No monkey patching exists to port | |
rails generate authentication | generators/authentication.md | auth |
Credentials (credentials.yml.enc) | Env vars; secrets from the deploy platform | config |
| Environments (dev/test/prod) | APP_ENV + Config | config |
| Kamal | Kamal 2, Fly.io, Render or Railway: the same Docker image everywhere | deployment |
| Rails console | No REPL. Write an app task, or use psql via app db console | cli |
| Minitest / system tests | TestApp integration tests via oneshot, on SQLite + Postgres | testing |
| Fixtures / FactoryBot | tests/app/factories.rs | testing |
rails new (+ its flags) | interview.md → generators/new.md | |
rails generate <kind> … | cargo offrails generate <kind> … (g): migration, model, scaffold (resource), controller, page, api, job, mailer, channel, test; optional, like a snippet | generators |
| a Rails guide's code sample, copied | cargo offrails snippets, snippet show <name>, snippet add <name> [--as <model>] (feed, slugs, Markdown, pager, theme toggle, backdated seeds, public page by slug) | snippets |
rails destroy <kind> … | cargo offrails destroy <kind> <name> (d): only files nobody changed | generators |
| Engines / gems that add features | parts: cargo offrails parts, add <part>, update-part <part> | template |
bin/rails commands | the app binary + cargo | cli |
| Rack middleware | tower layers in routes.rs | routing |
| I18n | English-only by default. If a spec requires locales, add rust-i18n and log the decision |
Active Record concepts
| Rails | Off Rails |
|---|---|
Post.find(id) | post::find(&db, id).await? → AppError::NotFound on miss |
Post.where(...), scopes | Named query fns: post::published(&db, page), post::by_author(&db, id) |
post.save | post::create(&db, author_id, new) / post::update(&db, post, new) |
has_many :comments | Relation::Comments + comment::for_post(&db, post_id) |
belongs_to :author | author_id: i64 + FK + Relation::Author; post.find_related(user::Entity) |
has_many :through | Join entity + Related impl with via() |
includes(:comments) (N+1 fix) | find_also_related (1:1) or LoaderTrait::load_many (1:N) |
| Validations | Params::validate(), plus DB constraints as the backstop |
validates :email, uniqueness: | UNIQUE index + map the unique violation to a field error |
Callbacks (after_create) | Banned. Call the side effect explicitly in the model fn |
| Concerns | Traits only when ≥3 models share behavior; otherwise duplicate |
| STI | A kind column + DeriveActiveEnum |
| Polymorphic associations | Avoid. Separate nullable FKs with a CHECK that exactly one is set |
enum status: {...} | Text column + CHECK constraint + DeriveActiveEnum |
counter_cache | Compute with COUNT(*); denormalize only for a measured reason |
dependent: :destroy | on_delete(Cascade) on the FK in the migration |
Soft delete (paranoia) | discarded_at TIMESTAMPTZ + queries filter it |
touch: true, updated_at | Set updated_at = Set(Utc::now()) in every update fn |
| Transactions | let txn = kernel::db::begin(db).await?; … txn.commit().await? (BEGIN IMMEDIATE on SQLite) |
find_each | Keyset pagination loop (WHERE id > $1 ORDER BY id LIMIT 1000) |
| Pagination (pagy/kaminari) | Page struct in models/mod.rs → .limit().offset() |
| Search (pg_search/ransack) | Portable LIKE; full-text via the escape hatch (tsvector / FTS5) |
Action Controller concepts
| Rails | Off Rails |
|---|---|
before_action :authenticate | RequireUser extractor as a handler argument |
params.require(:post).permit(...) | Form<PostParams>. The struct is the permit list |
redirect_to posts_path, notice: | Flash::notice(jar, "…") + Redirect::to(&paths::posts()) |
render :new, status: :unprocessable_entity | Re-render form view with errors, status 422 |
respond_to { format.json } | Separate API handlers under /api/v1; no content negotiation |
rescue_from | AppError → IntoResponse in error.rs |
helper_method :current_user | CurrentUser extractor passed into the view struct |
| Strong parameters | Typed Deserialize structs; unknown fields ignored |
posts_path, post_path(@p) | paths::posts(), paths::post(id) functions in routes.rs |
| Pundit / CanCanCan | policies/<resource>.rs pure fns: can_edit(user, post) -> bool |
Borrowed from Phoenix / Elixir
Rails isn't the only source. Where Elixir did it better, we copied it:
| Phoenix / Elixir | Off Rails | Guide |
|---|---|---|
| Oban (jobs as rows with a state machine, kept history, queue limits, pause/resume, snooze/cancel, unique jobs) | kernel/jobs/ | jobs |
| Oban Web | /admin/jobs (live, htmx-polled) | jobs-dashboard |
| Oban Cron plugin | src/jobs/recurring.rs + recurring_executions | jobs |
| Oban Lifeline / Pruner / Stager plugins | Kernel plugins run by every worker | jobs |
Oban.Testing (assert_enqueued, perform_job, drain_queue) | TestApp helpers | jobs |
Swoosh local adapter + /dev/mailbox | Mailer::Local + /dev/mailbox | mailers |
| Phoenix LiveDashboard | /dev (modest: mailbox, jobs, sockets, routes, system) | dev-tools |
| Phoenix.Channel + phoenix.js | Inspired by Phoenix Channels; our own protocol, offrails.v1: /socket (+ SSE and long-polling fallbacks), typed Room actors, seq resume, static/channels.js | channels |
| Phoenix.Presence | Hub::track/untrack, presence frames (here, joins/leaves); people, not tabs; minimal metadata | channels |
| Phoenix.PubSub | per-topic in-process broadcast + Postgres NOTIFY between nodes | channels |
| libcluster / Horde | kernel/cluster.rs: cluster_nodes heartbeats, rendezvous ownership, failover rebuilt from the DB | channels |
Phoenix / Rails pending-migrations page (ActiveRecord::PendingMigrationError) | Dev-only page with a Run migrations button; /up 503 in production while pending | dev-tools |
| Ecto changesets | <Model>Params::validate() -> Result<New<Model>, Errors> | models |
mix phx.routes | app routes / /dev/routes | cli |