chore: release v0.21.3
This commit is contained in:
+74
-3
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -143,7 +144,6 @@ func SetDialect(d string) {
|
||||
// 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) {
|
||||
@@ -161,18 +161,77 @@ func Open(path string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
Dialect = "sqlite"
|
||||
if err := migrate(conn, nil); err != nil {
|
||||
if err := migrate(conn, nil, path); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// LogSink receives this package's diagnostic lines. The app points it at
|
||||
// applog.Printf at startup (same pattern as cat / audio / extsvc); left nil in
|
||||
// tests and in the CLI tools under cmd/, where it is simply discarded.
|
||||
var LogSink func(format string, args ...any)
|
||||
|
||||
// logMigration records a migration that has just been applied, and how long it
|
||||
// took — the only trace an operator has that a data-rewriting migration ran.
|
||||
func logMigration(name string, start time.Time) {
|
||||
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
if LogSink != nil {
|
||||
LogSink(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// dataRewriteMarker flags a migration that rewrites existing user rows rather
|
||||
// than only altering the schema. Put it on its own line in the .sql file, and a
|
||||
// safety copy of the logbook is taken before it runs.
|
||||
const dataRewriteMarker = "-- opslog:rewrites-data"
|
||||
|
||||
// backupBeforeRewrite takes a one-off copy of the logbook before a migration
|
||||
// that rewrites user rows.
|
||||
//
|
||||
// It exists because the auto-updater gives the operator no say: the new build
|
||||
// relaunches and migrates before the changelog explaining it is ever shown, so
|
||||
// "back up first" is advice nobody can act on. The app takes the copy instead.
|
||||
//
|
||||
// Skipped when there is nothing to protect — a shared MySQL (no file to copy;
|
||||
// that server is the admin's to back up), the settings database, and a
|
||||
// freshly-created empty logbook all have no QSOs at stake.
|
||||
func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
if dbPath == "" {
|
||||
return
|
||||
}
|
||||
var n int
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n == 0 {
|
||||
return
|
||||
}
|
||||
dest := dbPath + ".pre-" + strings.TrimSuffix(migration, ".sql") + ".bak"
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return // a copy from an earlier attempt is already there — never overwrite it
|
||||
}
|
||||
// VACUUM INTO rather than copying the file: it writes a consistent,
|
||||
// self-contained snapshot even with WAL pages still outstanding, which a
|
||||
// plain file copy would silently miss. It cannot run inside a transaction,
|
||||
// so it happens here, before the migration opens one. The destination is
|
||||
// spliced (VACUUM INTO takes no bound parameter), with quotes doubled.
|
||||
start := time.Now()
|
||||
if _, err := conn.Exec(`VACUUM INTO '` + strings.ReplaceAll(dest, "'", "''") + `'`); err != nil {
|
||||
// Not fatal: the migration itself is a single atomic transaction, so
|
||||
// failing to take a belt-and-braces copy is no reason to block the update.
|
||||
logf("db: could not back up before %s: %v — continuing (the migration is atomic)", migration, err)
|
||||
return
|
||||
}
|
||||
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath 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
|
||||
@@ -219,12 +278,22 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
if applied[name] {
|
||||
continue // already applied
|
||||
}
|
||||
// Timed, and logged only once it has actually succeeded (below). Most
|
||||
// migrations are instant DDL, but some rewrite user rows — 0024 upper-cases
|
||||
// every callsign — and on a large logbook that is exactly what an operator
|
||||
// wants confirmed afterwards: that it ran, once, and what it cost.
|
||||
start := time.Now()
|
||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
sqlText := translate(string(content))
|
||||
|
||||
// A migration that rewrites user rows gets a safety copy taken first.
|
||||
if strings.Contains(string(content), dataRewriteMarker) {
|
||||
backupBeforeRewrite(conn, dbPath, name)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -238,6 +307,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -257,6 +327,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const mig0024 = "0024_normalise_callsign.sql"
|
||||
|
||||
// openWithUnappliedRewrite builds a logbook that already holds QSOs and has not
|
||||
// yet had the callsign-normalising migration applied — i.e. exactly what an
|
||||
// existing installation looks like the moment the auto-updater relaunches it.
|
||||
func openWithUnappliedRewrite(t *testing.T, rows [][2]string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "logbook.db")
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := conn.Exec(
|
||||
`INSERT INTO qso (callsign, qso_date, band, mode) VALUES (?, ?, '20m', 'SSB')`, r[0], r[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Rewind so the migration runs against real data on the next Open.
|
||||
if _, err := conn.Exec(`DELETE FROM schema_migrations WHERE name = ?`, mig0024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func callsigns(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rows, err := conn.Query(`SELECT callsign FROM qso ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The auto-updater migrates before the operator ever sees the changelog, so the
|
||||
// app has to take the safety copy itself. The copy must hold the data as it was
|
||||
// BEFORE the rewrite — a copy of the already-migrated rows would be worthless.
|
||||
func TestRewriteMigrationBacksUpOriginalData(t *testing.T) {
|
||||
p := openWithUnappliedRewrite(t, [][2]string{
|
||||
{"f5lit", "2026-07-01"},
|
||||
{" Pa3Eyf ", "2026-07-02"},
|
||||
{"F4BPO", "2026-07-03"},
|
||||
})
|
||||
|
||||
var logged []string
|
||||
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
|
||||
defer func() { LogSink = nil }()
|
||||
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// The live logbook is normalised.
|
||||
if got, want := callsigns(t, p), []string{"F5LIT", "PA3EYF", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("logbook callsigns = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The backup exists, next to the logbook, named after the migration.
|
||||
backup := p + ".pre-0024_normalise_callsign.bak"
|
||||
if _, err := os.Stat(backup); err != nil {
|
||||
t.Fatalf("no safety copy at %s: %v\nlogged:\n%s", backup, err, strings.Join(logged, "\n"))
|
||||
}
|
||||
// …and it holds the ORIGINAL rows, untouched.
|
||||
if got, want := callsigns(t, backup), []string{"f5lit", " Pa3Eyf ", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("backup callsigns = %v, want the pre-migration values %v", got, want)
|
||||
}
|
||||
|
||||
var sawBackup bool
|
||||
for _, l := range logged {
|
||||
if strings.Contains(l, "backed up 3 QSO(s)") {
|
||||
sawBackup = true
|
||||
}
|
||||
}
|
||||
if !sawBackup {
|
||||
t.Errorf("the backup was not logged; got:\n%s", strings.Join(logged, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// No QSOs, nothing to protect: the settings database and a freshly created
|
||||
// logbook must not litter the folder with pointless copies.
|
||||
func TestRewriteMigrationSkipsBackupWhenNoQSOs(t *testing.T) {
|
||||
p := openWithUnappliedRewrite(t, nil)
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if _, err := os.Stat(p + ".pre-0024_normalise_callsign.bak"); err == nil {
|
||||
t.Error("an empty database should not be backed up")
|
||||
}
|
||||
}
|
||||
|
||||
// schema_migrations is what makes "runs once" a guarantee rather than a promise:
|
||||
// a second launch must neither re-run the rewrite nor take a second copy.
|
||||
func TestRewriteMigrationRunsOnce(t *testing.T) {
|
||||
p := openWithUnappliedRewrite(t, [][2]string{{"f5lit", "2026-07-01"}})
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
backup := p + ".pre-0024_normalise_callsign.bak"
|
||||
first, err := os.Stat(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var logged []string
|
||||
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
|
||||
defer func() { LogSink = nil }()
|
||||
|
||||
conn2, err := Open(p) // second launch
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn2.Close()
|
||||
|
||||
for _, l := range logged {
|
||||
if strings.Contains(l, mig0024) {
|
||||
t.Errorf("migration ran again on the second launch: %s", l)
|
||||
}
|
||||
}
|
||||
second, err := os.Stat(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.ModTime().Equal(second.ModTime()) {
|
||||
t.Error("the existing safety copy was overwritten on the second launch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- opslog:rewrites-data
|
||||
-- Normalise stored callsigns so lookups can use idx_qso_callsign.
|
||||
--
|
||||
-- WorkedBefore matched rows with `upper(trim(callsign)) = ?`. Wrapping the
|
||||
-- column in functions makes the predicate non-sargable: SQLite cannot use the
|
||||
-- index and falls back to scanning. Measured on a 190 000-row logbook, the
|
||||
-- COUNT went from 0.3 ms (SEARCH ... USING INDEX) to 20.8 ms (SCAN), and the
|
||||
-- entries query — which needs every column, so not even a covering index helps
|
||||
-- — scans the whole table. That runs on every keystroke of a callsign, and it
|
||||
-- made the entry strip's history arrive too late to auto-fill the name and
|
||||
-- locator from the previous QSO. Small logbooks never showed it.
|
||||
--
|
||||
-- Add, bulk insert and Update have always upper-cased and trimmed the callsign,
|
||||
-- so this only rewrites rows left by older versions or foreign imports, and the
|
||||
-- queries can then compare the column directly.
|
||||
--
|
||||
-- SQLite compares case-sensitively, so the WHERE finds exactly the rows that
|
||||
-- need it. MySQL's default collation is case- and trailing-space-insensitive:
|
||||
-- there the UPDATE is largely a no-op and equally unnecessary, because `=`
|
||||
-- already matches those rows through the index.
|
||||
UPDATE qso SET callsign = upper(trim(callsign))
|
||||
WHERE callsign <> upper(trim(callsign));
|
||||
@@ -200,7 +200,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
err = applyMySQLBaseline(conn)
|
||||
} else {
|
||||
// Existing database: apply only the migrations it's missing.
|
||||
err = migrate(conn, mysqlDDL)
|
||||
err = migrate(conn, mysqlDDL, "")
|
||||
}
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -287,7 +287,7 @@ func applyMySQLBaseline(conn *sql.DB) error {
|
||||
return fmt.Errorf("open baseline sqlite: %w", err)
|
||||
}
|
||||
defer mem.Close()
|
||||
if err := migrate(mem, nil); err != nil {
|
||||
if err := migrate(mem, nil, ""); err != nil {
|
||||
return fmt.Errorf("build baseline schema: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user