Help us shape the future of Go! Take the 2026 Go Developer Survey

seed

package module
v1.0.1 Latest Latest
Warning

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

Go to latest
Published: Sep 26, 2026 License: MIT Imports: 5 Imported by: 2

README

go-seed 🌱

✅ A generic, idempotent, row-asserting seed runner for Go. Declare ordered seed steps, run them, and have each step prove it actually seeded — because exit 0 is not proof.

📦 Install

go get github.com/Bugs5382/go-seed

🤔 Why

Hand-rolled seed scripts share three defects: they are not ordered in a reviewable way, they are not idempotent, and they treat a zero exit code as success. A script can exit clean having seeded nothing. go-seed fixes the third defect structurally: every step is followed by an assertion that the expected rows/state exist, and the run fails loudly (naming the step) if not.

🚀 Usage

Declare steps against any target — the runner is generic, so the target can be a *sql.DB, a repository, or an in-memory store. Each step has an idempotent Apply and an Assert that verifies the result.

runner := seed.New(db).Add(
    seed.Step[*sql.DB]{
        Name:   "admin-role",
        Apply:  func(ctx context.Context, db *sql.DB) error { /* idempotent write */ return nil },
        Assert: func(ctx context.Context, db *sql.DB) error { /* verify row exists */ return nil },
    },
)

if err := runner.Run(ctx); err != nil {
    // err is a *seed.StepError naming the step and phase (apply/assert) that failed.
    log.Fatal(err)
}
SQL helper

For the common relational case, declare a RowSpec instead of writing the hooks by hand. Apply must be an idempotent statement; Count proves it took effect.

step := seed.RowSpec{
    Name:      "admin-role",
    Apply:     "INSERT INTO roles(name) VALUES ($1) ON CONFLICT DO NOTHING",
    ApplyArgs: []any{"admin"},
    Count:     "SELECT count(*) FROM roles WHERE name = $1",
    CountArgs: []any{"admin"},
    // WantAtLeast defaults to 1.
}.Step()

// *sql.DB and *sql.Tx both satisfy seed.Executor.
err := seed.New[seed.Executor](db).Add(step).Run(ctx)

⚙️ Behavior

  • 🔢 Ordered: steps run in the order they are added.
  • 🔁 Idempotent: your Apply must be idempotent; the assertion is what makes a re-run safe to trust. Running twice does not duplicate or error.
  • 🔍 Row-asserting: a clean Apply is never trusted. A step with no Assert is applied but logged as unverified.
  • 🛑 Fail-fast: the first failing step stops the run and returns a *seed.StepError naming the step and the phase.
  • 📝 Logging: standard-library log/slog (seed.WithLogger to override). No third-party logging dependency.

🛠 Develop

task build    # go build ./...
task test     # go test ./...
task lint     # gofmt check + golangci-lint + yamllint
task ci       # full local gate
task license  # verify MIT headers (golic)

⚖️ License

MIT © 2026 Shane

Documentation

Overview

Package seed is a generic, idempotent, row-asserting seed runner. It replaces hand-rolled seed scripts with declarative, ordered seed steps that verify their own effect: applying a step is never trusted on its own, because a clean exit is not proof that any row was written.

An app declares Steps, each with an idempotent Apply hook and an Assert hook that checks the expected rows/state exist, then runs them in order with a Runner. The core is storage-agnostic: the target is a type parameter, so a step can run against a *sql.DB, a repository, or an in-memory store. For the common relational case, RowSpec builds a Step from a declarative write plus a count assertion against the database/sql Executor surface.

Logging uses the standard library log/slog; there is no third-party logging dependency.

Example

Example declares two ordered seed steps and runs them. Each step applies an idempotent write and then asserts the expected state exists.

package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"

	seed "github.com/Bugs5382/go-seed"
)

// roleStore is a tiny in-memory target, standing in for whatever storage an app
// seeds. Because the runner is generic over the target type, no database or
// driver is needed to demonstrate it.
type roleStore struct{ roles map[string]bool }

// quiet discards the runner's own log output so example output is deterministic.
func quiet() seed.Option {
	return seed.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
}

func main() {
	db := &roleStore{roles: map[string]bool{}}

	runner := seed.New(db, quiet()).Add(
		seed.Step[*roleStore]{
			Name:   "admin-role",
			Apply:  func(_ context.Context, s *roleStore) error { s.roles["admin"] = true; return nil },
			Assert: assertRole("admin"),
		},
		seed.Step[*roleStore]{
			Name:   "auditor-role",
			Apply:  func(_ context.Context, s *roleStore) error { s.roles["auditor"] = true; return nil },
			Assert: assertRole("auditor"),
		},
	)

	if err := runner.Run(context.Background()); err != nil {
		fmt.Println("seed failed:", err)
		return
	}
	fmt.Println("seeded and verified")
}

func assertRole(name string) func(context.Context, *roleStore) error {
	return func(_ context.Context, s *roleStore) error {
		if !s.roles[name] {
			return fmt.Errorf("role %q not present", name)
		}
		return nil
	}
}
Output:
seeded and verified
Example (AssertionCatchesUnseeded)

Example_assertionCatchesUnseeded shows the core guarantee: a step whose Apply exits cleanly but writes nothing is caught by its assertion, instead of being mistaken for a successful seed.

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"

	seed "github.com/Bugs5382/go-seed"
)

// roleStore is a tiny in-memory target, standing in for whatever storage an app
// seeds. Because the runner is generic over the target type, no database or
// driver is needed to demonstrate it.
type roleStore struct{ roles map[string]bool }

// quiet discards the runner's own log output so example output is deterministic.
func quiet() seed.Option {
	return seed.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
}

func main() {
	db := &roleStore{roles: map[string]bool{}}

	runner := seed.New(db, quiet()).Add(seed.Step[*roleStore]{
		Name:   "admin-role",
		Apply:  func(_ context.Context, _ *roleStore) error { return nil }, // exits 0, seeds nothing
		Assert: assertRole("admin"),
	})

	err := runner.Run(context.Background())

	var stepErr *seed.StepError
	if errors.As(err, &stepErr) {
		fmt.Printf("caught: step %q failed during %s\n", stepErr.Step, stepErr.Phase)
	}
}

func assertRole(name string) func(context.Context, *roleStore) error {
	return func(_ context.Context, s *roleStore) error {
		if !s.roles[name] {
			return fmt.Errorf("role %q not present", name)
		}
		return nil
	}
}
Output:
caught: step "admin-role" failed during assert

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrEmptyStepName is returned when a step has no name.
	ErrEmptyStepName = errors.New("seed: step name must not be empty")
	// ErrDuplicateStepName is returned when two steps share a name.
	ErrDuplicateStepName = errors.New("seed: duplicate step name")
	// ErrNilApply is returned when a step has a nil Apply hook.
	ErrNilApply = errors.New("seed: step Apply must not be nil")
)

Configuration errors returned by Run before any step executes.

Functions

func Count

func Count(ctx context.Context, db Executor, query string, args ...any) (int, error)

Count runs a query that must return a single integer (typically a SELECT count(*)) and returns it. It is exported so apps can build custom assertions without depending on RowSpec.

Types

type Executor

type Executor interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Executor is the minimal database surface a SQL seed step needs. It binds only to database/sql, never to a driver; both *sql.DB and *sql.Tx satisfy it, so a step may run against a pool or inside a transaction the app controls.

type Option

type Option func(*options)

Option configures a Runner at construction time.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the slog.Logger the Runner uses. When unset, the Runner uses slog.Default().

type Phase

type Phase string

Phase identifies which hook of a step failed.

const (
	// PhaseApply marks a failure raised by a step's Apply hook.
	PhaseApply Phase = "apply"
	// PhaseAssert marks a failure raised by a step's Assert hook.
	PhaseAssert Phase = "assert"
)

type RowSpec

type RowSpec struct {
	// Name identifies the step. Required.
	Name string
	// Apply is the idempotent write statement, e.g. an
	// INSERT ... ON CONFLICT DO NOTHING or an upsert. Required.
	Apply string
	// ApplyArgs are the bound arguments for Apply.
	ApplyArgs []any
	// Count is a query returning a single integer row count that proves the
	// seed exists, e.g. SELECT count(*) FROM roles WHERE name = $1. Required.
	Count string
	// CountArgs are the bound arguments for Count.
	CountArgs []any
	// WantAtLeast is the minimum row count the assertion requires. A value of
	// zero is treated as 1, so the default assertion is "at least one row".
	WantAtLeast int
}

RowSpec declares a SQL seed step: an idempotent write plus the count query that proves it took effect. RowSpec.Step turns it into a Step[Executor].

func (RowSpec) Step

func (s RowSpec) Step() Step[Executor]

Step builds a Step[Executor] from the spec. Its Apply runs the write; its Assert runs Count and fails unless the result is at least WantAtLeast. "At least" (not "exactly") is intentional: a re-run over already-seeded data must still pass.

type Runner

type Runner[T any] struct {
	// contains filtered or unexported fields
}

Runner applies an ordered set of Steps against a single target and asserts each one. It is not safe for concurrent use.

func New

func New[T any](target T, opts ...Option) *Runner[T]

New returns a Runner bound to target. Steps are added with Add and applied in the order they were added.

func (*Runner[T]) Add

func (r *Runner[T]) Add(steps ...Step[T]) *Runner[T]

Add appends steps to the runner in order and returns the runner for chaining.

func (*Runner[T]) Run

func (r *Runner[T]) Run(ctx context.Context) error

Run validates the step set, then applies and asserts each step in order.

Validation runs first and rejects an empty or duplicate step name or a nil Apply hook, returning a configuration error before any step executes. Each step then runs Apply and, if set, Assert. A step with no Assert is applied but logged as unverified, because a clean Apply is not proof of seeding.

Run is fail-fast: the first failing step stops the run and returns a *StepError naming the step and the phase (PhaseApply or PhaseAssert) that failed. Because every Apply must be idempotent, Run is safe to call repeatedly.

type Step

type Step[T any] struct {
	// Name identifies the step in logs and errors. It must be non-empty and
	// unique within a Runner.
	Name string
	// Apply performs the idempotent write. It must be non-nil.
	Apply func(ctx context.Context, target T) error
	// Assert verifies the expected rows/state exist after Apply. It may be nil,
	// in which case the Runner logs a warning that the step is unverified.
	Assert func(ctx context.Context, target T) error
}

Step is one named unit of seeding. Both hooks receive the app-supplied target T, so the core binds to no particular storage.

Apply performs the write and must be idempotent: running it a second time must not duplicate rows or fail. Assert verifies the expected state exists after Apply and returns an error if it does not; a zero-error Apply is never trusted on its own. Assert is optional but strongly recommended, because a step with no assertion cannot prove it seeded anything.

type StepError

type StepError struct {
	Step  string
	Phase Phase
	Err   error
}

StepError reports which step failed and in which phase. It wraps the underlying cause, which is available via errors.Unwrap / errors.Is / errors.As.

func (*StepError) Error

func (e *StepError) Error() string

Error implements error, naming the step and the phase that failed.

func (*StepError) Unwrap

func (e *StepError) Unwrap() error

Unwrap returns the underlying cause.

Jump to

Keyboard shortcuts

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