diff --git a/src/main/infra/db.test.ts b/src/main/infra/db.test.ts new file mode 100644 index 0000000..a609eb2 --- /dev/null +++ b/src/main/infra/db.test.ts @@ -0,0 +1,87 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { openDatabase, type DbHandle } from './db'; + +let tmpDir: string; +let db: DbHandle | null = null; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-')); +}); +afterEach(() => { + try { db?.close(); } catch { /* ignore */ } + db = null; + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('openDatabase', () => { + test('creates a new file', () => { + const target = path.join(tmpDir, 'a.db'); + db = openDatabase(target); + expect(fs.existsSync(target)).toBe(true); + expect(typeof db.run).toBe('function'); + expect(typeof db.get).toBe('function'); + expect(typeof db.all).toBe('function'); + expect(typeof db.close).toBe('function'); + expect(typeof db.transaction).toBe('function'); + expect(typeof db.runBatch).toBe('function'); + }); + + test('schema_meta row exists with schema_version=5', () => { + db = openDatabase(path.join(tmpDir, 'b.db')); + const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']); + expect(row?.value).toBe('5'); + }); + + test('WAL mode active', () => { + db = openDatabase(path.join(tmpDir, 'c.db')); + const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode'); + expect(row?.journal_mode).toBe('wal'); + }); + + test('idempotent open: existing file keeps schema_version=5', () => { + const target = path.join(tmpDir, 'd.db'); + db = openDatabase(target); + db.close(); + db = openDatabase(target); + const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']); + expect(row?.value).toBe('5'); + }); + + test('run + get + all roundtrip on downloaded_vods', () => { + db = openDatabase(path.join(tmpDir, 'e.db')); + db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']); + db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']); + const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']); + expect(one?.vod_id).toBe('1234'); + const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id'); + expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']); + }); + + test('transaction commits as bracket', () => { + db = openDatabase(path.join(tmpDir, 'f.db')); + const handle = db; + const inserted = handle.transaction(() => { + handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t1']); + handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t2']); + return 2; + }); + expect(inserted).toBe(2); + const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods'); + expect(c?.c).toBe(2); + }); + + test('transaction rolls back on throw', () => { + db = openDatabase(path.join(tmpDir, 'g.db')); + const handle = db; + expect(() => { + handle.transaction(() => { + handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['x1']); + throw new Error('boom'); + }); + }).toThrow('boom'); + const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods'); + expect(c?.c).toBe(0); + }); +}); diff --git a/src/main/infra/db.ts b/src/main/infra/db.ts new file mode 100644 index 0000000..4b75a49 --- /dev/null +++ b/src/main/infra/db.ts @@ -0,0 +1,60 @@ +import Database, { type Database as DatabaseT } from 'better-sqlite3'; +import { SCHEMA_V5_SQL } from './schema-v5'; + +/** + * Public DB-Handle. Schmaler Wrapper um better-sqlite3. + */ +export interface DbHandle { + run(sql: string, params?: unknown[]): void; + get(sql: string, params?: unknown[]): T | undefined; + all(sql: string, params?: unknown[]): T[]; + transaction(fn: () => R): R; + runBatch(sql: string): void; + close(): void; + readonly raw: DatabaseT; +} + +function splitStatements(sql: string): string[] { + return sql + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0); +} + +function runMultiStatement(db: DatabaseT, sql: string): void { + for (const stmt of splitStatements(sql)) { + db.prepare(stmt).run(); + } +} + +export function openDatabase(filePath: string): DbHandle { + const db = new Database(filePath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + db.pragma('foreign_keys = ON'); + + runMultiStatement(db, SCHEMA_V5_SQL); + + const handle: DbHandle = { + run(sql, params) { + db.prepare(sql).run(...(params ?? []) as unknown[]); + }, + get(sql: string, params?: unknown[]): T | undefined { + return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined; + }, + all(sql: string, params?: unknown[]): T[] { + return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[]; + }, + transaction(fn: () => R): R { + return db.transaction(fn)(); + }, + runBatch(sql) { + runMultiStatement(db, sql); + }, + close() { + db.close(); + }, + get raw() { return db; }, + }; + return handle; +}