package database import ( "fmt" "os" "path/filepath" "strings" "gorm.io/gorm" ) // Seed files run on first boot. Order matters: lookups first, then rows // that depend on them (admin user is independent; task templates reference // lookup categories). var initialSeedFiles = []string{ "001_lookups.sql", "003_admin_user.sql", "003_task_templates.sql", } // SeedInitialDataApplied is set true during startup if the seed migration // just ran. main.go reads it post-cache-init to invalidate stale Redis // entries for /api/static_data (24h TTL) so clients see the new lookups. var SeedInitialDataApplied bool func init() { RegisterDataMigration("20260414_seed_initial_data", seedInitialData) } // seedInitialData executes the baseline SQL seed files exactly once. Because // each INSERT uses ON CONFLICT DO UPDATE, rerunning the files is safe if the // tracking row is ever lost. func seedInitialData(tx *gorm.DB) error { sqlDB, err := tx.DB() if err != nil { return fmt.Errorf("get underlying sql.DB: %w", err) } for _, filename := range initialSeedFiles { content, err := readSeedFile(filename) if err != nil { return fmt.Errorf("read seed %s: %w", filename, err) } for i, stmt := range splitSQL(content) { if _, err := sqlDB.Exec(stmt); err != nil { preview := stmt if len(preview) > 120 { preview = preview[:120] + "..." } return fmt.Errorf("seed %s statement %d failed: %w\nstatement: %s", filename, i+1, err, preview) } } } SeedInitialDataApplied = true return nil } func readSeedFile(filename string) (string, error) { paths := []string{ filepath.Join("seeds", filename), filepath.Join("./seeds", filename), filepath.Join("/app/seeds", filename), } var lastErr error for _, p := range paths { content, err := os.ReadFile(p) if err == nil { return string(content), nil } lastErr = err } return "", lastErr } // splitSQL splits raw SQL into individual statements, respecting single-quoted // string literals (including '' escapes) and skipping comment-only fragments. func splitSQL(sqlContent string) []string { var out []string var current strings.Builder inString := false stringChar := byte(0) for i := 0; i < len(sqlContent); i++ { c := sqlContent[i] if (c == '\'' || c == '"') && (i == 0 || sqlContent[i-1] != '\\') { if !inString { inString = true stringChar = c } else if c == stringChar { if c == '\'' && i+1 < len(sqlContent) && sqlContent[i+1] == '\'' { current.WriteByte(c) i++ current.WriteByte(sqlContent[i]) continue } inString = false } } if c == ';' && !inString { current.WriteByte(c) stmt := strings.TrimSpace(current.String()) if stmt != "" && !isSQLCommentOnly(stmt) { out = append(out, stmt) } current.Reset() continue } current.WriteByte(c) } if stmt := strings.TrimSpace(current.String()); stmt != "" && !isSQLCommentOnly(stmt) { out = append(out, stmt) } return out } func isSQLCommentOnly(stmt string) bool { for _, line := range strings.Split(stmt, "\n") { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "--") { return false } } return true }