This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+52 -15
View File
@@ -29,8 +29,12 @@ func (c MySQLConfig) dsn() string {
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",
// 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)
}
@@ -50,8 +54,12 @@ func validDBIdent(s string) bool {
// 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.
// 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")
@@ -63,17 +71,37 @@ func PingMySQL(c MySQLConfig) error {
return fmt.Errorf("open mysql: %w", err)
}
defer conn.Close()
conn.SetConnMaxLifetime(5 * time.Second)
conn.SetConnMaxLifetime(15 * 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 {
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
}
@@ -81,6 +109,13 @@ func PingMySQL(c MySQLConfig) error {
//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.
@@ -138,13 +173,15 @@ func Open(path string) (*sql.DB, error) {
// (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(`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 {
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
@@ -182,7 +219,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
// "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 mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}