/** * lib/db.mjs — SQLite 저장소 공통 계층 (node:sqlite, 외부 의존성 없음). * * 목적 * HAL·포털·Agent 가 모두 같은 방식으로 DB 를 열고 마이그레이션을 적용하게 한다. * - WAL 모드: 읽기와 쓰기가 서로 막지 않아 화면 조회 중 작업 엔진이 기록해도 된다. * - foreign_keys ON: 그룹·사이트·작업 사이의 참조 무결성을 DB 가 지킨다. * - busy_timeout: 짧은 잠금 경쟁(두 요청이 동시에 예약을 시도)은 기다렸다가 처리한다(§13.3 동시 요청). * * 사용 * const store = openDb('/srv/ariweb/portal/data/portal.sqlite', [{ id: '0001_init', sql: '...' }]); * store.get('SELECT * FROM users WHERE id=?', id) → 한 행 또는 undefined * store.all(...) → 행 배열 * store.run(...) → { changes, lastInsertRowid } * store.tx(() => { ... }) → BEGIN IMMEDIATE … COMMIT (예외 시 ROLLBACK). 중첩되면 바깥 트랜잭션에 합류한다. * * 마이그레이션 * `_migrations` 표에 적용한 id 를 남기고, 아직 없는 것만 순서대로 적용한다. 각 모듈(lib/session·jobs·quota·notify·audit)은 * 자기 표의 마이그레이션을 `MIGRATIONS` 로 내보내고, 각 프로세스의 schema 파일이 그것을 모아 openDb 에 넘긴다. */ import { DatabaseSync } from 'node:sqlite'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; /** * DB 를 열고 마이그레이션을 적용한다. * @param {string} file 파일 경로. ':memory:' 면 메모리 DB(테스트). * @param {Array<{id:string, sql:string}>} migrations 적용할 마이그레이션(순서대로) * @returns {{db: DatabaseSync, get: Function, all: Function, run: Function, exec: Function, tx: Function, close: Function, file: string}} */ export function openDb(file, migrations = []) { if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true }); const db = new DatabaseSync(file); // 운영 안정성 설정. WAL 은 파일 DB 에서만 의미가 있다. db.exec('PRAGMA foreign_keys = ON'); if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); } db.exec('PRAGMA busy_timeout = 5000'); db.exec('CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)'); const applied = new Set(db.prepare('SELECT id FROM _migrations').all().map((r) => r.id)); for (const m of migrations) { if (applied.has(m.id)) continue; // 한 마이그레이션은 한 트랜잭션: 중간에 실패하면 전부 되돌려 반쪽 스키마가 남지 않게 한다. db.exec('BEGIN'); try { db.exec(m.sql); db.prepare('INSERT INTO _migrations(id, applied_at) VALUES(?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 (${m.id}): ${e.message}`); } } let depth = 0; // 중첩 tx 깊이 — 안쪽 tx 는 BEGIN 을 다시 하지 않는다 const store = { db, file, get: (sql, ...p) => db.prepare(sql).get(...p), all: (sql, ...p) => db.prepare(sql).all(...p), run: (sql, ...p) => db.prepare(sql).run(...p), exec: (sql) => db.exec(sql), /** * 즉시 잠금 트랜잭션. 쓰기 경쟁(예약·주문 중복)을 DB 수준에서 직렬화한다. * 동기 함수만 받는다(node:sqlite 는 동기 API 라 await 사이에 잠금을 쥐고 있으면 안 된다). */ tx(fn) { if (depth > 0) return fn(store); db.exec('BEGIN IMMEDIATE'); depth++; try { const r = fn(store); db.exec('COMMIT'); depth--; return r; } catch (e) { depth--; try { db.exec('ROLLBACK'); } catch (_) {} throw e; } }, close: () => db.close(), }; return store; } /** JSON 열 읽기 도우미: 깨진 값은 fallback(화면이 깨지지 않게). */ export function parseJson(text, fallback = null) { if (text == null || text === '') return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } } /** JSON 열 쓰기 도우미 */ export const toJson = (v) => (v === undefined ? null : JSON.stringify(v)); /** 현재 시각(ISO, UTC) */ export const nowIso = () => new Date().toISOString();