// 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 // 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) } if err := migrate(conn); 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). func migrate(conn *sql.DB) error { if _, err := conn.Exec(`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) } tx, err := conn.Begin() if err != nil { return fmt.Errorf("begin tx for %s: %w", name, err) } if _, err := tx.Exec(string(content)); 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 }