From 4ec4bbbfe88b6060e17c046e0fe2b321f7110bf1 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 15 Apr 2026 08:37:55 -0500 Subject: [PATCH] Auto-seed lookups + admin + templates on first API boot Add a data_migration that runs seeds/001_lookups.sql, seeds/003_admin_user.sql, and seeds/003_task_templates.sql exactly once on startup and invalidates the Redis seeded_data cache afterwards so /api/static_data/ returns fresh results. Removes the need to remember `./dev.sh seed-all`; the data_migrations tracking row prevents re-runs, and each INSERT uses ON CONFLICT DO UPDATE so re-execution is safe. --- cmd/api/main.go | 7 + .../database/migration_seed_initial_data.go | 129 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 internal/database/migration_seed_initial_data.go diff --git a/cmd/api/main.go b/cmd/api/main.go index cd7055b..c69122a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -81,6 +81,13 @@ func main() { cache = nil } else { defer cache.Close() + if database.SeedInitialDataApplied { + if err := cache.InvalidateSeededData(context.Background()); err != nil { + log.Warn().Err(err).Msg("Failed to invalidate seeded data cache after initial seed") + } else { + log.Info().Msg("Invalidated seeded_data cache after initial seed migration") + } + } } // Initialize monitoring service (if Redis is available) diff --git a/internal/database/migration_seed_initial_data.go b/internal/database/migration_seed_initial_data.go new file mode 100644 index 0000000..1ef9cea --- /dev/null +++ b/internal/database/migration_seed_initial_data.go @@ -0,0 +1,129 @@ +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 +}