// Alle SQL-Anweisungen des Projekts stehen hier. Kein anderer Ort schreibt // SQL - das haelt das Schema an einer Stelle nachvollziehbar. import type { Db } from './index.ts'; import type { ChatMessage, EventType, StreamEvent } from '#shared/events.ts'; import { ulid } from '#shared/events.ts'; /** Spalten, die nach dem Anlegen eines Clips noch veraendert werden duerfen. */ const CLIP_COLUMNS = [ 'twitch_clip_id', 'raw_path', 'vertical_path', 'subtitle_path', 'duration_s', 'title', 'transcript', 'status', 'score', 'uploaded_to', ] as const; export type ClipColumn = (typeof CLIP_COLUMNS)[number]; export type ClipRow = { id: string; twitch_clip_id: string | null; stream_id: string | null; created_at: string; trigger: string; trigger_score: number | null; source: string; raw_path: string | null; vertical_path: string | null; status: string; score: number | null; }; export type PostRow = { id: string; stream_id: string | null; kind: string; platform: string; mode: string; created_at: string; status: string; title: string | null; category: string | null; url: string | null; detail: string | null; }; export type StreamRow = { id: string; started_at: string; ended_at: string | null; peak_viewers: number; unique_chatters: number; }; export function repos(db: Db) { const stmt = { insertEvent: db.prepare( 'INSERT OR IGNORE INTO events (id, ts, type, stream_id, user_id, payload) VALUES (?,?,?,?,?,?)' ), upsertUser: db.prepare(` INSERT INTO users (id, login, display_name, first_seen_at, last_seen_at, message_count) VALUES (?,?,?,?,?,0) ON CONFLICT(id) DO UPDATE SET login = excluded.login, display_name = excluded.display_name, last_seen_at = excluded.last_seen_at `), bumpUserMessage: db.prepare( 'UPDATE users SET message_count = message_count + 1, last_seen_at = ? WHERE id = ?' ), getUser: db.prepare('SELECT * FROM users WHERE id = ?'), insertChat: db.prepare(` INSERT OR IGNORE INTO chat_messages (id, stream_id, ts, user_id, text, emotes, is_first, is_command, bits) VALUES (?,?,?,?,?,?,?,?,?) `), openStream: db.prepare( 'INSERT OR IGNORE INTO streams (id, started_at) VALUES (?,?)' ), closeStream: db.prepare('UPDATE streams SET ended_at = ? WHERE id = ? AND ended_at IS NULL'), getStream: db.prepare('SELECT * FROM streams WHERE id = ?'), latestOpenStream: db.prepare( 'SELECT * FROM streams WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1' ), sample: db.prepare( 'INSERT OR REPLACE INTO viewer_samples (stream_id, ts, viewers) VALUES (?,?,?)' ), bumpPeak: db.prepare('UPDATE streams SET peak_viewers = ? WHERE id = ? AND peak_viewers < ?'), touchUserStream: db.prepare(` INSERT INTO user_streams (user_id, stream_id, first_ts, last_ts, messages) VALUES (?,?,?,?,?) ON CONFLICT(user_id, stream_id) DO UPDATE SET last_ts = excluded.last_ts, messages = user_streams.messages + excluded.messages `), countUserStreams: db.prepare( 'SELECT COUNT(*) AS n FROM user_streams WHERE user_id = ?' ), recountChatters: db.prepare(` UPDATE streams SET unique_chatters = (SELECT COUNT(*) FROM user_streams WHERE stream_id = ? AND messages > 0) WHERE id = ? `), recentChat: db.prepare( 'SELECT ts, text, user_id FROM chat_messages WHERE ts >= ? ORDER BY ts' ), insertClip: db.prepare(` INSERT INTO clips (id, stream_id, created_at, trigger, trigger_score, source, status) VALUES (?,?,?,?,?,?,?) `), getClip: db.prepare('SELECT * FROM clips WHERE id = ?'), clipsOfStream: db.prepare( 'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC' ), insertPost: db.prepare(` INSERT INTO posts (id, stream_id, kind, platform, mode, created_at, status, title, category, url, detail) VALUES (?,?,?,?,?,?,?,?,?,?,?) `), postFor: db.prepare(` SELECT * FROM posts WHERE stream_id IS ? AND kind = ? AND platform = ? AND status = 'gesendet' LIMIT 1 `), lastPost: db.prepare(` SELECT * FROM posts WHERE kind = ? AND platform = ? AND status = 'gesendet' ORDER BY created_at DESC LIMIT 1 `), stats: db.prepare(` SELECT (SELECT COUNT(*) FROM chat_messages) AS messages, (SELECT COUNT(*) FROM users) AS users, (SELECT COUNT(*) FROM streams) AS streams, (SELECT COUNT(*) FROM events) AS events `), }; return { event(type: EventType, payload: unknown, streamId: string | null = null, userId: string | null = null, ts = new Date().toISOString()): StreamEvent { const ev: StreamEvent = { id: ulid(), ts, type, streamId, userId, payload }; stmt.insertEvent.run(ev.id, ev.ts, ev.type, ev.streamId, ev.userId, JSON.stringify(payload)); return ev; }, /** Schreibt Nutzer, Nachricht und Anwesenheit in einer Transaktion. */ recordMessage(msg: ChatMessage, streamId: string | null): { streamsChatted: number } { db.exec('BEGIN'); try { stmt.upsertUser.run(msg.userId, msg.login, msg.displayName, msg.ts, msg.ts); stmt.bumpUserMessage.run(msg.ts, msg.userId); stmt.insertChat.run( msg.id, streamId, msg.ts, msg.userId, msg.text, JSON.stringify(msg.emotes), msg.isFirst ? 1 : 0, msg.isCommand ? 1 : 0, msg.bits ); if (streamId) { stmt.touchUserStream.run(msg.userId, streamId, msg.ts, msg.ts, 1); stmt.recountChatters.run(streamId, streamId); } db.exec('COMMIT'); } catch (err) { db.exec('ROLLBACK'); throw err; } const row = stmt.countUserStreams.get(msg.userId) as { n: number } | undefined; return { streamsChatted: row?.n ?? 0 }; }, openStream(id: string, startedAt: string): void { stmt.openStream.run(id, startedAt); }, closeStream(id: string, endedAt: string): void { stmt.closeStream.run(endedAt, id); }, getStream(id: string) { return stmt.getStream.get(id) as StreamRow | undefined; }, latestOpenStream() { return stmt.latestOpenStream.get() as StreamRow | undefined; }, sample(streamId: string, ts: string, viewers: number): void { stmt.sample.run(streamId, ts, viewers); stmt.bumpPeak.run(viewers, streamId, viewers); }, recentChat(sinceIso: string) { return stmt.recentChat.all(sinceIso) as Array<{ ts: string; text: string; user_id: string }>; }, stats() { return stmt.stats.get() as { messages: number; users: number; streams: number; events: number }; }, insertClip(c: { id: string; streamId: string | null; trigger: string; triggerScore: number | null; source: string; status: string; }): void { stmt.insertClip.run(c.id, c.streamId, new Date().toISOString(), c.trigger, c.triggerScore, c.source, c.status); }, /** Aktualisiert einzelne Spalten eines Clips. Nur bekannte Spalten sind erlaubt. */ updateClip(id: string, fields: Partial>): void { const keys = Object.keys(fields).filter((k): k is ClipColumn => (CLIP_COLUMNS as readonly string[]).includes(k)); if (!keys.length) return; const set = keys.map((k) => `${k} = ?`).join(', '); db.prepare(`UPDATE clips SET ${set} WHERE id = ?`) .run(...keys.map((k) => fields[k] ?? null), id); }, /** * Haelt fest, was rausgegangen ist. Wirft nicht, wenn der eindeutige Index * zuschlaegt - dann war jemand anders schneller, und genau das soll er. */ insertPost(p: { streamId: string | null; kind: string; platform: string; mode: string; status: string; title?: string | null; category?: string | null; url?: string | null; detail?: string | null; }): string | null { const id = ulid(); try { stmt.insertPost.run(id, p.streamId, p.kind, p.platform, p.mode, new Date().toISOString(), p.status, p.title ?? null, p.category ?? null, p.url ?? null, p.detail ?? null); return id; } catch (err) { if (/UNIQUE/i.test((err as Error).message)) return null; throw err; } }, /** Gab es fuer diesen Stream schon einen gesendeten Post dieser Art? */ postFor(streamId: string | null, kind: string, platform: string) { return stmt.postFor.get(streamId, kind, platform) as PostRow | undefined; }, /** Der letzte gesendete Post dieser Art - fuer die Abklingzeit. */ lastPost(kind: string, platform: string) { return stmt.lastPost.get(kind, platform) as PostRow | undefined; }, getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; }, clipsOfStream(streamId: string | null) { return stmt.clipsOfStream.all(streamId) as ClipRow[]; }, }; } export type Repos = ReturnType;