Skip to content

feat(db): versioned schema migrations with idempotent backfill#21

Merged
kevincodex1 merged 2 commits into
mainfrom
feat/versioned-migrations
Jun 1, 2026
Merged

feat(db): versioned schema migrations with idempotent backfill#21
kevincodex1 merged 2 commits into
mainfrom
feat/versioned-migrations

Conversation

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

What

Add a versioned migration system for the Postgres schema. Migrations are
now recorded in a schema_migrations table and applied at most once per
node, inside a single transaction.

Closes the gap called out in docs/OSS-READINESS-AUDIT.md:78
("no versioned database migration system; schema is created/altered
from code at startup, which is convenient early on but risky for live
upgrades").

Why

  • Today every node restart re-executes ~140 inline &str SQL statements.
    That's safe only because every statement is IF NOT EXISTS. The
    moment we need to add a non-IF NOT EXISTS change (rename, add a
    NOT NULL column to a large existing table, add an index without
    IF NOT EXISTS, etc.) we have no record of what's been applied where.
  • A live network with 2+ public nodes needs a way to confirm every node
    is at the same schema version.
  • This is the prerequisite for v0.4.x work that will require real
    schema changes (UCAN revocation table, per-repo authorization,
    metrics counters, etc.).

Behavior change

None observable to operators. The schema content is byte-for-byte
identical. What changes internally:

  1. A new schema_migrations table is created.
  2. On a fresh install: all 25 tables / ~140 statements run inside
    one transaction and are recorded as v1 applied.
  3. On an existing install: v1 is recorded as already applied without
    re-running statements (detected via the canonical repos table).

Operator action required: none.

Safety properties

  • Atomic per migration: each migration runs in a single transaction.
    A failure mid-way rolls the whole version back; the node refuses to
    start rather than booting on a partially-applied schema.
  • Idempotent: if a node is killed mid-migration, on restart the
    version is still unrecorded, so the runner picks it up again and
    completes it.
  • Statement-level error context: with_context includes the
    failing SQL string and migration version in any error chain, so
    operators don't have to dig through logs.
  • Backwards-compatible for existing installs: the legacy-install
    path detects the repos table and short-circuits the v1
    application. No existing operator will see their 140 statements
    re-run.

Testing

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace — all tests pass, including 6 new
    db::migration_tests::* tests that validate the static migration
    catalogue:
    • migrations_are_non_empty
    • migration_versions_are_strictly_increasing
    • migration_versions_start_at_one
    • migration_names_are_non_empty_and_distinct
    • migration_bodies_are_non_empty
    • v1_name_is_the_initial_schema (catches an accidental rename
      of v1, which would silently break the legacy-install backfill)

A new Db::migration_status() returns Vec<(version, name, applied_at)>
for ops/observability. It is intentionally not wired into any
HTTP route in this PR — that's a one-line follow-up. The catalogue
itself is the API.

Risk

Low. The change is purely additive. Every existing IF NOT EXISTS
guard is preserved inside the v1 migration body, so the schema content
is identical. Failures roll back the transaction and prevent the node
from starting (the correct behavior on a partially-applied schema).

The only failure mode worth flagging: if a node's Postgres is reachable
but the repos table is missing for some reason (manual DROP TABLE,
a fresh empty DB, etc.), the legacy-install backfill will run v1 from
scratch. That is the correct behavior.

Follow-ups (not in this PR)

  • Surface Db::migration_status() via gl status and/or
    /api/v1/stats so operators can confirm a node's schema version
    at a glance.
  • Begin adding real schema changes as v2, v3, … as they are needed
    (this PR lays the groundwork; v2 will be a no-op Migration { version: 2, name: "noop_placeholder", stmts: &[] } if we want a clean version
    bump point).

🤖 Generated with Claude Code

Replace the inline &str SQL array that re-runs on every node startup with a
versioned migration system. Each migration is recorded in a new
schema_migrations table and applied at most once per node, inside a single
transaction.

For nodes upgrading from a pre-migration-versioning build, the canonical
`repos` table is used as a signal to mark v1 as already applied without
re-running its ~140 statements — so existing operators see zero behavior
change on first restart after this commit.

Closes the gap called out in docs/OSS-READINESS-AUDIT.md:78.

- Adds Db::migration_status() returning applied (version, name, applied_at)
- Adds 6 unit tests validating the static migration catalogue
  (versions strictly increasing, names distinct, bodies non-empty, v1
  name locked to initial_schema)
- cargo fmt + clippy -D warnings + cargo test --workspace all clean
Address two robustness gaps in the v1 migration runner on the live network:

- Remove the "repos table present => v1 complete" backfill short-circuit.
  It assumed every existing node already had the full current schema; a node
  that was behind would be marked v1-applied while still missing newer tables
  or columns. Every v1 statement is idempotent (CREATE TABLE/INDEX IF NOT
  EXISTS, ADD COLUMN IF NOT EXISTS), so legacy installs now simply run v1 once:
  existing objects are no-ops, missing ones get created.

- Guard the whole runner with a Postgres advisory lock so two instances
  against the same database (blue/green or rolling deploy) can't race to apply
  the same migration and trip the schema_migrations primary key. The lock is
  released explicitly, and automatically if the connection drops.

Also document that per-migration transactions preclude CREATE INDEX
CONCURRENTLY in future migrations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@kevincodex1 kevincodex1 merged commit 927e4d0 into main Jun 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants