import Database from 'better-sqlite3'; import { randomUUID } from 'crypto'; import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { DATA_DIR } from './data-dir.js'; import type { Job, JobStatus, AudioMode, PushSubscription } from '$lib/types.js'; /** * Add a column to a table if it does not already exist. * Uses PRAGMA table_info for an existence check, consistent with the * existing CREATE TABLE IF NOT EXISTS idiom — no migration tool introduced. */ function addColumnIfMissing(table: string, column: string, typeDef: string): void { const columns = db.pragma(`table_info(${table})`) as { name: string }[]; if (!columns.some((c) => c.name === column)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${typeDef}`); } } if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); const db = new Database(join(DATA_DIR, 'jobs.db')); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', title TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '', audio_mode TEXT NOT NULL DEFAULT 'auto', mean_volume REAL, whisper_job_id TEXT, progress INTEGER NOT NULL DEFAULT 0, output_dir TEXT, segments_json TEXT, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); // Add retry columns idempotently (may already exist on restart) try { addColumnIfMissing('jobs', 'retry_count', 'INTEGER DEFAULT 0'); addColumnIfMissing('jobs', 'next_retry_at', 'TEXT'); } catch (e) { console.warn('[db] failed to add retry columns:', e); } const stmts = { insertJob: db.prepare(` INSERT INTO jobs (id, status, title, source, audio_mode) VALUES (@id, 'pending', @title, @source, @audioMode) `), updateJob: db.prepare(` UPDATE jobs SET status = @status, title = @title, mean_volume = @meanVolume, whisper_job_id = @whisperJobId, progress = @progress, output_dir = @outputDir, segments_json = @segmentsJson, error = @error, retry_count = @retryCount, next_retry_at = @nextRetryAt, updated_at = datetime('now') WHERE id = @id `), setStatus: db.prepare(` UPDATE jobs SET status = @status, progress = @progress, updated_at = datetime('now') WHERE id = @id `), resetJob: db.prepare(` UPDATE jobs SET status = 'pending', progress = 0, error = NULL, mean_volume = NULL, whisper_job_id = NULL, output_dir = NULL, segments_json = NULL, retry_count = 0, next_retry_at = NULL, updated_at = datetime('now') WHERE id = @id `), deleteJob: db.prepare('DELETE FROM jobs WHERE id = ?'), getJob: db.prepare('SELECT * FROM jobs WHERE id = ?'), listJobs: db.prepare('SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT 100'), upsertSub: db.prepare(` INSERT INTO push_subscriptions (endpoint, p256dh, auth) VALUES (@endpoint, @p256dh, @auth) ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth `), allSubs: db.prepare('SELECT * FROM push_subscriptions'), deleteSub: db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?') }; function rowToJob(row: Record): Job { return { id: row.id as string, status: row.status as JobStatus, title: row.title as string, source: row.source as string, audioMode: row.audio_mode as AudioMode, meanVolume: row.mean_volume as number | null, whisperJobId: row.whisper_job_id as string | null, progress: row.progress as number, outputDir: row.output_dir as string | null, segmentsJson: row.segments_json as string | null, error: row.error as string | null, retryCount: row.retry_count as number | null, nextRetryAt: row.next_retry_at as string | null, createdAt: row.created_at as string, updatedAt: row.updated_at as string }; } export function createJob(source: string, title: string, audioMode: AudioMode): Job { const id = randomUUID(); stmts.insertJob.run({ id, title, source, audioMode }); return getJob(id)!; } export function getJob(id: string): Job | null { const row = stmts.getJob.get(id) as Record | undefined; return row ? rowToJob(row) : null; } export function listJobs(): Job[] { return (stmts.listJobs.all() as Record[]).map(rowToJob); } export function updateJob(job: Partial & { id: string }): void { const current = getJob(job.id); if (!current) return; const merged = { ...current, ...job }; stmts.updateJob.run({ id: merged.id, status: merged.status, title: merged.title, meanVolume: merged.meanVolume, whisperJobId: merged.whisperJobId, progress: merged.progress, outputDir: merged.outputDir, segmentsJson: merged.segmentsJson, error: merged.error, retryCount: merged.retryCount ?? null, nextRetryAt: merged.nextRetryAt ?? null }); } export function setJobStatus(id: string, status: JobStatus, progress = 0): void { stmts.setStatus.run({ id, status, progress }); } export function savePushSubscription(sub: { endpoint: string; p256dh: string; auth: string }): void { stmts.upsertSub.run(sub); } export function getAllSubscriptions(): PushSubscription[] { return stmts.allSubs.all() as PushSubscription[]; } export function deletePushSubscription(endpoint: string): void { stmts.deleteSub.run(endpoint); } export function resetJob(id: string): void { stmts.resetJob.run({ id }); } export function deleteJob(id: string): void { stmts.deleteJob.run(id); }