migratekit

package module
v1.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: MIT Imports: 17 Imported by: 0

README

migratekit

Minimal database migration library with app-scoped migrations and automatic locking.

Install

go get github.com/open-rails/migratekit

Usage

PostgreSQL (Complete Example)
package main

import (
    "context"
    "database/sql"
    "embed"

    "github.com/open-rails/migratekit"
    _ "github.com/lib/pq"
)

//go:embed migrations/postgres/*.sql
var postgresFS embed.FS

func main() {
    ctx := context.Background()
    db, _ := sql.Open("postgres", "postgres://...")

    // Load migrations from embedded FS
    migrations, _ := migratekit.LoadFromFS(postgresFS, "migrations/postgres")

    // Run migrations (2 lines; ApplyMigrations ensures the tracking table)
    m := migratekit.NewPostgres(db, "doujins")
    m.ApplyMigrations(ctx, migrations)

    // Optional: target a configured schema. Unqualified migration SQL runs
    // under SET LOCAL search_path = "<schema>", public.
    m = migratekit.NewPostgres(db, "doujins").WithSchema(cfg.DB.Schema)

    // Optional: if migrations are authored with hard-qualified canonical DDL,
    // rewrite that app-owned schema to the configured schema while applying.
    m = migratekit.NewPostgres(db, "openrails").WithSchema(cfg.DB.Schema, "openrails")
}
ClickHouse (Complete Example)

ClickHouse support lives in its own subpackage, migratekit/chmigrate, which imports github.com/ClickHouse/clickhouse-go/v2. The root migratekit package has zero ClickHouse imports: if you only need the Postgres migrator, go mod tidy never pulls in the ClickHouse client (Go's module graph pruning drops it once nothing in your module imports migratekit/chmigrate).

package main

import (
    "context"
    "database/sql"
    "embed"

    "github.com/open-rails/migratekit"
    "github.com/open-rails/migratekit/chmigrate"
    _ "github.com/lib/pq"
)

//go:embed migrations/clickhouse/*.sql
var clickhouseFS embed.FS

func main() {
    ctx := context.Background()
    pg, _ := sql.Open("postgres", "postgres://...")

    // Load migrations from embedded FS
    migrations, _ := migratekit.LoadFromFS(clickhouseFS, "migrations/clickhouse")

    // Run migrations (3 lines)
    m := chmigrate.New(&chmigrate.Config{
        ClientAddr: "clickhouse:9000",
        Database:   "analytics",
        Username:   "analytics_user",
        Password:   "analytics_password",
        App:        "doujins",

        // ClickHouse migrations are tracked in Postgres public.migrations
        // (database='clickhouse') and use Postgres advisory locks.
        PostgresDB: pg,
    })
    m.ApplyMigrations(ctx, migrations)
}
Startup validation (read-only)
// Fail app startup if any migration is pending. Never creates tables.
err := migratekit.ValidatePostgresMigrations(ctx, db,
    migratekit.MigrationSource{App: "authkit", FS: authkitFS},
    migratekit.MigrationSource{App: "billing", FS: billingFS},
)

// ClickHouse equivalent, in the chmigrate subpackage:
err = chmigrate.ValidateMigrations(ctx, &chmigrate.Config{App: "doujins", PostgresDB: pg}, clickhouseFS)

When boot refuses: operator runbook

The identity checks stop a migration from silently never running. That is worth a boot refusal — but only if there is a way out of it that is not psql and a hand-written UPDATE public.migrations. Since v1.6.0 there is:

go run github.com/open-rails/migratekit/cmd/migratekit@latest status \
  -app tensorhub -dir migrations/postgres -dsn "$DATABASE_URL"

status prints, for every discrepancy, WHAT is wrong (file, number, both digests), the LIKELY CAUSES, and the command that resolves each one. Every boot refusal ends by pointing at it.

What you see What it means Resolution
number "N" was already applied by a DIFFERENT file (error) Two lanes claimed N; the other one merged first. Your file would be recorded as already applied and its DDL would never run. Renumber your file to the next free number. Nothing in the database changes.
migration X was EDITED after it was applied (warning — boot proceeds) X's bytes changed since it ran here. This database has the old schema, a fresh one gets the new. Cosmetic edit: repair accept-content N --reason "…". Otherwise revert the file and add a new migration.
migration X sorts BEFORE Y, which is already applied (error, WithStrictOrdering only) A lane branched below the high-water mark and merged late. Renumber X above Y. Genuine backport: apply --allow-below-applied N --reason "…", which applies it once and records the deviation.
Row 1's error, but your files are right and the ledger is the wrong side A restored backup, an adopted database, or a hand-fixed row: the ledger remembers a tree that no longer exists. repair adopt N --reason "…" — or repair adopt --all-unmatched --reason "…" when every row mismatches, which is what a restore actually looks like.

Rows 1 and 4 are the same error with opposite fixes, which is why status prints both causes and you pick. The question to ask is which side is stale, the files or the ledger? Renumber when a colliding file really exists; adopt when it does not.

The repair verbs
migratekit repair adopt 42 --reason "restored the 2026-08-10 backup"
migratekit repair adopt --all-unmatched --reason "adopted the doujins cluster"
migratekit repair accept-content 7 --reason "comment typo; DDL byte-identical"
migratekit apply --allow-below-applied 91 --reason "backport of the th#1712 index"
migratekit history          # everything anyone has ever repaired here

Every one of them:

  • requires --reason, recorded verbatim — a ledger repair with no recorded reason is indistinguishable from tampering;
  • writes an audit row to public.migration_repairs (verb, reason, --operator, the OS user, the host, and the old and new identity) in the same transaction as the change. A repaired ledger is visible history, not an erased one;
  • touches the ledger's identity columns only — never schema, never DDL, and never marks an unapplied migration applied;
  • supports --dry-run, which prints the exact before/after and writes nothing;
  • refuses to run in CI. A repair rewrites one database's ledger after a human has read the diff. If every database trips over the same thing, the chain is wrong and the fix belongs in the repository.

The same verbs are available to Go callers as (*Postgres).RepairAdopt, RepairAdoptAllUnmatched, RepairAcceptContent, ApplyWithOrderingException and RepairHistory, all taking a RepairRequest{Reason, Operator, DryRun}.

Stable API (v1)

Everything in this section is the v1 compatibility boundary. Within v1.x it will only grow — no removals, no signature changes, no breaking behavior changes to the documented contracts below. Anything NOT listed here (unexported helpers, exact error message text, internal locking mechanics) is an implementation detail and may change in any release.

Loading
Symbol Contract
type Migration struct { Name, Content string } One SQL migration: filename + raw file content.
LoadFromFS(fsys fs.FS, dir ...string) ([]Migration, error) Loads every *.up.sql in dir (default "."), ordered by numeric prefix. Errors on duplicate normalized prefixes. Only the first dir element is used.
Prefix(name string) string Normalized numeric prefix of a migration filename ("001_x.up.sql" → "1"). This is the tracking key stored in public.migrations.name.
CheckChain(names []string) error (v1.5.0) Validates a chain as a file listing — duplicate numbers, gaps, monotonicity — with no database. For a CI gate on the merge boundary.
ContentDigest(content string) string (v1.5.0) The sha256 the ledger records for a migration's bytes.
type AppliedRecord struct { Key, Filename, Digest string } (v1.5.0) One ledger row. Filename/Digest are empty for rows written by ≤v1.4.0.
Postgres
Symbol Contract
NewPostgres(db *sql.DB, app string) *Postgres Migrator for one app's migrations. Never closes db.
(*Postgres) WithSchema(schema string, rewriteFrom ...string) *Postgres Migrations run under SET LOCAL search_path = "<schema>", public. Optional rewriteFrom canonical schema names are rewritten to schema in migration SQL before execution, for portable hard-qualified app DDL such as openrails.foo. Tracking stays in public.migrations.
(*Postgres) ApplyMigrations(ctx, []Migration) error The one-call path: ensures the tracking table, applies every unapplied migration in order under the advisory lock (lock taken only when there is work), records each by Prefix. Each migration runs in its own transaction.
(*Postgres) Applied(ctx) ([]string, error) Recorded migration names (normalized prefixes) for this app, database='postgres'.
(*Postgres) Setup(ctx) error Ensures public.migrations exists (idempotent). ApplyMigrations calls it for you.
(*Postgres) ValidateAllApplied(ctx, []Migration) error Read-only startup gate: error naming pending migrations, never creates tables.
(*Postgres) WithStrictOrdering() *Postgres (v1.5.0) Refuse a pending migration that sorts below one already applied. Opt-in.
(*Postgres) AppliedRecords(ctx) (map[string]AppliedRecord, error) (v1.5.0) Ledger keyed by tracking key, carrying the recorded filename and content digest.
(*Postgres) WithStrictContent() *Postgres (v1.6.0) Turn content drift back into a hard error. Default is a warning.
(*Postgres) WithWarnFunc(func(Discrepancy)) *Postgres (v1.6.0) Replace the warning sink. Default logs through slog.Default() at warn level; never silent unless you make it so.
(*Postgres) Status(ctx, []Migration) (Status, error) (v1.6.0) Applied set, pending set, every discrepancy with cause and resolution, and the repair history. Read-only apart from Setup.
(*Postgres) RepairAdopt(ctx, Migration, RepairRequest) (RepairResult, error) (v1.6.0) Bind the file in the tree as the applied identity for its number. For a ledger that is the stale side.
(*Postgres) RepairAdoptAllUnmatched(ctx, []Migration, RepairRequest) ([]RepairResult, error) (v1.6.0) The same for every mismatched row at once — the restored-backup shape.
(*Postgres) RepairAcceptContent(ctx, Migration, RepairRequest) (RepairResult, error) (v1.6.0) Re-stamp the digest after a verified edit; clears the drift warning. Refuses on an identity mismatch.
(*Postgres) ApplyWithOrderingException(ctx, []Migration, allowBelow []string, RepairRequest) error (v1.6.0) Apply with a one-shot exemption from the ordering rule. Identity checks are not relaxed.
(*Postgres) RepairHistory(ctx) ([]RepairRecord, error) (v1.6.0) The audit trail, newest first.
type RepairRequest struct { Reason, Operator string; DryRun bool } (v1.6.0) Reason is required. Every repair refuses under CI (DetectCI).
type Status, type Discrepancy, type RepairResult, type RepairRecord, Severity, DiscrepancyKind (v1.6.0) Reporting types. Discrepancy.String() is the full explanation; OneLine() is the log-line form.
DetectCI() (string, bool) (v1.6.0) Names the CI environment variable that is set, if any.
ClickHouse (migratekit/chmigrate, since v1.4.0)

ClickHouse is a separate subpackage so the root package never imports github.com/ClickHouse/clickhouse-go/v2. Its stable surface:

Symbol Contract
type Config struct { ClientAddr, Database, Username, Password, App, Cluster string; PostgresDB *sql.DB } PostgresDB is required: tracking rows live in Postgres public.migrations (database='clickhouse') and locking uses Postgres advisory locks. Cluster enables {{ON_CLUSTER}} expansion.
New(*Config) *ClickHouse Migrator; connects to ClickHouse lazily via native protocol.
(*ClickHouse) ApplyMigrations(ctx, []migratekit.Migration) error Same shape as Postgres. Statements run individually (no transactions) with up-to-30s retry on transient distributed-DDL errors.
(*ClickHouse) Applied(ctx) ([]string, error) Recorded names for this app, database='clickhouse'.
(*ClickHouse) Setup(ctx) error Ensures the Postgres tracker is ready.
(*ClickHouse) ValidateAllApplied(ctx, []migratekit.Migration) error Read-only startup gate.
(*ClickHouse) Close() error Closes only the native ClickHouse connection the migrator itself opened (never PostgresDB).
ValidateMigrations(ctx, *Config, fs.FS) error LoadFromFS + ValidateAllApplied in one call, mirroring ValidatePostgresMigrations for a single ClickHouse app.
Multi-source startup validation
Symbol Contract
type MigrationSource struct { App string; FS fs.FS } One app's migration filesystem.
ValidatePostgresMigrations(ctx, db, ...MigrationSource) error ValidateAllApplied across several apps in one call. MigrationSource.Schema and RewriteFrom mirror WithSchema for schema-aware callers.
Frozen behavioral contracts

These behaviors are part of the API and will not change within v1.x:

  1. Tracking table: public.migrations (id, app, database, schema, name, filename, content_sha256, migrated_at, UNIQUE(app, database, schema, name)) with database ∈ {postgres, clickhouse}. Its shape is stable. Since v1.6.0 public.migration_repairs records every repair; prefer migratekit repair over editing either table by hand, because only the verbs write the audit row.
  2. Tracking key: the normalized numeric prefix (Prefix), not the filename — renaming 001_users.up.sql to 001_accounts.up.sql does not re-apply it. Since v1.5.0 the row also records the full filename and a content digest: a number applied by a different file is a hard error instead of a silent skip, and an edit to a migration that already ran is reported (a warning since v1.6.0, an error under WithStrictContent).
  3. Discovery: only *.up.sql files; *.down.sql is reserved; numeric-prefix ordering; duplicate prefixes are a load error.
  4. Locking: appliers are serialized by Postgres advisory locks held on a dedicated pinned connection for the duration of the apply; the lock is taken only when unapplied migrations exist; process death releases the lock with the connection.
  5. Templates: {{VAR}} / ${VAR} substitute from the environment at apply time; an unset variable is an error; an explicitly-empty variable substitutes as-is; {{ON_CLUSTER}}/${ON_CLUSTER} expand from chmigrate.Config.Cluster (empty → removed).
  6. Postgres atomicity: one migration = one transaction (DDL + tracking row commit together).
  7. Postgres schema targeting: WithSchema(schema) sets a per-migration transaction search path for unqualified SQL. WithSchema(schema, "canonical") also rewrites app-owned canonical schema references to schema before execution; use this for portable hard-qualified DDL, not for shared schemas like public.
  8. ClickHouse non-atomicity: statements are split (quote-aware) and run individually; a partial failure leaves earlier statements applied and the migration unrecorded — every statement must be individually idempotent.
  9. Ownership: migrators never close a *sql.DB you pass in.
Added in v1.5.0 (migration identity)

The applied-migrations ledger was keyed by the migration NUMBER alone, which is not an identity: two different files that each claim number N normalize to the same key, so once one is applied the other is reported "already applied" and its DDL never runs — no error, clean boot, green suite. LoadFromFS rejects two colliding files in one tree, but the damaging case never has both in one tree: lane A's N is applied to a live database, lane B renumbers or reverts, and B's N is skipped forever.

v1.5.0 records filename and content_sha256 next to the key and checks them on every apply:

  • Identity — number N applied by a different file is a hard error naming both files and demanding a renumber.
  • Integrity — a migration edited after it ran is a hard error.

Both are always on and cannot fire spuriously: rows written by ≤v1.4.0 carry no identity, and unknown reads as unknown, never as a mismatch. The two new columns are added by Setup(); no consumer action is required.

  • Ordering — WithStrictOrdering() additionally refuses a pending migration that sorts below one already applied. Opt-in, because existing chains legitimately carry gaps and late arrivals that predate the rule.
  • CheckChain(names) validates a chain as a plain file listing (duplicates, gaps, monotonicity) with no database, for a CI gate on the merge boundary.

Execution is unchanged: appliers are serialized by the advisory lock and migrations run one at a time, in order.

Changed in v1.6.0 (the resolution path)

v1.5.0 made three failures visible. It did not make any of them resolvable: the boot refused, named the problem, and left the operator with psql. v1.6.0 is the other half — status, four audited repair verbs, and one relaxation.

Content drift is now a WARNING, not a boot error. An operator who edited a migration that already ran often cannot restore the old bytes, and a database held down over a comment change is a worse outcome than the divergence the check guards against. The boot proceeds and emits a warning naming the file, both digests, the risk, and repair accept-content. WithStrictContent() restores the v1.5.0 refusal.

The other two are unchanged. A number applied by a different file is still a hard error — it is the silent-never-runs killer, and its fix (renumber, or adopt) is always available. WithStrictOrdering() behaves exactly as before.

Everything else is additive: Status, WithWarnFunc, the repair verbs, the public.migration_repairs audit table (created by Setup), and the cmd/migratekit CLI. No consumer action is required, and no call site changes.

Removed in v1.0 (was public in v0.x)
  • Postgres.Apply, Postgres.Lock, Postgres.Unlock, ClickHouse.Apply, ClickHouse.Lock, ClickHouse.Unlock — the manual lock/apply path bypassed the applied-set check and made stateful locking part of the surface; ApplyMigrations is the supported path. (No known consumer used these.)
  • Postgres.Close — closed the caller's *sql.DB, which the migrator never owned.
  • MigrationSource.FS and the ValidateClickHouseMigrations filesystem parameter are now fs.FS instead of embed.FS (source-compatible: embed.FS satisfies fs.FS).
Changed in v1.4.0 (ClickHouse moved to its own subpackage)

Everything ClickHouse-related moved from the root package into migratekit/chmigrate, to get github.com/ClickHouse/clickhouse-go/v2 (and its ch-go dependency) out of every Postgres-only consumer's build:

  • migratekit.ClickHouseConfig → chmigrate.Config (same fields).
  • migratekit.NewClickHouse → chmigrate.New (same signature, returns *chmigrate.ClickHouse).
  • migratekit.ClickHouse → chmigrate.ClickHouse (same methods).
  • migratekit.ValidateClickHouseMigrations → chmigrate.ValidateMigrations.

Migration for existing ClickHouse consumers: change the import to add "github.com/open-rails/migratekit/chmigrate", and replace migratekit.NewClickHouse(&migratekit.ClickHouseConfig{...}) with chmigrate.New(&chmigrate.Config{...}). migratekit.Migration and migratekit.Prefix are unchanged and still used for migration content.

Postgres-only consumers need no code changes; running go mod tidy is enough to drop clickhouse-go/ch-go from go.mod/go.sum.

Compatibility policy
  • v1.x releases may add symbols, struct fields with useful zero values, and optional behavior — never remove or change what is documented above.
  • Exact error message text is not part of the API; only the documented error conditions are. (Typed sentinel errors may be added additively later.)
  • The module follows Go module semver: any future break means v2 with a new import path. The bar for v2 is intentionally very high.

Schema

migratekit creates two tables in the public schema on first Setup():

CREATE TABLE public.migrations (
    id BIGSERIAL PRIMARY KEY,
    app TEXT NOT NULL,
    database TEXT NOT NULL,
    schema TEXT NOT NULL DEFAULT '',
    name TEXT NOT NULL,             -- the ledger KEY: Prefix(filename)
    filename TEXT,                  -- v1.5.0 identity; NULL on older rows
    content_sha256 TEXT,            -- v1.5.0 integrity; NULL on older rows
    migrated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(app, database, schema, name)
);

-- v1.6.0: every `migratekit repair` lands here, in the same transaction as the
-- identity change it made. The checks are only worth having if there is a way
-- past them; this is what keeps that way honest.
CREATE TABLE public.migration_repairs (
    id BIGSERIAL PRIMARY KEY,
    app TEXT NOT NULL,
    database TEXT NOT NULL,
    schema TEXT NOT NULL DEFAULT '',
    name TEXT NOT NULL,             -- the ledger key repaired
    verb TEXT NOT NULL,
    reason TEXT NOT NULL,
    operator TEXT NOT NULL DEFAULT '',
    os_user TEXT NOT NULL DEFAULT '',
    host TEXT NOT NULL DEFAULT '',
    old_filename TEXT, old_digest TEXT,
    new_filename TEXT, new_digest TEXT,
    repaired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Locking:

  • Postgres uses advisory locks (no lock table). The lock is acquired and released on a single dedicated connection pinned for the lock's lifetime — session advisory locks belong to the connection that took them, so going through the pool would acquire on one connection and "release" on another. If the process dies mid-migration, Postgres releases the lock when the pinned connection drops.
  • ClickHouse uses Postgres advisory locks (same pinning) and requires chmigrate.Config.PostgresDB.

Migration Files

Naming Convention

Files must follow this pattern: {number}{separator}{description}.up.sql

  • Number: Any positive integer (leading zeros optional: 1, 01, 001 all work)
  • Separator: Underscore _ or hyphen -
  • Suffix: Must end with .up.sql

✅ Valid:

001_create_users.up.sql
1_create_users.up.sql
01-add-indexes.up.sql
42-add-timestamps.up.sql
0003_migrations.up.sql

❌ Invalid (will be skipped):

001_create_users.sql        # Missing .up.sql
create_users.up.sql         # Missing numeric prefix
001.create.users.up.sql     # Invalid separator (use _ or -)

Why .up.sql is required:

  • Standard convention used by golang-migrate, bun, etc.
  • Reserves .down.sql for future rollback support
  • Prevents accidental execution of non-migration SQL files

What gets stored: Numeric prefixes are normalized (leading zeros removed) before storage:

  • 001, 01, 1 all become "1"
  • 042, 42 both become "42"

Ordering: migrations apply in numeric-prefix order (2_x before 10_x), not lexical filename order, so unpadded prefixes are safe.

Duplicate prefixes are rejected: because tracking is keyed by the normalized prefix, two files sharing a prefix (002_users.up.sql + 002_roles.up.sql, or 0042_x vs 42_y) would mean the second silently never runs. LoadFromFS returns an error instead.

Template Variables

Migration SQL may reference environment variables as {{VAR_NAME}} or ${VAR_NAME}; they are substituted at execution time. A referenced variable that is not set is an error — silent empty-string substitution previously meant a typo'd {{CLICKHOUSE_PASWORD}} shipped an empty password into DDL. A variable explicitly set to the empty string is substituted as-is. {{ON_CLUSTER}} is special-cased for ClickHouse (expanded from chmigrate.Config.Cluster). Avoid ${...}/{{...}} sequences in migration SQL that are not meant as templates.

ClickHouse Semantics

ClickHouse has no transactional DDL. A multi-statement migration that fails partway leaves the earlier statements applied and the migration unrecorded, so the rerun re-executes them. Every statement in a ClickHouse migration must therefore be individually idempotent (CREATE TABLE IF NOT EXISTS, DROP ... IF EXISTS, etc.). Statements are split on ; with full awareness of string literals and comments, so semicolons inside quoted strings are safe.

Features

  • Smart locking: Only locks when there's work to do
  • App-scoped: Each app has independent migration sequences
  • Correct Postgres locking: Uses Postgres advisory locks (no lock table)
  • ClickHouse compatibility: Runs ClickHouse DDL via native protocol; tracks applied migrations in Postgres
  • Resolvable: every refusal has a migratekit status explanation and an audited repair verb
  • Self-contained: Creates own tables on first run
  • Minimal: small codebase + minimal dependencies

Design

Why lock only when needed?

Checking what's applied (SELECT) doesn't need a lock. Only write operations need locks. This allows multiple services to check migrations concurrently without blocking.

Why per-app scoping?

Different apps (doujins, hentai0, billing) have independent migration sequences and can migrate concurrently.

Why single table?

Easy to query "show all migrations" and simpler permissions.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrNothingToRepair = errors.New("migratekit: nothing to repair")

ErrNothingToRepair is returned when the ledger already matches the tree.

View Source
var ErrRepairInCI = errors.New("migratekit: repairs are operator-runtime verbs and refuse to run in CI")

ErrRepairInCI is returned when a repair verb is invoked in CI.

Functions

func CheckChain added in v1.0.1

func CheckChain(names []string) error

CheckChain validates a migration chain as a FILE LISTING — no database. It reports duplicate numbers, gaps and non-monotonic numbering, which is what a CI gate on the merge boundary needs. names may be in any order.

func ContentDigest added in v1.0.1

func ContentDigest(content string) string

ContentDigest is the ledger's digest of a migration's bytes.

func DetectCI added in v1.0.1

func DetectCI() (string, bool)

DetectCI reports the name of the CI environment variable that is set, if any. An empty-string value does not count — some shells export CI="".

func Prefix

func Prefix(name string) string

Prefix extracts numeric prefix from migration filenames and normalizes it. Supports both underscore and hyphen separators. Examples:

"001_create_users.up.sql" -> "1"
"1-create-users.up.sql"   -> "1"
"0042_add_field.up.sql"   -> "42"

func ValidatePostgresMigrations

func ValidatePostgresMigrations(ctx context.Context, db *sql.DB, sources ...MigrationSource) error

ValidatePostgresMigrations validates multiple Postgres migration sources at once. Returns an error if any migrations are pending.

Types

type AppliedRecord added in v1.0.1

type AppliedRecord struct {
	// Key is the ledger key — Prefix(filename).
	Key string
	// Filename is the full migration filename that claimed Key. Empty for
	// rows written before v1.5.0.
	Filename string
	// Digest is the sha256 of the applied migration's content. Empty for
	// rows written before v1.5.0.
	Digest string
}

AppliedRecord is one row of the applied-migrations ledger.

type Discrepancy added in v1.0.1

type Discrepancy struct {
	Kind     DiscrepancyKind
	Severity Severity

	// Key is the ledger key (the bare migration number).
	Key string
	// File is the migration file in the tree. Empty for KindLedgerOnly.
	File string
	// LedgerFilename is the filename the ledger recorded, if any.
	LedgerFilename string
	// LedgerDigest is the content digest the ledger recorded, if any.
	LedgerDigest string
	// FileDigest is the digest of the file in the tree, if there is one.
	FileDigest string
	// Blocker is the already-applied migration a KindOrderViolation sorts
	// below.
	Blocker string
}

Discrepancy is one disagreement between the migration files and the ledger, with everything an operator needs to resolve it.

func (Discrepancy) Headline added in v1.0.1

func (d Discrepancy) Headline() string

Headline is the one-sentence WHAT.

func (Discrepancy) OneLine added in v1.0.1

func (d Discrepancy) OneLine() string

OneLine is the whole warning in one line: what, the risk, and the verb that resolves it. This is what a log line has room for.

func (Discrepancy) String added in v1.0.1

func (d Discrepancy) String() string

String renders the full operator-facing explanation: what, why, and what to do about it, ending with the pointer at `migratekit status`.

type DiscrepancyKind added in v1.0.1

type DiscrepancyKind string

DiscrepancyKind names a class of ledger/tree disagreement.

const (
	// KindNumberMismatch: the ledger says this number was applied by a
	// different file. Hard error — the file in the tree would never run.
	KindNumberMismatch DiscrepancyKind = "number_applied_by_different_file"
	// KindContentDrift: an applied migration's file has been edited since it
	// ran. Warning by default; error under WithStrictContent.
	KindContentDrift DiscrepancyKind = "applied_migration_edited"
	// KindOrderViolation: a pending migration sorts below one already
	// applied. Error under WithStrictOrdering.
	KindOrderViolation DiscrepancyKind = "pending_sorts_below_applied"
	// KindLedgerOnly: a ledger row with no migration file behind it.
	KindLedgerOnly DiscrepancyKind = "ledger_row_without_file"
)

type Migration

type Migration struct {
	Name    string
	Content string
}

Migration is a single SQL migration

func LoadFromFS

func LoadFromFS(fsys fs.FS, dir ...string) ([]Migration, error)

LoadFromFS loads migrations from an embedded filesystem. Reads all .up.sql files, ordered by numeric prefix (so unpadded names like 2_x and 10_x apply in numeric order), falling back to filename order for non-numeric names. Returns an error if two files normalize to the same Prefix() — tracking is prefix-keyed, so a duplicate prefix would silently skip the second file as "already applied". If dir is empty, defaults to "." (root of the filesystem).

type MigrationSource

type MigrationSource struct {
	App string
	FS  fs.FS
	// Schema optionally mirrors Postgres.WithSchema for validation helpers.
	// RewriteFrom carries canonical schema names to rewrite when applying via
	// Postgres.WithSchema(schema, rewriteFrom...).
	Schema      string
	RewriteFrom []string
}

MigrationSource represents a migration source with an app name and a migration filesystem (any fs.FS; embed.FS satisfies it).

type Postgres

type Postgres struct {
	// contains filtered or unexported fields
}

Postgres handles PostgreSQL migrations

func NewPostgres

func NewPostgres(db *sql.DB, app string) *Postgres

NewPostgres creates a Postgres migrator

func (*Postgres) Applied

func (p *Postgres) Applied(ctx context.Context) ([]string, error)

Applied returns list of applied migration names

func (*Postgres) AppliedRecords added in v1.0.1

func (p *Postgres) AppliedRecords(ctx context.Context) (map[string]AppliedRecord, error)

AppliedRecords returns the applied-migrations ledger keyed by ledger key.

func (*Postgres) ApplyMigrations

func (p *Postgres) ApplyMigrations(ctx context.Context, migrations []Migration) error

ApplyMigrations applies all unapplied migrations (only locks if needed). Setup() always runs first, so there is no missing-table special case.

func (*Postgres) ApplyWithOrderingException added in v1.0.1

func (p *Postgres) ApplyWithOrderingException(ctx context.Context, migrations []Migration, allowBelow []string, req RepairRequest) error

ApplyWithOrderingException applies the chain while exempting the named migrations from the strict-ordering rule — the one-shot escape for a genuine backport that must land below the high-water mark. allowBelow entries may be bare numbers or filenames. Every exemption that actually applies a migration records an audit row.

The identity checks are NOT relaxed: this exempts ordering only.

func (*Postgres) RepairAcceptContent added in v1.0.1

func (p *Postgres) RepairAcceptContent(ctx context.Context, m Migration, req RepairRequest) (RepairResult, error)

RepairAcceptContent re-stamps the recorded digest of an applied migration after a verified edit. It is the "I looked at the diff and it is cosmetic" verb: it acknowledges the content-drift warning and silences it, on the record. It refuses when the ledger names a DIFFERENT file, because that is an identity problem and `repair adopt` is the verb that says so.

func (*Postgres) RepairAdopt added in v1.0.1

func (p *Postgres) RepairAdopt(ctx context.Context, m Migration, req RepairRequest) (RepairResult, error)

RepairAdopt binds the file in the tree as the applied identity for its ledger key: filename and content digest both become this file's.

This is the verb for a ledger that is the WRONG SIDE — a restored backup, a database adopted from somewhere else, a row somebody fixed by hand. The files are right and the ledger remembers a tree that no longer exists. It is NOT the verb for a lane collision: if two files genuinely claim one number, adopting one of them silently drops the other's DDL, which is exactly the failure this package exists to prevent. Read the diff first.

func (*Postgres) RepairAdoptAllUnmatched added in v1.0.1

func (p *Postgres) RepairAdoptAllUnmatched(ctx context.Context, migrations []Migration, req RepairRequest) ([]RepairResult, error)

RepairAdoptAllUnmatched adopts EVERY ledger row whose identity disagrees with the file in the tree that carries its number. This is the restored- backup shape: one dump, one restore, and every row mismatches at once. Rows that already match, and rows with no file behind them, are left alone.

func (*Postgres) RepairHistory added in v1.0.1

func (p *Postgres) RepairHistory(ctx context.Context) ([]RepairRecord, error)

RepairHistory returns this app's repair audit trail, newest first.

func (*Postgres) Setup

func (p *Postgres) Setup(ctx context.Context) error

Setup ensures migration tables exist (idempotent)

func (*Postgres) Status added in v1.0.1

func (p *Postgres) Status(ctx context.Context, migrations []Migration) (Status, error)

Status reports the ledger, the pending set, every discrepancy and the repair history for this app. It is read-only apart from ensuring the tracking tables exist, and it never fails on a discrepancy — reporting one is the whole point.

func (*Postgres) ValidateAllApplied

func (p *Postgres) ValidateAllApplied(ctx context.Context, migrations []Migration) error

ValidateAllApplied checks if all provided migrations have been applied. Returns an error listing any pending migrations if validation fails. This is intended for use during application startup to ensure the database schema is up-to-date before the app starts serving requests.

func (*Postgres) WithSchema

func (p *Postgres) WithSchema(schema string, rewriteFrom ...string) *Postgres

WithSchema configures the schema to target for migrations.

Unqualified DDL/DML runs under:

SET LOCAL search_path = "<schema>", public

Passing one or more canonical schema names also rewrites those schema references in migration SQL before execution. This lets apps author portable hard-qualified DDL against a default schema and relocate it at runtime:

migratekit.NewPostgres(db, "app").WithSchema(cfg.Schema, "openrails")

Tracking always stays in public.migrations.

func (*Postgres) WithStrictContent added in v1.6.0

func (p *Postgres) WithStrictContent() *Postgres

WithStrictContent turns content drift — an applied migration whose file has been edited since it ran — back into a hard error. The default is a warning (see the integrity note above); this is for consumers whose chain must be byte-reproducible and who would rather not boot than diverge.

func (*Postgres) WithStrictOrdering added in v1.0.1

func (p *Postgres) WithStrictOrdering() *Postgres

WithStrictOrdering refuses to apply a pending migration that sorts below a migration already applied. Off by default; see the ordering note above.

func (*Postgres) WithWarnFunc added in v1.0.1

func (p *Postgres) WithWarnFunc(fn func(Discrepancy)) *Postgres

WithWarnFunc replaces the sink for warning-severity discrepancies. The default logs them through slog.Default() at warn level. A warning nobody sees is the failure mode this whole package exists to prevent, so the sink is never silent by default — pass func(Discrepancy){} to silence it deliberately.

type RepairRecord added in v1.0.1

type RepairRecord struct {
	ID          int64
	App         string
	Schema      string
	Key         string
	Verb        string
	Reason      string
	Operator    string
	OSUser      string
	Host        string
	OldFilename string
	OldDigest   string
	NewFilename string
	NewDigest   string
	At          time.Time
}

RepairRecord is one row of the repair audit trail.

func (RepairRecord) Line added in v1.0.1

func (r RepairRecord) Line() string

Line renders one audit row for a terminal.

type RepairRequest added in v1.0.1

type RepairRequest struct {
	// Reason is required and recorded verbatim. "Why" is the only part of a
	// repair a future reader cannot reconstruct.
	Reason string
	// Operator optionally names the human, alongside the OS user the audit
	// row records anyway.
	Operator string
	// DryRun computes and returns the repair without writing anything.
	DryRun bool
}

RepairRequest is the operator's authorization for a repair.

type RepairResult added in v1.0.1

type RepairResult struct {
	Verb        string
	Key         string
	OldFilename string
	OldDigest   string
	NewFilename string
	NewDigest   string
	DryRun      bool
}

RepairResult describes one ledger identity row before and after a repair.

func (RepairResult) String added in v1.0.1

func (r RepairResult) String() string

type Severity added in v1.0.1

type Severity string

Severity ranks a discrepancy.

const (
	// SeverityError blocks the apply.
	SeverityError Severity = "error"
	// SeverityWarning is reported and logged; the apply proceeds.
	SeverityWarning Severity = "warning"
	// SeverityInfo is worth an operator's attention but is not a defect.
	SeverityInfo Severity = "info"
)

type Status added in v1.0.1

type Status struct {
	App    string
	Schema string
	// Applied is the ledger, in apply order.
	Applied []AppliedRecord
	// Pending names the migrations that have not run.
	Pending []string
	// Discrepancies is every disagreement, most severe first.
	Discrepancies []Discrepancy
	// Repairs is the audit trail, newest first.
	Repairs []RepairRecord
}

Status is the whole picture for one app: what is applied, what is pending, what disagrees, and every repair anybody has ever run here.

func (Status) HasErrors added in v1.0.1

func (s Status) HasErrors() bool

HasErrors reports whether any discrepancy would block an apply.

func (Status) Report added in v1.0.1

func (s Status) Report() string

Report renders Status for a terminal.

Directories

Path Synopsis
Package chmigrate is migratekit's ClickHouse migration driver.
Package chmigrate is migratekit's ClickHouse migration driver.
cmd
migratekit command
Command migratekit is the operator's side of migration identity: it explains a boot refusal and resolves it.
Command migratekit is the operator's side of migration identity: it explains a boot refusal and resolves it.
internal
coremigrate
Package coremigrate holds implementation primitives shared by migratekit's Postgres migrator (the root package) and its ClickHouse migrator (migratekit/chmigrate): the migrations-tracking table DDL and environment template substitution.
Package coremigrate holds implementation primitives shared by migratekit's Postgres migrator (the root package) and its ClickHouse migrator (migratekit/chmigrate): the migrations-tracking table DDL and environment template substitution.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL