https://github.com/kamysh/kryzhen
Forward-only, dependency-resolved SQL migrations for PostgreSQL — a Rust port of the Haskell mallard tool. Library crate + CLI.
https://github.com/kamysh/kryzhen
cli database-migrations mallard migrations postgresql rust sql
Last synced: about 7 hours ago
JSON representation
Forward-only, dependency-resolved SQL migrations for PostgreSQL — a Rust port of the Haskell mallard tool. Library crate + CLI.
- Host: GitHub
- URL: https://github.com/kamysh/kryzhen
- Owner: kamysh
- License: other
- Created: 2026-06-12T07:10:01.000Z (25 days ago)
- Default Branch: main
- Last Pushed: 2026-06-12T09:19:47.000Z (25 days ago)
- Last Synced: 2026-06-12T11:11:09.803Z (25 days ago)
- Topics: cli, database-migrations, mallard, migrations, postgresql, rust, sql
- Language: Rust
- Size: 68.4 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Cla: CLA.md
Awesome Lists containing this project
README
# kryzhen
A Rust port of the Haskell [`mallard`](https://hackage.haskell.org/package/mallard) SQL migration tool ([source](https://github.com/AndrewRademacher/mallard)). Forward-only (accretive — no down-migrations), dependency-resolved migrations for PostgreSQL. Compatible with mallard's on-disk migration file format and its `mallard.applied_migrations` tracking table.
Available as:
- **library crate** `kryzhen` — call `migrate()` programmatically.
- **CLI binary** `kryzhen` (crate `kryzhen-cli`) — run migrations from the command line.
---
## Migration file format
Migration files are plain `.sql` files. Each file may contain one or more `#!migration` blocks. A block begins with a header comment and is followed by the SQL body:
```sql
-- #!migration
-- name: "tables/phone",
-- description: "Phone numbers attached to a person.",
-- requires: ["tables/person"];
CREATE TABLE phone (id bigint);
```
### Header fields
| Field | Required | Type | Notes |
|---|---|---|---|
| `name` | yes | string | Unique identifier for this migration. |
| `description` | yes | string | Human-readable description. |
| `requires` | no | string or list | A single `"name"` or a list `["a", "b"]`. |
Header fields are separated by commas; the last field is terminated with `;`. The SQL body follows directly after the header.
> **Note:** `#!test` blocks are **not** supported — kryzhen handles migrations only, and a `#!test` block is a parse error.
### Multiple blocks per file + implicit linear dependency
A single `.sql` file may contain several `#!migration` blocks. Each block after the first **implicitly depends on the block immediately before it** in the same file, in addition to any explicit `requires`. This merged requires set is what gets persisted to the tracking table.
---
## Dependency resolution
Migrations are applied in **topological order** of their `requires` graph. The following are hard errors:
- Dependency cycles.
- A `requires` reference to a migration name that does not exist.
- Duplicate migration names (across all files).
---
## Tracking table
kryzhen creates a `mallard` schema (if absent) with an `applied_migrations` table:
| Column | Description |
|---|---|
| `id` | Serial primary key. |
| `name` | Migration name. |
| `description` | Migration description. |
| `requires` | Dependencies recorded at apply time. |
| `checksum` | SHA-256 of the whitespace-trimmed SQL body (`bytea`, 32 raw bytes). |
| `script_text` | Full SQL body as applied. |
| `applied_on` | Timestamp of application. |
This is the same schema used by mallard, so the two tools can share a database.
kryzhen also keeps a companion table `mallard.applied_migration_schemas` recording, per migration, which schema(s) it has been applied to (one row per `(migration_name, schema)`), and a `mallard.migrator_version` table marking the ledger format. See [Multiple schemas](#multiple-schemas-templated-migrations).
---
## Multiple schemas (templated migrations)
The same migration set can be applied independently to many schemas — one schema per customer/tenant, or one per RAG corpus. Migrations act as **templates**:
- **Unqualified** DDL (`CREATE TABLE widgets …`) is created in the **target schema** — kryzhen runs each migration body under `SET LOCAL search_path TO `.
- **Schema-qualified** DDL (`CREATE TABLE other.thing …`) goes exactly where it names.
Because the body runs with `search_path` set to the target schema only, a migration that references an object in *another* schema (e.g. an extension function) by an unqualified name will not resolve it — schema-qualify such references.
Apply to a schema with `--schema` (library: the `schema` argument to `migrate`). It defaults to `public`, which reproduces single-schema behaviour. To roll the same migrations out to several schemas, run once per schema:
```bash
kryzhen --database app --schema customer_a migrations/
kryzhen --database app --schema customer_b migrations/
```
Tracking is **per schema**: the `mallard.applied_migration_schemas` table records which migrations have reached which schema. Different schemas may sit at different points in the chain — onboarding a new customer applies the whole chain into a fresh schema, while adding a new migration only applies the missing tail to each existing schema. The canonical `mallard.applied_migrations` row (body + checksum) is written once, on the first schema to apply a given migration, and tamper detection is verified against it on every run (the body is schema-independent).
> **Note:** the per-schema id uses `gen_random_uuid()`, which is built in on **PostgreSQL 13+**. On older servers, enable the `pgcrypto` extension.
### Upgrading an existing database
A database first migrated by an older kryzhen (no per-schema table) is upgraded automatically: on the next run, kryzhen creates the new tables and backfills an association for every already-applied migration against the schema the connection currently resolves to (`current_schema()` — where those objects actually live), then stamps `migrator_version`. Nothing is re-applied. This runs once and is idempotent.
---
## Tamper detection
On every run, kryzhen recomputes the SHA-256 checksum of each already-applied migration's SQL body and **aborts if it differs** from the stored value. Do not edit a migration file after it has been applied.
---
## Atomicity and idempotency
Each migration runs inside its own transaction together with its bookkeeping `INSERT` into `applied_migrations`. A failure aborts the run. Already-applied migrations are skipped on re-run, so running kryzhen multiple times is safe.
---
## Library usage
The caller owns the database connection. Build it with `tokio_postgres` and pass it to
`migrate`:
```rust
use kryzhen::{file, migrate, Report};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (mut client, conn) = tokio_postgres::connect(
"host=127.0.0.1 user=postgres dbname=mydb",
tokio_postgres::NoTls,
).await?;
tokio::spawn(async move { let _ = conn.await; });
let migrations = file::load_dir("migrations")?;
// Apply to the `public` schema; pass a different schema per tenant to template.
let report: Report = migrate(&mut client, &migrations, "public", false).await?;
println!("Applied: {:?}", report.applied);
println!("Already applied: {:?}", report.already_applied);
Ok(())
}
```
Pass `dry_run = true` to resolve and plan without applying anything (still connects and
verifies checksums).
### `Report` fields
| Field | Type | Description |
|---|---|---|
| `applied` | `Vec` | Names applied this run (or planned, in dry-run mode), in topological order. |
| `already_applied` | `Vec` | Names that were already present before this run. |
---
## CLI usage
```
kryzhen --database mydb [ROOT]
```
`ROOT` defaults to `.` (current directory).
### Flags
| Flag | Default | Description |
|---|---|---|
| `--database ` | *(required)* | Database name. |
| `--host ` | `127.0.0.1` | Server host. |
| `--port ` | `5432` | Server port. |
| `--user ` | `postgres` | Username. |
| `--schema ` | `public` | Target schema for the migrations. See [Multiple schemas](#multiple-schemas-templated-migrations). |
| `--password ` | *(empty)* | Password. |
| `--sslmode ` | `prefer` | TLS mode: `disable`, `prefer`, `require`, `verify-ca`, or `verify-full`. See [TLS](#tls). |
| `--ssl-root-cert ` | *(none)* | CA certificate for `verify-ca` / `verify-full` (PEM). |
| `--ssl-client-cert ` | *(none)* | Client certificate for mutual TLS (PEM). |
| `--ssl-client-key ` | *(none)* | Client private key for mutual TLS (PEM). |
| `--dry-run` | off | Print the planned migration order; apply nothing. |
| `-v, --verbose` | off | Enable debug-level logging. |
| `-h, --help` | | Print help. |
| `-V, --version` | | Print version. |
### Example — dry run
```
kryzhen --database mydb --dry-run migrations/
```
Prints the migrations that would be applied, in topological order, without modifying the database. (It still connects, to load the applied set and verify checksums.)
---
## TLS
kryzhen negotiates TLS using the standard libpq `sslmode` values:
| Mode | Behaviour |
|---|---|
| `disable` | Never use TLS; connect in plaintext. |
| `prefer` *(default)* | Use TLS if the server offers it; fall back to plaintext otherwise. |
| `require` | Require TLS; fail if the server does not offer it. Certificate not verified. |
| `verify-ca` | Require TLS and verify the server certificate against `ssl_root_cert`. |
| `verify-full` | Like `verify-ca`, and also verify the server hostname matches the certificate CN/SAN. |
In `prefer` and `require` the connection is **encrypted but the server certificate is not verified** — matching libpq's behaviour for those modes. This lets kryzhen connect to a database using a self-signed or private-CA certificate (common for internal PostgreSQL deployments).
For `verify-ca` and `verify-full`, set `ssl_root_cert` (library) or `--ssl-root-cert` (CLI) to the CA certificate PEM file.
### Mutual TLS
To present a client certificate to the server (for PostgreSQL `clientcert=verify-full` authentication), set both `ssl_client_cert` and `ssl_client_key` (library) or `--ssl-client-cert` and `--ssl-client-key` (CLI). Mutual TLS can be combined with any sslmode that establishes a TLS connection (`prefer`, `require`, `verify-ca`, `verify-full`).
TLS uses OpenSSL (via `native-tls`), so the build needs OpenSSL and `pkg-config` available — see [docs/development.md](docs/development.md).
---
## Escape hatches
The `kryzhen hack` subcommand provides low-level manipulation of `mallard.applied_migrations` for situations where normal migration flow isn't sufficient.
### `hack add` — record a migration as applied without running its SQL
```bash
kryzhen hack add --root migrations/ --database mydb
```
Records `NAME` as applied (to `--schema`, default `public`) without executing the SQL body. Useful when a migration has already been applied by other means (e.g. provisioned by Terraform, applied by another tool). Fails if any of the migration's `requires` are not yet applied **to that schema**.
### `hack delete` — remove a migration record without running any SQL
```bash
kryzhen hack delete --database mydb
```
Removes `NAME`'s association with `--schema` (default `public`) from `mallard.applied_migration_schemas`. Fails if any migration applied to that schema lists `NAME` in its `requires`. When that was `NAME`'s last remaining schema, the canonical `mallard.applied_migrations` row is also removed.
### `hack fix-checksum` — update a stored checksum to match the current file
```bash
kryzhen hack fix-checksum --root migrations/ --database mydb
```
Recomputes the checksum from the current on-disk content of migration `NAME` and overwrites the stored value in `mallard.applied_migrations`. Use this when a migration file was legitimately edited after it was applied (e.g. reformatted, whitespace-only change) and tamper detection is now blocking normal runs with:
```
checksum mismatch for already-applied migration [NAME]: file content changed — run `kryzhen hack fix-checksum NAME` to update
```
> **Warning:** `hack fix-checksum` bypasses tamper detection. Only use it when you are certain the file change was intentional and harmless.
---
## Migrating from sqlx
If you have an existing project that uses [sqlx migrations](https://docs.rs/sqlx/latest/sqlx/migrate/index.html), `kryzhen hack migrate-from sqlx` imports your applied migration history into `mallard.applied_migrations` in two phases:
```bash
# Phase 1: verify checksums, rewrite files with #!migration headers, write receipt
kryzhen hack migrate-from sqlx convert [MIGRATIONS_DIR] --database mydb
# Phase 2: re-verify _sqlx_migrations against the receipt, insert into mallard
kryzhen hack migrate-from sqlx import [MIGRATIONS_DIR] --database mydb
# Or run both phases in one command:
kryzhen hack migrate-from sqlx all [MIGRATIONS_DIR] --database mydb
```
`MIGRATIONS_DIR` defaults to `.`. The `_sqlx_migrations` table is never modified.
After a successful import you can use `kryzhen migrate` as normal. sqlx should no longer manage those migrations.
### What each phase does
| Phase | Input | Output |
|---|---|---|
| `convert` | `_sqlx_migrations` rows + original files | Files rewritten with `#!migration` headers; `.kryzhen-import-receipt.json` written |
| `import` | Receipt + `_sqlx_migrations` (re-verified) + converted files | Rows inserted into `mallard.applied_migrations` |
### Team workflow
After Person A runs `convert` and `import`, commit both the rewritten `.sql` files and `.kryzhen-import-receipt.json` to git. Teammates who sync the repo run only `import` — it re-verifies their `_sqlx_migrations` against the receipt and inserts any missing rows, skipping rows already present.
---
## Development
See [docs/development.md](docs/development.md) for how to build, test, and contribute.
API documentation is available via `cargo doc --open`.
---
## License
Apache License 2.0 — see [LICENSE](LICENSE).
Contributions are subject to the [Contributor License Agreement](CLA.md).