fix: repair a MySQL logbook already left with TEXT columns

Translating the schema correctly does nothing for a database that already
exists. This operator's had been created by an earlier attempt, so qso.state was
already TEXT and every launch died on the same statement — CREATE INDEX … ON
qso(state), error 1170 — with the connection failing at startup and no way
forward from the UI.

Columns that MUST be VARCHAR (the indexed and keyed ones) are now converted
before the migrations run. It reads information_schema, touches only the columns
in varcharColumns, and only when they are actually TEXT — a no-op on a healthy
database, and it preserves each column's NULL/NOT NULL.

A failure to repair IS reported: it is the difference between a logbook that
opens and one that does not. A failure to READ information_schema is not — a
server that will not answer it can still be running a perfectly good schema, and
refusing to open would turn a non-problem into an outage.
This commit is contained in:
2026-07-29 16:35:57 +02:00
parent e3aabc06a4
commit 35db1440e4
2 changed files with 64 additions and 3 deletions
+62 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"log"
"regexp"
"strings"
"time"
@@ -238,7 +239,15 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
// Existing database: repair any column that must be VARCHAR before the
// migrations run — an index on a TEXT column fails, and that failure
// otherwise repeats at every start with no way out from the UI.
if rerr := repairMySQLTextColumns(context.Background(), conn); rerr != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, rerr
}
// Then apply only the migrations it's missing.
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
}
if err != nil {
@@ -572,3 +581,55 @@ func isIgnorableDDLError(err error) bool {
}
return false
}
// repairMySQLTextColumns converts columns that MUST be VARCHAR but are TEXT.
//
// Translating the schema correctly does not help a database that already exists.
// A logbook created by an earlier version — or left half-built by a migration
// that failed partway — keeps its TEXT columns, and every later run dies on the
// same statement: "CREATE INDEX … ON qso(state)" → 1170, because MySQL cannot
// index a TEXT column without a prefix length. The operator sees the connection
// fail at startup, for ever, with no way forward from the UI.
//
// So the schema is repaired before the migrations run. Only the columns in
// varcharColumns are touched — the ones that are indexed or part of a key — and
// only when they are actually TEXT, so this is a no-op on a healthy database.
func repairMySQLTextColumns(ctx context.Context, conn *sql.DB) error {
rows, err := conn.QueryContext(ctx, `
SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND DATA_TYPE IN ('text','mediumtext','longtext')`)
if err != nil {
// Not fatal: a server that will not answer information_schema can still
// run a healthy schema, and failing here would block a working logbook.
return nil
}
type col struct{ table, name, nullable string }
var todo []col
for rows.Next() {
var c col
if err := rows.Scan(&c.table, &c.name, &c.nullable); err != nil {
break
}
if varcharColumns[c.name] {
todo = append(todo, c)
}
}
rows.Close()
for _, c := range todo {
null := "NULL"
if c.nullable == "NO" {
null = "NOT NULL"
}
stmt := fmt.Sprintf("ALTER TABLE `%s` MODIFY `%s` VARCHAR(255) %s", c.table, c.name, null)
if _, err := conn.ExecContext(ctx, stmt); err != nil {
// Report it: this is the difference between a logbook that opens and
// one that does not, so a failure here must not be silent.
return fmt.Errorf("repair %s.%s to VARCHAR: %w", c.table, c.name, err)
}
log.Printf("db[mysql]: repaired %s.%s TEXT → VARCHAR(255) (it is indexed)", c.table, c.name)
}
return nil
}