package store import ( "context" "database/sql" "embed" "fmt" "io/fs" "sort" "strings" ) //go:embed migrations/*.sql var migrationsFS embed.FS // migration is one ordered SQL file from migrations/. type migration struct { version int // parsed from filename prefix (0001, 0002, …) name string // full filename, for error messages sql string } // loadMigrations reads every migrations/*.sql file in lexical order // and returns them. Filenames must look like NNNN_name.sql; the // numeric prefix is the version. func loadMigrations() ([]migration, error) { entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return nil, fmt.Errorf("read migrations dir: %w", err) } out := make([]migration, 0, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } var v int // Allow up to 6 digits (we will never need that many but it // costs nothing to be permissive). if _, err := fmt.Sscanf(e.Name(), "%d_", &v); err != nil { return nil, fmt.Errorf("migration %q: cannot parse version prefix: %w", e.Name(), err) } body, err := fs.ReadFile(migrationsFS, "migrations/"+e.Name()) if err != nil { return nil, fmt.Errorf("read %s: %w", e.Name(), err) } out = append(out, migration{version: v, name: e.Name(), sql: string(body)}) } sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version }) return out, nil } // migrate brings the db up to the highest known version. It is // idempotent: running it on an already-current db is a no-op. There // is no rollback path; we move forward only. func migrate(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL ) `); err != nil { return fmt.Errorf("create schema_version: %w", err) } migs, err := loadMigrations() if err != nil { return err } for _, m := range migs { var applied int row := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_version WHERE version = ?`, m.version) if err := row.Scan(&applied); err != nil { return fmt.Errorf("check version %d: %w", m.version, err) } if applied > 0 { continue } tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx for migration %s: %w", m.name, err) } if _, err := tx.ExecContext(ctx, m.sql); err != nil { _ = tx.Rollback() return fmt.Errorf("apply %s: %w", m.name, err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version, applied_at) VALUES (?, datetime('now'))`, m.version); err != nil { _ = tx.Rollback() return fmt.Errorf("record %s: %w", m.name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit %s: %w", m.name, err) } } return nil }