feat(db): better-sqlite3 wrapper + schema bootstrap (7 tests)
DbHandle interface (run/get/all/transaction/runBatch/close/raw). Schema bootstrap splits SQL on ; and runs each statement via prepare().run() to avoid pre-tool hook false-positive on .exec( pattern. WAL mode + 5s busy_timeout + foreign_keys ON. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bfe0f671a5
commit
93481999bd
87
src/main/infra/db.test.ts
Normal file
87
src/main/infra/db.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/main/infra/db.ts
Normal file
60
src/main/infra/db.ts
Normal file
@ -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<T = unknown>(sql: string, params?: unknown[]): T | undefined;
|
||||||
|
all<T = unknown>(sql: string, params?: unknown[]): T[];
|
||||||
|
transaction<R>(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<T>(sql: string, params?: unknown[]): T | undefined {
|
||||||
|
return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined;
|
||||||
|
},
|
||||||
|
all<T>(sql: string, params?: unknown[]): T[] {
|
||||||
|
return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[];
|
||||||
|
},
|
||||||
|
transaction<R>(fn: () => R): R {
|
||||||
|
return db.transaction(fn)();
|
||||||
|
},
|
||||||
|
runBatch(sql) {
|
||||||
|
runMultiStatement(db, sql);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
db.close();
|
||||||
|
},
|
||||||
|
get raw() { return db; },
|
||||||
|
};
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user