package db import ( "context" "database/sql" "log/slog" "os" "path/filepath" "strings" "time" ) // InitializeDatabase initializes the libSQL database and session store. // Returns: (read-write DB, read-only DB, session store) func InitializeDatabase(dbPath string, cfg LibsqlConfig) (*sql.DB, *sql.DB, *SessionStore) { // Ensure directory exists dbDir := filepath.Dir(dbPath) if err := os.MkdirAll(dbDir, 0700); err != nil { slog.Warn("Failed to create UI database directory", "error", err) return nil, nil, nil } // Initialize read-write database (for writes and auth operations) database, err := InitDB(dbPath, cfg) if err != nil { slog.Warn("Failed to initialize UI database", "error", err) return nil, nil, nil } // Open read-only connection for public queries (search, user pages, etc.) // Uses ?mode=ro to prevent writes from public-facing handlers roDSN := dbPath if !strings.HasPrefix(dbPath, "file:") && !strings.HasPrefix(dbPath, ":memory:") { roDSN = "file:" + dbPath } // Append ?mode=ro for read-only access if strings.Contains(roDSN, "?") { roDSN += "&mode=ro" } else { roDSN += "?mode=ro" } // Wrap with busyTimeoutConnector so every pooled read-only connection // gets PRAGMA busy_timeout. Without this, reads return SQLITE_BUSY // immediately when a write is in progress on the read-write connection // (busy_timeout is per-connection, so a one-shot PRAGMA only configures // whichever conn served it). roBase, err := openLibsqlLocalConnector(roDSN) if err != nil { slog.Warn("Failed to open read-only database connector", "error", err) return nil, nil, nil } readOnlyDB := sql.OpenDB(&busyTimeoutConnector{base: roBase, timeoutMs: 5000}) slog.Info("UI database initialized", "mode", "readonly", "path", dbPath) // Create session store sessionStore := NewSessionStore(database) // The periodic cleanup loop used to start here. It now runs under a lease // so it executes on one instance rather than all of them; see // RunPeriodicCleanup and AppViewServer.startCleanupWorker. return database, readOnlyDB, sessionStore } // CleanupExpiredRecords deletes expired UI sessions, old OAuth sessions, // expired OAuth authorization requests and stale pending device authorizations. // Every statement is an idempotent DELETE, so running it twice is harmless. func CleanupExpiredRecords(ctx context.Context, database *sql.DB, sessionStore *SessionStore) { sessionStore.Cleanup() oauthStore := NewOAuthStore(database) oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour) oauthStore.CleanupExpiredAuthRequests(ctx) NewDeviceStore(database).CleanupExpired() } // RunPeriodicCleanup runs CleanupExpiredRecords every interval until ctx is // cancelled. It always returns ctx.Err(), so a lease manager treats a lost lease // or a shutdown as "stopped" rather than "finished". func RunPeriodicCleanup(ctx context.Context, database *sql.DB, sessionStore *SessionStore, interval time.Duration) error { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: CleanupExpiredRecords(ctx, database, sessionStore) } } }