Move schema_migrations table creation into 000.sql (#36)
All checks were successful
check / check (push) Successful in 1m43s
All checks were successful
check / check (push) Successful in 1m43s
## Summary Moves the `schema_migrations` table definition from inline Go code into `internal/database/schema/000.sql`, so the migration tracking table schema lives alongside all other schema files. closes #29 ## Changes ### New file: `internal/database/schema/000.sql` - Contains the `CREATE TABLE IF NOT EXISTS schema_migrations` DDL - This is applied as a bootstrap step before the normal migration loop ### Refactored: `internal/database/database.go` - Removed the inline `CREATE TABLE IF NOT EXISTS schema_migrations` SQL from both `runMigrations` and `ApplyMigrations` - Added `bootstrapMigrationsTable()` which: - Checks `sqlite_master` to see if the table already exists - If missing: reads and executes `000.sql` to create it, then records version `000` - If present (backwards compat with existing DBs created by old inline code): back-fills version `000` so the normal loop skips the bootstrap file - Deduplicated: both `Database.runMigrations()` and the exported `ApplyMigrations()` now delegate to a single `applyMigrations()` helper - Added `logInfo`/`logDebug` helpers to handle the optional logger (nil when called from `ApplyMigrations` in tests) ### New file: `internal/database/database_test.go` - `TestApplyMigrations_CreatesSchemaAndTables` — verifies all migrations apply and all expected tables exist - `TestApplyMigrations_Idempotent` — verifies running migrations twice produces no errors or duplicates - `TestBootstrapMigrationsTable_FreshDatabase` — verifies bootstrap creates the table and records version 000 - `TestBootstrapMigrationsTable_ExistingTableBackwardsCompat` — verifies existing DBs (from old inline-SQL code) get version 000 back-filled without data loss ## Conflict note [PR #33](#33) (for [issue #28](#28)) is also modifying migration code. This PR is based on current `main` and the conflict will be resolved at merge time. Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: clawbot <clawbot@sneak.berlin> Co-authored-by: clawbot <clawbot@eeqj.de> Co-authored-by: Jeffrey Paul <sneak@noreply.example.org> Reviewed-on: #36 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #36.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -21,6 +22,10 @@ import (
|
||||
//go:embed schema/*.sql
|
||||
var schemaFS embed.FS
|
||||
|
||||
// bootstrapVersion is the migration that creates the schema_migrations
|
||||
// table itself. It is applied before the normal migration loop.
|
||||
const bootstrapVersion = 0
|
||||
|
||||
// Params defines dependencies for Database.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -38,35 +43,40 @@ type Database struct {
|
||||
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
||||
// filename. Filenames must follow the pattern "<version>.sql" or
|
||||
// "<version>_<description>.sql", where version is a zero-padded numeric
|
||||
// string (e.g. "001", "002"). Returns the version string and an error if
|
||||
// the filename does not match the expected pattern.
|
||||
func ParseMigrationVersion(filename string) (string, error) {
|
||||
// string (e.g. "001", "002"). Returns the version as an integer and an
|
||||
// error if the filename does not match the expected pattern.
|
||||
func ParseMigrationVersion(filename string) (int, error) {
|
||||
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("invalid migration filename %q: empty name", filename)
|
||||
return 0, fmt.Errorf("invalid migration filename %q: empty name", filename)
|
||||
}
|
||||
|
||||
// Split on underscore to separate version from description.
|
||||
// If there's no underscore, the entire stem is the version.
|
||||
version := name
|
||||
versionStr := name
|
||||
if idx := strings.IndexByte(name, '_'); idx >= 0 {
|
||||
version = name[:idx]
|
||||
versionStr = name[:idx]
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
return "", fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
|
||||
if versionStr == "" {
|
||||
return 0, fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
|
||||
}
|
||||
|
||||
// Validate the version is purely numeric.
|
||||
for _, ch := range version {
|
||||
for _, ch := range versionStr {
|
||||
if ch < '0' || ch > '9' {
|
||||
return "", fmt.Errorf(
|
||||
return 0, fmt.Errorf(
|
||||
"invalid migration filename %q: version %q contains non-numeric character %q",
|
||||
filename, version, string(ch),
|
||||
filename, versionStr, string(ch),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
version, err := strconv.Atoi(versionStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
@@ -143,17 +153,34 @@ func collectMigrations() ([]string, error) {
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// ensureMigrationsTable creates the schema_migrations tracking table if
|
||||
// it does not already exist.
|
||||
func ensureMigrationsTable(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||
// by applying 000.sql if the table is missing.
|
||||
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
var tableExists int
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||
).Scan(&tableExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||
}
|
||||
|
||||
if tableExists > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := schemaFS.ReadFile("schema/000.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -164,7 +191,7 @@ func ensureMigrationsTable(ctx context.Context, db *sql.DB) error {
|
||||
// This is exported so tests can apply the real schema without the full fx
|
||||
// lifecycle.
|
||||
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
if err := ensureMigrationsTable(ctx, db); err != nil {
|
||||
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user