Skip to content
Spice Framework on GitHub

Module-owned database migrations

frameworkMaturity: previewSource: spice@c0641b3Exact reviewed source

The migration package builds one immutable application plan from module-owned SQL declarations:

plan, err := migration.NewPlan([]migration.Spec{
{
Version: 202607250001,
Module: "example.com/shop/orders",
Name: "create orders",
SQL: ordersSchema,
},
})
result, err := runner.Run(ctx, plan)

Versions are positive, application-global, and monotonically increasing. This lets every module add later migrations without inserting work ahead of a version that another module already applied. Duplicate versions fail during plan construction.

SQL line endings are normalized to LF and checksummed with SHA-256. The durable registry must be an exact prefix of the current plan: version, module, name, checksum, and applied time are all checked before new SQL executes. Removed, reordered, renamed, or edited migrations fail closed with no SQL text in the error.

Backend.RunLocked owns the database-specific advisory lock and must invoke its callback exactly once. The supplied Session reads the durable registry and atomically executes each migration with its registry insert. Spice enforces sequential version order, cancellation between entries, stop-on-first-failure, and bounded SQL-free observations.

Core does not assume transactional DDL, invent a portable lock, start a goroutine, or choose a driver. Dialect starters provide lock, transaction, registry schema, and SQL execution policies appropriate to their database.

PostgreSQL

The independent starter-postgres module supplies a concrete backend over a caller-owned pgx database/sql pool:

backend, err := postgres.NewMigrationBackend(database, postgres.MigrationOptions{
Schema: "public",
})
if err != nil {
return err
}
runner, err := migration.NewRunner(backend)
if err != nil {
return err
}
result, err := runner.Run(ctx, plan)

The configured schema must already exist. The backend owns the fixed spice_schema_history table within that schema and validates the schema as a PostgreSQL identifier; table names and registry SQL are never derived from migration content. A zero lock ID selects Spice’s stable default. Applications sharing a database but intentionally maintaining independent registries should select distinct nonzero lock IDs and schemas.

Each run pins one physical pgx connection and holds a PostgreSQL session-level advisory lock across reconciliation and application. Each migration script and its parameterized registry insert commit in one transaction. Scripts can contain multiple PostgreSQL statements. A failed script or registry write rolls back both. Lock waits honor cancellation. If unlock cannot be confirmed, Spice closes the physical connection so a session lock is never returned to the pool.

The registry stores the complete Go uint64 version domain as constrained numeric(20,0), orders versions numerically, and returns timestamps in UTC. Errors contain migration identity but never SQL text, connection URLs, or credentials.