erlang_migrate

Erlang/OTP database migration library — pixel-perfect reference implementation of golang-migrate/migrate v4.

Erlang/OTP 数据库迁移库 —— 像素级对标 golang-migrate/migrate v4 的设计实现。

Supported databases / 支持的数据库:PostgreSQL 18+ · MySQL 8+ · SQLite 3+


Design Philosophy / 设计理念

erlang_migrate directly inherits the architectural philosophy of golang-migrate/migrate/v4:

erlang_migrate 直接继承 golang-migrate/migrate/v4 的架构哲学:

1. Source/Database Separation / 来源与数据库分离

golang-migrate decouples migration source (where SQL files come from) from migration target (which database to run against). erlang_migrate follows the same separation: erlang_migrate_source handles file discovery, erlang_migrate_pg handles PostgreSQL execution.

golang-migrate 将迁移来源(SQL 文件从哪里读)与迁移目标(运行在哪个数据库)彻底解耦。 erlang_migrate 遵循同样的分离:erlang_migrate_source 负责文件发现,erlang_migrate_pg 负责 PostgreSQL 执行。

2. Dirty State Machine / Dirty 状态机

Every migration is executed in a two-phase commit pattern:

每个迁移以两阶段提交模式执行:

set_version(V, dirty=true)   ← mark as in-progress / 标记为执行中
run SQL                       ← execute migration / 执行迁移
set_version(V, dirty=false)  ← mark as complete / 标记为完成

If the process crashes between phases, dirty=true is permanently recorded, blocking future runs until force/2 is called. This is identical to golang-migrate's SetVersion(v, true)Run()SetVersion(v, false) pattern.

如果进程在两阶段之间崩溃,dirty=true 会被永久记录,阻止后续运行直到调用 force/2。 这与 golang-migrate 的 SetVersion(v, true)Run()SetVersion(v, false) 模式完全一致。

3. Advisory Lock for Distributed Safety / Advisory Lock 分布式安全

Both golang-migrate and erlang_migrate use the database's own advisory lock mechanism to prevent concurrent migrations across multiple application nodes. The lock is always released in an after block (equivalent to Go's defer), ensuring no orphaned locks.

golang-migrate 和 erlang_migrate 都使用数据库自身的 advisory lock 机制,防止多个应用节点并发执行迁移。锁始终在 after 块中释放(等价于 Go 的 defer),确保不会产生孤立锁。

4. Version as Integer / 版本号为整数

Versions are unsigned integers — either sequential (1, 2, 3…) or Unix timestamps. There is no semantic versioning. The ordering is strict numeric, making "which migration runs next" deterministic and unambiguous.

版本号是无符号整数——可以是顺序整数(1, 2, 3…)或 Unix 时间戳。没有语义版本控制,排序是严格数值排序,使"下一个运行哪个迁移"具有确定性且无歧义。

5. Plain SQL Only / 仅使用纯 SQL

Migrations are plain .sql files. No ORM, no DSL, no code generation. The SQL you write is exactly what runs against the database. This keeps migrations auditable, portable, and debuggable.

迁移是纯 .sql 文件。没有 ORM,没有 DSL,没有代码生成。你写的 SQL 就是直接在数据库上运行的 SQL。这使迁移可审计、可移植、可调试。


Migration File Rules / 迁移文件规则

File Naming Pattern / 文件命名规则

Rule / 规则 Pattern / 格式 Example / 示例
Up migration / 正向迁移 {version}_{title}.up.sql00000001_create_users.up.sql
Down migration / 反向迁移 {version}_{title}.down.sql00000001_create_users.down.sql
Version format / 版本格式 Positive integer / 正整数 1, 00000001, 20240101120000
Title format / 标题格式 [a-z0-9_]+create_users, add_email_index
Separator / 分隔符 Underscore _ between version and title 00000001_create_users
Extension / 扩展名 .up.sql or .down.sql.up.sql

Version Rules / 版本号规则

Rule / 规则 Description / 说明 Valid / 合法 Invalid / 非法
Must be positive integer / 必须是正整数 No zero, no negative / 非零,非负 1, 1000, -1
Zero-padded recommended / 建议补零 For consistent sorting / 保证排序一致 000000011 (still works / 也能用)
Unix timestamp allowed / 允许 Unix 时间戳 14-digit preferred / 推荐 14 位 20240101120000
No gaps required / 不要求连续 Gaps are fine / 允许跳号 1, 2, 5, 10
Must be unique / 必须唯一 Duplicate versions are rejected / 重复版本会被拒绝 Two files with same version

Directory Rules / 目录规则

Rule / 规则 Description / 说明
Flat directory / 平铺目录 No subdirectories scanned / 不扫描子目录
Any filename is scanned / 扫描所有文件 Only .up.sql and .down.sql are processed / 只处理 .up.sql.down.sql
.up.sql required / .up.sql 必须存在 Every version must have an up file / 每个版本必须有 up 文件
.down.sql optional / .down.sql 可选 If missing, down/2 will error for that version / 缺少则 down/2 该版本会报错
Must be readable / 必须可读 File permission errors abort the scan / 权限错误会中止扫描

SQL Content Rules / SQL 内容规则

Rule / 规则 Description / 说明
Multi-statement supported / 支持多语句 epgsql:squery executes the full file / epgsql:squery 直接执行整个文件
No explicit transaction needed / 无需显式事务 Each migration runs in its own auto-transaction / 每个迁移在自身事务中运行
DDL and DML both allowed / DDL 和 DML 均可 CREATE TABLE, INSERT, ALTER, etc. / 均支持
Empty file allowed / 允许空文件 Acts as a no-op version marker / 作为无操作版本标记
Comments allowed / 允许注释 Standard SQL -- and /* */ / 标准 SQL 注释均可

Example Directory Layout / 示例目录布局

priv/migrations/
  00000001_create_users.up.sql       ← required / 必须
  00000001_create_users.down.sql     ← recommended / 建议
  00000002_add_email_index.up.sql
  00000002_add_email_index.down.sql
  00000003_add_roles_table.up.sql
  00000003_add_roles_table.down.sql
  20240101120000_add_audit_log.up.sql
  20240101120000_add_audit_log.down.sql

Configuration / 配置

All behaviour is controlled by a single Config map passed to every API call. No changes to erlang_migrate source are needed — everything is configured at call site.

所有行为通过传入每个 API 调用的 Config map 控制。 无需修改 erlang_migrate 源码 —— 所有定制均在调用方配置。

Three key customisation points / 三个核心定制项

Key / 键 What it controls / 控制什么 Default / 默认值
driver Which database backend to use / 使用哪个数据库后端 erlang_migrate_pg
dir Where migration SQL files live / 迁移 SQL 文件目录 (required / 必填)
table Name of the tracking table / 迁移状态跟踪表名 <<"schema_migrations">>
%% PostgreSQL — default driver, custom path and table name
%% PostgreSQL —— 默认驱动,自定义路径和表名
Config = #{
    conn   => Conn,
    driver => erlang_migrate_pg,            % default, can be omitted / 默认可省略
    dir    => "priv/migrations/postgres",   % your SQL file directory / 你的迁移文件目录
    table  => <<"myapp_schema_migrations">> % custom tracking table / 自定义跟踪表名
},
ok = erlang_migrate:up(Config).

%% MySQL 8+
Config = #{
    conn   => Conn,
    driver => erlang_migrate_mysql,
    dir    => "priv/migrations/mysql",
    table  => <<"myapp_schema_migrations">>
},
ok = erlang_migrate:up(Config).

%% SQLite 3+
Config = #{
    conn   => Conn,
    driver => erlang_migrate_sqlite,
    dir    => "priv/migrations/sqlite",
    table  => <<"myapp_schema_migrations">>
},
ok = erlang_migrate:up(Config).

The tracking table is created automatically on first run if it does not exist. Lock ID is auto-derived from the table name, so different table names are lock-isolated.

跟踪表在首次运行时自动创建(如不存在)。 锁 ID 从表名自动派生,不同表名之间的锁互相隔离。


Quick Start / 快速开始

%% 1. Connect to PostgreSQL / 连接 PostgreSQL
{ok, Conn} = epgsql:connect(#{
    host     => "localhost",
    port     => 5432,
    database => "mydb",
    username => "user",
    password => "pass"
}),

%% 2. Build config — see "Configuration" section for driver/dir/table options
%% 构建配置 —— driver/dir/table 定制项见上方「Configuration」章节
Config = #{
    conn => Conn,
    dir  => "priv/migrations"
},

%% 3. Apply all pending migrations / 应用所有待执行迁移
ok = erlang_migrate:up(Config),

%% 4. Apply next 2 migrations / 应用接下来 2 个迁移
ok = erlang_migrate:up(Config, 2),

%% 5. Check current version and dirty flag / 查询当前版本和 dirty 状态
{ok, Version, Dirty} = erlang_migrate:version(Config),

%% 6. Roll back 1 migration / 回滚 1 个迁移
ok = erlang_migrate:down(Config, 1),

%% 7. Roll back all applied migrations / 回滚全部迁移
ok = erlang_migrate:down(Config),

%% 8. Jump to a specific version (auto up or down) / 跳转到指定版本(自动判断方向)
ok = erlang_migrate:goto(Config, 5),

%% 9. Force-set version after manual recovery / 手动恢复后强制设置版本
ok = erlang_migrate:force(Config, 5),

%% 10. Drop schema_migrations table (tests only) / 删除 schema_migrations 表(仅测试用)
ok = erlang_migrate:drop(Config).

API Reference / API 参考

Function / 函数 golang-migrate equivalent Description / 说明
up(Config)Up() Apply all pending migrations / 应用所有待执行迁移
up(Config, N)Steps(+N) Apply up to N pending migrations / 应用最多 N 个待执行迁移
down(Config)Down() Roll back all applied migrations / 回滚所有已应用迁移
down(Config, N)Steps(-N) Roll back N migrations / 回滚 N 个迁移
goto(Config, Version)Migrate(version) Migrate to exact version (auto up/down) / 迁移到指定版本(自动判断方向)
force(Config, Version)Force(version) Force set version, clears dirty flag / 强制设置版本,清除 dirty 标志
version(Config)Version() Return {ok, Version, Dirty} / 返回版本和 dirty 状态
drop(Config)Drop()(partial) Drop schema_migrations table / 删除 schema_migrations 表

Note on drop/1 / drop/1 说明

golang-migrate's Drop() drops all tables in the target database. erlang_migrate:drop/1 only drops the schema_migrations tracking table. Use in tests only.

golang-migrate 的 Drop() 会删除目标数据库中的所有表erlang_migrate:drop/1 仅删除 schema_migrations 状态跟踪表。仅在测试环境使用。


Config Keys / 配置项

Key / 键 Required / 必填 Default / 默认值 Description / 说明
conn yes / 是 Database connection pid / 数据库连接进程
dir yes / 是 Path to migration files / 迁移文件目录路径
driver no / 否 erlang_migrate_pg Driver module / 驱动模块,见下方驱动说明
table no / 否 <<"schema_migrations">> Tracking table name / 迁移状态表名
lock_id no / 否 erlang:phash2(Table) Advisory lock ID (auto-derived) / 锁 ID(自动派生)
lock_timeout no / 否 15000 Lock wait timeout in ms / 获锁等待超时毫秒数
logger no / 否 undefinedfun(Level, Msg) -> ok callback / 日志回调函数

Schema Migrations Table / 状态跟踪表

CREATE TABLE schema_migrations (
    version    BIGINT PRIMARY KEY,
    dirty      BOOLEAN NOT NULL DEFAULT false,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The table always contains at most one row — the current version state. Every set_version call is a DELETE + optional INSERT (when Version =/= undefined). An empty table means no migrations have been applied.

表中永远最多只有一行——即当前版本状态。 每次 set_version 调用都是 DELETE + 可选 INSERTVersion =:= undefined 时只 DELETE)。 空表表示尚未执行任何迁移。

This is identical to golang-migrate's TRUNCATE + INSERT semantics, ensuring force/2 always produces a clean single-row state with no stale dirty rows.

这与 golang-migrate 的 TRUNCATE + INSERT 语义完全一致,确保 force/2 总能产生干净的单行状态,不存在残留 dirty 行。


Dirty State / Dirty 状态

If a migration fails mid-execution, the dirty flag is set to true. All future up/down/goto calls will refuse to proceed.

迁移执行中途失败,dirty 标志被设为 true。所有后续 up/down/goto 调用均会拒绝执行。

  ┌──────────┐   up/goto    ┌──────────────┐  success   ┌──────────┐
  │  clean   │ ────────────►│  executing   │ ──────────►│  clean   │
  │ (v=N)    │              │  dirty=true  │             │ (v=N+1)  │
  └──────────┘              └──────────────┘             └──────────┘
                                    │
                              failure / error
                                    │
                                    ▼
                            ┌──────────────┐
                            │    dirty     │  ← blocks all future runs / 阻断所有操作
                            │  dirty=true  │
                            └──────────────┘
                                    │
                        manual DB fix + force(Config, V)
                                    │
                                    ▼
                            ┌──────────────┐
                            │    clean     │
                            │  dirty=false │
                            └──────────────┘

Recovery steps / 恢复步骤:

  1. Inspect the database and fix any partial state manually / 检查数据库并手动修复部分状态
  2. Call erlang_migrate:force(Config, LastGoodVersion) / 调用 force/2 设置最后一个正常版本

Concurrency Safety / 并发安全

erlang_migrate uses pg_advisory_lock — equivalent to golang-migrate's database-layer advisory lock. Safe for multi-node Erlang clusters. Only one node executes migrations at a time.

erlang_migrate 使用 pg_advisory_lock,等价于 golang-migrate 的数据库层 advisory lock。 对多节点 Erlang 集群安全。同一时刻只有一个节点执行迁移。

Lock timeout is configurable via lock_timeout in Config (default 15000ms, matching golang-migrate). Internally uses pg_try_advisory_lock + 100ms retry loop until deadline.

锁超时通过 Config 中的 lock_timeout 配置(默认 15000ms,与 golang-migrate 一致)。 内部使用 pg_try_advisory_lock + 100ms 重试循环直到超时。

Config = #{
    conn         => Conn,
    dir          => "priv/migrations",
    lock_timeout => 5000,
    logger       => fun(Level, Msg) ->
        logger:log(Level, "erlang_migrate: ~s", [Msg])
    end
}.

Implementation Status / 实现进度

Feature / 功能 golang-migrate erlang_migrate Status / 状态
up all Up()up/1 ✅ Done
up N steps Steps(+N)up/2 ✅ Done
down all Down()down/1 ✅ Done
down N steps Steps(-N)down/2 ✅ Done
goto version Migrate(v)goto/2 ✅ Done
force version Force(v)force/2 ✅ Done
version + dirty Version()version/1{ok, V, Dirty} ✅ Done
drop state table ✅ all tables ✅ state table only ⚠️ Partial
Advisory lock pg_advisory_lock ✅ Done
Dirty state machine ✅ Done
Migration history ❌ single-row ✅ per-version + applied_at ✅ Extended
Lock timeout ✅ 15s lock_timeout ms (default 15s) ✅ Done
GracefulStop ✅ channel 🔲 Planned
Logger interface ✅ pluggable ✅ optional logger fun/2 in Config ✅ Done
CLI tooling ❌ library only 🔲 Future
Source abstraction ✅ 15+ sources filesystem only 🔲 Future
Multi-database ✅ 15+ PostgreSQL / MySQL / SQLite ✅ Done
Integration tests ✅ Docker 🔲 planned 🔲 Planned

Installation / 安装

PostgreSQL

Add epgsql to your own deps. erlang_migrate has zero hard dependencies.

在你的 deps 中添加 epgsqlerlang_migrate没有任何硬依赖

{deps, [
    {erlang_migrate, "0.1.0"},
    {epgsql, "4.7.1"}
]}.
Config = #{conn => Conn, dir => "priv/migrations"},
ok = erlang_migrate:up(Config).

MySQL 8+

Add mysql to your own deps, then set driver => erlang_migrate_mysql in Config.

在你的项目 deps 中添加 mysql,Config 中指定驱动即可。

{deps, [
    {erlang_migrate, "0.1.0"},
    {mysql, "1.8.0"}           %% add mysql driver yourself / 自行添加驱动依赖
]}.
{ok, Conn} = mysql:start_link([{host, "localhost"}, {user, "root"},
                                {password, "pass"}, {database, "mydb"}]),
Config = #{conn => Conn, dir => "priv/migrations", driver => erlang_migrate_mysql},
ok = erlang_migrate:up(Config).

SQLite 3+

Add esqlite to your own deps, then set driver => erlang_migrate_sqlite in Config.

在你的项目 deps 中添加 esqlite,Config 中指定驱动即可。

{deps, [
    {erlang_migrate, "0.1.0"},
    {esqlite, "0.8.1"}         %% add esqlite driver yourself / 自行添加驱动依赖
]}.
{ok, Conn} = esqlite3:open("mydb.sqlite"),
Config = #{conn => Conn, dir => "priv/migrations", driver => erlang_migrate_sqlite},
ok = erlang_migrate:up(Config).

From GitHub / 从 GitHub 安装

{deps, [
    {erlang_migrate, {git, "https://github.com/imboy-pub/erlang_migrate.git", {tag, "v0.1.0"}}}
]}.

Development / 开发

rebar3 compile
rebar3 eunit

License / 许可证

Apache 2.0 — see LICENSE