Runs Arcadic.Migrations in order and tracks applied versions in the
_arcadic_migrations document type. Tenant-blind: it runs DDL/DML and records
integer versions — no tenant, scope, or Ash concept. Assumes single-process,
deploy-time execution (like every migration tool); it does not take an advisory
lock, so do not run two migrators concurrently against one database.
Crash recovery (the marker contract)
ArcadeDB session transactions do NOT roll back DDL (live-probed 2026-10-15, v26.10.2:
begin → CREATE TYPE → INSERT → rollback discards the row but keeps the type), so
up + version-recording cannot be made atomic. Instead every migration attempt is
bracketed by a durable marker row:
up: INSERT {version, status: "up_started"} → mod.up/1 → UPDATE status "done"
down: UPDATE status "down_started" → mod.down/1 → DELETE the rowThe marker is removed ONLY by successful finalization or an explicit resolve/3 —
NEVER by an error cleanup. An error return (or raise, or a lost connection) after
any committed statement proves nothing about what was applied, so the marker stays
and the next migrate/2/rollback/3/reset/2/status/2 REFUSES with
{:error, %Arcadic.Error{reason: :interrupted_migration}} (value-free — run
interrupted/1 to see which versions are stuck) instead of silently re-running a
possibly half-applied migration. The operator inspects schema AND data (including
outstanding follow-on work), then records the established outcome with
resolve(conn, version, :applied | :not_applied).
Idempotent migrations (IF NOT EXISTS / IF EXISTS) remain RECOMMENDED — see
Arcadic.Migration's example — but the marker, not author discipline, is the
safety mechanism.
Rows written before this contract (no status property) read as done at read
time — no backfill write; legacy databases migrate transparently.
Every entry point rejects a session-scoped conn (value-free ArgumentError): the
marker writes are autocommit by construction, and a caller's transaction/3
rollback would discard the marker while keeping the DDL.
Summary
Functions
The versions with an interrupted marker (up_started/down_started), ascending —
the structured reader for a :interrupted_migration refusal (the refusal itself is
value-free; this is where the stuck versions surface to the operator).
Run all pending migrations (up). Returns {:ok, count_run}.
Registered migrations not yet applied, ascending by version (pure).
Roll back every applied migration, then migrate all. Returns {:ok, migrated_count}.
Record the operator's established outcome for an interrupted migration:
:applied (the migration's effects are in — finalizes the marker as done) or
:not_applied (nothing of it remains — deletes the tracking row). Direction-agnostic
by design: reconciliation expresses the RESULTING applied/unapplied state, so both a
crashed up that never landed and a crashed down that never reversed resolve the
same way. Requires an existing interrupted marker and a verified (non-zero-row)
transition; value-free errors.
Roll back the last n applied migrations (down), newest first. Returns {:ok, count}.
Status of each registered migration: {version, :up | :down}, ascending. Refuses
(:interrupted_migration) while an interrupted migration marker exists.
Functions
@spec interrupted(Arcadic.Conn.t()) :: {:ok, [integer()]} | {:error, Exception.t()}
The versions with an interrupted marker (up_started/down_started), ascending —
the structured reader for a :interrupted_migration refusal (the refusal itself is
value-free; this is where the stuck versions surface to the operator).
@spec migrate(Arcadic.Conn.t(), module()) :: {:ok, non_neg_integer()} | {:error, Exception.t() | term()}
Run all pending migrations (up). Returns {:ok, count_run}.
Registered migrations not yet applied, ascending by version (pure).
@spec reset(Arcadic.Conn.t(), module()) :: {:ok, non_neg_integer()} | {:error, Exception.t() | term()}
Roll back every applied migration, then migrate all. Returns {:ok, migrated_count}.
@spec resolve(Arcadic.Conn.t(), integer(), :applied | :not_applied) :: :ok | {:error, Exception.t()}
Record the operator's established outcome for an interrupted migration:
:applied (the migration's effects are in — finalizes the marker as done) or
:not_applied (nothing of it remains — deletes the tracking row). Direction-agnostic
by design: reconciliation expresses the RESULTING applied/unapplied state, so both a
crashed up that never landed and a crashed down that never reversed resolve the
same way. Requires an existing interrupted marker and a verified (non-zero-row)
transition; value-free errors.
@spec rollback(Arcadic.Conn.t(), module(), pos_integer()) :: {:ok, non_neg_integer()} | {:error, Exception.t() | term()}
Roll back the last n applied migrations (down), newest first. Returns {:ok, count}.
@spec status(Arcadic.Conn.t(), module()) :: {:ok, [{pos_integer(), :up | :down}]} | {:error, Exception.t()}
Status of each registered migration: {version, :up | :down}, ascending. Refuses
(:interrupted_migration) while an interrupted migration marker exists.