Schema, routes, seeds, and importmap
A family of files under a Rails app are not treated as general code —
they're recognized as declarative inputs and ingested into dedicated
IR structures. The four covered in depth here are db/schema.rb,
config/routes.rb, db/seeds.rb, and config/importmap.rb; the
same family also takes in test fixtures (below), db/migrate/
migrations (below), config/routes/ split files, and sig/**/*.rbs
RBS sidecars (App::rbs_signatures, parsed by src/rbs.rs). This
doc covers what each one contributes, the IR shape it produces, and
the *_to_library lowering pass that turns each one into a
LibraryFunction (or LibraryClass for fixtures) for the universal
post-lowering IR that emitters consume.
The pattern is consistent throughout:
file → dedicated IR (App::<field>) → *_to_library → LibraryFunction → emit
The lowered shape is the designed contract, and the preferred path is
for emitters to consume it rather than the source IR (Schema,
RouteTable, Importmap, App::seeds). Direct source-IR reads do
survive in emitters (e.g. src/emit/typescript.rs reads app.schema
directly; src/emit/roda.rs writes db/migrate files from
Schema) — those are candidates to migrate, not a second contract.
See ../pipeline/lower.md for the two-shape
contract.
db/schema.rb → Schema → Schema.statements
Source IR: src/schema.rs::Schema — an IndexMap<Symbol, Table>.
Each Table carries its columns (typed via ColumnType), indexes,
and foreign-key declarations. Iteration order is source order, so
downstream consumers (schema DDL lowering, persistence lowering, model
attribute seeding) produce deterministic output.
Ingest: src/ingest/schema.rs::ingest_schema. Recognizes the
ActiveRecord::Schema[…].define do DSL: create_table, t.string,
t.integer, t.references, t.timestamps, add_index,
add_foreign_key, etc.
Downstream consumers (analyze/lower):
- Analyzer seeds each model's
attributesrow from its matching table — this is howarticle.title : Stringgets its type without any annotation in the model file. src/emit/shared/schema_sql.rs::render_schema_statementsproduces theCREATE TABLE …DDL statement list in the SQLite dialect, which every caller uses today (the joined-stringrender_schema_sqlsurvives for some targets);render_schema_statements_for(schema, Dialect::Postgres)renders the same schema for Postgres (see the shape limits below). The siblingsrc/emit/shared/seed_sql.rsrendersdb/seeds.rbdata to adb/seed.sqlfor text-only archives.src/lower/persistence.rsuses the column list to build INSERT / UPDATE / DELETE / SELECT strings per model.
Lowered to LibraryFunction: src/lower/schema_to_library/
produces a single LibraryFunction — Schema.statements() -> Array<Str> under module_path: ["Schema"]. The body is an array
literal of the rendered DDL statements, one Lit::Str each. Empty
when schema.tables is empty (apps without persisted models don't
need a Schema artifact).
Per-target emit: TS writes src/schema.ts exporting
statements. main.ts passes schemaStatements: Schema.statements() to the runtime's startServer({ … }).
Known shape limits. Every emitter that renders this DDL uses the
SQLite dialect. Per-engine DDL sits behind the Dialect enum in
schema_sql.rs, without changing the Schema IR itself (it's already
dialect-neutral) or the lowerer. Dialect::Postgres (stage (a) of
#91; no target emits it yet) spells column types as Rails' PostgreSQL
adapter creates them, quotes every identifier, and gives a key Rails'
default convention for its type: bigserial, serial for an
integer key, and uuid … DEFAULT gen_random_uuid() for a uuid
one. Both dialects preserve supported CHECK constraints and expression indexes.
Neither dialect reproduces source column defaults or foreign keys: Postgres synthesizes the key defaults above, so
a custom or suppressed one is not reproduced, and the model layer
applies supported literal defaults. A virtual table has no Postgres
DDL, so that dialect returns an error for it. Postgres renders what
ingest kept, so it shares the current ingest and IR limits.
schema.rb ingest drops array: true; an index's order: and
opclass:; precision on numeric,
datetime and time; and schema qualifiers. An integer limit: of 1
or 2 still normalizes to ColumnType::Integer for ordinary typing, but
its original smallint width is retained as negative evidence for generated
int4 results; 3 and 4 are exact PostgreSQL integer, and 5 to 8 normalize
to bigint, as in Rails. The key forms the
PostgreSQL dumper writes are read as the keys they name: id: :serial
is an integer key, and a hash-valued id: { type: :string, limit: 32 } keeps its type and limit. And the folds below
apply (timestamptz renders timestamp). The IR preserves json and
jsonb separately so PostgreSQL DDL keeps the source type. SQLite stores both
as text, and both use the same Ruby RoundhouseJsonColumn encoding/decoding path; this
does not add PostgreSQL runtime support.
Roda's current Sequel schema mapping remains text-backed and records the
original type in a comment rather than claiming native JSONB support.
A partial index's predicate (t.index … where:,
add_index … where: in the migration fold, or WHERE in
structure.sql) is kept as the source database wrote it. Postgres
renders it on every index. SQLite renders it on a unique index, where
it decides which rows must be distinct, and so do the insert_all
conflict guard and upsert_all's conflict target (unique_by: picks
the first unique index by name with those columns, as Rails does).
SQLite leaves it off a non-unique index, which then covers every row,
and off a unique one whose predicate falls outside the syntax both
engines read alike (Dialect::index_predicate: the table's columns,
literals, boolean and comparison operators, a few shared functions).
A Postgres dump's ::text casts or = ANY (ARRAY[…]) fall outside it;
that index is unique over every row, and the transpile names it in a
warning. The migration fold refuses to rename or remove a column a
predicate names.
Case conversion in expression keys and predicates uses the destination database's
lower and upper. SQLite's built-in functions fold ASCII only: a unique lower(email) index accepts
both É@example.test and é@example.test, unlike PostgreSQL with a Unicode-aware locale.
An index's literal access method is kept from schema.rb's using: or
structure.sql's USING clause and quoted in PostgreSQL DDL. Schema Ruby
methods must be ASCII unquoted SQL identifiers and are folded to lowercase.
The structure.sql parser likewise folds ASCII unquoted methods and retains
the case of quoted methods. PostgreSQL DDL preserves these methods;
SQLite DDL uses SQLite's default index method, so query plans and index
performance can differ while row semantics stay the same. Custom PostgreSQL
methods are preserved as identifiers, but their extension must already be
installed by a PostgreSQL deployment.
Postgres column types map to their SQLite storage at
ingest (uuid → TEXT via ColumnType::Uuid, json/jsonb → the same
text-backed JSON representation, citext → text, timestamptz → datetime,
interval → INTEGER via ColumnType::Interval with an ActiveSupport::Duration
reader, inet/cidr/macaddr/enum → string); a type with no mapping is an
ingest error (a ledger line under --survey), never a silent drop — its index would still be
emitted and the DDL would not apply. A non-integer primary key
(create_table …, id: :uuid / primary_key: "identifier", id: :string) renders as TEXT PRIMARY KEY in SQLite and is carried end
to end by the ruby-shape emit: the analyzer
types id, ids and the key-taking finders from that column; the
emitted find/exists?/update/delete/reload primitives
compare it with the key's type; insert writes it (minting a blank
uuid key with SecureRandom.uuid, as the Postgres default would)
and answers it instead of the rowid; a key not called id gets
id/id= aliases; and a uuid foreign key's "no row" sentinel is
"", not 0. Spinel carries it too: the shared ActiveRecord::Base
holds no @id slot (under Spinel a base-class ivar is the union of
every subclass's writes, so one String-keyed model would widen every
model's key to poly), its id/id= are a raise-bodied contract each
model's own typed accessors implement, and an app with a string key
ships the runtime sidecar with that contract widened to
(Integer | String) (project::widen_key_contract) while an
integer-only app ships it byte for byte. The compiled targets' model
layers still pin an integer id and report non_integer_primary_key
as an unsupported construct at emit time, per target, rather than as
an ingest gap that would be false of the ruby family (#90;
fixtures/tiny-blog-uuid + tests/uuid_key_ruby.rs run the shape
on CRuby, and the same fixture compiles and serves under Spinel).
Generated columns
Column.generated retains a generated expression and its stored or
virtual mode separately from its declared result type. For example:
t.string "first_name"
t.string "last_name"
t.virtual "display_name", type: :string,
as: "first_name || ' ' || coalesce(last_name, '')", stored: true
The portable expression subset is deliberately bounded: unbounded string/text
columns, SQL string literals, parentheses, ||, and coalesce with at least
two arguments. Expressions are validated against the complete table and kept
verbatim. A separate PostgreSQL DDL-only ingest API opts into the same subset
plus ::text, ::varchar, ::character varying, and equivalent
CAST(... AS ...) forms over unbounded string/text expressions. The default
application ingest remains portable, so PostgreSQL-only casts still fail app
checking and cannot reach current SQLite project emission. Use
ingest_schema_with_generated_expression_dialect or the corresponding
structure.sql/migration entry point with
schema::generated::GeneratedExpressionDialect::Postgres, then call
render_schema_statements_for(..., Dialect::Postgres) to render DDL. This
source-expression mode is not a database selector and does not enable
PostgreSQL model persistence or a PostgreSQL application target.
That PostgreSQL DDL mode also accepts ::integer, ::int4, and
CAST(... AS integer/int4) only when the cast input is text, such as
(payload ->> 'count'::text)::integer. The declared generated result must
be an exact PostgreSQL int4. It does not admit integer-column operands,
integer literals, arithmetic, or other numeric casts; any coalesce input
must still satisfy the existing text-only rules. Because ordinary typing folds
several source widths into ColumnType::Integer,
schema.rb integer limits 1 and 2, and structure.sql smallint/int2,
serial/serial4, and integer types with typmods retain negative width
evidence and are refused as generated int4 results. Bare integer, int,
and int4, and Rails limits 3 and 4, remain eligible. This does not change
ordinary model typing or rendered SQLite types. In structure.sql, a
qualified integer type is considered exact only as unquoted pg_catalog.int4;
integer and int are unqualified SQL grammar aliases, not catalog type
names. The parser otherwise keeps its ordinary normalized type but refuses
generated int4 output because another schema can define a domain or type
with the same name.
That explicit PostgreSQL DDL mode also accepts -> and ->> when the left
operand is an exact json or jsonb column and the selector is a SQL string
literal (including Rails' 'key'::text form) or a decimal array index
with an optional leading minus, within the full signed int4 range.
The operators can be chained; -> may produce an intermediate JSON value,
and ->> produces text. That text can be the generated result or feed the
explicit int4 cast described above.
Expressions remain verbatim, so PostgreSQL preserves the source JSON type's
behavior. The default application ingest remains portable, and SQLite DDL
validation rejects a schema imported in PostgreSQL mode. This adds no
PostgreSQL model persistence and leaves the shared serialized-text
RoundhouseJsonColumn model path unchanged.
Other PostgreSQL operators, casts to non-text types apart from the bounded
text-to-int4 casts above, other functions,
generated-column references, defaults, generated keys/timestamps,
length-limited casts or operand types remain explicit errors. A table must
contain an ordinary column. Source types normalized to text for ordinary model
typing (such as network types, enums, citext, and fixed-width characters)
remain unsupported here. Column names that are SQL keywords must be
double-quoted in the expression. Unresolved keyword splats in column options
are rejected because they can hide generated-column metadata.
Migration folding permits renaming or dropping an unindexed generated output
when the resulting table still validates. change_column on an existing
generated output and replacement of a generated output by an ordinary column
are rejected. Renaming or dropping a source column that a generated expression
uses is rejected without rewriting the source SQL; renaming or dropping an
indexed generated output is also an explicit error.
Assigning a default to a generated output is rejected before changing schema
state, including during survey recovery. Ordinary defaults and generated-column
nullability changes remain supported.
remove_reference and remove_belongs_to cannot remove a generated output;
their folded changes are also validated before altering generated expressions.
The same metadata is read from a complete GENERATED ALWAYS AS (...) STORED
or VIRTUAL clause in structure.sql; unsupported clauses cannot silently
become writable columns. Roda emission rejects generated columns. SQLite DDL
supports both modes; the separate PostgreSQL DDL renderer currently accepts
stored columns only, even though PostgreSQL 18 also supports virtual columns.
PostgreSQL text casts and JSON extraction require the explicit DDL-only ingest
mode described above; this does not enable a PostgreSQL runtime backend.
Normal model inserts and updates omit generated columns, while SELECT and
reload retain them. Generated attributes start nil, including a database
NOT NULL attribute. The ruby, jruby, and spinel persistence runtimes
use one INSERT ... RETURNING statement to read the inserted key and generated
values before create callbacks; there is no separate post-insert SELECT. The
SQLite persistence path requires SQLite 3.35 or newer, matching the
Db.exec_returning runtime gate. SQLite 3.31 through 3.34 support generated
columns in DDL but cannot run this model-persistence path. Project emission
also refuses targets whose runtime does not provide Db.exec_returning. These
runtime gates do not change the standalone schema DDL renderers: they continue
to preserve every accepted SQLite generated-column mode and stored-only
PostgreSQL generated columns. The single statement does not wrap the rest of
model save or callbacks in a transaction; callers that need rollback when
later application code raises must use their transaction.
Tables without a declared primary key can return SQLite's rowid from the
insert adapter primitive, but the existing model create/save lifecycle does
not support keyless models end to end.
An explicitly assigned nonnil value remains in memory through callbacks and
until reload. This create-time hydration does not add generated-column dirty
tracking or refresh generated values after later writes; updates leave the
previous value in memory until explicit reload, as observed with Rails 8.1.4
and SQLite. Existing
full-column update behavior also remains: saving only a generated-field
assignment on a mixed table may still write unchanged ordinary fields. A
table with no writable non-key fields uses DEFAULT VALUES on insert and
issues no UPDATE.
Direct writes that the current lowering would silently discard remain
unsupported: generated-model insert_all/insert_all!, and update_column
or named touch calls that can address a generated field, including
belongs_to ..., touch: :generated_column callbacks. Ordinary literal
column names retain their existing behavior; dynamic names on generated
models fail closed. Static seed SQL follows ordinary create! filtering.
Explicit generated values in YAML fixtures are rejected with fixture, record,
and column context. The regression suites are tests/generated_columns*.rs;
generated_columns_spinel executes the SQLite contract natively in the
selected Spinel CI suite.
config/database.yml → App::databases and model ownership
config/database.yml is resolved when Roundhouse builds the app, as
Rails' ActiveRecord::DatabaseConfigurations reads it: every
environment, each one's configurations (three-tier when every value of
the environment is a mapping, otherwise the one configuration
primary), the primary (named primary, else the first), replicas, and
the adapter (adapter:, or the scheme of a literal url:, which wins;
an environment given as a URL string is that URL's configuration).
Only identity keys are read (adapter, url, replica,
database_tasks, migrations_paths, schema_search_path), so no
connection value or credential reaches the IR. ERB is not run: an
output tag in a connection value does not matter, one in an identity key
leaves that configuration out with a warning, and a control tag leaves
them all out. DATABASE_URL is not read. The primary's SQLite
pragmas: are read from the same file (App::database_pragmas).
Which database a model runs on is read from source, never from the
file: a literal connects_to database: { writing:, reading: } in an
abstract class, inherited by its subclasses (App::model_databases).
The emitted runtime opens only the primary, so a model whose writing
database is another, a name the file does not configure, a computed or
conditional connects_to, shards:, establish_connection, an
app-defined connection_pool, and any role or shard switch are errors
at their source (App::database_refusals). A switch is Active Record's
when it is called on a class (self, a constant, or no receiver) and,
for connected_to, connected_to_many and connecting_to, passes the
role: or shard: Rails requires; a call on another object is the
app's own method. A connects_to whose
writing database is the primary checks clean and runs.
The primary the test environment names decides the SQL dialect of the
Ruby target's emit (schema_sql::Dialect::for_app). For postgresql, the
schema DDL, the lowered queries (boolean literals, truncation) and the
tree are PostgreSQL's: runtime/spinel/db_pg.rb over the pg gem as
the emitted tree's Db, gem "pg", and the database DATABASE_URL names, at
boot and in the emitted tests (project::apply_postgresql_tree). Every
other target, and every other adapter, keeps SQLite. The runtime's
database tasks (db:schema:load, prepare_all, migrate) and
data_source_exists? / column_exists? still ask SQLite's catalog
(sqlite_master, PRAGMA, INSERT OR IGNORE), so a PostgreSQL app
that reaches them fails at run time.
The enum database::DatabaseAdapter names a configuration's adapter;
it is not the analyzer's DatabaseAdapter trait.
config/routes.rb → RouteTable → RouteHelpers.<x>_path
Source IR: src/dialect.rs::RouteTable — a list of RouteSpec
entries plus direct_helpers (direct :name custom URL helpers,
lowered by src/lower/routes_to_library/direct.rs). RouteSpec has
four variants — Explicit, Root, Resources, and Scope
(namespace/scope nesting); see src/dialect.rs for what each
carries (Resources knows about singular resource and as:
renames; Explicit records its member/collection scope).
Ingest: src/ingest/routes.rs::ingest_routes finds the outer
Rails.application.routes.draw do … end and walks its statements. Whole-app
ingest also supplies source-backed engine metadata to
ingest_routes_with_engines for the literal mount slice described below.
The recognizer covers the verb shortcuts (get/post/…), match,
root, resources/resource (with only:/except:/as:/
controller:/param:/path:, symbol or string spellings alike, as Rails
to_syms them), namespace/scope,
member/collection/constraints blocks, draw(:name)
split files under config/routes/, and options like defaults:,
on:, and via: — src/ingest/routes.rs is the authority on the
current surface. Literal redirect("/path") targets on a verb or root
are synthesized into controller actions. Some block redirects are synthesized
too: redirect_block accepts bodies that pass its string-expression check,
including string literals and interpolations, conditionals whose branches
pass the check, sequences whose final expression passes it, and selected
method calls. It checks for at most two required block parameters, named
_, params, request, or req. Other dynamic targets outside this
recognizer remain a separate known gap: they are dropped and reported only
in survey mode. The engine-mount support below does not change those
non-mount target semantics.
One literal isolated-engine mount shape is composed into the shared route
scope: a top-level mount Catalog::Engine, at: "/catalog" where the engine
comes from a locked, in-tree PATH gem, declares one plain Rails::Engine
subclass under lib/ with a direct literal isolate_namespace, and has one
source-backed Catalog::Engine.routes.draw block. The mount prefix must
be a static, absolute, non-root path without a trailing slash. Its routes are
flattened at the mount's source position with the engine controller
namespace, so earlier host routes, engine routes, and later host routes retain
their order. The prefix is matched as a path segment (/catalogue does not
match /catalog); the mounted root accepts both /catalog and
/catalog/.
The engine's lib/ source must have a plain load shape: literal require or
require_relative calls may resolve only to another checked .rb file under
that engine's lib/, with require "rails/engine" (optionally .rb) as the
sole external bootstrap. Files may define namespace modules along the path
to the engine owner and below it, plus classes without custom superclasses,
instance methods, ordinary self methods, and scalar constants at the owner
namespace and below; Ruby load-hook methods are excluded. At file level, only
those declarations and the checked literal requires are accepted. The
Engine declaration itself may only set its literal isolated namespace.
Other load-time calls or computed declarations remain explicit mount errors,
including initializer or middleware registration through a gem entrypoint.
The engine route body is limited to bare, unconditional root and HTTP
shortcut declarations whose paths, to: targets, controller/action options,
and supported as:/match via: options are literal. Nonliteral targets
and all redirect(...) route targets, resources/resource,
namespace/scope, and other routing wrappers remain explicit errors; they
are not flattened into public routes. The engine's route helpers resolve as
Rails resolves them: inside the engine's own code (its app/, lib/ and
views) a bare products_path is the engine's route under the mount
(/catalog/products), main_app.products_path is the host's, and host code
reaches the engine's through the proxy, catalog.products_path. The routes
are drawn under the proxy's name as a helper prefix (catalog_products_path)
and each such call is rewritten to it as it is ingested
(src/ingest/engine_helpers.rs); the engine's root keeps the trailing slash
Rails joins to the mount (/catalog/). A host route already named like one of
those prefixed helpers keeps its name, the engine's helpers are left out, and
each call that reached them is a located route mount error. The host's
named mount helper (catalog_path) is not composed. Rack applications and all other engine mount forms remain explicit
errors, including dynamic or patterned prefixes, as: aliases,
nested or repeated mounts, custom engine initializers and middleware, route
constraints or conditional branches, route prepends, Devise visibility
wrappers, and engine direct helpers. Strict emission refuses these errors;
--allow-unsupported can write an incomplete project, and survey mode records
the gap without clearing the diagnostic. Literal reflective (send, public_send,
or __send__) uses of engine route helpers or the mounted proxy are located
errors.
The generic CRuby dispatch contract is exercised by
tests/emit_and_run.rs; tests/spinel_toolchain.rs carries the ignored native
Spinel HTTP witness.
The fixed runtime's top-level mount ActionCable.server => "/cable" (or
at: "/cable") remains a separate exception. The existing CRuby/JRuby
pruning policy still omits Cable from apps without a live broadcast surface;
the literal engine support does not change that policy.
Downstream consumers (analyze/lower):
- Analyzer uses the controller/action pairings to wire up before- action and render edges.
src/lower/routes.rs::flatten_routesexpands the source-shapeRouteTableinto a flatVec<FlatRoute>: one entry per(method, path, controller, action), withnamespace/scopeprefixes composed in, a helper name (article→article_path,edit_article→edit_article_path), and the ordered list of path parameter names.FlatRoutealso records whether the route is named (unnamed dynamic routes get no helper) and any route-forced response format.
Lowered to LibraryFunction: src/lower/routes_to_library/
produces one LibraryFunction per named route under module_path: ["RouteHelpers"]. Body is a typed StringInterp building the path
from path-params (id and <x>_id typed as Int, others as Str).
Multiple HTTP verbs on the same path collapse to a single helper
(e.g. articles_path covers both GET /articles and POST /articles).
Route helpers are only half of what src/lower/routes_to_library/
emits: lower_routes_to_dispatch_functions builds the dispatch
surface under module_path: ["RouteTable"] (emitted to
app/routes.ts on TS) — the table that makes requests reach
controllers — and lower_url_option_helpers adds resolvers for
hash-form url_for options.
Per-target emit: TS writes app/route_helpers.ts with one
export function per helper plus the namespace const. Controller
and view bodies that call RouteHelpers.article_path(id) resolve
through the namespace import unchanged.
Known shape limits. Custom routes with constraints: are
preserved in the IR but the helper-emit ignores them.
db/seeds.rb → App::seeds → Seeds.run
Source IR: src/app.rs::App::seeds: Option<Expr> — the seeds
file is stored as a single top-level Expr (usually a Seq of
AR-create sends, frequently guarded by an early-return on "already
populated"). No special dialect wrapping; it's just Ruby in IR form.
Ingest: ingest_ruby_program on the source.
Analyze: the body is typed against the model registry exactly
as any controller body — Article.create!(...) binds its argument
types from the Article class's attribute row.
Lowered to LibraryFunction: src/lower/seeds_to_library/
produces one LibraryFunction — Seeds.run() -> nil under
module_path: ["Seeds"]. The body is the seeds Expr verbatim;
analyze has already attached types and effects, so the walker
emits Article.create!(...) etc. the same way it would in any
other class context.
Per-target emit: TS writes db/seeds.ts with export function run() plus the namespace const. main.ts passes () => Seeds.run() as the seeds callback to startServer({ … }); the
runtime invokes it on first boot when the DB is empty.
Known shape limits. No special handling today for Rails.env
gates or unless guards on seed records — whatever Ruby the file
contains is ingested as-is and the analyzer sorts out the types.
config/importmap.rb → Importmap → Importmap.{pins, entry}
Source IR: src/app.rs::App::importmap: Option<Importmap> — a
list of ImportmapPin { name, path } in declaration order (Rails
preserves order for modulepreload link emission).
Ingest: src/ingest/app.rs::ingest_importmap. The DSL has three
common shapes: pin "<name>", pin "<name>", to: "<path>", and
pin_all_from "<dir>", under: "<prefix>" (which expands by walking
the named directory).
Lowered to LibraryFunction: src/lower/importmap_to_library/
produces two LibraryFunctions under module_path: ["Importmap"]:
pins() returns the structured pin list (an Array of
Record{name, path}), and entry() returns the name of the
importmap's entry module. The <script type="importmap">…</script>
element that Rails' view layer emits via javascript_importmap_tags
is built by the view-helper lowering, which consumes
pins()/entry() (see src/lower/view_to_library/helpers.rs).
Per-target emit: TS writes app/importmap.ts with one export function per method plus the namespace const; the lowered layout
view reaches them through the javascript_importmap_tags helper.
Known shape limits. pin_all_from walks the local file system
at ingest time; if the source moves files between ingest and emit,
the resolved pins go stale. Today the ingest+emit cycle runs in
one process so this isn't an issue.
What about migrations?
db/schema.rb is canonical whenever it exists, because:
schema.rbis the denormalized, authoritative snapshot — the same view everyrails db:preparewould construct.- Migrations are imperative; schema.rb is declarative. Typing against the final shape is straightforward; replaying migrations to derive it is avoidable work.
When schema.rb is absent, Roundhouse next reads db/structure.sql
(src/ingest/structure_sql.rs), the SQL dump Rails writes under
config.active_record.schema_format = :sql. Its reader handles
PostgreSQL's pg_dump format. It skips the \restrict and
\unrestrict lines that pg_dump 18 (and the August 2025 minor
releases) brackets a dump with and that Rails before 7.2.3/8.0.3 keeps;
any other psql meta-command is ledgered as a statement it does not
model. A composite primary key (PRIMARY KEY (shop_id, id)) is kept
whole in Table.primary_key_columns, with no column flagged as the key.
A model on such a table keys on the column it declares
(self.primary_key = :id, in its own file or a reopen), which ingest
then flags; a model that declares none is refused, since Rails would key
it on every column and this compiler does not yet. When there is neither (never migrated locally, or gitignored),
the walk falls back to folding db/migrate/*.rb in filename order —
src/ingest/schema.rs::ingest_migration, called from
src/ingest/app.rs. A change_table block folds through the verbs
its t.<method> calls stand for in Rails' Table: t.change,
t.change_default, t.change_null, t.rename, t.remove (once per
name), column adds through t.<type> (once per name, with index:) and
t.column, t.index, t.remove_index, t.rename_index,
t.timestamps, t.remove_timestamps, t.references / t.belongs_to
and their removals, and t.foreign_key / t.remove_foreign_key.
remove_foreign_key, top-level or in the block, drops the key Rails
picks: the first matching every part it gives (target table, column:,
primary_key:), and without column: one on the default column
(author_id for :authors) when there is one; a key named by name:
is a gap, since the fold does not
record key names. Questions (column_exists? and the like) and other
constraint verbs change nothing the fold models. A non-literal table or
column name, a block that is not a literal block, an unmapped column
type, and a t used other than as the receiver of a plain statement
(under an if or case, or passed to a helper) are gaps. Removing a
column, top-level or in the block, removes the indexes over it.
change_column (and t.change)
keeps the column's nullability, default and key unless the call gives
them, as every Rails adapter does, and redefines the column in place.
execute folds when every statement in its literal SQL only sets the
table's default character set or collation (ALTER TABLE posts COLLATE=utf8mb4_bin, … DEFAULT CHARSET=utf8mb4), which applies only
to columns added later; any other SQL, CONVERT TO CHARACTER SET
included (it can retype the string columns it re-encodes), is a gap that
quotes the statement.
Migration shapes it can't fold deterministically
(see UNSUPPORTED_VERBS) error with a pointer to rails db:migrate,
which materializes the schema.rb this fallback substitutes for. Roda
apps get the same fallback for Sequel-DSL migrations via
src/ingest/sequel_migration.rs.
The real-blog fixture generator (scripts/create-blog) runs
rails db:prepare after generating migrations, so schema.rb
always exists by the time ingest runs. See
fixture setup.
Test fixtures: test/fixtures/*.yml
Not under db/ or config/, but worth naming here since it rounds
out the declarative-inputs picture. Each <table>.yml becomes a
Fixture entry in App::fixtures; src/lower/fixtures.rs::lower_fixtures
turns them into a per-target-renderable load plan (which columns
receive literals, which are foreign-key references to another
fixture's eventual AUTOINCREMENT rowid). Record values are held as
FixtureValue::Scalar(String) or FixtureValue::Ruby(Expr) for
inline <%= … %> values, and a fixture file's ERB statement tags
land in the fixture's preamble of ingested Ruby (src/dialect.rs,
src/ingest/fixture.rs); emitters coerce scalars per column type.
Lowered to LibraryClass (not LibraryFunction — fixtures are
class-shaped because they have a state-like notion of "the loaded
records by label"). src/lower/fixture_to_library/ produces one
LibraryClass per fixture file: <Plural>Fixtures with one class
method per label (articles(:one) → ArticlesFixtures.one()).
Per-target emit: TS writes test/fixtures/<plural>.ts with one
export class <Plural>Fixtures declaring all the labeled record
methods.
Key files
| File | Role |
|---|---|
src/schema.rs |
Schema / Table / Column source IR |
src/ingest/ |
ingest_schema + ingest_migration (schema.rs), ingest_routes (routes.rs), ingest_importmap (app.rs); seeds go through ingest_ruby_program (expr.rs) |
src/dialect.rs |
RouteTable, RouteSpec, Fixture, LibraryClass, LibraryFunction |
src/app.rs |
App::seeds, App::fixtures, App::importmap, App::rbs_signatures |
src/database.rs |
Database identity: Database, ModelDatabase, DatabaseRefusal |
src/ingest/database_yml.rs |
config/database.yml → configurations and pragmas, at build time |
src/ingest/database_ownership.rs |
connects_to and connection overrides → App::model_databases, routing refusals |
src/rbs.rs |
sig/**/*.rbs sidecars → App::rbs_signatures |
src/emit/shared/schema_sql.rs |
Schema → CREATE TABLE DDL statements (sibling seed_sql.rs renders seed rows) |
src/lower/routes.rs |
RouteTable → Vec<FlatRoute> |
src/lower/fixtures.rs |
YAML fixtures → loader plan |
src/lower/schema_to_library/ |
Schema → LibraryFunction (Schema.statements) |
src/lower/routes_to_library/ |
FlatRoutes → Vec<LibraryFunction> (RouteHelpers + the RouteTable dispatch surface + direct helpers) |
src/lower/seeds_to_library/ |
App::seeds → LibraryFunction (Seeds.run) |
src/lower/importmap_to_library/ |
Importmap → Vec<LibraryFunction> (Importmap.{pins, entry}) |
src/lower/fixture_to_library/ |
Fixtures → LibraryClass per fixture file |
Related docs
ruby-and-erb.md— how the general-purpose Ruby ingest path works (used bydb/seeds.rb).catalog.md— the AR method catalog that lets the analyzer understand whatArticle.create!(...)means.../pipeline/lower.md— the two-shape IR contract and detailed coverage of each*_to_librarylowerer.../pipeline/emit.md— how each shape is rendered per target (e.g. TSexport function+ namespace const).