CUB-183: SQLite schema migration + DB init

This commit is contained in:
2026-05-18 17:44:50 -04:00
parent ede6696792
commit dcd64ee199
4 changed files with 258 additions and 2 deletions
+133
View File
@@ -1,2 +1,135 @@
// Package db provides SQLite database initialization and schema management.
package db
import (
"database/sql"
_ "embed"
"log"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
//go:embed migrations/001_create_tables.sql
var migration001 string
// DB wraps the sql.DB with connection-level settings.
type DB struct {
*sql.DB
}
// Open opens the SQLite database at the given path, enables WAL mode,
// and runs all migrations if the tables don't exist yet.
func Open(path string) (*DB, error) {
// Ensure the directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
// Enable WAL for concurrent read/write performance
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, err
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, err
}
// Check if tables already exist (idempotent migration)
var count int
if err := db.QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
`).Scan(&count); err != nil {
db.Close()
return nil, err
}
if count < 4 {
log.Printf("Running migrations for %s...", path)
if err := migrate(db, migration001); err != nil {
db.Close()
return nil, err
}
log.Println("Migrations complete")
}
return &DB{db}, nil
}
// migrate executes a SQL migration string.
func migrate(db *sql.DB, sql string) error {
// Split on semicolons to handle multiple statements
statements := splitSQL(sql)
for _, stmt := range statements {
stmt = stripWhitespace(stmt)
if stmt == "" {
continue
}
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
func splitSQL(sql string) []string {
var stmts []string
var current string
inQuote := false
quoteChar := rune(0)
for _, r := range sql {
if inQuote {
current += string(r)
if r == quoteChar {
inQuote = false
}
continue
}
switch r {
case '"', '\'', '`':
inQuote = true
quoteChar = r
current += string(r)
case ';':
stmts = append(stmts, current)
current = ""
default:
current += string(r)
}
}
if len(current) > 0 {
stmts = append(stmts, current)
}
return stmts
}
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
func stripWhitespace(s string) string {
result := ""
runningSpace := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !runningSpace {
result += " "
runningSpace = true
}
} else {
result += string(r)
runningSpace = false
}
}
return result
}
@@ -0,0 +1,57 @@
-- RemoteRig Database Schema (SQLite)
-- Migration: 001_create_tables
-- Cameras table: registry of all GoPro cameras
CREATE TABLE IF NOT EXISTS cameras (
camera_id TEXT PRIMARY KEY,
friendly_name TEXT NOT NULL,
mac_address TEXT UNIQUE,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
-- Status logs: every poll from an ESP8266 node
CREATE TABLE IF NOT EXISTS status_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id TEXT NOT NULL REFERENCES cameras(camera_id),
recorded_at DATETIME NOT NULL DEFAULT (datetime('now')),
battery_pct INTEGER,
video_remaining_sec INTEGER,
recording_state INTEGER NOT NULL DEFAULT 0,
mode TEXT,
resolution TEXT,
fps INTEGER,
online INTEGER NOT NULL DEFAULT 1,
raw_battery_pct REAL
);
CREATE INDEX IF NOT EXISTS idx_status_logs_camera_time
ON status_logs(camera_id, recorded_at DESC);
-- Recording events: explicit start/stop events
CREATE TABLE IF NOT EXISTS recording_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id TEXT NOT NULL REFERENCES cameras(camera_id),
started_at DATETIME NOT NULL,
stopped_at DATETIME,
reason TEXT,
duration INTEGER
);
CREATE INDEX IF NOT EXISTS idx_recording_events_camera_time
ON recording_events(camera_id, started_at DESC);
-- Settings: system-wide and per-camera config
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
-- Seed: default poll interval and thresholds
INSERT INTO settings (key, value) VALUES
('poll_interval_sec', '30'),
('low_battery_threshold', '15'),
('low_storage_alert_sec', '300');