- Go module at github.com/felt-app/felt with go-libsql pinned to commit hash - Full directory structure per research recommendations (cmd/leaf, internal/*, frontend/) - Makefile with build, run, run-dev, test, frontend, all, clean targets - LibSQL database with WAL mode, foreign keys, and embedded migration runner - SvelteKit SPA stub served via go:embed - Package stubs for all internal packages (server, nats, store, auth, clock, etc.) - go build and go vet pass cleanly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
121 lines
3 KiB
Go
121 lines
3 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// devOnlyMigrations lists migration filenames that should only be applied
|
|
// in development mode (--dev flag).
|
|
var devOnlyMigrations = map[string]bool{
|
|
"004_dev_seed.sql": true,
|
|
}
|
|
|
|
// RunMigrations applies all pending SQL migrations embedded in the binary.
|
|
// Migrations are sorted by filename (numeric prefix ensures order) and
|
|
// executed within individual transactions. Each successful migration is
|
|
// recorded in the _migrations table to prevent re-application.
|
|
//
|
|
// If devMode is false, migrations listed in devOnlyMigrations are skipped.
|
|
func RunMigrations(db *sql.DB, devMode bool) error {
|
|
// Create migrations tracking table
|
|
_, err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS _migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
applied_at INTEGER NOT NULL
|
|
)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("create _migrations table: %w", err)
|
|
}
|
|
|
|
// Read all migration files from embedded filesystem
|
|
entries, err := migrationsFS.ReadDir("migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("read migrations dir: %w", err)
|
|
}
|
|
|
|
// Sort by filename to ensure order
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Name() < entries[j].Name()
|
|
})
|
|
|
|
applied := 0
|
|
skipped := 0
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
|
continue
|
|
}
|
|
|
|
name := entry.Name()
|
|
|
|
// Skip dev-only migrations unless in dev mode
|
|
if devOnlyMigrations[name] && !devMode {
|
|
log.Printf("store: skipping dev-only migration %s (not in dev mode)", name)
|
|
continue
|
|
}
|
|
|
|
// Check if already applied
|
|
var count int
|
|
err := db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count)
|
|
if err != nil {
|
|
return fmt.Errorf("check migration %s: %w", name, err)
|
|
}
|
|
if count > 0 {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Read migration SQL
|
|
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
|
|
if err != nil {
|
|
return fmt.Errorf("read migration %s: %w", name, err)
|
|
}
|
|
|
|
// Execute migration within a transaction
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction for %s: %w", name, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(string(sqlBytes)); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("execute migration %s: %w", name, err)
|
|
}
|
|
|
|
// Record migration as applied
|
|
now := time.Now().Unix()
|
|
if _, err := tx.Exec(
|
|
"INSERT INTO _migrations (name, applied_at) VALUES (?, ?)",
|
|
name, now,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("record migration %s: %w", name, err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
|
}
|
|
|
|
applied++
|
|
log.Printf("store: applied migration %s", name)
|
|
}
|
|
|
|
if applied > 0 {
|
|
log.Printf("store: %d migration(s) applied, %d already up-to-date", applied, skipped)
|
|
} else if skipped > 0 {
|
|
log.Printf("store: all %d migration(s) already applied", skipped)
|
|
}
|
|
|
|
return nil
|
|
}
|