feat: Verlauf -- wer hat wann was abgelegt, ersetzt oder geloescht

Neuer Reiter, und dahinter eine eigene Tabelle. WARUM NICHT EINFACH
media.created_at -- drei Gruende, jeder allein wuerde reichen:

  1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg, samt
     der Frage, wer es geloescht hat.
  2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
     Auskunft, wann die Datei urspruenglich kam.
  3. media.token_id ist beim Upload aus dem Dashboard NULL -- dort hat niemand
     einen Token, sondern eine Sitzung. Das "von wem" stand also nirgends.

Das ist NICHT das Logging, das die ROADMAP ablehnt: dort geht es um
Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je Upload in
derselben SQLite-Datei, und nach einem halben Jahr raeumt pruneEvents auf.

DER VERLAUF STARTET NICHT LEER. Beim ersten Start wird er aus dem Bestand
nachgetragen (68 Eintraege) -- ein Tab, der am ersten Tag leer ist, obwohl 68
Bilder in der Ablage liegen, sieht aus wie ein kaputter Tab. Die nachgetragenen
Eintraege sind als solche gekennzeichnet: Zeitpunkt stimmt, Urheber steht als
"vor der Aufzeichnung", weil ihn niemand mehr kennt. Die Oberflaeche sagt das
unter der Tabelle, statt es zu verschweigen.

logEvent wirft nie: ein Verlauf, der einen Upload scheitern laesst, waere die
Buchhaltung, die das Geschaeft verhindert.

Nachgemessen: Upload, Ersetzen und Loeschen aus dem Dashboard erscheinen als
user:admin, die Uploads des Fotostudios als token:d4rk_photostudio; beim
Loeschen wird die Groesse VOR dem Loeschen geholt, sonst stuende dort nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:33:52 +02:00
co-authored by Claude Opus 5
parent 3ddab937e7
commit 0b295a2302
6 changed files with 412 additions and 3 deletions
+97
View File
@@ -62,8 +62,62 @@ CREATE TABLE IF NOT EXISTS media (
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC);
-- Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht.
--
-- WARUM EINE EIGENE TABELLE und nicht einfach media.created_at. Drei Gruende,
-- und jeder allein wuerde reichen:
--
-- 1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg --
-- samt der Frage, wer es geloescht hat.
-- 2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
-- Auskunft, wann die Datei urspruenglich kam.
-- 3. media.token_id ist bei einem Upload aus dem Dashboard NULL: dort hat
-- niemand einen Token, sondern eine Sitzung. Das "von wem" stand also
-- nirgends.
--
-- Das ist NICHT das Logging, das in der ROADMAP abgelehnt wird -- dort geht es
-- um Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je
-- Upload in derselben SQLite-Datei.
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
-- 'upload' | 'replace' | 'delete'
kind TEXT NOT NULL,
path TEXT NOT NULL,
size INTEGER,
-- 'token' | 'user' | 'unbekannt'
actor_kind TEXT NOT NULL,
actor TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_at ON events(at DESC);
`)
// Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur
// wenn er leer ist.
//
// Ohne das stuende der Tab am ersten Tag leer da, obwohl 46 Bilder in der
// Ablage liegen; das sieht aus wie ein kaputter Tab. Die nachgetragenen
// Eintraege sind ehrlich als solche gekennzeichnet: sie tragen den
// urspruenglichen Zeitpunkt, aber ueber Loeschungen und Ersetzungen von
// frueher weiss niemand mehr etwas.
{
const vorhanden = db.prepare('SELECT COUNT(*) AS n FROM events').get() as { n: number }
const medien = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
if (vorhanden.n === 0 && medien.n > 0) {
const nachtragen = db.prepare(
`INSERT INTO events (at, kind, path, size, actor_kind, actor)
SELECT m.created_at, 'upload', m.path, m.size,
CASE WHEN t.name IS NULL THEN 'unbekannt' ELSE 'token' END,
COALESCE(t.name, 'vor der Aufzeichnung')
FROM media m LEFT JOIN tokens t ON t.id = m.token_id`,
)
const info = nachtragen.run()
console.log(`[db] Verlauf aus dem Bestand nachgetragen: ${info.changes} Eintraege`)
}
}
export type User = {
id: number
username: string
@@ -92,8 +146,51 @@ export type Media = {
updated_at: number
}
export type EventKind = 'upload' | 'replace' | 'delete'
export type MediaEvent = {
id: number
at: number
kind: EventKind
path: string
size: number | null
actor_kind: 'token' | 'user' | 'unbekannt'
actor: string
}
export const now = () => Date.now()
const eventEinfuegen = db.prepare(
`INSERT INTO events (at, kind, path, size, actor_kind, actor)
VALUES (?, ?, ?, ?, ?, ?)`,
)
/** Einen Eintrag in den Verlauf schreiben.
*
* Wirft NICHT: ein Verlauf, der einen Upload scheitern laesst, waere die
* Buchhaltung, die das Geschaeft verhindert. Wenn hier etwas schiefgeht,
* steht es in der Konsole und die Datei liegt trotzdem richtig. */
export function logEvent(
kind: EventKind,
path: string,
size: number | null,
actorKind: MediaEvent['actor_kind'],
actor: string,
): void {
try {
eventEinfuegen.run(now(), kind, path, size, actorKind, actor)
} catch (err) {
console.error('[events] konnte nicht schreiben:', err)
}
}
/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
export function pruneEvents(tage = 180) {
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
}
/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich —
* sonst waechst die Tabelle ewig, und niemand merkt es. */
export function pruneSessions() {