From 1673000af9f9d185b478077b84ecfc872be82f14 Mon Sep 17 00:00:00 2001 From: Hannah Cheng Date: Fri, 14 Aug 2026 13:29:54 +0800 Subject: [PATCH] feat(migrations): expose the SQL migrations as an embed.FS 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 apply these migrations had only one option: copy them. FootprintAI/grandturks#987 did exactly that, into deploy/demo/restcol-migrations/ behind a drift test. It was the right stopgap for a broken deployment, but a copy of a file versioned elsewhere drifts - and silently in the direction that matters, since the copy keeps working while the pinned version moves underneath it. Exporting the FS makes "the version pinned" and "the migrations applied" the same fact by construction. Consumers read it with golang-migrate's iofs source driver. The tests are about coverage rather than SQL: a migration on disk but not in the embed is invisible to every consumer, and nothing else in the build would say so. TestEmbeddedMigrationsCoverEveryFileOnDisk walks both and compares, so a .sql file that lands outside the embed pattern - in a subdirectory, say - fails instead of being silently skipped. Verified it does fail that way before trusting it. Asked for by FootprintAI/grandturks#1004. --- migrations/embed.go | 40 +++++++++++++ migrations/embed_test.go | 121 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 migrations/embed.go create mode 100644 migrations/embed_test.go 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) + } +}