Files
OpsLog/internal/db/db.go
T

263 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package db handles the SQLite connection and migrations.
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
)
// MySQLConfig targets a shared MySQL database for multi-operator logging
// (multiple OpsLog instances on one logbook, à la Log4OM).
type MySQLConfig struct {
Host string
Port int
User string
Password string
Database string
}
func (c MySQLConfig) dsn() string {
port := c.Port
if port == 0 {
port = 3306
}
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
// or wrong-port server fails fast with a real error instead of hanging
// startup (which would surface only as "db not initialized"); read/write
// timeouts cap a stuck statement (generous, so normal migrations/imports
// never trip them).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
c.User, c.Password, c.Host, port, c.Database)
}
// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't
// use a placeholder). Only plain identifiers allowed.
func validDBIdent(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') {
return false
}
}
return true
}
// PingMySQL verifies a shared-database connection and creates the logbook
// database if it doesn't exist yet. It connects at server level first (no
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
// database is actually usable. A restricted user (common on shared hosting)
// may lack the CREATE DATABASE privilege but still have full rights on a
// pre-created database — so a denied CREATE is not fatal as long as the
// database already exists and we can connect to it. Backs the "Test
// connection" button.
func PingMySQL(c MySQLConfig) error {
if strings.TrimSpace(c.Host) == "" {
return fmt.Errorf("host is required")
}
server := c
server.Database = "" // connect to the server, not a specific DB
conn, err := sql.Open("mysql", server.dsn())
if err != nil {
return fmt.Errorf("open mysql: %w", err)
}
defer conn.Close()
conn.SetConnMaxLifetime(15 * time.Second)
if err := conn.Ping(); err != nil {
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
}
name := strings.TrimSpace(c.Database)
if name == "" {
return nil
}
if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
createErr := error(nil)
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
if !isAccessDenied(err) {
return fmt.Errorf("create database %q: %w", name, err)
}
createErr = err // remember it in case the DB also turns out to be unreachable
}
// Confirm the database is usable (it may have been pre-created by an admin
// even though this user can't CREATE one).
dbConn, err := sql.Open("mysql", c.dsn())
if err != nil {
return fmt.Errorf("open mysql db: %w", err)
}
defer dbConn.Close()
dbConn.SetConnMaxLifetime(15 * time.Second)
if err := dbConn.Ping(); err != nil {
if createErr != nil {
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
}
return fmt.Errorf("connect to database %q: %w", name, err)
}
return nil
}
//go:embed migrations/*.sql
var migrationsFS embed.FS
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
// dialect; run through the dialect translator for MySQL.
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`
// Dialect names the backend opened at startup. Repos consult it for the few
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
var Dialect = "sqlite"
// IsMySQL reports whether the active LOGBOOK backend is the shared MySQL
// server. Config tables always live in local SQLite, so this only governs
// qso-table SQL (see columnExpr).
func IsMySQL() bool { return Dialect == "mysql" }
// SetDialect pins the logbook dialect ("mysql" | "sqlite"). Called once at
// startup after the logbook backend is chosen, so it doesn't depend on the
// order in which the local and logbook connections were opened.
func SetDialect(d string) {
if d == "mysql" {
Dialect = "mysql"
} else {
Dialect = "sqlite"
}
}
// NowISO returns the current UTC time as the ISO-8601 string OpsLog stores in
// *_at / date columns — millisecond precision, matching the old SQLite
// strftime('%Y-%m-%dT%H:%M:%fZ','now') default. Bind this as a parameter so the
// same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
// Escape only the two characters a path could contain that the DSN would
// otherwise read as its query/fragment delimiters. Windows separators
// (\\ and the drive ':') are left intact — url.PathEscape would mangle them.
safePath := strings.NewReplacer("?", "%3F", "#", "%23").Replace(path)
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", safePath)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
mysqlPath := translate != nil
if translate == nil {
translate = func(s string) string { return s }
}
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
// Fetch every applied migration name in ONE query rather than one round-trip
// per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
// connect time (each RTT × migration count) — the whole logbook connect could
// take tens of seconds. One SELECT + an in-memory set is effectively instant.
applied := map[string]bool{}
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
applied[n] = true
}
}
rows.Close()
} else {
return fmt.Errorf("read applied migrations: %w", err)
}
for _, name := range names {
if applied[name] {
continue // already applied
}
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
sqlText := translate(string(content))
// MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on
// "duplicate column". Instead apply statement-by-statement and tolerate
// "already exists" errors, making migrations idempotent (and self-healing
// for a previously half-applied database).
if mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
continue
}
// SQLite supports DDL inside a transaction, so keep it atomic there.
tx, err := conn.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(sqlText); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}