package main import ( "database/sql" "fmt" "os" "path/filepath" "strings" "sync" _ "github.com/mattn/go-sqlite3" ) // DBEntry represents a registered database. type DBEntry struct { Alias string // "registry" or "ops:app_name" Path string // absolute path to .db file Kind string // "registry" or "operations" } // DBPool manages read-only connections to registered databases. type DBPool struct { mu sync.RWMutex entries map[string]DBEntry conns map[string]*sql.DB } func NewDBPool() *DBPool { return &DBPool{ entries: make(map[string]DBEntry), conns: make(map[string]*sql.DB), } } // Register adds a database entry. Does not open the connection until first use. func (p *DBPool) Register(entry DBEntry) { p.mu.Lock() defer p.mu.Unlock() p.entries[entry.Alias] = entry } // Get returns a read-only connection to the named database. func (p *DBPool) Get(alias string) (*sql.DB, error) { p.mu.RLock() if db, ok := p.conns[alias]; ok { p.mu.RUnlock() return db, nil } p.mu.RUnlock() p.mu.Lock() defer p.mu.Unlock() // Double-check after acquiring write lock. if db, ok := p.conns[alias]; ok { return db, nil } entry, ok := p.entries[alias] if !ok { return nil, fmt.Errorf("database %q not found", alias) } dsn := fmt.Sprintf("file:%s?mode=ro", entry.Path) db, err := sql.Open("sqlite3", dsn) if err != nil { return nil, fmt.Errorf("opening %s: %w", alias, err) } if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("pinging %s: %w", alias, err) } db.SetMaxOpenConns(4) p.conns[alias] = db return db, nil } // List returns all registered database entries. func (p *DBPool) List() []DBEntry { p.mu.RLock() defer p.mu.RUnlock() out := make([]DBEntry, 0, len(p.entries)) for _, e := range p.entries { out = append(out, e) } return out } // Close closes all open connections. func (p *DBPool) Close() { p.mu.Lock() defer p.mu.Unlock() for _, db := range p.conns { db.Close() } } // Config holds the server configuration. type Config struct { Bind string // address to bind (default "127.0.0.1:8484") RegistryDir string // root of fn_registry (where registry.db lives) } // DiscoverDatabases finds registry.db and all operations.db files. func DiscoverDatabases(root string) []DBEntry { var entries []DBEntry // registry.db regPath := filepath.Join(root, "registry.db") if _, err := os.Stat(regPath); err == nil { entries = append(entries, DBEntry{ Alias: "registry", Path: regPath, Kind: "registry", }) } // apps/*/operations.db appsDir := filepath.Join(root, "apps") dirEntries, err := os.ReadDir(appsDir) if err != nil { return entries } for _, d := range dirEntries { if !d.IsDir() { continue } opsPath := filepath.Join(appsDir, d.Name(), "operations.db") if _, err := os.Stat(opsPath); err == nil { entries = append(entries, DBEntry{ Alias: "ops:" + d.Name(), Path: opsPath, Kind: "operations", }) } } // projects/*/apps/*/operations.db projectsDir := filepath.Join(root, "projects") projEntries, err := os.ReadDir(projectsDir) if err != nil { return entries } for _, p := range projEntries { if !p.IsDir() { continue } projAppsDir := filepath.Join(projectsDir, p.Name(), "apps") projAppEntries, err := os.ReadDir(projAppsDir) if err != nil { continue } for _, a := range projAppEntries { if !a.IsDir() { continue } opsPath := filepath.Join(projAppsDir, a.Name(), "operations.db") if _, err := os.Stat(opsPath); err == nil { entries = append(entries, DBEntry{ Alias: "ops:" + a.Name(), Path: opsPath, Kind: "operations", }) } } } return entries } // ValidateQuery checks that the SQL is a read-only statement (SELECT or PRAGMA). func ValidateQuery(sql string) error { trimmed := strings.TrimSpace(sql) upper := strings.ToUpper(trimmed) // Remove leading comments for strings.HasPrefix(upper, "--") { idx := strings.Index(upper, "\n") if idx < 0 { return fmt.Errorf("query contains only comments") } upper = strings.TrimSpace(upper[idx+1:]) } if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "PRAGMA") || strings.HasPrefix(upper, "WITH") || strings.HasPrefix(upper, "EXPLAIN") { return nil } return fmt.Errorf("only SELECT, PRAGMA, WITH, and EXPLAIN queries are allowed") }