Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
75 lines
4.2 KiB
JavaScript
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();
|