lode/drift
Drift checking: verify a live database against the schema spec.
The spec is the source of truth (DESIGN §14); migrations stay
hand-authored. This module is what keeps the two honest: it compares the
spec’s DDL projection (stored columns only — virtual fields are
invisible by construction) against the engine’s catalog
(information_schema on Postgres; sqlite_master + PRAGMA on SQLite)
and reports every difference. Run it as a test or CI guard after migrating:
let assert Ok([]) = drift.check(repo: r, schema: “public”, tables: db_spec.tables())
Reported: missing/extra tables and columns, column type and nullability
mismatches, primary-key mismatches, and foreign keys the spec’s
associations imply but the database lacks (plus the reverse, for FKs
between spec’d tables). The migrator’s own schema_migrations table is
ignored.
SQLite caveat: SQLite enforces only type affinities (INTEGER, REAL,
TEXT, BLOB, NUMERIC) and the DDL dialect maps every intent onto them, so
drift compares at the affinity level there. Intent changes within one
affinity are undetectable on SQLite: Text/VarChar/Uuid/Date/
Time/NaiveDatetime/UtcDatetime/Jsonb/string enums/embeds are all
TEXT; Integer/BigInt/Boolean/int enums are all INTEGER; VarChar
lengths are never enforced. Tables, columns, nullability, primary keys,
and foreign keys are checked with full fidelity on both engines.
Types
One difference between the spec and the live database.
pub type Drift {
MissingTable(table: String)
ExtraTable(table: String)
MissingColumn(table: String, column: String)
ExtraColumn(table: String, column: String)
TypeMismatch(
table: String,
column: String,
expected: String,
actual: String,
)
NullabilityMismatch(
table: String,
column: String,
expected_nullable: Bool,
)
PrimaryKeyMismatch(
table: String,
expected: List(String),
actual: List(String),
)
MissingForeignKey(
child_table: String,
fk_column: String,
parent_table: String,
parent_column: String,
)
ExtraForeignKey(
child_table: String,
fk_column: String,
parent_table: String,
parent_column: String,
)
}
Constructors
-
MissingTable(table: String)The spec declares a table the database doesn’t have.
-
ExtraTable(table: String)The database has a table the spec doesn’t declare.
-
MissingColumn(table: String, column: String)The spec declares a stored column the database doesn’t have.
-
ExtraColumn(table: String, column: String)The database has a column the spec doesn’t declare.
-
TypeMismatch( table: String, column: String, expected: String, actual: String, )The column exists but its database type doesn’t match the spec’s intent.
-
NullabilityMismatch( table: String, column: String, expected_nullable: Bool, )The column exists but NULL-ability differs.
-
PrimaryKeyMismatch( table: String, expected: List(String), actual: List(String), )The table’s primary-key columns differ from the spec’s.
-
MissingForeignKey( child_table: String, fk_column: String, parent_table: String, parent_column: String, )An association implies this foreign key but the database lacks it.
-
ExtraForeignKey( child_table: String, fk_column: String, parent_table: String, parent_column: String, )The database has a foreign key between spec’d tables that no association implies.
Values
pub fn check(
repo repo: repo.Repo,
schema schema_name: String,
tables tables: List(spec.TableSpec),
) -> Result(List(Drift), error.LodeError)
Introspect the database and compare it against the spec. Ok([]) means no
drift. (schema_name is ignored on SQLite — pass "main".)
pub fn compare(
engine engine: adapter.Engine,
tables tables: List(spec.TableSpec),
db_tables db_tables: List(codegen.TableInfo),
db_fks db_fks: List(codegen.ForeignKey),
) -> List(Drift)
The pure comparison check runs (exposed for tests and custom pipelines).
engine selects the type expectations: Postgres compares
information_schema data_type strings; SQLite collapses declared types
to their affinity first (see the module doc for what that hides).
pub fn expected_fks(
tables tables: List(spec.TableSpec),
) -> List(codegen.ForeignKey)
The foreign keys the spec’s belongs_to/has_many/has_one associations
imply (both sides describe the same constraint, so the list is deduplicated).
many_to_many and through imply none directly — declare the join table’s
own belongs_tos if you want its constraints checked.