Arcadic.Migrator (Arcadic v1.2.3)

Copy Markdown View Source

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 row

The 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

interrupted(conn)

@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).

migrate(conn, registry)

@spec migrate(Arcadic.Conn.t(), module()) ::
  {:ok, non_neg_integer()} | {:error, Exception.t() | term()}

Run all pending migrations (up). Returns {:ok, count_run}.

pending_migrations(mods, applied)

@spec pending_migrations([module()], [integer()]) :: [module()]

Registered migrations not yet applied, ascending by version (pure).

reset(conn, registry)

@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}.

resolve(conn, version, outcome)

@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.

rollback(conn, registry, n \\ 1)

@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}.

status(conn, registry)

@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.