// Package store owns the SQLite database. It is the single source of truth for // the Control Service (SYSTEM_MAP.md). package store import ( "context" "database/sql" "embed" "fmt" "io/fs" "sort" _ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo) ) //go:embed migrations/*.sql var migrationFS embed.FS // Store wraps the database handle. type Store struct { db *sql.DB } // Open opens (creating if needed) the SQLite database at path. func Open(path string) (*Store, error) { dsn := fmt.Sprintf( "file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path, ) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // modernc/sqlite serialises writes; one connection avoids lock churn. db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) return &Store{db: db}, nil } // DB exposes the handle for read-only queries in later phases. func (s *Store) DB() *sql.DB { return s.db } // Ping verifies the database is reachable. func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } // Close closes the database. func (s *Store) Close() error { return s.db.Close() } // Migrate applies every embedded migration that has not run yet. It tracks the // applied version with PRAGMA user_version, so reruns are safe. func (s *Store) Migrate(ctx context.Context) error { files, err := migrationFiles() if err != nil { return err } var version int if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return fmt.Errorf("read user_version: %w", err) } for i := version; i < len(files); i++ { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin migration %d: %w", i+1, err) } if _, err := tx.ExecContext(ctx, files[i].sql); err != nil { _ = tx.Rollback() return fmt.Errorf("migration %s: %w", files[i].name, err) } if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil { _ = tx.Rollback() return fmt.Errorf("set user_version %d: %w", i+1, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", files[i].name, err) } } return nil } type migration struct { name string sql string } func migrationFiles() ([]migration, error) { entries, err := fs.ReadDir(migrationFS, "migrations") if err != nil { return nil, fmt.Errorf("read migrations: %w", err) } names := make([]string, 0, len(entries)) for _, e := range entries { if !e.IsDir() { names = append(names, e.Name()) } } sort.Strings(names) // 001_, 002_, ... apply in order out := make([]migration, 0, len(names)) for _, n := range names { b, err := fs.ReadFile(migrationFS, "migrations/"+n) if err != nil { return nil, fmt.Errorf("read migration %s: %w", n, err) } out = append(out, migration{name: n, sql: string(b)}) } return out, nil } // Audit appends one row to the append-only audit log. func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error { _, err := s.db.ExecContext(ctx, `INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`, actor, action, deviceID, detail, ) if err != nil { return fmt.Errorf("audit insert: %w", err) } return nil } // CountDevices returns the number of registered devices (0 in Phase 1). func (s *Store) CountDevices(ctx context.Context) (int, error) { var n int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices`).Scan(&n); err != nil { return 0, fmt.Errorf("count devices: %w", err) } return n, nil }