migrate

package
v0.3.2 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package migrate is the imperative front door as a library: one parsed statement in — gate, resolve, introspect, classify, route, execute — and exactly one verdict out. The CLI's migrate command and orchestrators embedding pg-sprite share this one pipeline, so a verdict means the same thing no matter which caller produced it.

RunDesired is the declarative execution loop on the same pipeline: one parsed desired-state schema in, the convergence plan derived through diffplan.Plan, and every planned statement executed back through Run — per-statement verdicts, committed-prefix semantics, stop at the first refusal or failure. Both entry points share Options, the executors, and the verdict contract; the desired loop adds only plan admission and sequencing.

Callers own the boundary concerns: parse the statement through statement.ParseOne (or the desired schema through statement.ParseDesired; a parse failure surfaces at the caller) and build the connection through dbconn.NewPool. Gate is exported so a caller can refuse an unsupported statement kind before dialing; Run re-checks it, so a caller that skips the early gate still cannot execute a gated kind.

Run takes the concrete pgxpool.Pool that dbconn.NewPool returns — a deliberate concrete dependency, not an oversight: the execution paths need the full pool surface (dedicated sessions for concurrent builds, per-step transactions), a narrower interface would admit handles those paths cannot use, and it is the same handle the declarative front door (diffplan.Plan) takes, so the two front doors embed identically.

Before a v1 module tag the Go API carries no compatibility promise: the JSON verdict.Verdict is the stability boundary, the Go API follows at v1 (see docs/architecture.md).

Example (Run)

Example_run is the full library flow an orchestrator embeds: parse the statement, gate it before dialing, connect, and drive the imperative pipeline to exactly one verdict. It is compile-checked but not executed — Run needs a live PostgreSQL database.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/block/pg-sprite/pkg/dbconn"
	"github.com/block/pg-sprite/pkg/migrate"
	"github.com/block/pg-sprite/pkg/statement"
	"github.com/block/pg-sprite/pkg/verdict"
)

func main() {
	ctx := context.Background()

	// Parse failures surface here, at the boundary where the embedder can
	// render them.
	st, err := statement.ParseOne("ALTER TABLE users ALTER COLUMN email SET NOT NULL")
	if err != nil {
		log.Print(err)
		return
	}

	// Gate needs no database: an unsupported statement kind refuses before
	// dialing. Run re-checks it, so skipping this early gate is safe —
	// only slower.
	if v, refused := migrate.Gate(st); refused {
		fmt.Println(v.Reason, v.Detail)
		return
	}

	pool, err := dbconn.NewPool(ctx, dbconn.Config{URL: "postgres://engine@localhost:5432/app"})
	if err != nil {
		log.Print(err)
		return
	}
	defer pool.Close()

	// The zero Options is not a runnable policy — Run rejects it.
	// DefaultOptions is the sanctioned starting point (the CLI's flag
	// defaults); tune it per table: a large table needs more generous
	// concurrent-build and validate bounds, a hot table a tighter lock
	// budget.
	opts := migrate.DefaultOptions()

	// The verdict-and-error contract has three shapes: a refusal returns
	// the verdict with a nil error; an execution failure returns the
	// failed verdict (the stable code and the committed prefix) together
	// with the operational error; an error with a zero verdict means the
	// pipeline stopped before executing anything.
	v, err := migrate.Run(ctx, pool, st, opts)
	if err != nil {
		log.Print(err)
	}
	switch v.Outcome {
	case verdict.OutcomeExecuted:
		fmt.Println(v.ExecutedSQL)
	case verdict.OutcomeRefused:
		fmt.Println(v.Reason, v.Detail)
	case verdict.OutcomeFailed:
		fmt.Println(v.Code, v.ExecutedSQL)
	}
}
Example (RunDesired)

Example_runDesired is the declarative execution flow: parse the desired-state schema, connect, and converge the live table onto it — the engine derives the plan and drives every planned statement through the same pipeline Run uses. It is compile-checked but not executed — RunDesired needs a live PostgreSQL database.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/block/pg-sprite/pkg/dbconn"
	"github.com/block/pg-sprite/pkg/migrate"
	"github.com/block/pg-sprite/pkg/statement"
	"github.com/block/pg-sprite/pkg/verdict"
)

func main() {
	ctx := context.Background()

	// One desired file describes one table: exactly one CREATE TABLE plus
	// its indexes. Parse failures surface here, at the boundary where the
	// embedder can render them.
	desired, err := statement.ParseDesired(`CREATE TABLE users (id bigint PRIMARY KEY, email text);
CREATE INDEX users_email_idx ON users (email);`)
	if err != nil {
		log.Print(err)
		return
	}

	pool, err := dbconn.NewPool(ctx, dbconn.Config{URL: "postgres://engine@localhost:5432/app"})
	if err != nil {
		log.Print(err)
		return
	}
	defer pool.Close()

	// The result-and-error contract mirrors Run's three shapes: a refusal
	// (at plan admission or on a mid-plan statement) returns the result
	// with a nil error; an execution failure returns the failed result
	// together with the operational error; an error with a zero result
	// means nothing was planned or executed. Verdicts[i] is the verdict of
	// Plan.Statements[i] — fewer verdicts than planned statements means
	// execution stopped there.
	res, err := migrate.RunDesired(ctx, pool, migrate.DesiredRequest{
		Schema:  "public",
		Desired: desired,
		// ExpectedFingerprint pins a reviewed plan: leave it empty to run
		// whatever plan the live table needs now.
	}, migrate.DefaultOptions())
	if err != nil {
		log.Print(err)
	}
	switch res.Outcome {
	case verdict.OutcomeExecuted:
		fmt.Println(len(res.Plan.Statements), "statements converged")
	case verdict.OutcomeRefused:
		fmt.Println(res.Reason, res.Detail)
	case verdict.OutcomeFailed:
		fmt.Println(res.Detail)
	}
}

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrForceNotSupported = errors.New(
	"the force acknowledgement applies to the imperative front door only; desired-state execution never runs a submitted form blind")

ErrForceNotSupported is returned by RunDesired when Options.Force is set: the force acknowledgement applies to the imperative front door only — desired-state execution never runs a submitted form blind. It is a sentinel so an embedder can tell the unsupported option apart from an operational failure with errors.Is.

Functions

func Gate

Gate is the statement-type gate: ALTER TABLE and CREATE INDEX proceed to classification (a blocking CREATE INDEX is substituted with its concurrent build, a submitted concurrent build is driven directly); the index-maintenance forms the executor cannot drive yet are pointed at their concurrent idiom, everything else is unsupported. Refused statements are never executed. Gate needs no database, so a caller can refuse before dialing; Run re-checks it regardless.

func ResolvedSchema

func ResolvedSchema(st statement.Statement) string

ResolvedSchema is the schema the engine plans against: the statement's qualification, or public — the default the engine introspects — when a table-targeted statement leaves it unqualified. A report carries the resolved name, never the submitted one: a stored plan must not depend on the reader's search_path to say which table it describes.

func Run

Run drives one schema change end to end: gate the statement type, resolve the target, classify and route the statement exactly as a dry run would, execute the routed SQL — the planner's safer native sequence by default when the submitted form blocks — and end in exactly one verdict.

The verdict-and-error contract has three shapes. A refusal returns the refusal verdict and a nil error; the caller maps it to its refusal exit path. An execution failure returns the failed verdict — the stable executor code plus the committed prefix — together with the operational error; the verdict is the error's machine-readable twin. An error with a zero verdict means the pipeline stopped before reaching a verdict (a resolution, introspection, or acknowledgement error) and nothing was executed.

Run does not close the pool; one pool serves any number of calls.

Types

type DesiredRequest

type DesiredRequest struct {
	// Schema is the target schema the desired table lives in.
	Schema string
	// Desired is the parsed desired-state schema for the table.
	Desired statement.DesiredSchema
	// ExpectedFingerprint optionally pins the plan: when set, the plan
	// derived at execution time must carry exactly this fingerprint
	// (plan.Report.Fingerprint) or nothing runs. It is how a caller that
	// had a plan reviewed enforces that the plan the reviewer approved is
	// the plan that executes; empty skips the check.
	ExpectedFingerprint string
}

DesiredRequest names the inputs to RunDesired. Zero-value fields are invalid: the schema must be set, and the desired state must come from statement.ParseDesired — the zero DesiredSchema is refused.

type DesiredResult

type DesiredResult struct {
	// Plan is the convergence plan derived at execution time; empty
	// Plan.Statements means the live table already matched the desired
	// schema.
	Plan plan.Report `json:"plan"`
	// Verdicts are the per-statement verdicts, in plan order, one per
	// attempted statement.
	Verdicts []verdict.Verdict `json:"verdicts,omitempty"`
	// Outcome is what happened overall: executed when every planned
	// statement committed (or there was nothing to run), refused when the
	// plan or one of its statements was refused and execution stopped,
	// failed when execution stopped on an operational error. A failed
	// result whose stopping statement has no verdict means the pipeline
	// stopped before reaching one — nothing about that statement was
	// executed; a failed verdict means the statement was attempted and
	// failed. Detail says which.
	Outcome verdict.Outcome `json:"outcome"`
	// Reason is the typed refusal cause; empty unless Outcome is refused.
	Reason verdict.Reason `json:"reason,omitempty"`
	// Detail is the human explanation: why refused, what committed, or
	// that there was nothing to do.
	Detail string `json:"detail,omitempty"`
}

DesiredResult is the aggregate report for one desired-state execution: the plan that was derived, the verdict of every statement that was attempted, and the overall outcome.

Verdicts[i] is the verdict of Plan.Statements[i]; fewer verdicts than planned statements means execution stopped and the remaining statements were never attempted. The committed prefix is read from the verdicts: every executed verdict committed in full, and a failed verdict's own ExecutedSQL discloses the committed steps inside the statement that failed. Whether anything changed is read from the plan: an executed outcome with empty Plan.Statements is the no-op signal — the live table already matched the desired schema and nothing ran.

func RunDesired

func RunDesired(ctx context.Context, pool *pgxpool.Pool, req DesiredRequest, opts Options) (DesiredResult, error)

RunDesired converges one live table onto its desired-state schema: derive the convergence plan with diffplan.Plan, admit the plan as a whole, then execute each planned statement back through the full Run pipeline — fresh introspection, classification, and routing per statement, so a statement that became unsafe after planning refuses instead of running — stopping at the first refusal or failure.

A table that does not exist yet takes the greenfield create path instead: the plan is the desired schema itself, and after the same whole-plan admission the executor's create path verifies the name is free and the role can create in the schema, then runs the CREATE TABLE and the index builds as brief bounded steps. An occupied name is a typed verdict.ReasonCreateCollision refusal — the caller re-derives the plan against the live catalog rather than assuming the occupant's shape.

Plan-time admission is all-or-nothing: a plan that contains a destructive statement, routes any statement away from execution, or does not match the pinned fingerprint is refused before anything runs. Execution-time semantics are committed-prefix: once statements start running, an executed statement stays committed even when a later one refuses or fails, and the result's verdicts disclose exactly how far convergence got.

The result-and-error contract mirrors Run's three shapes. A refusal — at plan admission or on a mid-plan statement — returns the result with a nil error. An execution failure returns the failed result together with the operational error. An error with a zero result means the pipeline stopped before planning or executing anything.

Options.Force is rejected: the declarative front door never runs a submitted form blind. A destructive or force-worthy change belongs on the imperative front door where the operator states it explicitly.

RunDesired does not close the pool; one pool serves any number of calls.

type Facts

type Facts struct {
	// Classifier feeds [planner.Classify]. Statements without a single
	// table target (index drops, REINDEX) and missing tables classify
	// with zero facts — a strictly more conservative plan.
	Classifier planner.Facts

	// Target carries the preflight facts (partitioning, server major)
	// for plan-time partition checks; zero whenever Classifier is.
	Target preflight.TargetFacts

	// TableExists mirrors the introspection outcome for a plan report:
	// true when the table was found, false when it was looked up and
	// missing, nil when the statement has no single table target to
	// introspect.
	TableExists *bool
}

Facts is one introspection pass over the statement's target table. Both Run and a dry-run plan classify from one Facts value, so execution and its plan describe the same live state.

func LiveFacts

func LiveFacts(ctx context.Context, pool *pgxpool.Pool,
	st statement.Statement) (Facts, error)

LiveFacts introspects the statement's target table for classifier facts.

type Options

type Options struct {
	// Force is the typed acknowledgement to run the submitted form as-is,
	// overriding a safer-sequence substitution or a rewrite-required /
	// backend-unavailable refusal. It must name the resolved
	// schema-qualified target table exactly; empty means the engine's
	// routing decides. Planner refusals (no known safe path) and gated
	// statement kinds cannot be forced.
	Force string

	// MaxTableSizeBytes is the threshold above which a blind bounded
	// attempt of the submitted form is refused, measured as the table's
	// full on-disk footprint: heap, indexes, and TOAST, all partitions.
	// Substituted safer sequences and planner-proven online idioms are not
	// size-guarded — long work on large tables is their purpose.
	MaxTableSizeBytes int64

	// Budget bounds every executed step: brief steps under lock and
	// statement timeouts, concurrent index builds and constraint
	// validation under their overall bounds.
	Budget executor.SequenceBudget

	// Retry is the bounded retry policy when native DDL exceeds its lock
	// budget. The zero value uses [executor.DefaultRetryPolicy]; a
	// partially configured policy is rejected by the executor.
	Retry executor.RetryPolicy

	// Logger receives decision diagnostics (routing, preflight, execution
	// transitions); nil discards them.
	Logger *slog.Logger

	// Audit receives the force-override audit record; nil discards it.
	// The verdict's Forced field is the machine-readable record and is
	// always set, so the run's outcome never depends on this logger; the
	// CLI wires an always-on stderr handler here so an operator's
	// deliberate safety override is visible even without diagnostics.
	Audit *slog.Logger
}

Options carries the execution policy for one Run: the safety budgets, the retry policy, the size guard, and the operator's force acknowledgement. The zero value is not a runnable policy: callers set the budgets and the size guard deliberately — DefaultOptions is the sanctioned starting point (the same policy the CLI's flag defaults wire), an embedding orchestrator tunes from there.

func DefaultOptions

func DefaultOptions() Options

DefaultOptions is the sanctioned starting point for an embedding caller: the same budgets, size guard, and retry policy the CLI's flag defaults wire. These are defaults to tune, not a recommendation — a large table needs a more generous concurrent-build and validate bound, a hot table a tighter lock budget. Force, Logger, and Audit stay zero: overriding safety and receiving diagnostics are always deliberate choices.

Jump to

Keyboard shortcuts

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