From dcd64ee199352b6c54a397132c8edeb45a64154d Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 18 May 2026 17:44:50 -0400 Subject: [PATCH] CUB-183: SQLite schema migration + DB init --- go.mod | 19 ++- go.sum | 51 +++++++ internal/db/db.go | 133 +++++++++++++++++++ internal/db/migrations/001_create_tables.sql | 57 ++++++++ 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 internal/db/migrations/001_create_tables.sql diff --git a/go.mod b/go.mod index 1de8c6c..a4be2b7 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ module github.com/cubecraft/remoterig -go 1.24 +go 1.25.0 -require gopkg.in/yaml.v3 v3.0.1 +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.50.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index a62c313..4abd016 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/db/db.go b/internal/db/db.go index f9e2c3e..1af91c0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/migrations/001_create_tables.sql b/internal/db/migrations/001_create_tables.sql new file mode 100644 index 0000000..d0f29cb --- /dev/null +++ b/internal/db/migrations/001_create_tables.sql @@ -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');