// 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) for _, name := range names { var dummy string err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy) if err == nil { continue // already applied } if err != sql.ErrNoRows { return fmt.Errorf("check migration %s: %w", name, err) } 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 }