Files
AriWeb-Suite/lib/db.mjs

75 lines
4.2 KiB
JavaScript

/**
* 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();