/** * lib/db.ts * Singleton libSQL (SQLite) client. Call getDb() anywhere on the server. * * WAL journal mode is set for better concurrency. * On first boot, existing meta.json / users.json are imported and renamed * to *.migrated so the one-time import never runs twice. */ import { createClient, type Client } from '@libsql/client' import path from 'path' import fs from 'fs' const DATA_DIR = path.join(process.cwd(), 'data') const DB_URL = `file:${path.join(DATA_DIR, 'backerup.db')}` let _client: Client | null = null export function getDb(): Client { if (_client) return _client fs.mkdirSync(DATA_DIR, { recursive: true }) _client = createClient({ url: DB_URL }) // Fire-and-forget: migrations run before first real query via ensureReady() return _client } /** Call once at startup (or await before first query if needed). */ let _ready: Promise | null = null export function ensureReady(): Promise { if (_ready) return _ready _ready = (async () => { const db = getDb() await applyMigrations(db) await importLegacyJson(db) })() return _ready } // ── Schema ──────────────────────────────────────────────────────────────────── async function applyMigrations(db: Client): Promise { await db.executeMultiple(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA synchronous = NORMAL; CREATE TABLE IF NOT EXISTS files ( token TEXT PRIMARY KEY, original_name TEXT NOT NULL, display_name TEXT, size INTEGER NOT NULL, type TEXT NOT NULL, uploaded_at TEXT NOT NULL, description TEXT, group_name TEXT, container_name TEXT, image TEXT ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, salt TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL ); `) } // ── One-time JSON → SQLite import ───────────────────────────────────────────── async function importLegacyJson(db: Client): Promise { const metaPath = path.join(DATA_DIR, 'meta.json') if (fs.existsSync(metaPath)) { try { const { rows } = await db.execute('SELECT COUNT(*) as cnt FROM files') if ((rows[0].cnt as number) === 0) { type OldRecord = { token: string; originalName: string; displayName?: string; size: number type: string; uploadedAt: string; description?: string; group?: string containerName?: string; image?: string } const records = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as OldRecord[] for (const r of records) { await db.execute({ sql: `INSERT OR IGNORE INTO files (token,original_name,display_name,size,type,uploaded_at,description,group_name,container_name,image) VALUES (?,?,?,?,?,?,?,?,?,?)`, args: [r.token, r.originalName, r.displayName ?? null, r.size, r.type, r.uploadedAt, r.description ?? null, r.group ?? null, r.containerName ?? null, r.image ?? null], }) } fs.renameSync(metaPath, metaPath + '.migrated') console.log(`[db] Imported ${records.length} file records from meta.json`) } } catch (e) { console.error('[db] Failed to import meta.json:', e) } } const usersPath = path.join(DATA_DIR, 'users.json') if (fs.existsSync(usersPath)) { try { const { rows } = await db.execute('SELECT COUNT(*) as cnt FROM users') if ((rows[0].cnt as number) === 0) { type OldUser = { id: string; username: string; passwordHash: string; salt: string role: string; createdAt: string } const users = JSON.parse(fs.readFileSync(usersPath, 'utf-8')) as OldUser[] for (const u of users) { await db.execute({ sql: `INSERT OR IGNORE INTO users (id,username,password_hash,salt,role,created_at) VALUES (?,?,?,?,?,?)`, args: [u.id, u.username, u.passwordHash, u.salt, u.role, u.createdAt], }) } fs.renameSync(usersPath, usersPath + '.migrated') console.log(`[db] Imported ${users.length} users from users.json`) } } catch (e) { console.error('[db] Failed to import users.json:', e) } } }