Skip to content

Migrations

rhizodat.migrations.format

The language-neutral migration format: one TOML file per migration.

NNNN_name.toml (the number is the migration's id, at least 1; the name is lower-case letters, digits and _) holds an optional description and a list of steps. A step names the store it runs on (a store name of the Rhizodat configuration) and has exactly one body, keyed by its kind::

description = "bugs and their graph index"

[[steps]]
store = "pg"
sql = "CREATE TABLE IF NOT EXISTS bug (id uuid PRIMARY KEY, title text NOT NULL)"

[[steps]]
store = "graph"
cypher = ["CREATE INDEX bug_title IF NOT EXISTS FOR (b:Bug) ON (b.title)"]

The kind (sql, cypher, iceberg, or a plug-in adapter's own) decides which store can run the step; the format only checks its shape. A body is strings, integers, booleans, arrays and tables: floats and dates have no single text form across languages, so they are refused.

The checksum is the sha256 (hex) of the canonical form: the JSON of {"id", "name", "steps": [{"body", "kind", "store"}]} with keys sorted, no whitespace and non-ASCII text as UTF-8 (\uXXXX escapes only for control characters). The description is not part of it.

MigrationFormatError

Bases: ValueError

A migration file or directory that does not follow the format.

Migration dataclass

stores property

stores: tuple[str, ...]

The stores its steps run on, in order of first appearance.

of classmethod

of(
    id: int,
    name: str,
    steps: Iterable[Step],
    description: str | None = None,
) -> Migration

A migration built in code, checked like a file {id:04}_{name}.toml.

canonical

canonical(migration: Migration) -> str

The text the checksum is taken of (see the module documentation).

parse_migration

parse_migration(filename: str, text: str) -> Migration

The migration in text, read from a file named filename.

to_toml

to_toml(migration: Migration) -> str

The file text of migration; parse_migration reads it back unchanged.

statements

statements(kind: str, body: object) -> list[str]

A sql / cypher-like body: one statement or a non-empty list of them.

load_migrations

load_migrations(directory: str | Path) -> list[Migration]

Every *.toml in directory, by id; other files are ignored.

by_id

by_id(migrations: Sequence[Migration]) -> list[Migration]

migrations sorted by id; MigrationFormatError on a duplicate id.

rhizodat.migrations.runner

Running migrations against a Rhizodat: the history lives in the journal store.

Before any step runs, the source is checked against the history: an applied migration must still be there unchanged (same checksum), and a new one must be newer than every applied one. Every pending step must name a configured store that runs its kind. Then each pending migration runs its steps in order and is recorded with the HLC time of the record, so schema_versions(as_of=...) says which schema applied at a moment.

A migration over several stores is not atomic: a failing step stops the run with MigrationFailed and leaves the migration unrecorded, so the next run runs all of it again. That is why steps must be idempotent. Run one runner at a time; a second one that records a migration first is reported, not repeated.

MigrationError

Bases: RuntimeError

The migrations cannot run: the history does not match the source, or a step names a store that cannot run it. Nothing ran.

MigrationFailed

Bases: MigrationError

A step failed (its error is the __cause__); earlier migrations stay applied.

MigrationReport dataclass

applied instance-attribute

applied: tuple[int, ...]

Run and recorded by this call.

pending instance-attribute

pending: tuple[int, ...]

Still to run: a dry run, or beyond to.

already instance-attribute

already: tuple[int, ...]

Applied before this call.