This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/database/migrate.go
Jeffrey Smith 3d4228f868
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / build-and-deploy (push) Successful in 26s
Feat v0.6.3 dead code sweep (#38)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 12:37:47 +00:00

180 lines
4.7 KiB
Go

package database
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
)
// schemaVersion tracks the latest applied migration.
var schemaVersion string = "none"
// SchemaVersion returns the current schema version string.
func SchemaVersion() string { return schemaVersion }
// Migrate runs all pending migrations for the current dialect.
// Migration files live in migrations/<dialect>/ subdirectories.
// Falls back to the root migrations/ directory if no subdirectory exists
// (backward compatible with postgres-only layouts).
func Migrate() error {
if DB == nil {
return fmt.Errorf("database not connected")
}
// Create tracking table — syntax compatible with both drivers.
createMigrations := `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
)`
if IsPostgres() {
createMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)`
}
if _, err := DB.Exec(createMigrations); err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
// Locate migration files for current dialect.
migrationsDir := findMigrationsDir()
if migrationsDir == "" {
log.Println("⚠ No migrations directory found — skipping schema migration")
return nil
}
entries, err := os.ReadDir(migrationsDir)
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
if len(files) == 0 {
log.Println(" No migration files found")
return nil
}
// Compat: rename old numeric-only version entries to full filenames.
for _, file := range files {
prefix := strings.SplitN(file, "_", 2)[0]
DB.Exec("UPDATE schema_migrations SET version = ? WHERE version = ? AND version != ?", file, prefix, file)
if IsPostgres() {
DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix)
}
}
// Apply pending migrations.
applied := 0
for _, file := range files {
version := extractVersion(file)
if version == "" {
continue
}
var exists bool
if IsPostgres() {
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
} else {
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", version).Scan(&exists)
}
if exists {
schemaVersion = version
continue
}
path := filepath.Join(migrationsDir, file)
sqlBytes, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", file, err)
}
log.Printf(" Applying migration %s...", file)
if _, err := DB.Exec(string(sqlBytes)); err != nil {
return fmt.Errorf("apply %s: %w", file, err)
}
if IsPostgres() {
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
}
} else {
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
}
}
schemaVersion = version
applied++
}
if applied > 0 {
log.Printf(" ✅ Applied %d migration(s), schema at %s", applied, schemaVersion)
} else {
log.Printf(" Schema up to date at %s", schemaVersion)
}
return nil
}
// extractVersion returns the filename as the version key if it's a valid
// migration file (starts with digit, ends with .sql).
func extractVersion(filename string) string {
if !strings.HasSuffix(filename, ".sql") {
return ""
}
if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' {
return ""
}
return filename
}
// findMigrationsDir locates the dialect-specific migrations directory.
// Looks for migrations/<dialect>/ first, then falls back to migrations/.
func findMigrationsDir() string {
dialectDir := CurrentDialect.String() // "postgres" or "sqlite"
// Candidates: dialect-specific first, then generic.
bases := []string{
"database/migrations",
"server/database/migrations",
"../database/migrations",
}
// Also check relative to this source file (for tests).
_, thisFile, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Dir(thisFile)
bases = append(bases, filepath.Join(dir, "migrations"))
}
// Try dialect-specific subdir first.
for _, base := range bases {
candidate := filepath.Join(base, dialectDir)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
// Fall back to root migrations/ (backward compat for postgres).
if IsPostgres() {
for _, base := range bases {
if info, err := os.Stat(base); err == nil && info.IsDir() {
return base
}
}
}
return ""
}