// 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…). return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4", 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) so a not-yet-created DB isn't an error, then runs // CREATE DATABASE IF NOT EXISTS. Backs the settings "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(5 * time.Second) if err := conn.Ping(); err != nil { return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err) } if name := strings.TrimSpace(c.Database); name != "" { if !validDBIdent(name) { return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) } if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { return fmt.Errorf("create database %q: %w", name, err) } } return nil } //go:embed migrations/*.sql var migrationsFS embed.FS // 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 { if translate == nil { translate = func(s string) string { return s } } if _, err := conn.Exec(translate(`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')) )`)); 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 IsMySQL() { 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 }