<p align="center">
<img src="assets/logo.png" alt="Gorrión" width="400"/>
</p>
<p align="center">
<a href="https://hex.pm/packages/gorrion"><img src="https://img.shields.io/hexpm/v/gorrion.svg?style=flat-square" alt="Hex.pm version"/></a>
<a href="https://hex.pm/packages/gorrion"><img src="https://img.shields.io/hexpm/dt/gorrion.svg?style=flat-square" alt="Hex.pm downloads"/></a>
<a href="https://hex.pm/packages/gorrion"><img src="http://img.shields.io/hexpm/l/gorrion.svg?style=flat" alt="License"/></a>
</p>
Ecto-like database migration library for Gleam + PostgreSQL.
Reads plain `.sql` files from a directory, tracks applied migrations in a `_schema_migrations` table, and supports forward migration and rollback.
## Installation
Add gorrion as a path dependency in your `gleam.toml`:
```toml
[dependencies]
gorrion = { path = "../gorrion" }
```
## Migration file convention
Place your SQL files in a `migrations/` directory (outside `src/` to avoid squirrel conflicts):
```
your_project/
migrations/
001_create_users.sql # up migration (required)
001_create_users_down.sql # rollback (optional)
002_add_email_to_users.sql
003_create_orders.sql
003_create_orders_down.sql
```
- **Up**: `{NNN}_{name}.sql` — the migration SQL to apply
- **Down**: `{NNN}_{name}_down.sql` — optional rollback SQL. If missing, rollback will only remove the tracking record without running any SQL.
The version number `NNN` can be any integer (e.g., `001`, `042`, `20240101`). Migrations are applied in version order.
## Usage
```gleam
import gorrion
import gorrion/types
import pog
pub fn main() {
// ... set up your pog connection ...
// Run all pending migrations
case gorrion.migrate(db: db, migrations_dir: "migrations") {
Ok(Nil) -> io.println("Done")
Error(types.MigrationFailed(version:, name:, reason:)) ->
io.println("Migration " <> int.to_string(version) <> " failed: " <> reason)
Error(types.FileError(reason)) ->
io.println("File error: " <> reason)
Error(types.QueryError(reason)) ->
io.println("Query error: " <> reason)
}
}
```
## API
### `gorrion.migrate(db:, migrations_dir:)`
Runs all pending migrations in version order. Skips migrations already recorded in `_schema_migrations`.
### `gorrion.rollback(db:, migrations_dir:)`
Rolls back the most recently applied migration. Executes the `_down.sql` file if it exists, otherwise just removes the tracking record.
### `gorrion.rollback_to(db:, migrations_dir:, target_version:)`
Rolls back all migrations above `target_version` in reverse order.
### `gorrion.status(db:, migrations_dir:)`
Returns a `MigrationStatus` with lists of applied and pending migrations:
```gleam
case gorrion.status(db: db, migrations_dir: "migrations") {
Ok(status) -> {
io.println("Applied: " <> int.to_string(list.length(status.applied)))
io.println("Pending: " <> int.to_string(list.length(status.pending)))
}
Error(_) -> io.println("Could not read status")
}
```
## Types
```gleam
// A migration loaded from disk
type Migration {
Migration(version: Int, name: String, up: String, down: String)
}
// A migration that has been applied (from _schema_migrations)
type AppliedMigration {
AppliedMigration(version: Int, name: String, applied_at: String)
}
// Status report
type MigrationStatus {
MigrationStatus(applied: List(AppliedMigration), pending: List(Migration))
}
// Error types
type MigrationError {
QueryError(String)
MigrationFailed(version: Int, name: String, reason: String)
RollbackFailed(version: Int, name: String, reason: String)
NoMigrationsToRollback
FileError(String)
}
```
## How it works
1. Scans the migrations directory for `*.sql` files (excluding `*_down.sql`)
2. Creates `_schema_migrations` table if it doesn't exist
3. Compares file versions against recorded versions
4. Applies pending migrations in order, recording each in `_schema_migrations`
The `_schema_migrations` table:
```sql
CREATE TABLE _schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```