feat(migrate): add lifecycle commands and standalone authoring (#18)
Documentation / docs (push) Successful in 22s
ci / check (push) Successful in 41s
release / release (push) Successful in 25s

Add apply/require startup policies and five database-only migration commands.

Provide standalone corestack-goose creation and reconciliation with fresh
provider-local Go registrations. Default optional --dir to the working directory.
Document the production boundary and source recovery requirements.

Closes #9
This commit was merged in pull request #18.
This commit is contained in:
end committed 2026-10-28 16:10:40 -07:00
1 parent 0034a561ba
commit a99e364ac3
12 files changed
+1678 -30

No files matched your search

@@ -0,0 +1,424 @@
// Package authoring owns the standalone tool's bounded migration source format.
// It requires one writer and a complete source backup; it never accesses a DB.
package authoring
import (
"bytes"
"errors"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"io"
"math"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"text/template"
"time"
"github.com/pressly/goose/v3"
)
const marker = "// Code generated by corestack-goose. DO NOT EDIT.\n"
const goTemplate = `package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func migration{{.CamelName}}(version int64) *goose.Migration {
return goose.NewGoMigration(version, &goose.GoFunc{RunTx: up{{.CamelName}}}, &goose.GoFunc{RunTx: down{{.CamelName}}})
}
func up{{.CamelName}}(ctx context.Context, tx *sql.Tx) error {
return nil
}
func down{{.CamelName}}(ctx context.Context, tx *sql.Tx) error {
return nil
}
`
type migration struct {
name, factory string
version int64
data []byte
}
type change struct {
source, dest string
data []byte
replace bool
}
type sourceSet struct {
dir string
migrations []migration
register []byte
}
// Create stages Goose's supported templates, then publishes a preflighted set.
// Go names use lowercase ASCII words separated by underscores. All versions and
// identifiers must be unique across the directory; existing bodies are preserved.
func Create(dir, name, kind string) error {
if !validName(name) || (kind != "sql" && kind != "go") {
return errors.New("create: expected lowercase_words name and sql|go")
}
set, err := scan(dir)
if err != nil {
return err
}
stage, err := os.MkdirTemp("", "corestack-goose-*")
if err != nil {
return err
}
defer os.RemoveAll(stage)
var tmpl *template.Template
if kind == "go" {
tmpl, err = template.New("migration").Parse(goTemplate)
if err != nil {
return err
}
}
if err := goose.CreateWithTemplate(nil, stage, tmpl, name, kind); err != nil {
return fmt.Errorf("stage creation: %w", err)
}
entries, err := os.ReadDir(stage)
if err != nil {
return err
}
if len(entries) != 1 {
return errors.New("stage creation: expected one migration")
}
filename := entries[0].Name()
data, err := os.ReadFile(filepath.Join(stage, filename))
if err != nil {
return err
}
if kind == "go" {
filename = strings.TrimSuffix(filename, ".go") + "_migration.go"
data, err = format.Source(data)
if err != nil {
return err
}
}
item, err := parseMigration(filename, data)
if err != nil {
return err
}
items := append(slices.Clone(set.migrations), item)
if err := validate(items); err != nil {
return err
}
register, err := aggregate(items)
if err != nil {
return err
}
changes := []change{{dest: filename, data: data}}
changes = appendRegister(changes, set, register)
return publish(set, changes, writeExclusive)
}
// Fix preserves sequential IDs and allocates timestamp IDs after their maximum.
// It never rewrites migration bodies or database history. Commit or separately
// back up all sources first; on failure restore that backup before retrying.
func Fix(dir string) error {
set, err := scan(dir)
if err != nil {
return err
}
items := slices.Clone(set.migrations)
var highest int64
for _, item := range items {
if !timestamp(item.version) && item.version > highest {
highest = item.version
}
}
var changes []change
for i := range items {
if !timestamp(items[i].version) {
continue
}
if highest == math.MaxInt64 {
return errors.New("fix: sequential version overflow")
}
highest++
old := items[i].name
items[i].version = highest
items[i].name = fmt.Sprintf("%05d_%s", highest, strings.SplitN(old, "_", 2)[1])
changes = append(changes, change{source: old, dest: items[i].name, data: items[i].data})
}
if err := validate(items); err != nil {
return err
}
register, err := aggregate(items)
if err != nil {
return err
}
changes = appendRegister(changes, set, register)
return publish(set, changes, writeExclusive)
}
func timestamp(version int64) bool {
t, err := time.Parse("20060102150405", strconv.FormatInt(version, 10))
return err == nil && t.Unix() > 0
}
func validName(name string) bool {
for _, word := range strings.Split(name, "_") {
if word == "" {
return false
}
for _, r := range word {
if r < 'a' || r > 'z' {
return false
}
}
}
return true
}
func parseMigration(name string, data []byte) (migration, error) {
item := migration{name: name, data: data}
ext := filepath.Ext(name)
parts := strings.SplitN(strings.TrimSuffix(name, ext), "_", 2)
if len(parts) != 2 {
return item, fmt.Errorf("unsupported migration filename %s", name)
}
raw := parts[0]
if strings.IndexFunc(raw, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
return item, fmt.Errorf("invalid version in %s", name)
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v <= 0 {
return item, fmt.Errorf("invalid positive int64 version in %s", name)
}
item.version = v
if ext == ".sql" {
return item, nil
}
logical, ok := strings.CutSuffix(parts[1], "_migration")
if !ok || !validName(logical) {
return item, fmt.Errorf("unsupported Go filename %s: use <version>_<lowercase_words>_migration.go", name)
}
var camel strings.Builder
for _, word := range strings.Split(logical, "_") {
camel.WriteString(strings.ToUpper(word[:1]) + word[1:])
}
item.factory = "migration" + camel.String()
file, err := parser.ParseFile(token.NewFileSet(), name, data, 0)
if err != nil {
return item, fmt.Errorf("parse %s: %w", name, err)
}
if file.Name.Name != "migrations" {
return item, fmt.Errorf("%s: expected package migrations", name)
}
count := 0
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name.Name != item.factory {
continue
}
var signature bytes.Buffer
if err := format.Node(&signature, token.NewFileSet(), fn.Type); err != nil {
return item, err
}
if fn.Recv != nil || signature.String() != "func(version int64) *goose.Migration" || fn.Body == nil {
return item, fmt.Errorf("%s: expected %s(version int64) *goose.Migration factory", name, item.factory)
}
count++
}
if count != 1 {
return item, fmt.Errorf("%s: expected one %s factory", name, item.factory)
}
return item, nil
}
func validate(items []migration) error {
slices.SortFunc(items, func(a, b migration) int {
if a.version < b.version {
return -1
}
if a.version > b.version {
return 1
}
return 0
})
factories := make(map[string]bool)
for i, item := range items {
if i > 0 {
previous := items[i-1]
if previous.version == item.version {
return fmt.Errorf("duplicate version: %s and %s", previous.name, item.name)
}
if previous.name >= item.name {
return fmt.Errorf("lexical/numeric order disagreement: %s and %s; use consistent padding", previous.name, item.name)
}
}
if item.factory != "" {
if factories[item.factory] {
return fmt.Errorf("duplicate factory %s", item.factory)
}
factories[item.factory] = true
}
}
return nil
}
func aggregate(items []migration) ([]byte, error) {
var out strings.Builder
out.WriteString(marker + "\n// Package migrations contains application-owned SQL and Go migrations.\n// corestack-goose owns this aggregate; registrations are provider-local.\npackage migrations\n\nimport \"github.com/pressly/goose/v3\"\n\n// GoMigrations returns fresh provider-local registrations on every call.\nfunc GoMigrations() []*goose.Migration {\nreturn []*goose.Migration{\n")
for _, item := range items {
if item.factory != "" {
fmt.Fprintf(&out, "%s(%d),\n", item.factory, item.version)
}
}
out.WriteString("}\n}\n")
return format.Source([]byte(out.String()))
}
func scan(dir string) (sourceSet, error) {
set := sourceSet{dir: dir}
absolute, err := filepath.Abs(dir)
if err != nil {
return set, err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return set, err
}
if resolved != absolute {
return set, fmt.Errorf("%s: symlink paths are unsupported", dir)
}
entries, err := os.ReadDir(dir)
if err != nil {
return set, err
}
for _, entry := range entries {
name := entry.Name()
if name != "register.go" && filepath.Ext(name) != ".go" && filepath.Ext(name) != ".sql" {
continue
}
info, err := entry.Info()
if err != nil {
return set, err
}
if !info.Mode().IsRegular() {
return set, fmt.Errorf("%s: expected regular file", name)
}
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return set, err
}
if name == "register.go" {
set.register = data
continue
}
item, err := parseMigration(name, data)
if err != nil {
return set, err
}
set.migrations = append(set.migrations, item)
}
if err := validate(set.migrations); err != nil {
return set, err
}
expected, err := aggregate(set.migrations)
if err != nil {
return set, err
}
if set.register != nil {
normalized, err := format.Source(set.register)
if err != nil || !bytes.HasPrefix(set.register, []byte(marker)) || !bytes.Equal(normalized, expected) {
return set, errors.New("register.go: unsupported or drifted aggregate; restore the complete source backup")
}
} else {
for _, item := range set.migrations {
if item.factory != "" {
return set, errors.New("register.go missing: restore the complete source backup")
}
}
}
return set, nil
}
func appendRegister(changes []change, set sourceSet, data []byte) []change {
if bytes.Equal(data, set.register) {
return changes
}
return append(changes, change{dest: "register.go", data: data, replace: set.register != nil})
}
func writeExclusive(path string, data []byte) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
if err != nil {
return err
}
_, writeErr := io.Copy(file, bytes.NewReader(data))
return errors.Join(writeErr, file.Close())
}
// invariant: publish complete destinations before deleting sources; a failure
// stops the plan and requires manual source restoration, never an automatic retry.
func publish(set sourceSet, changes []change, write func(string, []byte) error) error {
if len(changes) == 0 {
return nil
}
for _, op := range changes {
if !op.replace {
if _, err := os.Lstat(filepath.Join(set.dir, op.dest)); !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("destination %s exists or cannot be checked: %w", op.dest, errors.Join(err, os.ErrExist))
}
}
}
stage, err := os.MkdirTemp(set.dir, ".corestack-goose-*")
if err != nil {
return err
}
defer os.RemoveAll(stage)
for i, op := range changes {
if err := writeExclusive(filepath.Join(stage, strconv.Itoa(i)), op.data); err != nil {
return fmt.Errorf("stage %s: %w", op.dest, err)
}
}
var completed []string
for i, op := range changes {
dest := filepath.Join(set.dir, op.dest)
if op.replace {
var current []byte
info, statErr := os.Lstat(dest)
if statErr != nil || !info.Mode().IsRegular() {
err = fmt.Errorf("aggregate is no longer regular: %w", errors.Join(statErr, os.ErrInvalid))
} else {
current, err = os.ReadFile(dest)
if err == nil && !bytes.Equal(current, set.register) {
err = errors.New("aggregate changed since preflight")
}
if err == nil {
err = os.Rename(filepath.Join(stage, strconv.Itoa(i)), dest)
}
}
} else {
err = write(dest, op.data)
}
if err == nil {
completed = append(completed, op.dest)
if op.source != "" {
err = os.Remove(filepath.Join(set.dir, op.source))
if err == nil {
completed = append(completed, "removed "+op.source)
}
}
}
if err != nil {
var outstanding []string
for _, pending := range changes[i:] {
outstanding = append(outstanding, fmt.Sprintf("%s -> %s", pending.source, pending.dest))
}
return fmt.Errorf("authoring stopped: %w; completed %v; inspect current/partial destination %s; outstanding %v; restore complete source backup before retry", err, completed, op.dest, outstanding)
}
}
return nil
}
@@ -0,0 +1,313 @@
package authoring
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func directory(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
return dir
}
func put(t *testing.T, dir, name string, data []byte) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
t.Fatal(err)
}
}
func snapshot(t *testing.T, dir string) map[string]string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
result := map[string]string{}
for _, entry := range entries {
if entry.IsDir() {
result[entry.Name()] = "<directory>"
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
t.Fatal(err)
}
result[entry.Name()] = string(data)
}
return result
}
func seedGo(t *testing.T, dir, name string, version int64) {
t.Helper()
if err := Create(dir, name, "go"); err != nil {
t.Fatal(err)
}
set, err := scan(dir)
if err != nil {
t.Fatal(err)
}
item := set.migrations[len(set.migrations)-1]
old := item.name
item.name = fmt.Sprintf("%05d_%s", version, strings.SplitN(old, "_", 2)[1])
item.version = version
if err := os.Rename(filepath.Join(dir, old), filepath.Join(dir, item.name)); err != nil {
t.Fatal(err)
}
set.migrations[len(set.migrations)-1] = item
data, err := aggregate(set.migrations)
if err != nil {
t.Fatal(err)
}
put(t, dir, "register.go", data)
}
func TestCreateFixAndPreserveEdits(t *testing.T) {
dir := directory(t)
seedGo(t, dir, "test", 20261028093000)
name := "20261028093000_test_migration.go"
before := snapshot(t, dir)[name]
edited := strings.Replace(before, "return nil", "// application body\n return tx.QueryRowContext(ctx, \"SELECT 1\").Err()", 1)
put(t, dir, name, []byte(edited))
put(t, dir, "00007_existing.sql", []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n"))
put(t, dir, "20261028103000_next.sql", []byte("-- +goose Up\nSELECT 2;\n-- +goose Down\nSELECT 2;\n"))
if err := Fix(dir); err != nil {
t.Fatal(err)
}
after := snapshot(t, dir)
if after["00008_test_migration.go"] != edited || after["00009_next.sql"] == "" || !strings.Contains(after["register.go"], "migrationTest(8)") {
t.Fatal(after)
}
if err := Fix(dir); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(after, snapshot(t, dir)) {
t.Fatal("repeated fix changed set")
}
if err := Create(dir, "new_sql", "sql"); err != nil {
t.Fatal(err)
}
if err := Fix(dir); err != nil {
t.Fatal(err)
}
if snapshot(t, dir)["00010_new_sql.sql"] == "" {
t.Fatal("creation failed to preserve sequence")
}
}
func TestPreflightRejections(t *testing.T) {
for _, tc := range []struct {
name string
prepare func(*testing.T, string)
want string
}{
{"duplicate", func(t *testing.T, d string) { put(t, d, "00001_one.sql", nil); put(t, d, "1_two.sql", nil) }, "duplicate version"},
{"invalid", func(t *testing.T, d string) { put(t, d, "-1_bad.sql", nil) }, "invalid version"},
{"zero", func(t *testing.T, d string) { put(t, d, "00000_bad.sql", nil) }, "positive int64"},
{"overflow", func(t *testing.T, d string) { put(t, d, "9223372036854775808_bad.sql", nil) }, "positive int64"},
{"order", func(t *testing.T, d string) { put(t, d, "2_two.sql", nil); put(t, d, "10_ten.sql", nil) }, "order disagreement"},
{"output-order", func(t *testing.T, d string) { put(t, d, "1_one.sql", nil); put(t, d, "20261028093000_two.sql", nil) }, "order disagreement"},
{"factory", func(t *testing.T, d string) {
seedGo(t, d, "example", 1)
p := filepath.Join(d, "00001_example_migration.go")
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
put(t, d, filepath.Base(p), bytes.ReplaceAll(b, []byte("version int64"), []byte("version int")))
}, "expected migrationExample"},
{"package", func(t *testing.T, d string) {
seedGo(t, d, "example", 1)
b := snapshot(t, d)["00001_example_migration.go"]
put(t, d, "00001_example_migration.go", []byte(strings.Replace(b, "package migrations", "package other", 1)))
}, "package migrations"},
{"stock", func(t *testing.T, d string) { put(t, d, "00001_example.go", []byte("package migrations")) }, "unsupported Go filename"},
{"missing-aggregate", func(t *testing.T, d string) {
seedGo(t, d, "example", 1)
if err := os.Remove(filepath.Join(d, "register.go")); err != nil {
t.Fatal(err)
}
}, "register.go missing"},
{"drift", func(t *testing.T, d string) {
seedGo(t, d, "example", 1)
b := snapshot(t, d)["register.go"]
put(t, d, "register.go", []byte(strings.Replace(b, "migrationExample(1)", "migrationExample(2)", 1)))
}, "drifted aggregate"},
{"handwritten", func(t *testing.T, d string) { put(t, d, "register.go", []byte("package migrations")) }, "drifted aggregate"},
{"destination-dir", func(t *testing.T, d string) {
put(t, d, "20261028093000_one.sql", nil)
if err := os.Mkdir(filepath.Join(d, "00001_one.sql"), 0o700); err != nil {
t.Fatal(err)
}
}, "regular file"},
{"sequential-overflow", func(t *testing.T, d string) {
put(t, d, "0000020261028093000_one.sql", nil)
put(t, d, "9223372036854775807_last.sql", nil)
}, "sequential version overflow"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := directory(t)
tc.prepare(t, dir)
before := snapshot(t, dir)
// Directories are checked separately; every file must survive preflight unchanged.
err := Fix(dir)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("wanted %s: %v", tc.want, err)
}
if !reflect.DeepEqual(before, snapshot(t, dir)) {
t.Fatal("preflight failure changed files")
}
})
}
}
func TestCreateValidationAndCollisions(t *testing.T) {
for _, name := range []string{"", "../bad", "Test", "bad__name", "bad2", "bad-name"} {
if err := Create(directory(t), name, "go"); err == nil {
t.Fatalf("accepted %q", name)
}
}
if err := Create(directory(t), "valid", "other"); err == nil {
t.Fatal("accepted unsupported type")
}
dir := directory(t)
seedGo(t, dir, "example", 1)
before := snapshot(t, dir)
if err := Create(dir, "example", "go"); err == nil || !strings.Contains(err.Error(), "duplicate factory") {
t.Fatal(err)
}
if !reflect.DeepEqual(before, snapshot(t, dir)) {
t.Fatal("rejected create changed sources")
}
}
func TestSymlinksRejected(t *testing.T) {
dir := directory(t)
link := filepath.Join(directory(t), "link")
if err := os.Symlink(dir, link); err != nil {
t.Fatal(err)
}
if err := Fix(link); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatal(err)
}
source := filepath.Join(directory(t), "source")
if err := os.WriteFile(source, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(source, filepath.Join(dir, "00001_one.sql")); err != nil {
t.Fatal(err)
}
if err := Fix(dir); err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatal(err)
}
}
func TestTimestampClassification(t *testing.T) {
for _, tc := range []struct {
v int64
want bool
}{{1, false}, {19700101000000, false}, {19700101000001, true}, {19691231235959, false}, {20260230093000, false}, {20261028093000, true}} {
if got := timestamp(tc.v); got != tc.want {
t.Fatalf("%d timestamp=%v", tc.v, got)
}
}
}
func TestPartialPublicationAndManualRecovery(t *testing.T) {
dir := directory(t)
seedGo(t, dir, "example", 20261028093000)
before := snapshot(t, dir)
set, err := scan(dir)
if err != nil {
t.Fatal(err)
}
item := set.migrations[0]
item.name = "00001_example_migration.go"
item.version = 1
aggregateData, err := aggregate([]migration{item})
if err != nil {
t.Fatal(err)
}
changes := []change{{source: set.migrations[0].name, dest: item.name, data: item.data}, {dest: "00002_next.sql", data: []byte("example")}, {dest: "register.go", replace: true, data: aggregateData}}
failure := errors.New("injected disk failure")
err = publish(set, changes, func(path string, data []byte) error {
if strings.HasSuffix(path, "00002_next.sql") {
if err := writeExclusive(path, []byte("partial")); err != nil {
t.Fatal(err)
}
return failure
}
return writeExclusive(path, data)
})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "completed [00001_example_migration.go removed 20261028093000_example_migration.go]") || !strings.Contains(err.Error(), "outstanding") || !strings.Contains(err.Error(), "restore complete source backup") {
t.Fatal(err)
}
if snapshot(t, dir)["00002_next.sql"] != "partial" {
t.Fatal("partial destination not reported/preserved")
}
if err := Fix(dir); err == nil {
t.Fatal("inconsistent interrupted set accepted")
}
// Manual recovery restores all paths, including untracked sources and aggregate.
for name := range snapshot(t, dir) {
if err := os.Remove(filepath.Join(dir, name)); err != nil {
t.Fatal(err)
}
}
for name, data := range before {
put(t, dir, name, []byte(data))
}
if err := Fix(dir); err != nil {
t.Fatal(err)
}
if snapshot(t, dir)["00001_example_migration.go"] == "" {
t.Fatal("recovered fix failed")
}
}
func TestExclusiveDestinationAndAggregateRecheck(t *testing.T) {
dir := directory(t)
if err := Fix(dir); err != nil {
t.Fatal(err)
}
set, err := scan(dir)
if err != nil {
t.Fatal(err)
}
put(t, dir, "00001_collision.sql", []byte("existing"))
err = publish(set, []change{{dest: "00001_collision.sql", data: []byte("replace")}}, writeExclusive)
if err == nil || snapshot(t, dir)["00001_collision.sql"] != "existing" {
t.Fatal("overwrote destination", err)
}
err = writeExclusive(filepath.Join(dir, "00001_collision.sql"), []byte("replace"))
if !errors.Is(err, os.ErrExist) {
t.Fatal(err)
}
err = publish(set, []change{{dest: "00002_ok.sql"}, {dest: "register.go", replace: true, data: []byte("new")}}, func(path string, data []byte) error {
put(t, dir, "register.go", []byte("editor changed"))
return writeExclusive(path, data)
})
if err == nil || !strings.Contains(err.Error(), "aggregate changed since preflight") || snapshot(t, dir)["register.go"] != "editor changed" {
t.Fatal(err)
}
}
func TestRemovalFailureStopsPublication(t *testing.T) {
dir := directory(t)
source := "20261028093000_one.sql"
if err := os.Mkdir(filepath.Join(dir, source), 0o700); err != nil {
t.Fatal(err)
}
put(t, filepath.Join(dir, source), "block-removal", nil)
err := publish(sourceSet{dir: dir}, []change{{source: source, dest: "00001_one.sql", data: []byte("complete destination")}, {dest: "00002_later.sql"}}, writeExclusive)
if err == nil || !strings.Contains(err.Error(), "completed [00001_one.sql]") || !strings.Contains(err.Error(), "outstanding") {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "00002_later.sql")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("continued after remove failure", err)
}
data, err := os.ReadFile(filepath.Join(dir, "00001_one.sql"))
if err != nil || string(data) != "complete destination" {
t.Fatalf("destination lost: %s %v", data, err)
}
}