Migraterl

Built with Nix[Nix] BuildHex.pm VersionLicense

A SQL migration engine for PostgreSQL, built on gen_statem and a temporal tables, hash-based journal.

Status

Note

This is still experimental, expect heavy API chages before 1.0.0.

Requirements

How it works

A run is modelled as a gen_statem that walks a linear lifecycle:

stateDiagram-v2
direction LR
[*] --> idle
idle --> ensuring_journal
ensuring_journal --> locking
locking --> reading_state
reading_state --> scanning
scanning --> planning
planning --> applying
applying --> done
done --> [*]

Usage

Conn = migraterl:default_connection(),
{ok, Summary} = migraterl:migrate(Conn, #{
namespace => <<"app">>,
sources => [
{once, "priv/migrations/schema"},
{on_change, "priv/migrations/views"},
{always, "priv/migrations/grants"}
],
txn => per_script, % per_script | single | none
on_out_of_order => warn, % warn | error | ignore
variables => #{<<"env">> => <<"prod">>}
}),
%% Summary :: #{planned := [...], applied := [...], skipped := [...], warnings := [...]}
%% Dry run, compute the plan without applying:
{ok, Plan} = migraterl:plan(Conn, Opts),
%% Inspect the currently-applied state:
{ok, State} = migraterl:status(Conn, <<"app">>).

Variables written as $name$ in a script are substituted at execution time.

Lifecycle layouts (opt-in)

For projects that prefer a conventional directory lifecycle, Migraterl can expand a Grate-inspired profile into the existing once, on_change, and always classes:

{ok, Summary} = migraterl:migrate(Conn, #{
namespace => <<"app">>,
layout => #{
profile => grate,
root => "priv/migrations"
}
}).

The profile checks the following optional directories in order:

StageDirectoryClass
before_migrationbeforeMigrationalways
alter_databasealterDatabaseon_change
before_oncerunBeforeUpon_change
linearuponce
after_oncerunFirstAfterUponce
functionsfunctionson_change
viewsviewson_change
proceduressprocson_change
triggerstriggerson_change
indexesindexeson_change
after_changerunAfterOtherAnyTimeScriptson_change
permissionspermissionsalways
after_migrationafterMigrationalways

Layouts can also define a fully custom ordered lifecycle. Custom directories are required by default, set required => false to skip a missing directory.

#{
namespace => <<"app">>,
layout => #{
root => "priv/migrations",
stages => [
#{id => prepare_source, stage => prepare,
path => "prepare", class => always},
#{id => linear_source, stage => linear,
path => "up", class => once},
#{id => functions_source, stage => replaceable,
path => "functions",
class => on_change, required => false}
]
}
}

Stage list order takes precedence across directories, files remain lexically ordered within each directory. sources and layout are mutually exclusive, and stage paths must be relative to the layout root. Because the journal keeps its backward-compatible {namespace, basename} identity, duplicate SQL basenames across directories are rejected before user scripts execute.

Every custom source requires both a stable id and a lifecycle stage. Source IDs must be unique within the layout; stage names may repeat. IDs and stages are diagnostic metadata and do not alter the backward-compatible journal identity.

Option validation

Migration options are fully validated before Migraterl uses the database connection. Unknown top-level keys, invalid enum or boolean values, malformed paths, invalid layouts, duplicate source IDs, and non-binary variable keys or values return {error, {invalid_options, Reason}}. Layout-specific reasons retain the {invalid_layout, Reason} envelope.

The supported script classes remain once, on_change, and always. The conceptual name repeat is not accepted by the v0.5 API; use always for scripts that must execute on every invocation.

Reactive Extras (opt-in)

Configure watchers in the application environment and migraterl_sup keeps one alive per entry, so the app auto-migrates on change without any glue code:

{migraterl, [
{watchers, [
#{conn => #{host => "127.0.0.1", username => "app",
password => "app", database => "app"},
debounce_ms => 300,
migrate => #{namespace => <<"app">>,
sources => [{once, "priv/migrations/schema"}]}}
]}
]}.

With no watchers configured the supervisor runs empty and each migraterl:migrate/2 call spawns its own transient runner.

Development

We have devenv setup and everything is based on Nix, you can check our flake.nix to learn how it looks like.

nix develop --impure
# to spawn a postgres database
devenv up

there's also a justfile to manage builds and tests.

# will show all commands supported
just

Testing

# You can either run rebar directly
rebar3 ct
# or
just t

Inspiration