Migration Guide
This document covers how database migrations work in Alloy, including naming conventions, the dual-backend requirement, type differences between PostgreSQL and SQLite, how refinery embeds migrations into the binary, keeping version numbers aligned, and testing migrations.
Overview
Alloy supports two database backends: PostgreSQL (multi-tenant with RLS)
and SQLite (single-tenant, single-file). Every migration must exist in both
backends. Migrations are embedded into the compiled binary via refinery’s
embed_migrations! macro — there are no external SQL files at runtime.
Directory Layout
migrations/
├── postgres/
│ ├── V1__create_schema_info.sql
│ ├── V2__create_organizations.sql
│ ├── ...
│ └── V32__add_time_entry_indexes.sql
└── sqlite/
├── V1__create_schema_info.sql
├── V2__create_organizations.sql
├── ...
└── V32__add_time_entry_indexes.sql
Each directory contains the same set of version numbers. The Rust code in
crates/alloy-api/src/db.rs embeds them:
#![allow(unused)]
fn main() {
mod pg_migrations {
refinery::embed_migrations!("../../migrations/postgres");
}
mod sqlite_migrations {
refinery::embed_migrations!("../../migrations/sqlite");
}
}
Naming Convention
Migration files follow refinery’s naming scheme:
V{number}__{description}.sql
- V — prefix (uppercase)
- {number} — sequential integer, no zero-padding (1, 2, … 32)
- __ — double underscore separator
- {description} — snake_case description of the change
- .sql — file extension
Examples:
V7__create_tickets.sqlV17__add_project_capitalization.sqlV31__add_ticket_indexes.sql
To find the next version number, check the highest existing number:
ls migrations/postgres/ | sort -t_ -k1 -V | tail -1
Dual-Backend Requirement
Every migration version must exist in both migrations/postgres/ and
migrations/sqlite/. Even when a migration is backend-specific (like RLS
policies), the other backend needs a matching no-op file to keep version
numbers aligned.
Example — V9 enables RLS in PostgreSQL:
migrations/postgres/V9__enable_rls.sql — Full RLS setup with policies,
roles, and grants.
migrations/sqlite/V9__enable_rls.sql — No-op placeholder:
-- No-op: RLS is PostgreSQL-only. This migration exists to keep
-- SQLite and PostgreSQL version numbers aligned.
If you forget the counterpart file, the backends will diverge and future migrations will fail with version-mismatch errors.
Type Differences
Write SQL that works on both backends where possible. When that isn’t possible, write backend-specific SQL in each file.
| Concept | PostgreSQL | SQLite |
|---|---|---|
| Strings | TEXT | TEXT |
| Variable-length strings | Use TEXT (not VARCHAR) | TEXT |
| Primary keys | TEXT PRIMARY KEY (UUIDs as text) | TEXT PRIMARY KEY |
| Auto-increment integers | SERIAL or BIGSERIAL | INTEGER PRIMARY KEY AUTOINCREMENT |
| UUIDs | UUID (native type) | TEXT |
| Timestamps | TIMESTAMPTZ NOT NULL DEFAULT NOW() | TEXT NOT NULL DEFAULT (datetime('now')) |
| Booleans | BOOLEAN NOT NULL DEFAULT false | INTEGER NOT NULL DEFAULT 0 |
| JSON | JSONB | TEXT |
Rules of thumb:
- Always use
TEXTinstead ofVARCHAR— works on both backends. - Use
TEXTfor UUIDs on both backends (PostgreSQL can cast, SQLite needs it). - Timestamps differ:
TIMESTAMPTZ+NOW()for PG,TEXT+datetime('now')for SQLite. - Booleans differ:
BOOLEANfor PG,INTEGERfor SQLite. - Avoid
SERIAL— use application-generated UUIDs as primary keys instead.
PostgreSQL-Only Features
Some migrations contain PostgreSQL-specific SQL that has no SQLite equivalent:
- Row Level Security (RLS):
ALTER TABLE ... ENABLE ROW LEVEL SECURITY,CREATE POLICY,current_setting(). SQLite uses explicitAND org_id = ?in application queries instead. - Roles and grants:
CREATE ROLE,GRANT,ALTER DEFAULT PRIVILEGES. - PL/pgSQL functions:
CREATE OR REPLACE FUNCTION ... LANGUAGE sql. DO $$blocks: Anonymous code blocks for conditional DDL.
For all of these, create a no-op SQLite migration with a comment explaining why.
How Refinery embed_migrations! Works
Refinery’s embed_migrations! macro reads the SQL files at compile time
and embeds them as string constants in the binary. This means:
- No runtime file access — the binary contains all migrations. No need to ship SQL files alongside the executable.
- Compile-time validation — if a migration file is malformed or missing, the build fails.
- Version tracking — refinery creates a
refinery_schema_historytable in the database to track which migrations have been applied. - Forward-only — refinery does not support down migrations. To undo a change, write a new migration that reverses it.
The migration runner is triggered in crates/alloy-api/src/db.rs via the
Database::migrate() method. In production, auto-migration on startup is
controlled by ALLOY_AUTO_MIGRATE=true.
Writing a New Migration
Step 1: Determine the next version number
ls migrations/postgres/ | sort -t_ -k1 -V | tail -1
# If the last file is V32__add_time_entry_indexes.sql, your next is V33
Step 2: Create both files
touch migrations/postgres/V33__your_description.sql
touch migrations/sqlite/V33__your_description.sql
Step 3: Write the SQL
Write the PostgreSQL version first, then adapt for SQLite. If the SQL is
identical (e.g., ALTER TABLE ... ADD COLUMN ... TEXT), the files can be the
same. If not, write backend-specific SQL in each.
Example — adding a column (identical on both):
ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
Example — adding an index (identical on both):
CREATE INDEX idx_tickets_assignee ON tickets(assignee_id);
Example — PostgreSQL-specific (adding RLS policy):
migrations/postgres/V33__add_rls_for_new_table.sql:
ALTER TABLE new_table ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON new_table
FOR ALL USING (org_id = current_tenant_id());
migrations/sqlite/V33__add_rls_for_new_table.sql:
-- No-op: RLS is PostgreSQL-only.
Step 4: Verify the build
cargo check --workspace --all-targets
This compiles the migrations into the binary. If your SQL files are not in the right location or have naming issues, this step will catch it.
Step 5: Test the migration
Run the full test suite to ensure the migration applies cleanly:
cargo test --workspace --all-features
SQLite tests use in-memory databases and run all migrations from scratch on every test, so any migration issue will surface immediately.
For PostgreSQL tests (gated behind #[cfg(feature = "postgres-tests")]), the
migration runner also applies from scratch against a test database.
Keeping Version Numbers Aligned
The version numbers in migrations/postgres/ and migrations/sqlite/ must
always match one-to-one. Refinery tracks applied versions per database — if
PostgreSQL is on V32 but SQLite only has files up to V31, SQLite tests will
fail when they encounter V32 in the PostgreSQL directory (or vice versa if you
run them against the wrong directory).
Checklist before committing a migration:
- Both
migrations/postgres/V{N}__name.sqlandmigrations/sqlite/V{N}__name.sqlexist - Version number
Nis the next sequential integer (no gaps, no duplicates) - File names match the
V{N}__{description}.sqlpattern (double underscore) -
cargo check --workspacepasses (migrations compile into binary) -
cargo test --workspacepasses (migrations apply cleanly)
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Missing SQLite counterpart | PG tests pass, SQLite tests fail with version error | Add no-op SQLite file |
Using VARCHAR instead of TEXT | Works on PG, fails on strict SQLite parsers | Always use TEXT |
Using NOW() in SQLite | Syntax error | Use datetime('now') for SQLite |
Using BOOLEAN in SQLite | Column created but stores 0/1 as text | Use INTEGER for SQLite |
| Gap in version numbers | refinery refuses to run | Renumber to fill the gap |
| Duplicate version number | Compile error from refinery | Use next available number |
Single underscore V1_name | refinery ignores the file | Use double underscore V1__name |
| Modifying an applied migration | Checksum mismatch error in production | Write a new migration instead |
Seed Data Migrations
Some migrations insert data rather than alter schema. For example,
V28__seed_default_workflows.sql inserts default workflow states. These follow
the same dual-backend rules — the SQL syntax for INSERT is usually identical
across both backends, but watch for type differences in the values (e.g., UUID
format, timestamp format).