initial commit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// Package db provides a database connection and functions for interacting with the database.
|
||||
// The DB object should be held and maintained by the application, not by this package.
|
||||
// The application should be responsible for connecting to, migrating, and initial population
|
||||
// of the database. This package should only provide the schema definitions and helper functions.
|
||||
// Schema definitions should clone API response structures as closely as possible.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
connectMaxAttempts = 5
|
||||
connectRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Connect opens the SQLite database at dbpath with WAL mode and a hardened
|
||||
// lock-recovery strategy suitable for kiosk use where the process may have
|
||||
// crashed and left stale lock artefacts behind.
|
||||
func Connect(dbpath string) (*gorm.DB, error) {
|
||||
|
||||
// ── Stale lock recovery ───────────────────────────────────────────────
|
||||
// SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file.
|
||||
// If the previous process crashed, the OS releases file locks but the
|
||||
// artefacts remain. SQLite recovers them automatically on the next open,
|
||||
// UNLESS an OS-level advisory lock is still held (rare but possible on
|
||||
// Linux with certain crash patterns). Removing them before opening is
|
||||
// safe: SQLite treats absent WAL/SHM files as a clean slate and will
|
||||
// recreate them. We only do this if the main DB file already exists
|
||||
// (i.e. this is not a first-run) to avoid masking legitimate errors.
|
||||
// if _, err := os.Stat(dbpath); err == nil {
|
||||
// for _, ext := range []string{"-wal", "-shm"} {
|
||||
// artefact := dbpath + ext
|
||||
// if _, err := os.Stat(artefact); err == nil {
|
||||
// if removeErr := os.Remove(artefact); removeErr != nil {
|
||||
// // Non-fatal: log and continue — SQLite may still recover.
|
||||
// fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr)
|
||||
// } else {
|
||||
// fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// ── Connection with retry ─────────────────────────────────────────────
|
||||
// Even after removing WAL/SHM, the main DB file itself may still be
|
||||
// briefly locked if another process is shutting down. Retry with
|
||||
// backoff before giving up.
|
||||
var (
|
||||
db *gorm.DB
|
||||
err error
|
||||
)
|
||||
for attempt := 1; attempt <= connectMaxAttempts; attempt++ {
|
||||
db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt == connectMaxAttempts {
|
||||
return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err)
|
||||
}
|
||||
fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n",
|
||||
attempt, connectMaxAttempts, err, connectRetryDelay)
|
||||
time.Sleep(connectRetryDelay)
|
||||
}
|
||||
|
||||
// ── Connection pool ───────────────────────────────────────────────────
|
||||
// SQLite does not support concurrent writers. Setting MaxOpenConns(1)
|
||||
// forces gorm to serialise all queries through a single connection,
|
||||
// preventing "database is locked" errors from competing goroutines
|
||||
// within this process. This is the single most important setting for
|
||||
// SQLite in a multi-goroutine application.
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get underlying sql.DB: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
// ── Pragmas ───────────────────────────────────────────────────────────
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=FULL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
// 5 s gives the serialised connection time to finish a long write
|
||||
// before returning SQLITE_BUSY to the application layer.
|
||||
"PRAGMA busy_timeout=5000",
|
||||
}
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
||||
return nil, fmt.Errorf("failed to set %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate the database schema.
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&AppConfig{},
|
||||
&Log{},
|
||||
&Proclamation{},
|
||||
&ProclamationCategory{},
|
||||
&SolfeggioFrequency{},
|
||||
&AudioFile{},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// schema.go defines the database schema for the GORM models.
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type AppConfig struct {
|
||||
gorm.Model
|
||||
DataDir string
|
||||
AssetsDir string
|
||||
EmailAddress string
|
||||
LicenseKey string
|
||||
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
|
||||
SelectedAudioDevice string // The pipewire device name
|
||||
InitialFontSize int // How large to create the font atlas
|
||||
StandardFontSize int // The font size used for most text rendering
|
||||
LinePattern string // "fibonacci"
|
||||
AudioEnabled bool
|
||||
SavedVolume float64 // Last saved volume as a percentage
|
||||
FirstRun bool // Is this the first time the application has been run?
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
gorm.Model
|
||||
Timestamp int64
|
||||
Level string
|
||||
Message string
|
||||
}
|
||||
|
||||
type Proclamation struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
Name string
|
||||
Content string
|
||||
SolfeggioFrequencyID int
|
||||
CategoryID int
|
||||
Selected bool
|
||||
Available bool
|
||||
Price float64
|
||||
}
|
||||
|
||||
type ProclamationCategory struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
ParentId int
|
||||
}
|
||||
|
||||
type SolfeggioFrequency struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
Value int
|
||||
}
|
||||
|
||||
type AudioFile struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
Path string
|
||||
Selected bool
|
||||
Order int
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// GetConfig fetches the application config from the database
|
||||
func GetConfig(db *gorm.DB) (*AppConfig, error) {
|
||||
var cfg AppConfig
|
||||
err := db.First(&cfg, 1).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
Reference in New Issue
Block a user