StatifierPersistence.Ecto.Migrations (StatifierPersistence v0.12.0)

Copy Markdown View Source

Versioned migrations for this package's tables, in the Oban.Migration mold: the host writes one ordinary migration that delegates here, and later package versions ship higher-numbered migration modules the same call picks up.

The supported spelling reads the host's compiled configuration, so the DDL cannot drift from the generated schemas (ADR-0002 decision 3):

defmodule MyApp.Repo.Migrations.AddStatifierPersistence do
  use Ecto.Migration

  def up, do: StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence)
  def down, do: StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence)
end

Alternatively, up/1 and down/1 accept the same literal options use StatifierPersistence.Ecto takes (:repo, :key, :table_prefix, :tables, :prefix), funneled through the same StatifierPersistence.Ecto.Config.new/1 - one resolver, both doors. The two spellings cannot be mixed in one call.

from: selects where a call starts and version: where it ends, in both directions: up/1 migrates from from: (default: V01) up through version: (default: the newest this package knows), and down/1 rolls back from from: (default: the newest) down through version: (default: V01, i.e. everything).

from: is what a host already running an older version writes its next migration with: a host that ran the migration above when this package shipped only V01 picks up V02 with a second ordinary migration,

defmodule MyApp.Repo.Migrations.AddStatifierPersistenceExecutionMetadata do
  use Ecto.Migration

  def up, do: StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, from: 2)
  def down, do: StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence, version: 2)
end

rather than re-running V01's CREATE TABLE against tables that already exist. A host migrating a fresh database with the first spelling gets every version in one call and needs no second migration at all.

Each migration's two calls cover the same span, up/1 upwards and down/1 downwards, and a capped migration needs both bounds spelled out. A host that caps its first migration at version: 2 - so that a fresh clone and an already-migrated database take the same steps in the same order - caps the rollback to match with from: 2:

defmodule MyApp.Repo.Migrations.AddStatifierPersistence do
  use Ecto.Migration

  def up, do: StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, version: 2)
  def down, do: StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence, from: 2)
end

Without that ceiling down/1 starts at the newest version this package knows however far up the migration beside it went, so mix ecto.rollback --all rolls V03 back twice - once from the later migration and once from this one - and the second call fails on a column that is already gone.

V04 is the one version whose full effect depends on how the host's own migration module is written. It rebuilds V03's metadata GIN index with CREATE INDEX CONCURRENTLY, which cannot run inside a transaction, and @disable_ddl_transaction / @disable_migration_lock are read from the module Ecto.Migrator runs - the host's - not from a module it delegates to. A host that wants the concurrent build therefore gives V04 a migration of its own:

defmodule MyApp.Repo.Migrations.RebuildStatifierPersistenceMetadataIndex do
  use Ecto.Migration

  @disable_ddl_transaction true
  @disable_migration_lock true

  def up, do: StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, from: 4)
  def down, do: StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence, version: 4)
end

Inside a transaction V04 skips the rebuild and leaves V03's index in place, which is the same index under the same name - so the one-call recipe above stays correct on a fresh database, where a plain build on an empty executions table costs nothing. It warns only when that table already holds rows. StatifierPersistence.Ecto.Migrations.V04 records the whole of it.

V05 needs no recipe of its own: it creates ADR-0010's input log table and its unique (execution_id, seq) index, on every backend, inside an ordinary transaction. A host already running V04 picks it up with up(for: MyApp.Persistence, from: 5).

V06 needs no recipe of its own either, and what it does depends on which database it finds (ADR-0011 decision 3). On a database this package built before 0.12.0 it renames the runs table to executions, both run_id columns to execution_id, and the indexes over them - in place, copying no data. On a database built at 0.12.0 or later there is nothing to rename, because V01-V05 create the execution names directly, and V06 is a no-op. Either way it is the version this package now expects.

Rolling back never renames anything: V06's down/1 is a no-op (RQ-SF041-25, ruled 2026-09-13). V01-V05 are rewritten to the execution names and drop the tables under them, on both kinds of database, so there is nothing a rename back would leave in a better state - and a downgrade to pre-0.12.0 code is unsupported, by the record and by this package. StatifierPersistence.Ecto.Migrations.V06 records the whole of it.

One ordering rule comes with it, on the way up only. An install that still owes V02, V03 or V04 - one capped below version 4 - runs V06 on its own first and the versions it skipped afterwards, because those three alter the executions table, which on a database built before 0.12.0 carries that name only once V06 has renamed it. For a host capped at V01:

defmodule MyApp.Repo.Migrations.RenameStatifierPersistenceExecutions do
  use Ecto.Migration

  def up do
    StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, from: 6, version: 6)
    StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, from: 2, version: 5)
  end

  def down do
    StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence, from: 5, version: 2)
    StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence, from: 6, version: 6)
  end
end

Substitute your own cap in both spans: a host capped at V02 writes from: 3 on the second up and version: 3 on the first down, and one capped at V03 writes from: 4 and version: 4. The two down calls mirror the two up calls in reverse, and the V06 one does nothing at all. V02-V05's arms name the executions table under the name it carries on 0.12.0 code, which is the name V06 gave it - V05 drops the input log, V03 the metadata index and outcome_blob, V02 the metadata column - so a rename back would leave every one of them naming an object that is no longer there. The table itself is dropped by V01, below the cap, which is the host's own earlier migration's business; running 0.11.x against a database that was ever upgraded is unsupported either way (ADR-0011 decision 3).

An install already at V05 needs none of that: up(from: 6) is the whole upgrade.

expected_version/0 answers what that newest version is. A host that delegates its migrations here never needs it; a host whose schema is hand-written DDL has to check for itself that its tables are current, and that is the number it checks against.

When prefix: names a Postgres schema, up/1 creates the schema if it does not exist; down/1 leaves the schema in place (dropping a schema the host may share is not this package's call).

Summary

Functions

Rolls the tables back from from: (default: the newest version this package knows) down through version: (default: V01, i.e. everything).

The newest migration version this package knows - the newest key of the version map, the version an up-to-date schema has run through, and up/1's default target.

Migrates the tables from from: (default: V01) up through version: (default: the newest).

Functions

down(opts)

@spec down(keyword()) :: :ok

Rolls the tables back from from: (default: the newest version this package knows) down through version: (default: V01, i.e. everything).

A migration whose up/1 is capped at version: N caps its down/1 with from: N, so the rollback stops at the cap instead of reaching versions a later migration has already rolled back - see the moduledoc.

Every version's down/1 runs, unconditionally. V06's is a no-op (RQ-SF041-25, ruled 2026-09-13): V01-V05 are rewritten to the execution names and drop the tables under them, so there is nothing to rename back first, and a rollback reaches the same end state under one call or under one host migration per version.

Takes the same options as up/1.

expected_version()

@spec expected_version() :: pos_integer()

The newest migration version this package knows - the newest key of the version map, the version an up-to-date schema has run through, and up/1's default target.

It exists for the host whose schema is not a delegated migration. A host that writes up(for: MyApp.Persistence) never needs the number: Ecto.Migrator records that migration and the default target carries it to the newest version. A host whose DDL is hand-written - the tables generated by hand, or by another tool, or shared with a schema this package does not own - has to know which version its hand-written DDL corresponds to, and that number is not otherwise readable from here:

if MyApp.Schema.statifier_persistence_version() <
     StatifierPersistence.Ecto.Migrations.expected_version() do
  raise "statifier_persistence schema is behind the package"
end

There is no assert_version!/1, and there cannot be one. Asserting a version against a repo means reading a marker out of that repo's schema, and this package records none: it writes no versions table, no marker row and no version column, and Ecto's own schema_migrations holds the host's migration timestamps, which say nothing about which of V01..V06 a hand-written schema matches. The only way to grow such an assertion is to start writing a marker - a new table, in a new migration, carried by every host including the ones that delegate and already know - which is new stored surface bought for a comparison the host can make itself. So the package answers the half it knows, the version it expects, and the comparison stays the host's.

up(opts)

@spec up(keyword()) :: :ok

Migrates the tables from from: (default: V01) up through version: (default: the newest).

Takes for: HostModule or the literal options use takes - see the moduledoc.