diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..7770932 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,40 @@ +// Package migrations carries restcol's SQL migrations and exposes them as an +// embed.FS so a consuming module can apply exactly the migrations that shipped +// with the version it pins. +// +// WHY THIS FILE EXISTS. These .sql files are plain files in the module, and +// Go's embed cannot reach into a dependency — a consumer that imports restcol +// can see the package but not the files beside it. So a downstream service +// wanting to run these migrations had only one option: copy them. That is what +// FootprintAI/grandturks#987 did, into `deploy/demo/restcol-migrations/` behind +// a drift test, and it was the right stopgap for a deployment that was broken. +// +// But a copy of a file whose original is versioned elsewhere drifts, and the +// drift is silent in the direction that matters: the copy keeps working while +// the pinned version moves underneath it. Exporting the FS makes the pinned +// version and the applied migrations the same fact by construction, which is +// what FootprintAI/grandturks#1004 asked for. +// +// Consumers apply these with golang-migrate's iofs source driver: +// +// src, err := iofs.New(migrations.EmbeddedMigrations, ".") +// m, err := migrate.NewWithSourceInstance("iofs", src, databaseURL) +// err = m.Up() +// +// Files follow golang-migrate's naming convention, +// `_.{up,down}.sql`; see README.md in this directory. +package migrations + +import "embed" + +// EmbeddedMigrations holds every migration in this directory, in both +// directions. +// +// The pattern is deliberately `*.sql` at this level and nothing deeper: these +// migrations are a flat, ordered sequence, and a subdirectory would be a +// second ordering that golang-migrate does not read. TestEmbeddedMigrations- +// CoverEveryFileOnDisk fails if a .sql file ever appears outside this pattern, +// so an accidental subdirectory is caught rather than silently skipped. +// +//go:embed *.sql +var EmbeddedMigrations embed.FS diff --git a/migrations/embed_test.go b/migrations/embed_test.go new file mode 100644 index 0000000..bf01f56 --- /dev/null +++ b/migrations/embed_test.go @@ -0,0 +1,121 @@ +package migrations + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These migrations are consumed by OTHER modules — grandturks pins restcol and +// applies exactly the migrations that shipped with the version it pinned +// (FootprintAI/grandturks#1004). Go's embed cannot reach into a dependency, so +// without EmbeddedMigrations a consumer's only option is to copy the files, +// which is what grandturks#987 had to do. A copy of a file versioned elsewhere +// drifts; that is the whole reason this exists. +// +// The tests below are therefore about COVERAGE, not about the SQL: every file +// in this directory has to be reachable through the embed, or a consumer +// silently applies a subset and believes it is current. + +// readDiskSQL returns every .sql file in this directory, keyed by path +// relative to it — including any in subdirectories, which is exactly the case +// a bare `//go:embed *.sql` would miss. +func readDiskSQL(t *testing.T) map[string][]byte { + t.Helper() + found := map[string][]byte{} + require.NoError(t, filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".sql") { + return nil + } + raw, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + found[filepath.ToSlash(path)] = raw + return nil + })) + return found +} + +func readEmbeddedSQL(t *testing.T) map[string][]byte { + t.Helper() + found := map[string][]byte{} + require.NoError(t, fs.WalkDir(EmbeddedMigrations, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".sql") { + return nil + } + raw, readErr := EmbeddedMigrations.ReadFile(path) + if readErr != nil { + return readErr + } + found[path] = raw + return nil + })) + return found +} + +// TestEmbeddedMigrationsCoverEveryFileOnDisk is the one that matters. A +// migration that exists on disk but not in the embed is invisible to every +// consumer, and nothing else in the build would say so. +func TestEmbeddedMigrationsCoverEveryFileOnDisk(t *testing.T) { + disk := readDiskSQL(t) + embedded := readEmbeddedSQL(t) + + require.NotEmpty(t, disk, "no .sql files found — this test is not looking where it thinks it is") + + for name, want := range disk { + got, ok := embedded[name] + if !ok { + t.Errorf("%s is on disk but not in EmbeddedMigrations.\n"+ + "A consumer pinning this module would apply the other migrations and believe "+ + "its schema is current. Check the //go:embed pattern covers it.", name) + continue + } + assert.Equal(t, string(want), string(got), "%s differs between disk and the embed", name) + } + + for name := range embedded { + assert.Contains(t, disk, name, "%s is embedded but not on disk", name) + } +} + +// golang-migrate resolves a version by finding both directions. An up without +// a down is not a partial feature — it is a migration that cannot be rolled +// back, discovered at the moment someone needs to roll it back. +func TestEveryMigrationHasBothDirections(t *testing.T) { + embedded := readEmbeddedSQL(t) + + for name := range embedded { + var counterpart string + switch { + case strings.HasSuffix(name, ".up.sql"): + counterpart = strings.TrimSuffix(name, ".up.sql") + ".down.sql" + case strings.HasSuffix(name, ".down.sql"): + counterpart = strings.TrimSuffix(name, ".down.sql") + ".up.sql" + default: + t.Errorf("%s is neither .up.sql nor .down.sql — golang-migrate will not see it as a migration", name) + continue + } + assert.Contains(t, embedded, counterpart, "%s has no %s", name, counterpart) + } +} + +// An empty migration file applies cleanly and does nothing, so the version +// table advances and the schema does not. That is worse than a failure. +func TestNoEmbeddedMigrationIsEmpty(t *testing.T) { + for name, content := range readEmbeddedSQL(t) { + assert.NotEmpty(t, strings.TrimSpace(string(content)), + "%s is empty: it would advance the schema version without changing the schema", name) + } +}