From 686dede80284781ac093d93ef281ea0b8b14575b Mon Sep 17 00:00:00 2001 From: agent-bot Date: Tue, 29 Sep 2026 07:07:58 +0000 Subject: [PATCH] feat(db): embedded goose migrations fallback (U10a first slice) - deploy/postgres (package dbassets) embeds the full goose chain; RunMigrations prefers the on-disk dir (dev behavior unchanged) and falls back to the embedded FS when the source tree is absent (single-binary deployments per plan U10/KTD16) - goose SetBaseFS global state reset after use - TestRunMigrationsEmbeddedFallback verified against a throwaway isolated pgvector container: nonexistent dir -> embedded chain -> version 78, migration gate count agrees --- deploy/postgres/embed.go | 14 ++++++++++++++ internal/db/migrate.go | 19 ++++++++++++++++--- internal/db/migrate_test.go | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 deploy/postgres/embed.go diff --git a/deploy/postgres/embed.go b/deploy/postgres/embed.go new file mode 100644 index 00000000..6038cd32 --- /dev/null +++ b/deploy/postgres/embed.go @@ -0,0 +1,14 @@ +// Package dbassets 内嵌数据库迁移链(U10a:单文件应用无源码目录时回退使用)。 +// 磁盘目录存在时仍优先磁盘(开发态行为不变);磁盘与嵌入的一致性由 +// 迁移门禁测试(ExpectedMigrationCount)约束。 +package dbassets + +import "embed" + +// MigrationsFS 内嵌 deploy/postgres/goose 迁移链。 +// +//go:embed all:goose +var MigrationsFS embed.FS + +// MigrationsDir 是嵌入 FS 内的迁移子目录名(goose SetBaseFS 的根)。 +const MigrationsDir = "goose" diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 9a86b39c..3583e224 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "chat-agent-system/deploy/postgres" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" @@ -25,8 +26,9 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsDir string if err != nil { return err } + useDisk := true if _, err := os.Stat(absDir); err != nil { - return fmt.Errorf("db migrate: directory %s: %w", absDir, err) + useDisk = false } db := stdlib.OpenDB(*pool.Config().ConnConfig) @@ -37,8 +39,19 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsDir string } goose.SetTableName(schemaTable) - if err := goose.UpContext(ctx, db, absDir); err != nil { - return fmt.Errorf("db migrate up: %w", err) + if useDisk { + if err := goose.UpContext(ctx, db, absDir); err != nil { + return fmt.Errorf("db migrate up: %w", err) + } + return nil + } + + // U10a:源码目录不存在(单文件部署)时回退内嵌迁移链。 + // goose SetBaseFS 是全局状态;用后复位,避免污染后续磁盘调用。 + goose.SetBaseFS(dbassets.MigrationsFS) + defer goose.SetBaseFS(nil) + if err := goose.UpContext(ctx, db, dbassets.MigrationsDir); err != nil { + return fmt.Errorf("db migrate up (embedded): %w", err) } return nil } diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go index 45222950..e4c16a80 100644 --- a/internal/db/migrate_test.go +++ b/internal/db/migrate_test.go @@ -278,3 +278,21 @@ func rewriteDatabaseName(dsn, name string) string { } return dsn } + +func TestRunMigrationsEmbeddedFallback(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set; embedded fallback needs PG") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + // 不存在的磁盘目录必须触发内嵌回退并成功走完迁移链(幂等)。 + if err := RunMigrations(ctx, pool, filepath.Join(t.TempDir(), "no-such-dir")); err != nil { + t.Fatalf("embedded fallback migrations: %v", err) + } +} -- 2.54.0