- Device registry: add/edit name, child and platform, persisted in devices. - Stable per-device topic derived from name + id, unchanged by renames. - Desired state persisted in state; timed locks expire and are audited. - Dashboard cards: name, child, platform, status dot, state and time left. - Manual lock/unlock forms with CSRF; every change appends to audit (FR-15). - Append-only activity list on the dashboard. No hard deletes (rule 10). - Store and handler tests; version bumped to 0.2.0-phase2. Co-Authored-By: Paperclip <noreply@paperclip.ing>
126 lines
3.4 KiB
Go
126 lines
3.4 KiB
Go
// 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 {
|
|
return insertAudit(ctx, s.db, actor, action, deviceID, detail)
|
|
}
|
|
|
|
// 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
|
|
}
|