This commit is contained in:
2026-06-14 00:55:27 +02:00
parent 08162fa126
commit 67203cd4a8
16 changed files with 897 additions and 212 deletions
+6 -1
View File
@@ -17,7 +17,12 @@
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)", "Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)", "Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
"Read(//c/Perso/Seafile/Programmation/Golang/**)", "Read(//c/Perso/Seafile/Programmation/Golang/**)",
"Bash(gofmt -w internal/qslcard/*.go)" "Bash(gofmt -w internal/qslcard/*.go)",
"Bash(awk '{print $3}')",
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
"Bash(ls -la .claude/)",
"Bash(cat .claude/settings.local.json)",
"Bash(cat .claude/settings.json)"
] ]
} }
} }
+100 -58
View File
@@ -369,6 +369,9 @@ type App struct {
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location) dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
migratedFromAppData bool // true when we auto-copied AppData on first portable launch migratedFromAppData bool // true when we auto-copied AppData on first portable launch
@@ -540,6 +543,11 @@ func (a *App) startup(ctx context.Context) {
// to a separate cat.log in the old HamLog folder, which users couldn't find). // to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf cat.LogSink = applog.Printf
applog.Printf("startup: data dir = %s", dataDir) applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
// lists, the lookup cache. Only the QSO logbook itself may live on a shared
// MySQL server (the multi-operator feature). Keeping config local means it
// stays instant even when the shared logbook is on a far-away MySQL.
conn, err := db.Open(a.dbPath) conn, err := db.Open(a.dbPath)
if err != nil { if err != nil {
a.startupErr = "cannot open db: " + err.Error() a.startupErr = "cannot open db: " + err.Error()
@@ -547,7 +555,32 @@ func (a *App) startup(ctx context.Context) {
return return
} }
a.db = conn a.db = conn
a.qso = qso.NewRepo(conn)
// Choose where the QSO logbook lives. On a MySQL failure we fall back to the
// local SQLite logbook so the operator can still log (and fix the config).
logbookConn := conn
if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled {
applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database)
mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{
Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database,
})
if mErr != nil {
applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr)
a.dbBackendErr = "MySQL: " + mErr.Error()
} else {
logbookConn = mysqlConn
a.dbBackend = "mysql"
}
}
if a.dbBackend == "" {
a.dbBackend = "sqlite"
}
// db.Dialect describes the LOGBOOK backend — the only place SQL actually
// varies (qso JSON extraction). Config repos always run on SQLite.
db.SetDialect(a.dbBackend)
applog.Printf("startup: logbook backend = %s", a.dbBackend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
a.settings = settings.NewStore(conn) a.settings = settings.NewStore(conn)
a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
a.profiles = profile.NewRepo(conn) a.profiles = profile.NewRepo(conn)
@@ -882,6 +915,9 @@ func (a *App) shutdown(ctx context.Context) {
if a.qsoRec != nil { if a.qsoRec != nil {
a.qsoRec.Stop() a.qsoRec.Stop()
} }
if a.logDb != nil && a.logDb != a.db {
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
}
if a.db != nil { if a.db != nil {
_ = a.db.Close() _ = a.db.Close()
} }
@@ -981,32 +1017,49 @@ func copyFileData(src, dst string) error {
// ── Database location (config.json pointer) ──────────────────────────── // ── Database location (config.json pointer) ────────────────────────────
// dbPointer is the tiny bootstrap config stored in the data dir. It must // dbPointer is the tiny bootstrap config stored in the data dir. It must
// live outside the database because we read it to decide which DB to open. // live outside the database because we read it BEFORE opening any DB to decide
// which backend to use: a local SQLite file (DBPath) or a shared MySQL server
// (MySQL). The MySQL connection lives here — not in the settings table — for
// the same reason: we need it to choose and open the backend at startup.
type dbPointer struct { type dbPointer struct {
DBPath string `json:"db_path"` DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
} }
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// readDBPointer returns the user-chosen DB path, or "" for the default. // readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
func readDBPointer(dataDir string) string { // value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
var c dbPointer
b, err := os.ReadFile(dbPointerPath(dataDir)) b, err := os.ReadFile(dbPointerPath(dataDir))
if err != nil { if err != nil {
return "" return c
} }
var c dbPointer _ = json.Unmarshal(b, &c)
if json.Unmarshal(b, &c) != nil { c.DBPath = strings.TrimSpace(c.DBPath)
return "" return c
}
return strings.TrimSpace(c.DBPath)
} }
// writeDBPointer persists the chosen DB path ("" resets to default). func writeBootstrap(dataDir string, c dbPointer) error {
func writeDBPointer(dataDir, path string) error { c.DBPath = strings.TrimSpace(c.DBPath)
b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ") b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644) return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
} }
// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
return readBootstrap(dataDir).DBPath
}
// writeDBPointer persists the chosen DB path ("" resets to default), keeping
// any saved MySQL config intact.
func writeDBPointer(dataDir, path string) error {
c := readBootstrap(dataDir)
c.DBPath = strings.TrimSpace(path)
return writeBootstrap(dataDir, c)
}
// DatabaseSettings describes the active database file for the Settings UI. // DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct { type DatabaseSettings struct {
Path string `json:"path"` Path string `json:"path"`
@@ -1032,62 +1085,51 @@ type MySQLSettings struct {
Database string `json:"database"` Database string `json:"database"`
} }
const ( // DBBackendStatus reports which backend OpsLog actually opened at startup so
keyMySQLEnabled = "mysql.enabled" // the Settings UI can confirm the shared MySQL connection (or explain a
keyMySQLHost = "mysql.host" // fallback to SQLite when the configured server was unreachable).
keyMySQLPort = "mysql.port" type DBBackendStatus struct {
keyMySQLUser = "mysql.user" Active string `json:"active"` // "sqlite" | "mysql"
keyMySQLPassword = "mysql.password" Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
keyMySQLDatabase = "mysql.database" Error string `json:"error"` // the MySQL open error, when Fallback is true
) }
// GetMySQLSettings returns the stored shared-database config (defaults applied). // GetDBBackendStatus returns the active backend and any MySQL fallback error.
func (a *App) GetDBBackendStatus() DBBackendStatus {
return DBBackendStatus{
Active: a.dbBackend,
Fallback: a.dbBackendErr != "",
Error: a.dbBackendErr,
}
}
// GetMySQLSettings returns the stored shared-database config from the bootstrap
// file (config.json), with defaults applied. Read before the DB is open, so it
// must not depend on the settings table.
func (a *App) GetMySQLSettings() (MySQLSettings, error) { func (a *App) GetMySQLSettings() (MySQLSettings, error) {
out := MySQLSettings{Port: 3306} out := MySQLSettings{Port: 3306}
if a.settings == nil { if mb := readBootstrap(a.dataDir).MySQL; mb != nil {
return out, nil out = *mb
if out.Port <= 0 {
out.Port = 3306
} }
m, err := a.settings.GetMany(a.ctx, keyMySQLEnabled, keyMySQLHost, keyMySQLPort, keyMySQLUser, keyMySQLPassword, keyMySQLDatabase)
if err != nil {
return out, err
} }
out.Enabled = m[keyMySQLEnabled] == "1"
out.Host = m[keyMySQLHost]
if p, _ := strconv.Atoi(m[keyMySQLPort]); p > 0 {
out.Port = p
}
out.User = m[keyMySQLUser]
out.Password = m[keyMySQLPassword]
out.Database = m[keyMySQLDatabase]
return out, nil return out, nil
} }
// SaveMySQLSettings persists the shared-database config. (Switching the active // SaveMySQLSettings persists the shared-database config to the bootstrap file.
// backend takes effect on restart — wired in a later phase.) // Switching the active backend takes effect on the next launch (we read this
// file before opening any database).
func (a *App) SaveMySQLSettings(s MySQLSettings) error { func (a *App) SaveMySQLSettings(s MySQLSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Port <= 0 { if s.Port <= 0 {
s.Port = 3306 s.Port = 3306
} }
enabled := "0" s.Host = strings.TrimSpace(s.Host)
if s.Enabled { s.User = strings.TrimSpace(s.User)
enabled = "1" s.Database = strings.TrimSpace(s.Database)
} c := readBootstrap(a.dataDir)
for k, v := range map[string]string{ c.MySQL = &s
keyMySQLEnabled: enabled, return writeBootstrap(a.dataDir, c)
keyMySQLHost: strings.TrimSpace(s.Host),
keyMySQLPort: strconv.Itoa(s.Port),
keyMySQLUser: strings.TrimSpace(s.User),
keyMySQLPassword: s.Password,
keyMySQLDatabase: strings.TrimSpace(s.Database),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
} }
// TestMySQLConnection pings the shared MySQL database with the given settings // TestMySQLConnection pings the shared MySQL database with the given settings
@@ -4878,7 +4920,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED", // Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
// without a per-record DB read. // without a per-record DB read.
alreadyQrz := map[int64]bool{} alreadyQrz := map[int64]bool{}
if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil { if rs, e := a.logDb.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
for rs.Next() { for rs.Next() {
var id int64 var id int64
if rs.Scan(&id) == nil { if rs.Scan(&id) == nil {
+1 -1
View File
@@ -24,7 +24,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
<Mic className="size-3.5 text-primary" /> <Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span> <span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} /> <span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting</span>} {status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
<div className="flex-1" /> <div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}> <Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> Stop <Square className="size-3" /> Stop
+60 -25
View File
@@ -25,7 +25,7 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir, GetDataDir,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
@@ -194,11 +194,11 @@ const TREE: TreeNode[] = [
}, },
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { kind: 'item', label: 'Rotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' }, { kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' }, { kind: 'item', label: 'Antenna', id: 'antenna' },
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' }, { kind: 'item', label: 'Audio devices', id: 'audio' },
], ],
}, },
]; ];
@@ -220,7 +220,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
rotator: 'Rotator', rotator: 'Rotator',
winkeyer: 'CW Keyer (WinKeyer)', winkeyer: 'CW Keyer',
antenna: 'Antenna', antenna: 'Antenna',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -543,6 +543,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' }); const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch })); const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
const [mysqlMsg, setMysqlMsg] = useState(''); const [mysqlMsg, setMysqlMsg] = useState('');
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
const [dataDir, setDataDir] = useState(''); const [dataDir, setDataDir] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]); const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
@@ -633,6 +634,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setExtSvc(es as any); setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {} try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {} try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setDataDir(await GetDataDir()); } catch {} try { setDataDir(await GetDataDir()); } catch {}
try { try {
const locs: any = await ListTQSLStationLocations(); const locs: any = await ListTQSLStationLocations();
@@ -1725,7 +1727,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader <SectionHeader
title="CW Keyer (WinKeyer)" title="CW Keyer"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools WinKeyer CW keyer)." hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools WinKeyer CW keyer)."
/> />
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
@@ -2695,6 +2697,44 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<SectionHeader <SectionHeader
title="Database" title="Database"
/> />
{/* Backend selector — top of the panel. SQLite (solo) vs MySQL (shared). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-5">
<Label className="text-sm">Backend</Label>
<Select value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && (
<div className="max-w-2xl mb-4">
{backendStatus.fallback ? (
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
MySQL is enabled but the connection failed at startup — OpsLog is running on the local <strong>SQLite</strong> database.
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
) : backendStatus.active === 'mysql' ? (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
<strong>Logbook:</strong> shared MySQL <span className="text-emerald-700">connected ✓</span>
<span className="mx-1.5 text-muted-foreground">·</span>
<strong>Config:</strong> local SQLite
<div className="text-[10px] text-muted-foreground mt-1">
Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied import them into the shared log if you want your history there.
</div>
</div>
) : (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
Active backend: <strong className="uppercase">{backendStatus.active}</strong>
</div>
)}
</div>
)}
{!mysqlCfg.enabled && (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="space-y-1"> <div className="space-y-1">
<Label>Current database</Label> <Label>Current database</Label>
@@ -2721,24 +2761,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
)} )}
</div> </div>
)}
{/* Shared MySQL database (multi-operator) */} {/* Shared MySQL database (multi-operator) */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">Shared database (multi-operator)</div>
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Backend</Label>
<Select value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
</SelectContent>
</Select>
</div>
{mysqlCfg.enabled && ( {mysqlCfg.enabled && (
<> <div className="space-y-3 max-w-2xl">
<div className="text-[11px] text-muted-foreground leading-relaxed">
Several OpsLog instances pointed at one MySQL server see each other's QSOs live (à la Log4OM). Test the connection, then <strong>Save</strong> — OpsLog switches to MySQL (and creates all tables) on the next launch.
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center"> <div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label> <Label className="text-sm">Host</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} /> <Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
@@ -2757,14 +2787,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
Test &amp; create database Test &amp; create database
</Button> </Button>
<Button size="sm" className="h-8" <Button size="sm" className="h-8"
onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setMysqlMsg('Saved.')).catch((e: any) => setErr(String(e?.message ?? e))); }}> onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setMysqlMsg('Saved restart OpsLog to connect to MySQL.')).catch((e: any) => setErr(String(e?.message ?? e))); }}>
Save Save
</Button> </Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span> <span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div> </div>
</> {mysqlMsg.startsWith('Saved') && (
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3">
<span>Saved. OpsLog will use the shared MySQL database after a restart.</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
</div>
)} )}
</div> </div>
)}
{/* Data location */} {/* Data location */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl"> <div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
+2
View File
@@ -135,6 +135,8 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>; export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
export function GetDVKMessages():Promise<Array<main.DVKMessage>>; export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>; export function GetDVKStatus():Promise<main.DVKStatus>;
+4
View File
@@ -242,6 +242,10 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo'](); return window['go']['main']['App']['GetCtyDatInfo']();
} }
export function GetDBBackendStatus() {
return window['go']['main']['App']['GetDBBackendStatus']();
}
export function GetDVKMessages() { export function GetDVKMessages() {
return window['go']['main']['App']['GetDVKMessages'](); return window['go']['main']['App']['GetDVKMessages']();
} }
+16
View File
@@ -846,6 +846,22 @@ export namespace main {
this.file_mod_time = source["file_mod_time"]; this.file_mod_time = source["file_mod_time"];
} }
} }
export class DBBackendStatus {
active: string;
fallback: boolean;
error: string;
static createFrom(source: any = {}) {
return new DBBackendStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.active = source["active"];
this.fallback = source["fallback"];
this.error = source["error"];
}
}
export class DVKMessage { export class DVKMessage {
slot: number; slot: number;
label: string; label: string;
+2 -2
View File
@@ -94,7 +94,7 @@ func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (in
return 0, fmt.Errorf("clear refs: %w", err) return 0, fmt.Errorf("clear refs: %w", err)
} }
stmt, err := tx.PrepareContext(ctx, stmt, err := tx.PrepareContext(ctx,
`INSERT OR REPLACE INTO award_references `REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias) (award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil { if err != nil {
@@ -257,7 +257,7 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
return fmt.Errorf("empty award or reference code") return fmt.Errorf("empty award or reference code")
} }
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO award_references `REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias) (award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp, code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
+58 -6
View File
@@ -81,6 +81,34 @@ func PingMySQL(c MySQLConfig) error {
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsFS embed.FS 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, // Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations. // enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) { func Open(path string) (*sql.DB, error) {
@@ -97,7 +125,8 @@ func Open(path string) (*sql.DB, error) {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("ping sqlite: %w", err) return nil, fmt.Errorf("ping sqlite: %w", err)
} }
if err := migrate(conn); err != nil { Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, err return nil, err
} }
@@ -106,12 +135,16 @@ func Open(path string) (*sql.DB, error) {
// migrate applies all embedded *.sql migrations in alphabetical order, // migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system // skipping those already applied. Intentionally minimal in-house system
// (no external dependency). // (no external dependency). translate, when non-nil, rewrites each statement
func migrate(conn *sql.DB) error { // for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( 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, name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`); err != nil { )`)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err) return fmt.Errorf("create schema_migrations: %w", err)
} }
@@ -141,11 +174,30 @@ func migrate(conn *sql.DB) error {
if err != nil { if err != nil {
return fmt.Errorf("read migration %s: %w", name, err) 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() tx, err := conn.Begin()
if err != nil { if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err) return fmt.Errorf("begin tx for %s: %w", name, err)
} }
if _, err := tx.Exec(string(content)); err != nil { if _, err := tx.Exec(sqlText); err != nil {
_ = tx.Rollback() _ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err) return fmt.Errorf("apply migration %s: %w", name, err)
} }
+242
View File
@@ -0,0 +1,242 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
// ── SQLite → MySQL schema translation ──────────────────────────────────
//
// OpsLog's migrations are authored in SQLite dialect (the default backend).
// For the shared-MySQL backend we translate that DDL on the fly rather than
// maintaining a second set of migration files, so the two backends can never
// drift apart. The translation is deliberately narrow — it only rewrites the
// SQLite-isms that actually appear in our migrations:
//
// - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY
// - bare INTEGER → BIGINT (so FK child/parent types match)
// - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go)
// - TEXT → VARCHAR(255), except a few free-text
// columns that stay TEXT/LONGTEXT
// - reserved column `key` → backticked
// - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS)
//
// REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP
// TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys
// are all valid MySQL as written.
var (
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
)
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
// The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes
// each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page,
// ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column
// actually needs in-row storage: it's indexed, a primary key, or carries a
// DEFAULT (TEXT can't hold a literal default in MySQL).
// varcharColumns must be VARCHAR because they're indexed or part of a primary
// key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic
// below can't catch them). MySQL can't index a TEXT column without a prefix
// length. Keyed by column name (a name may repeat across tables — harmless).
var varcharColumns = map[string]bool{
// qso indexes (0001, 0003, 0019)
"callsign": true, "qso_date": true, "band": true, "mode": true,
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
// integrations_udp index (0011)
"direction": true,
// award_references composite primary key (0017)
"award_code": true, "ref_code": true,
}
// longTextColumns override the default TEXT with a bigger type where the
// content can be large (a full QSL-template document).
var longTextColumns = map[string]string{
"json": "LONGTEXT",
}
// mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration
// file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string {
s := stmt
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
// "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so
// inserts that omit the id still get one.
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY")
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY")
// Timestamp defaults: Go supplies ISO strings, so drop the SQLite function
// default (TEXT/VARCHAR can't carry it in MySQL anyway).
s = reStrftimeDefault.ReplaceAllString(s, "''")
// Remaining INTEGER columns → BIGINT so foreign-key parent/child types match.
s = reBareInteger.ReplaceAllString(s, "BIGINT")
// TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed,
// a primary key, or default-bearing — keeping the row under MySQL's limit.
s = translateTextColumns(s)
// `key` is a MySQL reserved word — backtick the settings column declaration.
s = reKeyColumn.ReplaceAllString(s, "$1`key`")
return s
}
// translateTextColumns rewrites each "<col> TEXT" column declaration to the
// right MySQL type, deciding per line so it can see whether the line carries a
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
func translateTextColumns(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
m := reColText.FindStringSubmatchIndex(line)
if m == nil {
continue
}
col := line[m[2]:m[3]]
var typ string
switch {
case longTextColumns[col] != "":
typ = longTextColumns[col]
case varcharColumns[col],
strings.Contains(line, "DEFAULT"),
strings.Contains(line, "PRIMARY KEY"):
typ = "VARCHAR(255)"
default:
typ = "TEXT"
}
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
}
return strings.Join(lines, "\n")
}
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
// then applies the (translated) embedded migrations. multiStatements is enabled
// so multi-statement migration files run in a single Exec.
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if strings.TrimSpace(c.Host) == "" {
return nil, fmt.Errorf("host is required")
}
name := strings.TrimSpace(c.Database)
if !validDBIdent(name) {
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
// Ensure the database exists (connect server-level first).
if err := PingMySQL(c); err != nil {
return nil, err
}
conn, err := sql.Open("mysql", c.dsn())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
// The UI fires bursts of concurrent queries (the Preferences dialog alone
// loads ~8 settings in parallel, plus the grid and live cluster status). A
// low cap turns such a burst into a deadlock-like stall against a remote
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
conn.SetConnMaxLifetime(5 * time.Minute)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
// Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql"
if err := migrate(conn, mysqlDDL); err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
}
return conn, nil
}
// applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors)
// keeps migrations idempotent and lets a half-applied database self-heal.
//
// It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a
// few migrations recreate tables by DROP/RENAME in an order that trips MySQL's
// foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the
// connection returns to the pool, so runtime queries keep FK enforcement.
func applyMySQLMigration(conn *sql.DB, sqlText string) error {
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
for _, stmt := range splitStatements(sqlText) {
if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
}
}
return nil
}
// splitStatements breaks a migration file into individual SQL statements,
// dropping full-line comments and blank fragments. Our migrations never embed
// a ';' inside a string literal, so a simple split is safe.
func splitStatements(sqlText string) []string {
var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
var out []string
for _, part := range strings.Split(b.String(), ";") {
if strings.TrimSpace(part) != "" {
out = append(out, part)
}
}
return out
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration.
func isIgnorableDDLError(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table
1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table
1060, // ER_DUP_FIELDNAME — ADD COLUMN already present
1061, // ER_DUP_KEYNAME — CREATE INDEX already present
1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist
return true
}
return false
}
+153
View File
@@ -0,0 +1,153 @@
package db
import (
"regexp"
"strings"
"testing"
)
func TestMySQLDDL_Translations(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
// The column-name/type whitespace collapses to a single space — harmless.
{"text default N", "qsl_sent TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
{"indexed col → varchar", "callsign TEXT NOT NULL,", "callsign VARCHAR(255) NOT NULL,"},
{"plain non-indexed col → text", "name TEXT,", "name TEXT,"},
{"plain text stays text", "comment TEXT,", "comment TEXT,"},
{"json longtext", " json TEXT NOT NULL,", " json LONGTEXT NOT NULL,"},
{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
}
for _, c := range cases {
if got := mysqlDDL(c.in); got != c.want {
t.Errorf("%s:\n in %q\n got %q\n want %q", c.name, c.in, got, c.want)
}
}
}
func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
in := "CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);"
got := mysqlDDL(in)
if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
t.Errorf("key column not backticked/translated:\n%s", got)
}
}
func TestSplitStatements(t *testing.T) {
in := "-- a comment\n" +
"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
"ALTER TABLE qso ADD COLUMN b TEXT; -- inline note\n" +
"\n" +
"CREATE INDEX idx ON qso(a);\n"
got := splitStatements(in)
if len(got) != 3 {
t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
}
if !strings.Contains(got[0], "ADD COLUMN a") ||
!strings.Contains(got[1], "ADD COLUMN b") ||
!strings.Contains(got[2], "CREATE INDEX") {
t.Errorf("unexpected split: %#v", got)
}
// No fragment should be a comment-only or blank statement.
for _, s := range got {
if strings.TrimSpace(s) == "" {
t.Errorf("empty statement in result: %#v", got)
}
}
}
// Every embedded migration must split into at least one runnable statement
// and never produce an empty fragment.
func TestSplitStatements_AllMigrations(t *testing.T) {
entries, _ := migrationsFS.ReadDir("migrations")
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
stmts := splitStatements(mysqlDDL(string(raw)))
if len(stmts) == 0 {
t.Errorf("%s: produced no statements", e.Name())
}
for _, s := range stmts {
if strings.TrimSpace(s) == "" {
t.Errorf("%s: empty statement fragment", e.Name())
}
}
}
}
// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
// clear of it. TEXT columns are off-page and don't count here.
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
varchars := 0
for _, name := range qsoMigrations {
raw, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
t.Fatal(err)
}
varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
}
const bytesPerVarchar = 255 * 4 // utf8mb4
rowBytes := varchars * bytesPerVarchar
t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
if rowBytes > 60000 { // leave headroom under the 65535 hard limit
t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
}
}
// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
// fails if any SQLite-only construct survives — a fast guard against a new
// migration sneaking in a dialect-ism the translator doesn't cover.
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
t.Fatal(err)
}
reInteger := regexp.MustCompile(`\bINTEGER\b`)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
t.Fatal(err)
}
out := mysqlDDL(string(raw))
if strings.Contains(out, "AUTOINCREMENT") {
t.Errorf("%s: AUTOINCREMENT survived", e.Name())
}
if strings.Contains(out, "strftime") {
t.Errorf("%s: strftime survived", e.Name())
}
if strings.Contains(out, "IF NOT EXISTS idx") {
t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
}
// Strip comment lines before checking for bare INTEGER (comments are
// prose and may legitimately mention the word).
var code strings.Builder
for _, ln := range strings.Split(out, "\n") {
if strings.HasPrefix(strings.TrimSpace(ln), "--") {
continue
}
code.WriteString(ln)
code.WriteByte('\n')
}
if reInteger.MatchString(code.String()) {
t.Errorf("%s: bare INTEGER survived in code", e.Name())
}
}
}
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"hamlog/internal/db"
) )
// Direction is "inbound" (we listen) or "outbound" (we emit). // Direction is "inbound" (we listen) or "outbound" (we emit).
@@ -112,10 +114,10 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
direction = ?, name = ?, port = ?, service_type = ?, direction = ?, name = ?, port = ?, service_type = ?,
multicast = ?, multicast_group = ?, destination_ip = ?, multicast = ?, multicast_group = ?, destination_ip = ?,
enabled = ?, sort_order = ?, enabled = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') updated_at = ?
WHERE id = ?`, WHERE id = ?`,
c.Direction, c.Name, c.Port, c.ServiceType, c.Direction, c.Name, c.Port, c.ServiceType,
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID) mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, db.NowISO(), c.ID)
if err != nil { if err != nil {
return fmt.Errorf("update udp: %w", err) return fmt.Errorf("update udp: %w", err)
} }
+21 -14
View File
@@ -12,6 +12,8 @@ import (
"sync" "sync"
"time" "time"
"unicode" "unicode"
"hamlog/internal/db"
) )
// ErrNotFound is returned by providers when a callsign is unknown. // ErrNotFound is returned by providers when a callsign is unknown.
@@ -357,24 +359,29 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
return r, true return r, true
} }
// Put upserts a lookup result. // Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
func (c *Cache) Put(ctx context.Context, r Result) error { func (c *Cache) Put(ctx context.Context, r Result) error {
_, err := c.db.ExecContext(ctx, ` updateCols := []string{
"name", "qth", "address", "state", "cnty",
"country", "grid", "lat", "lon",
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
"source", "fetched_at",
}
// The lookup cache always lives in the local SQLite database, so SQLite
// upsert syntax is used unconditionally.
sets := make([]string, len(updateCols))
for i, c := range updateCols {
sets[i] = c + " = excluded." + c
}
q := `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty, INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon, country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via, image_url, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
source, fetched_at) source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?, VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
ON CONFLICT(callsign) DO UPDATE SET _, err := c.db.ExecContext(ctx, q,
name = excluded.name, qth = excluded.qth, address = excluded.address,
state = excluded.state, cnty = excluded.cnty,
country = excluded.country, grid = excluded.grid,
lat = excluded.lat, lon = excluded.lon,
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
image_url = excluded.image_url,
source = excluded.source, fetched_at = excluded.fetched_at`,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address), r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
nullable(r.State), nullable(r.County), nullable(r.State), nullable(r.County),
nullable(r.Country), nullable(r.Grid), nullable(r.Country), nullable(r.Grid),
@@ -382,7 +389,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ), nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia), nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
nullable(r.ImageURL), nullable(r.ImageURL),
r.Source, r.Source, db.NowISO(),
) )
return err return err
} }
+6 -4
View File
@@ -8,6 +8,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
"hamlog/internal/db"
) )
// Station is a radio / TRX line. The display Name is also what gets // Station is a radio / TRX line. The display Name is also what gets
@@ -190,8 +192,8 @@ func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE operating_stations `UPDATE operating_stations
SET name = ?, tx_pwr = ?, sort_order = ?, SET name = ?, tx_pwr = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') updated_at = ?
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID) WHERE id = ?`, s.Name, pwr, s.SortOrder, db.NowISO(), s.ID)
if err != nil { if err != nil {
return fmt.Errorf("update station: %w", err) return fmt.Errorf("update station: %w", err)
} }
@@ -230,8 +232,8 @@ func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE operating_antennas `UPDATE operating_antennas
SET name = ?, sort_order = ?, SET name = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') updated_at = ?
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil { WHERE id = ?`, a.Name, a.SortOrder, db.NowISO(), a.ID); err != nil {
return fmt.Errorf("update antenna: %w", err) return fmt.Errorf("update antenna: %w", err)
} }
} }
+99 -36
View File
@@ -10,6 +10,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"hamlog/internal/db"
) )
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src // MergeNonZero copies every non-zero field of src onto dst. Zero-value src
@@ -255,6 +257,19 @@ var columnCount = countColumns(columnList)
// insertPlaceholders returns "?,?,?,..." matching columnCount. // insertPlaceholders returns "?,?,?,..." matching columnCount.
var insertPlaceholders = buildInsertPlaceholders() var insertPlaceholders = buildInsertPlaceholders()
// insertCols/insertVals append created_at + updated_at so they're set from Go
// (NowISO) on every backend — MySQL has no strftime default, and binding the
// timestamp keeps a single backend-agnostic INSERT. insertArgs pairs with them.
const insertCols = columnList + `, created_at, updated_at`
var insertVals = insertPlaceholders + ",?,?"
// insertArgs returns the column values plus the two timestamps for an INSERT.
func (q *QSO) insertArgs() []any {
now := db.NowISO()
return append(q.args(), now, now)
}
func countColumns(s string) int { func countColumns(s string) int {
n := 1 n := 1
for i := 0; i < len(s); i++ { for i := 0; i < len(s); i++ {
@@ -349,34 +364,30 @@ func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
q.QSODate = time.Now().UTC() q.QSODate = time.Now().UTC()
} }
res, err := r.db.ExecContext(ctx, res, err := r.db.ExecContext(ctx,
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`, `INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`,
q.args()...) q.insertArgs()...)
if err != nil { if err != nil {
return 0, fmt.Errorf("insert qso: %w", err) return 0, fmt.Errorf("insert qso: %w", err)
} }
return res.LastInsertId() return res.LastInsertId()
} }
// AddBatch inserts many QSOs inside a single transaction using a prepared // batchInsertRows is how many QSOs go into one multi-row INSERT on MySQL. Each
// statement. Empty-callsign records are skipped. Returns rows inserted. // row carries ~135 columns, so 200 rows ≈ 27k bound parameters — well under
// MySQL's 65535-placeholder limit and a modest packet — while cutting network
// round-trips ~200× versus one INSERT per row (critical for a remote server).
const batchInsertRows = 200
// AddBatch inserts many QSOs inside a single transaction. Empty-callsign records
// are skipped. On MySQL it uses chunked multi-row INSERTs so a 27k-record import
// over a remote link takes seconds, not many minutes; on local SQLite a prepared
// statement per row is already fast.
func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) { func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
if len(qsos) == 0 { if len(qsos) == 0 {
return 0, nil return 0, nil
} }
tx, err := r.db.BeginTx(ctx, nil) // Normalise and drop empty-callsign records up front.
if err != nil { rows := make([]QSO, 0, len(qsos))
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`)
if err != nil {
return 0, fmt.Errorf("prepare batch insert: %w", err)
}
defer stmt.Close()
var inserted int64
for _, q := range qsos { for _, q := range qsos {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign)) q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
if q.Callsign == "" { if q.Callsign == "" {
@@ -385,11 +396,58 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
if q.QSODate.IsZero() { if q.QSODate.IsZero() {
q.QSODate = time.Now().UTC() q.QSODate = time.Now().UTC()
} }
if _, err := stmt.ExecContext(ctx, q.args()...); err != nil { rows = append(rows, q)
return inserted, fmt.Errorf("insert qso %q: %w", q.Callsign, err) }
if len(rows) == 0 {
return 0, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var inserted int64
if db.IsMySQL() {
rowPlaceholder := "(" + insertVals + ")"
for start := 0; start < len(rows); start += batchInsertRows {
end := start + batchInsertRows
if end > len(rows) {
end = len(rows)
}
chunk := rows[start:end]
var sb strings.Builder
sb.WriteString("INSERT INTO qso (")
sb.WriteString(insertCols)
sb.WriteString(") VALUES ")
args := make([]any, 0, len(chunk)*(columnCount+2))
for j := range chunk {
if j > 0 {
sb.WriteByte(',')
}
sb.WriteString(rowPlaceholder)
args = append(args, chunk[j].insertArgs()...)
}
if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil {
return inserted, fmt.Errorf("batch insert: %w", err)
}
inserted += int64(len(chunk))
}
} else {
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`)
if err != nil {
return 0, fmt.Errorf("prepare batch insert: %w", err)
}
defer stmt.Close()
for i := range rows {
if _, err := stmt.ExecContext(ctx, rows[i].insertArgs()...); err != nil {
return inserted, fmt.Errorf("insert qso %q: %w", rows[i].Callsign, err)
} }
inserted++ inserted++
} }
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit batch: %w", err) return 0, fmt.Errorf("commit batch: %w", err)
} }
@@ -455,8 +513,8 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?, `UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark qrz uploaded %d: %w", id, err) return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
} }
@@ -468,8 +526,8 @@ func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?, `UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark clublog uploaded %d: %w", id, err) return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
} }
@@ -481,8 +539,8 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?, `UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark lotw uploaded %d: %w", id, err) return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
} }
@@ -494,8 +552,8 @@ func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) erro
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error { func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?, `UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark eqsl sent %d: %w", id, err) return fmt.Errorf("mark eqsl sent %d: %w", id, err)
} }
@@ -515,9 +573,9 @@ func (r *Repo) Update(ctx context.Context, q QSO) error {
q.QSODate = time.Now().UTC() q.QSODate = time.Now().UTC()
} }
setClause := buildUpdateSetClause() setClause := buildUpdateSetClause()
args := append(q.args(), q.ID) args := append(q.args(), db.NowISO(), q.ID)
res, err := r.db.ExecContext(ctx, res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+setClause+`, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, `UPDATE qso SET `+setClause+`, updated_at = ? WHERE id = ?`,
args...) args...)
if err != nil { if err != nil {
return fmt.Errorf("update qso %d: %w", q.ID, err) return fmt.Errorf("update qso %d: %w", q.ID, err)
@@ -714,6 +772,11 @@ func columnExpr(field string) (string, bool) {
return f, true return f, true
} }
if key, ok := filterableExtras[f]; ok { if key, ok := filterableExtras[f]; ok {
if db.IsMySQL() {
// JSON_EXTRACT errors on an invalid/empty document, so guard with
// NULLIF; JSON_UNQUOTE strips the quotes MySQL adds around strings.
return "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + key + "'))", true
}
return "json_extract(extras_json, '$." + key + "')", true return "json_extract(extras_json, '$." + key + "')", true
} }
return "", false return "", false
@@ -1348,7 +1411,7 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
// far cheaper than N exists-queries during the import loop. // far cheaper than N exists-queries during the import loop.
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) { func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode SELECT callsign, substr(qso_date, 1, 16), band, mode
FROM qso`) FROM qso`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1376,7 +1439,7 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
// confirmations back to local QSOs. // confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) { func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`) FROM qso`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1463,8 +1526,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?, `UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark qrz confirmed %d: %w", id, err) return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
} }
@@ -1476,8 +1539,8 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?, `UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark lotw confirmed %d: %w", id, err) return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
} }
+99 -39
View File
@@ -13,6 +13,7 @@ import (
"fmt" "fmt"
"sync" "sync"
"hamlog/internal/db"
"hamlog/internal/secret" "hamlog/internal/secret"
) )
@@ -27,10 +28,62 @@ type Store struct {
mu sync.RWMutex mu sync.RWMutex
cipher Cipher // non-nil when secrets are unlocked cipher Cipher // non-nil when secrets are unlocked
sensitive func(key string) bool // which keys are encrypted at rest sensitive func(key string) bool // which keys are encrypted at rest
// cache holds every setting's RAW (as-stored) value, loaded once. Reads are
// served from memory so the Preferences dialog (dozens of keys) doesn't pay
// a network round-trip per key against a remote MySQL. Decryption still
// happens on read, so a later Unlock takes effect without reloading.
cache map[string]string
cached bool
} }
func NewStore(db *sql.DB) *Store { return &Store{db: db} } func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// ensureCache lazily loads all settings into memory on first read. A concurrent
// double-load is harmless (the result is identical), so it's done without a
// long-held lock.
func (s *Store) ensureCache(ctx context.Context) error {
s.mu.RLock()
ok := s.cached
s.mu.RUnlock()
if ok {
return nil
}
rows, err := s.db.QueryContext(ctx, "SELECT `key`, value FROM settings")
if err != nil {
return err
}
defer rows.Close()
m := make(map[string]string, 256)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return err
}
m[k] = v
}
if err := rows.Err(); err != nil {
return err
}
s.mu.Lock()
if !s.cached {
s.cache = m
s.cached = true
}
s.mu.Unlock()
return nil
}
// cachePut updates the in-memory copy after a write so reads stay coherent.
func (s *Store) cachePut(key, raw string) {
s.mu.Lock()
if s.cache == nil {
s.cache = map[string]string{}
}
s.cache[key] = raw
s.mu.Unlock()
}
// SetSensitivePredicate registers which keys hold secrets. Set once at startup. // SetSensitivePredicate registers which keys hold secrets. Set once at startup.
func (s *Store) SetSensitivePredicate(fn func(key string) bool) { func (s *Store) SetSensitivePredicate(fn func(key string) bool) {
s.mu.Lock() s.mu.Lock()
@@ -101,39 +154,36 @@ func (s *Store) encodeWrite(key, val string) string {
// Get returns the value for key, or "" if not set. // Get returns the value for key, or "" if not set.
func (s *Store) Get(ctx context.Context, key string) (string, error) { func (s *Store) Get(ctx context.Context, key string) (string, error) {
var v string raw, err := s.GetRaw(ctx, key)
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil { if err != nil {
return "", err return "", err
} }
return s.decodeRead(key, v), nil return s.decodeRead(key, raw), nil
} }
// GetRaw returns the stored value WITHOUT decryption — used by the passphrase // GetRaw returns the stored value WITHOUT decryption — used by the passphrase
// migration which must read/re-write the raw ciphertext or plaintext. // migration which must read/re-write the raw ciphertext or plaintext.
func (s *Store) GetRaw(ctx context.Context, key string) (string, error) { func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
var v string if err := s.ensureCache(ctx); err != nil {
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) return "", err
if err == sql.ErrNoRows {
return "", nil
} }
return v, err s.mu.RLock()
v := s.cache[key] // "" when absent
s.mu.RUnlock()
return v, nil
} }
// SetRaw stores a value verbatim (no encryption) — used by the migration. // SetRaw stores a value verbatim (no encryption) — used by the migration.
// The settings table always lives in the local SQLite database (config is
// per-operator, never on the shared MySQL logbook), so SQLite syntax is used
// unconditionally. The backticks around `key` are accepted by SQLite too.
func (s *Store) SetRaw(ctx context.Context, key, value string) error { func (s *Store) SetRaw(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, ` q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
INSERT INTO settings(key, value) VALUES(?, ?) "ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
ON CONFLICT(key) DO UPDATE SET if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil {
value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
key, value)
if err != nil {
return fmt.Errorf("set %s: %w", key, err) return fmt.Errorf("set %s: %w", key, err)
} }
s.cachePut(key, value)
return nil return nil
} }
@@ -144,31 +194,41 @@ func (s *Store) Set(ctx context.Context, key, value string) error {
// All returns every stored setting (sensitive values decrypted when unlocked). // All returns every stored setting (sensitive values decrypted when unlocked).
func (s *Store) All(ctx context.Context) (map[string]string, error) { func (s *Store) All(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`) if err := s.ensureCache(ctx); err != nil {
if err != nil {
return nil, err return nil, err
} }
defer rows.Close() s.mu.RLock()
out := map[string]string{} raw := make(map[string]string, len(s.cache))
for rows.Next() { for k, v := range s.cache {
var k, v string raw[k] = v
if err := rows.Scan(&k, &v); err != nil {
return nil, err
} }
s.mu.RUnlock()
out := make(map[string]string, len(raw))
for k, v := range raw {
out[k] = s.decodeRead(k, v) out[k] = s.decodeRead(k, v)
} }
return out, rows.Err() return out, nil
} }
// GetMany fetches several keys in a single round-trip. // GetMany fetches several keys, all served from the in-memory cache (one DB
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) { // round-trip total, on first access). Every requested key is present in the
out := make(map[string]string, len(keys)) // result (absent settings map to "").
for _, k := range keys { func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
v, err := s.Get(ctx, k) out := make(map[string]string, len(keys))
if err != nil { if len(keys) == 0 {
return nil, err return out, nil
} }
out[k] = v if err := s.ensureCache(ctx); err != nil {
} return nil, err
}
s.mu.RLock()
raw := make([]string, len(keys))
for i, k := range keys {
raw[i] = s.cache[k]
}
s.mu.RUnlock()
for i, k := range keys {
out[k] = s.decodeRead(k, raw[i])
}
return out, nil return out, nil
} }