5f695ebe2a
Eine readonly-Verbindung (web-Container) kann eine WAL-Datenbank ohne aktiven Writer/-shm nicht öffnen -> "attempt to write a readonly database", die Seite blieb leer. Da collector (Writer) und web (Reader) getrennte Prozesse sind und der collector keine Dauerverbindung hält, existiert im Ruhezustand kein -shm. - collector/db.py + schema.sql: WAL entfernt (Rollback-Journal als Default); readonly-Reads sind damit jederzeit möglich. - web/lib/db.ts: busy_timeout=5s, falls gerade geschrieben wird. Bestehende DB einmalig umstellen: PRAGMA journal_mode=DELETE; Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
import Database from "better-sqlite3";
|
|
|
|
const DB_PATH = process.env.DB_PATH || "/data/charges.db";
|
|
|
|
export interface Charge {
|
|
id: number;
|
|
start_time: string;
|
|
end_time: string;
|
|
duration_s: number;
|
|
energy_wh: number;
|
|
}
|
|
|
|
export interface Totals {
|
|
month_wh: number;
|
|
year_wh: number;
|
|
total_wh: number;
|
|
install_date: string | null;
|
|
}
|
|
|
|
/**
|
|
* Öffnet die DB read-only und gibt sie an `fn`. Es wird bewusst pro Aufruf eine
|
|
* frische Verbindung geöffnet (und wieder geschlossen): Der Collector schreibt
|
|
* aus einem anderen Prozess/Container im WAL-Modus, und eine langlebige
|
|
* readonly-Verbindung bekommt diese Writes über den Docker-Desktop-FUSE-Mount
|
|
* nicht zuverlässig mit (inkohärenter WAL-Index im Shared-Memory). Eine frische
|
|
* Verbindung liest immer den aktuellen Stand.
|
|
*
|
|
* Gibt `fallback` zurück, wenn die Datei/das Schema noch nicht existiert
|
|
* (Collector legt sie beim ersten Start an) — die Seite bleibt dann leer statt
|
|
* zu crashen.
|
|
*/
|
|
function withDb<T>(fallback: T, fn: (conn: Database.Database) => T): T {
|
|
let conn: Database.Database | null = null;
|
|
try {
|
|
// timeout: wartet kurz, falls der collector gerade schreibt (statt
|
|
// sofort mit SQLITE_BUSY zu scheitern).
|
|
conn = new Database(DB_PATH, { readonly: true, fileMustExist: true, timeout: 5000 });
|
|
const hasTable = conn
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='charges'")
|
|
.get();
|
|
if (!hasTable) return fallback;
|
|
return fn(conn);
|
|
} catch {
|
|
return fallback;
|
|
} finally {
|
|
conn?.close();
|
|
}
|
|
}
|
|
|
|
export function getCharges(): Charge[] {
|
|
return withDb<Charge[]>([], (conn) =>
|
|
conn
|
|
.prepare(
|
|
`SELECT id, start_time, end_time, duration_s, energy_wh
|
|
FROM charges ORDER BY start_time DESC`,
|
|
)
|
|
.all() as Charge[],
|
|
);
|
|
}
|
|
|
|
export function getTotals(): Totals {
|
|
const empty: Totals = { month_wh: 0, year_wh: 0, total_wh: 0, install_date: null };
|
|
return withDb<Totals>(empty, (conn) => {
|
|
const month = conn
|
|
.prepare(
|
|
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
|
|
WHERE strftime('%Y-%m', start_time) = strftime('%Y-%m', 'now', 'localtime')`,
|
|
)
|
|
.get() as { s: number };
|
|
const year = conn
|
|
.prepare(
|
|
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
|
|
WHERE strftime('%Y', start_time) = strftime('%Y', 'now', 'localtime')`,
|
|
)
|
|
.get() as { s: number };
|
|
const total = conn
|
|
.prepare(`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges`)
|
|
.get() as { s: number };
|
|
const install = conn
|
|
.prepare(`SELECT value FROM meta WHERE key = 'install_date'`)
|
|
.get() as { value: string } | undefined;
|
|
|
|
return {
|
|
month_wh: month.s,
|
|
year_wh: year.s,
|
|
total_wh: total.s,
|
|
install_date: install?.value ?? null,
|
|
};
|
|
});
|
|
}
|