diff --git a/internal/database/migrations.go b/internal/database/migrations.go index 4ab4996..0982824 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -83,9 +83,7 @@ func collectMigrations() ([]string, error) { } // bootstrapMigrationsTable ensures the schema_migrations table exists by -// applying 000_migration.sql if the table is missing. For databases with a -// legacy TEXT-based schema_migrations table, it converts to the new INTEGER -// format. +// applying 000_migration.sql if the table is missing. func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error { var tableExists int @@ -100,8 +98,7 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) return applyBootstrapMigration(ctx, db, log) } - // Table exists — check for and convert legacy TEXT-based versions. - return convertLegacyMigrations(ctx, db, log) + return nil } // applyBootstrapMigration reads and executes 000_migration.sql to create the @@ -124,139 +121,6 @@ func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) return nil } -// convertLegacyMigrations converts a schema_migrations table that uses -// TEXT filename-based versions (e.g. "001_initial.sql") to INTEGER versions -// (e.g. 1). This is a one-time migration for existing databases. -func convertLegacyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error { - // Check if any version looks like a legacy filename (contains underscore). - var legacyCount int - - err := db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM schema_migrations WHERE INSTR(CAST(version AS TEXT), '_') > 0", - ).Scan(&legacyCount) - if err != nil { - return fmt.Errorf("failed to check for legacy migrations: %w", err) - } - - if legacyCount == 0 { - return ensureBootstrapVersion(ctx, db) - } - - if log != nil { - log.Info("converting legacy schema_migrations from TEXT to INTEGER format", - "legacy_entries", legacyCount) - } - - intVersions, err := readLegacyVersions(ctx, db) - if err != nil { - return err - } - - err = rebuildMigrationsTable(ctx, db, intVersions) - if err != nil { - return err - } - - if log != nil { - log.Info("legacy migration conversion complete", "versions_converted", len(intVersions)) - } - - return nil -} - -// ensureBootstrapVersion inserts version 0 if it is not already present. -func ensureBootstrapVersion(ctx context.Context, db *sql.DB) error { - _, err := db.ExecContext(ctx, - "INSERT OR IGNORE INTO schema_migrations (version) VALUES (0)") - if err != nil { - return fmt.Errorf("failed to ensure bootstrap version: %w", err) - } - - return nil -} - -// readLegacyVersions reads all version entries from the legacy schema_migrations -// table and parses them into integer versions. -func readLegacyVersions(ctx context.Context, db *sql.DB) ([]int, error) { - rows, err := db.QueryContext(ctx, "SELECT version FROM schema_migrations") - if err != nil { - return nil, fmt.Errorf("failed to read legacy migrations: %w", err) - } - - defer func() { _ = rows.Close() }() - - var intVersions []int - - for rows.Next() { - var version string - - scanErr := rows.Scan(&version) - if scanErr != nil { - return nil, fmt.Errorf("failed to scan legacy version: %w", scanErr) - } - - v, parseErr := ParseMigrationVersion(version) - if parseErr != nil { - return nil, fmt.Errorf("failed to parse legacy version %q: %w", version, parseErr) - } - - intVersions = append(intVersions, v) - } - - rowsErr := rows.Err() - if rowsErr != nil { - return nil, fmt.Errorf("failed to iterate legacy versions: %w", rowsErr) - } - - return intVersions, nil -} - -// rebuildMigrationsTable drops the old schema_migrations table, recreates it -// via 000_migration.sql, and re-inserts the given integer versions. The entire -// operation runs in a single transaction so a crash cannot lose migration data. -func rebuildMigrationsTable(ctx context.Context, db *sql.DB, versions []int) error { - content, err := migrationsFS.ReadFile("migrations/000_migration.sql") - if err != nil { - return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err) - } - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction for legacy conversion: %w", err) - } - - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - _, err = tx.ExecContext(ctx, "DROP TABLE schema_migrations") - if err != nil { - return fmt.Errorf("failed to drop legacy migrations table: %w", err) - } - - _, err = tx.ExecContext(ctx, string(content)) - if err != nil { - return fmt.Errorf("failed to create new migrations table: %w", err) - } - - for _, v := range versions { - _, err = tx.ExecContext(ctx, - "INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)", v) - if err != nil { - return fmt.Errorf("failed to insert converted version %d: %w", v, err) - } - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf("failed to commit legacy conversion: %w", err) - } - - return nil -} - // ApplyMigrations applies all pending migrations to db. An optional logger // may be provided for informational output; pass nil for silent operation. // This is exported so tests can apply the real schema without the full fx diff --git a/internal/database/migrations_test.go b/internal/database/migrations_test.go index fe6bc47..d36efeb 100644 --- a/internal/database/migrations_test.go +++ b/internal/database/migrations_test.go @@ -115,78 +115,3 @@ func TestApplyMigrationsIdempotent(t *testing.T) { require.NoError(t, err) assert.Equal(t, 8, count) } - -func TestApplyMigrationsLegacyConversion(t *testing.T) { - t.Parallel() - - db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on") - require.NoError(t, err) - - defer func() { _ = db.Close() }() - - ctx := context.Background() - - // Simulate the old TEXT-based schema_migrations table. - _, err = db.ExecContext(ctx, ` - CREATE TABLE schema_migrations ( - version TEXT PRIMARY KEY, - applied_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - require.NoError(t, err) - - // Insert legacy filename-based versions (as if migrations 001-007 were applied). - legacyVersions := []string{ - "001_initial.sql", - "002_remove_container_id.sql", - "003_add_ports.sql", - "004_add_commit_url.sql", - "005_add_webhook_secret_hash.sql", - "006_add_previous_image_id.sql", - "007_add_resource_limits.sql", - } - - for _, v := range legacyVersions { - _, err = db.ExecContext(ctx, - "INSERT INTO schema_migrations (version) VALUES (?)", v) - require.NoError(t, err) - } - - // Also create the application tables that would exist on a real database - // so that the migrations (001-007) don't fail when re-applied. - // Actually, since the legacy versions will be converted and recognized - // as already-applied, the DDL migrations won't re-run. We just need - // to make sure ApplyMigrations succeeds. - - // Run ApplyMigrations — this should convert legacy versions and - // skip all already-applied migrations. - err = database.ApplyMigrations(ctx, db, nil) - require.NoError(t, err) - - // Verify version 0 is now present (from bootstrap). - var zeroVersion int - - err = db.QueryRowContext(ctx, - "SELECT version FROM schema_migrations WHERE version = 0", - ).Scan(&zeroVersion) - require.NoError(t, err) - assert.Equal(t, 0, zeroVersion) - - // Verify all 8 versions are present (0 through 7). - var count int - - err = db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM schema_migrations", - ).Scan(&count) - require.NoError(t, err) - assert.Equal(t, 8, count) - - // Verify versions are stored as integers, not text filenames. - var maxVersion int - - err = db.QueryRowContext(ctx, - "SELECT MAX(version) FROM schema_migrations", - ).Scan(&maxVersion) - require.NoError(t, err) - assert.Equal(t, 7, maxVersion) -}