tomato·bazeldocs v0 · latest
Docs/Reference/Modules/pgmigrate

pgmigrate

Postgres schema migrations in Lean 4 — the layer where provable migration gets built. Today: the DDL-to-catalog fold. Next: a catalog transition that can fail, and the Migration theorem.

Latest17.6.3
Versions4
CategoryModules & tooling
MaintainersMatt Marshall
Registryhttps://registry.tbzl.dev/modules/pgmigrate/
Sourcegithub.com/leangres/pgmigrate
MODULE.bazelstarlark
bazel_dep(name = "pgmigrate", version = "17.6.3")

View source & releases on GitHub ↗

Postgres schema migrations, in Lean 4 — the layer where “provable migration” gets built.

Today this holds one thing: Fold, the DDL→catalog projection. The rest of the module is the work it exists for.

Part of leangres. It sits at the top of the dependency graph.

Why this module exists

A schema migration is a state transition, and almost nothing about one is checked before it runs. Current tooling can tell you the SQL parses and, with luck, that it ran once against a staging database. It cannot tell you:

  • every statement’s preconditions hold in the state the previous statement left behind;
  • the end state is exactly the schema you intended, not merely one that did not error;
  • the migration never holds ACCESS EXCLUSIVE while it scans a large table;
  • a backfill establishes the invariant the next statement assumes;
  • nothing else — a view, an RLS policy, a PL/pgSQL body — referred to the column you just dropped. Postgres does not dependency-track PL/pgSQL bodies, so this one is silently broken until something calls it at runtime.

Each is decidable given a faithful catalog model and a typed AST. leangres has both, in pgcatalog and pgast.

Fold — and why it must not be “fixed”

Pg.Migrate.Fold folds a parsed DDL stream into a Pg.Catalog.Snapshot. It is total, and it deliberately reproduces the quirks of a C tool it replaced: resolveType falling back to 2249, resolveBareColumn ignoring the FROM map, foldAlterTable silently skipping a missing table, columns with unresolvable types being dropped.

It carries a byte-equivalence claim against that tool over a 1,384-statement production schema — which is why the C was deleted. Correcting the quirks would void the claim.

So the validating transition function is written beside it, not over it:

step : Snapshot → Stmt → Except CatalogError Snapshot

with real preconditions and a typed error taxonomy. Where step and Fold disagree on a schema both accept, that disagreement is a finding to triage — several will be real defects in the production schema — not a bug in either. Expect the agreement theorem to fail on first run; that is the point of writing it.

Why Fold lives here and not in pgcatalog

Two independent reasons:

  1. It is the only module in the catalog family that reaches outside Pg.Catalog — it imports Pg.Query.Top. Keeping it in pgcatalog would drag the parse tree into a module whose whole value is being dep-free.
  2. It could not keep its old path either. pgcatalog publishes oleans rooted at Pg/Catalog/, so a second archive claiming that root would overlap on unpack. Hence Pg/Migrate/Fold.lean.

The namespace is unchanged. namespace Pg.Catalog inside the file stays, because module path and namespace are independent in Lean: Snapshot.ofTopParseResult and friends resolve exactly as before for anyone with open Pg.Catalog, and only the import line moves. Renaming the namespace too is a separate decision.

The roadmap this module is for

Roughly in dependency order, each step useful before the next:

  1. Statement closure over mutating DDL — the full ALTER TABLE action set, the DROP family with CASCADE/RESTRICT, renames, ALTER TYPE … ADD VALUE. Lands in pgast.
  2. Top-level DMLINSERT/UPDATE/DELETE/MERGE/SELECT as statements rather than fragments inside PL/pgSQL bodies. Also pgast.
  3. A catalog transition that can failstep, above. Needs the catalog kernel extended with pg_constraint, pg_index and pg_depend: without those there is no DROP … RESTRICT, no NOT VALID/VALIDATE, and no FK invariant preservation.
  4. Migration and its theorem — a migration carries its intended start and end catalogs; the obligation is that running it from the first yields exactly the second. Decidable, because schemas are concrete data.
  5. Safety beyond well-formedness — hazard classification, lock-level analysis against Postgres’s real lock table, reversibility (where none is the useful answer), invariant preservation.
  6. Backfill correctness — the step that needs a semantics for data, not just the catalog.

One correction worth recording up front

The canonical safe pattern is usually written as three statements — add nullable column, backfill, SET NOT NULL. That is wrong twice over: the third statement takes ACCESS EXCLUSIVE and scans the table, and a concurrent INSERT between the second and third leaves a NULL that makes the third fail.

The correct form is six statements, with ADD CONSTRAINT … CHECK … NOT VALID before the backfill — Postgres enforces NOT VALID constraints on new and updated rows immediately, which is what closes the race, while validating separately keeps the scan off the exclusive lock. Both facts are decidable consequences of the model rather than folklore, and demonstrating that is one of the clearest arguments for this layer existing.

Dependencies

pgcatalog (for Snapshot) and pgquery (for Query.Top, and for the generated SmokeFixtureTyped that the pipeline test folds) — both as compiled oleans. Lean core otherwise: no mathlib, no batteries. Proofs here lean on native_decide over concrete data, which needs neither.

Consuming it

bazel_dep(name = "pgmigrate", version = "17.6.0")

Versioning

<pg_major>.<pg_minor>.<patch>. ⚠ A convention, not enforced — compatibility_level would have been the mechanism and Bazel 9 made it a no-op. See pgcatalog’s MODULE.bazel.

Provenance

Carved from tomato-bazel/rules_postgres with git filter-repo, history preserved.

License

MIT.

Conformance#

No gate findings. 8 contested atoms. See how gating works or the full report.

Contested atoms

Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.

AtomResolved hereElsewhere
apple_support 1.24.2 2.2.0 ×1
bazel_skylib 1.8.2 1.9.0 ×2
nlohmann_json 3.6.1 3.12.0.bcr.1 ×1
protobuf 33.4 34.0.bcr.1 ×2
rules_jvm_external 6.7 6.8 ×4
rules_python 1.7.0 2.0.1 ×1
rules_swift 3.1.2 3.6.1 ×1
upb 0.0.0-20220923-a547704 0.0.0-20230516-61a97ef ×1

Dependencies#

pgmigrate in the registry graph — what it depends on (left) and what depends on it (right).

Depends on

Versions#

4 published versions, newest first. Each resolves to an immutable, integrity-checked archive.

VersionIntegrity (sha256)Source archive
17.6.3 latest B7zjUu2E8BOC8DOg… tag archive ↗
17.6.2 lHfIRyyjqQDjRXpN… tag archive ↗
17.6.1 gD2cMWIjn7tgDXID… tag archive ↗
17.6.0 GkOJ/G+6p6ZlmAnF… tag archive ↗

Changelog#

17.6.3 — CASCADE actually cascades, and pg_depend stops leaking

Two real bugs, both in the drop path.

DROP … CASCADE emitted only the target’s drop. It never dropped the dependents — which is the entire meaning of CASCADE. A DROP TABLE t CASCADE left every view on t in the catalog, describing a table that no longer existed. Now it emits the full transitive closure (pgcatalog 17.6.4’s cascadeClosure), deepest-first so a dependent is gone before the thing it depends on.

⚠ And it refuses a non-converged closure rather than emitting a short one. The closure is fuel-bounded because pg_depend is a general graph with real cycles; a partial cascade that half-drops the schema and reports success is worse than a refusal.

dropRelation never cleaned pg_depend. Edges at both ends survived the drop, and a stale edge whose referent is gone still counts in dependentsOf — so a later DROP … RESTRICT refused over a dependent that was already dropped, naming an object the user cannot find. Both ends are now filtered.

Pinned: RESTRICT still refuses; CASCADE emits three effects for a 3-deep chain, not one; drop order is deepest-first; the resulting state holds none of the three; dropping the middle view leaves the table; the depend edges are gone; and the table is droppable with RESTRICT afterwards — which it would not be if the edges had leaked.

⚠ One wart pinned as-emitted rather than fixed: dependentsExist names resource, not graph.resource. nameOf drops the schema, so an error cannot say which schema’s resource it meant. Qualifying it changes every existing error pin, so it is its own change — and this pin makes it visible.

17.6.2 — DML is identity on the catalog, and it is proved

Bumps pgast to 17.6.2, which adds top-level INSERT/UPDATE/DELETE.

The exhaustivity lock did its job: stmtKeyword stopped compiling the moment Stmt grew, which is the entire reason it has no catchall.

elabStmt now has explicit DML arms emitting no effects, rather than letting them fall through to unsupported. The distinction is the point — unsupported means “we cannot say what this does”, while these arms are a positive claim that they do nothing a catalog can observe.

That claim is now a theorem rather than a comment:

theorem insert_is_catalog_identity (s : CatalogState) (i : InsertStmt) :
    step s (.insert i) = .ok s := rfl

plus update/delete, and run_dml_only lifting it to whole scripts by induction. These are the module’s first universally quantified results — everything else here is native_decide over concrete states, true of what was tested and silent about the rest. A migration runner can now skip the catalog transition for a backfill on a proved basis.

Verified by deliberate break: making the DML arms emit one bogus effect turns all of them red (9 errors). They constrain the implementation, which is not something to assume of a proof in this codebase.

⚠ Three scope limits, one of which is a real hole rather than a simplification: the heap is not modelled (obvious, and not what CatalogState is); pg_class.reltuples/relpages do move on DML via autovacuum, so a differential gate must mask them; and a DML statement can fire a trigger whose body runs DDL, in which case the catalog does change and these theorems do not describe what happened. They are claims about the statement, not its trigger closure. Closing that needs pg_trigger in the kernel. Documented, not proved absent.

17.6.1 — a transition that can refuse

Fold projects: it folds whatever the parser produced into a catalog, TOTAL, never failing. Right for its job — reconstructing a catalog from a schema that already exists and already worked.

A migration is the other direction. It has to answer may this statement run against THIS catalog, and the interesting answers are no.

Pg.Migrate.step : CatalogState → Stmt → Except CatalogError CatalogState, with run over a list reporting which statement failed.

Checking is split from mutation

elabStmt decides and returns effects; applyEffect is total and just writes. Every precondition proof lives in one, every allocation argument in the other, and neither reasons about the other. It also means hazard and lock analysis can later read the effect list rather than re-matching Stmt — an effect list containing dropAttribute is data-lossy by construction.

It consumes the emitter-side AST

Fold takes Pg.Query.Top.TopStmt (what Postgres parsed); step takes Pg.Stmt.Stmt (what we are about to emit). Both directions are wanted, which is why this module now depends on pgast as well.

What it refuses

ALTER on a missing relation or column; ADD COLUMN or RENAME onto a name already taken; VALIDATE or DROP naming a constraint that was never added; re-adding a constraint name; DROP … RESTRICT while normal dependents remain; and a foreign key whose referenced columns are not covered by a unique index — the check Postgres performs that a schema-shape predicate cannot.

IF NOT EXISTS / IF EXISTS make the corresponding statement a no-op rather than an error, which is what a re-runnable migration depends on.

NOT VALID round-trips

ADD CONSTRAINT … NOT VALID leaves convalidated := false; VALIDATE CONSTRAINT flips it. That is the sequence an online migration is built from, and it is now expressible end to end: pgast emits it, pgcatalog models it, step transitions it.

Unmodelled statements ERROR

setDefault, setColumnType, renameTable, setSchema and most DROP kinds return .unsupported rather than succeeding quietly. A migration “proved” against a transition that silently ignored half its statements would be worse than no proof.

Fold is untouched

It keeps its byte-equivalence claim over a 1,384-statement production schema, and deliberately keeps the quirks step rejects. Where the two disagree on a schema both accept, that is a finding to triage — quite possibly a real defect in the schema — not a bug in either.

15 pins in Pg/Migrate/StepTest.lean; 3/3 targets pass.

17.6.0 — the migration layer, opened

Pg.Catalog.Fold extracted from tomato-bazel/rules_postgres with git filter-repo, history preserved (8 commits), and moved to Pg/Migrate/Fold.lean.

Why it moved, twice over. It is the only module in the catalog family that reaches outside Pg.Catalog — it imports Pg.Query.Top — so it cannot live in @pgcatalog without dragging the parse tree in. And it could not keep its old path here either: @pgcatalog publishes oleans rooted at Pg/Catalog/, so a second archive claiming that root would overlap on unpack.

The namespace did not move. namespace Pg.Catalog inside the file is unchanged. Module path and namespace are independent in Lean, so Snapshot.ofTopParseResult and friends resolve exactly as before for anyone with open Pg.Catalog — only the import line changes. Renaming the namespace is a separate, later decision; doing both at once would churn call sites for no gain. Fold.lean itself is byte-identical.

Fold is total, and must stay that way. It deliberately reproduces the quirks of a C tool it replaced — resolveType falling back to 2249, resolveBareColumn ignoring the FROM map, foldAlterTable silently skipping a missing table — and carries a byte-equivalence claim against that tool over a 1,384-statement production schema. It is not to be “fixed”. The validating transition function (step, with real preconditions and an error type) gets written beside it, and the disagreements between the two are findings to triage rather than bugs in either.

Sits at the top of the graph, depending on both @pgcatalog (for Snapshot) and @pgquery (for Query.Top, and for the generated SmokeFixtureTyped that the pipeline test folds). CI exercises the decoder and the fold together rather than either alone.

What comes next here

This module is where the provable-migration work lands: statement closure over mutating DDL, top-level DML, a catalog transition that can fail, the Migration object and its headline theorem, hazard and lock analysis, and backfill correctness. Today it holds Fold and nothing else.

Requires rules_lean 0.6.1 — earlier releases’ lean_olean_archive fails on linux.

← All modules