S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme
Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID, Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume, bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert. Siehe ADR-0005. Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und die Ausfuehrung von TypeScript mit. Das Projekt hat damit null Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001. Enthalten: - packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML - packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort - packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC - apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung - 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht) Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
4ceb61f095
commit
d07c18441b
82 files changed
+2775
-838
No files matched your search
@@ -1,2 +1,63 @@
|
||||
// @abdulive/db — siehe docs/03-data-model.md
|
||||
export {};
|
||||
// SQLite ueber das eingebaute node:sqlite - keine native Abhaengigkeit,
|
||||
// kein node-gyp, kein Build. Siehe ADR-0002.
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { paths } from '#core/config.ts';
|
||||
import { logger } from '#core/logger.ts';
|
||||
|
||||
const log = logger('db');
|
||||
const MIGRATIONS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations');
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
export function openDb(file = path.join(paths.db, 'abdulive.db')): Db {
|
||||
if (db) return db;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const handle = new DatabaseSync(file);
|
||||
|
||||
// WAL erlaubt beliebig viele Leser neben einem Schreiber - genau unsere
|
||||
// Topologie. busy_timeout faengt die kurzen Ueberschneidungen ab.
|
||||
handle.exec('PRAGMA journal_mode = WAL');
|
||||
handle.exec('PRAGMA busy_timeout = 5000');
|
||||
handle.exec('PRAGMA foreign_keys = ON');
|
||||
handle.exec('PRAGMA synchronous = NORMAL');
|
||||
|
||||
migrate(handle);
|
||||
db = handle;
|
||||
return db;
|
||||
}
|
||||
|
||||
export function migrate(handle: Db): void {
|
||||
handle.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||
const applied = new Set(
|
||||
(handle.prepare('SELECT name FROM schema_migrations').all() as Array<{ name: string }>)
|
||||
.map((r) => r.name)
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(MIGRATIONS).filter((f) => f.endsWith('.sql')).sort();
|
||||
for (const name of files) {
|
||||
if (applied.has(name)) continue;
|
||||
const sql = fs.readFileSync(path.join(MIGRATIONS, name), 'utf8');
|
||||
handle.exec('BEGIN');
|
||||
try {
|
||||
handle.exec(sql);
|
||||
handle.prepare('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)')
|
||||
.run(name, new Date().toISOString());
|
||||
handle.exec('COMMIT');
|
||||
log.info(`Migration angewendet: ${name}`);
|
||||
} catch (err) {
|
||||
handle.exec('ROLLBACK');
|
||||
throw new Error(`Migration ${name} fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
db?.close();
|
||||
db = null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ChatMessage } from '#shared/events.ts';
|
||||
import { migrate } from './index.ts';
|
||||
import { repos } from './repos.ts';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
function freshDb() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-test-'));
|
||||
const db = new DatabaseSync(path.join(dir, 't.db'));
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
migrate(db);
|
||||
return { db, repo: repos(db), dir };
|
||||
}
|
||||
|
||||
function msg(over: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: `m-${Math.random().toString(36).slice(2)}`,
|
||||
ts: '2026-09-09T20:00:00.000Z',
|
||||
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
|
||||
text: 'moin', emotes: [], isFirst: false, isReturning: false,
|
||||
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
|
||||
bits: 0, color: null, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('legt Nutzer an und zaehlt Nachrichten', () => {
|
||||
const { db, repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
repo.recordMessage(msg(), 's1');
|
||||
repo.recordMessage(msg({ text: 'zweite' }), 's1');
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('42') as
|
||||
{ message_count: number; login: string; first_seen_at: string };
|
||||
assert.equal(user.message_count, 2);
|
||||
assert.equal(user.login, 'maxi_2k');
|
||||
const n = db.prepare('SELECT COUNT(*) AS n FROM chat_messages').get() as { n: number };
|
||||
assert.equal(n.n, 2);
|
||||
});
|
||||
|
||||
test('Namenswechsel erhaelt die Historie', () => {
|
||||
const { db, repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
repo.recordMessage(msg(), 's1');
|
||||
// Gleiche user-id, neuer Login - so sieht eine Twitch-Namensaenderung aus
|
||||
repo.recordMessage(msg({ login: 'maxi_neu', displayName: 'Maxi_Neu' }), 's1');
|
||||
|
||||
const rows = db.prepare('SELECT * FROM users').all() as Array<{ login: string; message_count: number }>;
|
||||
assert.equal(rows.length, 1, 'darf keinen zweiten Nutzer angelegt haben');
|
||||
assert.equal(rows[0]!.login, 'maxi_neu');
|
||||
assert.equal(rows[0]!.message_count, 2);
|
||||
});
|
||||
|
||||
test('zaehlt Streams, an denen jemand geschrieben hat', () => {
|
||||
const { repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
repo.openStream('s2', '2026-09-10T19:00:00.000Z');
|
||||
assert.equal(repo.recordMessage(msg(), 's1').streamsChatted, 1);
|
||||
assert.equal(repo.recordMessage(msg(), 's1').streamsChatted, 1);
|
||||
assert.equal(repo.recordMessage(msg(), 's2').streamsChatted, 2);
|
||||
});
|
||||
|
||||
test('doppelte Nachrichten-IDs werden nicht zweimal gespeichert', () => {
|
||||
const { db, repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
const m = msg({ id: 'immer-gleich' });
|
||||
repo.recordMessage(m, 's1');
|
||||
repo.recordMessage(m, 's1');
|
||||
const n = db.prepare('SELECT COUNT(*) AS n FROM chat_messages').get() as { n: number };
|
||||
assert.equal(n.n, 1);
|
||||
});
|
||||
|
||||
test('Zuschauerzahlen setzen den Hoechststand', () => {
|
||||
const { repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
repo.sample('s1', '2026-09-09T19:01:00.000Z', 8);
|
||||
repo.sample('s1', '2026-09-09T19:02:00.000Z', 17);
|
||||
repo.sample('s1', '2026-09-09T19:03:00.000Z', 11);
|
||||
assert.equal(repo.getStream('s1')?.peak_viewers, 17);
|
||||
});
|
||||
|
||||
test('offene Sitzung wird gefunden und geschlossen', () => {
|
||||
const { repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
assert.equal(repo.latestOpenStream()?.id, 's1');
|
||||
repo.closeStream('s1', '2026-09-09T22:00:00.000Z');
|
||||
assert.equal(repo.latestOpenStream(), undefined);
|
||||
});
|
||||
|
||||
test('erneutes openStream ueberschreibt eine laufende Sitzung nicht', () => {
|
||||
const { repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
repo.openStream('s1', '2026-09-09T21:00:00.000Z');
|
||||
assert.equal(repo.getStream('s1')?.started_at, '2026-09-09T19:00:00.000Z');
|
||||
});
|
||||
|
||||
test('Nachrichten ohne laufenden Stream gehen trotzdem nicht verloren', () => {
|
||||
const { db, repo } = freshDb();
|
||||
repo.recordMessage(msg(), null);
|
||||
const row = db.prepare('SELECT stream_id FROM chat_messages').get() as { stream_id: string | null };
|
||||
assert.equal(row.stream_id, null);
|
||||
});
|
||||
|
||||
test('unique_chatters zaehlt Personen, nicht Nachrichten', () => {
|
||||
const { repo } = freshDb();
|
||||
repo.openStream('s1', '2026-09-09T19:00:00.000Z');
|
||||
repo.recordMessage(msg({ userId: '1', login: 'a' }), 's1');
|
||||
repo.recordMessage(msg({ userId: '1', login: 'a' }), 's1');
|
||||
repo.recordMessage(msg({ userId: '2', login: 'b' }), 's1');
|
||||
assert.equal(repo.getStream('s1')?.unique_chatters, 2);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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';
|
||||
|
||||
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'
|
||||
),
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Repos = ReturnType<typeof repos>;
|
||||
Reference in new issue
Block a user