package store import ( "database/sql" "errors" "fmt" "time" _ "modernc.org/sqlite" ) // ErrNotFound is returned when a requested entity does not exist. var ErrNotFound = errors.New("not found") // Store wraps the SQLite database connection and provides access to all query methods. type Store struct { db *sql.DB } // New opens a SQLite database at the given path and runs auto-migration. func New(dbPath string) (*Store, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("open database: %w", err) } // Enable WAL mode and foreign keys for better concurrency and referential integrity. pragmas := []string{ "PRAGMA journal_mode=WAL", "PRAGMA foreign_keys=ON", "PRAGMA busy_timeout=5000", } for _, p := range pragmas { if _, err := db.Exec(p); err != nil { db.Close() return nil, fmt.Errorf("exec pragma %q: %w", p, err) } } s := &Store{db: db} if err := s.migrate(); err != nil { db.Close() return nil, fmt.Errorf("migrate: %w", err) } return s, nil } // Close closes the underlying database connection. func (s *Store) Close() error { return s.db.Close() } // DB returns the underlying *sql.DB for advanced operations like transactions. func (s *Store) DB() *sql.DB { return s.db } // migrate creates all tables if they do not already exist. func (s *Store) migrate() error { _, err := s.db.Exec(schema) return err } const schema = ` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, registry TEXT NOT NULL DEFAULT '', image TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 0, healthcheck TEXT NOT NULL DEFAULT '', env TEXT NOT NULL DEFAULT '{}', volumes TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS stages ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, tag_pattern TEXT NOT NULL DEFAULT '*', auto_deploy INTEGER NOT NULL DEFAULT 0, max_instances INTEGER NOT NULL DEFAULT 1, confirm INTEGER NOT NULL DEFAULT 0, promote_from TEXT NOT NULL DEFAULT '', subdomain TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(project_id, name) ); CREATE TABLE IF NOT EXISTS registries ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, url TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'generic', token TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), domain TEXT NOT NULL DEFAULT '', server_ip TEXT NOT NULL DEFAULT '', network TEXT NOT NULL DEFAULT '', subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}', notification_url TEXT NOT NULL DEFAULT '', npm_url TEXT NOT NULL DEFAULT '', npm_email TEXT NOT NULL DEFAULT '', npm_password TEXT NOT NULL DEFAULT '', webhook_secret TEXT NOT NULL DEFAULT '', polling_interval TEXT NOT NULL DEFAULT '5m', updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS instances ( id TEXT PRIMARY KEY, stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, container_id TEXT NOT NULL DEFAULT '', image_tag TEXT NOT NULL, subdomain TEXT NOT NULL DEFAULT '', npm_proxy_id INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'stopped', port INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS deploys ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, instance_id TEXT NOT NULL DEFAULT '', image_tag TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', started_at TEXT NOT NULL DEFAULT (datetime('now')), finished_at TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS deploy_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE, message TEXT NOT NULL, level TEXT NOT NULL DEFAULT 'info', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS poll_states ( stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE, last_tag TEXT NOT NULL DEFAULT '', last_polled TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'viewer', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS auth_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), auth_mode TEXT NOT NULL DEFAULT 'local', oidc_client_id TEXT NOT NULL DEFAULT '', oidc_client_secret TEXT NOT NULL DEFAULT '', oidc_issuer_url TEXT NOT NULL DEFAULT '', oidc_redirect_url TEXT NOT NULL DEFAULT '' ); -- Seed the settings row if it does not exist. INSERT OR IGNORE INTO settings (id) VALUES (1); -- Seed the auth_settings row if it does not exist. INSERT OR IGNORE INTO auth_settings (id) VALUES (1); ` // now returns the current time formatted for SQLite storage. func now() string { return time.Now().UTC().Format("2006-01-02 15:04:05") }