// src/main/db/index.ts // 사용자별 SQLite 격리 (SaaS 데스크톱 클라이언트 Phase 1) // // - 로그인 전: DB 미오픈. getDatabase() 호출 시 throw. // - 로그인 후: userData/d3ro-voice/users/${userId}/d3ro.db 로 분리. // - 로그아웃: DB close. // - CloudSyncService가 lifecycle 단일 진입점(_onAuthenticated / signOut)에서 호출. import Database from 'better-sqlite3' import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import { app } from 'electron' import fs from 'fs' import path from 'path' import * as schema from './schema' import { getLogger } from '../services/LoggerService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' const logger = getLogger('database') // ───────────────────────────────────────────────────────────── // 모듈 상태 // ───────────────────────────────────────────────────────────── /** * 로컬(익명) 모드 user id. OAuth 로그인 없이 동작하는 무료 로컬 모드의 * 데이터 디렉토리 식별자. `userData/users/_local/d3ro.db`에 저장된다. * * 비즈니스 모델 관점: * - `_local`: 무료 익명 사용자 entry point (킬러 피처 — 회원가입 0, 로컬 STT/LLM) * - 실제 uuid: OAuth 로그인 후 클라우드 sync + premium 기능 (유료 SaaS 수익화) */ export const LOCAL_USER_ID = '_local' let currentUserId: string | null = null let sqlite: Database.Database | null = null let db: BetterSQLite3Database | null = null // ───────────────────────────────────────────────────────────── // 경로 헬퍼 // ───────────────────────────────────────────────────────────── /** 사용자별 DB 파일 경로. */ function getUserDbPath(userId: string): string { // 안전 가드: userId에 path separator 들어가지 않도록 if (!userId || userId.includes('/') || userId.includes('\\') || userId.includes('..')) { throw new D3ROError(ErrorCode.DBOpenFailed, `Invalid userId for DB path: "${userId}"`) } return path.join(app.getPath('userData'), 'users', userId, 'd3ro.db') } /** 현재 모드가 익명 로컬 모드인지. */ export function isLocalMode(): boolean { return currentUserId === LOCAL_USER_ID } /** 레거시(V1) 단일 DB 파일 경로. 한 번도 정식 서비스되지 않았으므로 발견 시 silent archive. */ function getLegacyDbPath(): string { return path.join(app.getPath('userData'), 'd3ro-voice.db') } function archiveLegacyDbIfExists(): void { const legacyPath = getLegacyDbPath() if (!fs.existsSync(legacyPath)) return try { const ts = new Date() .toISOString() .replace(/[:.]/g, '-') .replace('T', '_') .replace('Z', '') const archivePath = path.join( app.getPath('userData'), `d3ro-voice.legacy-${ts}.db` ) fs.renameSync(legacyPath, archivePath) logger.info(`Legacy DB archived: ${legacyPath} -> ${archivePath}`) } catch (err) { logger.warn( `Legacy DB archive failed (leaving in place): ${err instanceof Error ? err.message : String(err)}` ) } } // ───────────────────────────────────────────────────────────── // 스키마 적용 (테이블 생성 + 인라인 ALTER 마이그레이션) // ───────────────────────────────────────────────────────────── function applySchema(s: Database.Database): void { s.pragma('journal_mode = WAL') s.pragma('foreign_keys = ON') s.pragma('busy_timeout = 5000') s.exec(` CREATE TABLE IF NOT EXISTS history ( id TEXT PRIMARY KEY, original_text TEXT NOT NULL, polished_text TEXT, focused_app TEXT, focused_app_name TEXT, focused_app_window_title TEXT, mode TEXT NOT NULL DEFAULT 'dictation', status TEXT NOT NULL DEFAULT 'completed', error_code TEXT, audio_local_path TEXT, duration REAL NOT NULL, detected_language TEXT, mic_device TEXT, word_count INTEGER NOT NULL DEFAULT 0, stt_model TEXT, llm_model TEXT, stt_latency_ms INTEGER, llm_latency_ms INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, app_version TEXT NOT NULL DEFAULT '1.0.0' ); CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC); CREATE INDEX IF NOT EXISTS idx_history_status ON history(status); CREATE INDEX IF NOT EXISTS idx_history_detected_language ON history(detected_language); CREATE INDEX IF NOT EXISTS idx_history_focused_app_name ON history(focused_app_name); CREATE INDEX IF NOT EXISTS idx_history_mode ON history(mode); CREATE TABLE IF NOT EXISTS dictionary ( id TEXT PRIMARY KEY, word TEXT NOT NULL, pronunciation TEXT, category TEXT NOT NULL DEFAULT 'user', usage_count INTEGER NOT NULL DEFAULT 0, last_used_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category); CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at); CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC); CREATE TABLE IF NOT EXISTS stats ( id INTEGER PRIMARY KEY CHECK (id = 1), total_duration REAL NOT NULL DEFAULT 0, total_words INTEGER NOT NULL DEFAULT 0, session_count INTEGER NOT NULL DEFAULT 0, streak_days INTEGER NOT NULL DEFAULT 0, last_session_at INTEGER, last_updated INTEGER NOT NULL ); INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated) VALUES (1, 0, 0, 0, 0, ${Date.now()}); CREATE TABLE IF NOT EXISTS memo_tags ( id TEXT PRIMARY KEY, history_id TEXT NOT NULL, tag TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_memo_tags_unique ON memo_tags(history_id, tag); CREATE INDEX IF NOT EXISTS idx_memo_tags_history_id ON memo_tags(history_id); CREATE INDEX IF NOT EXISTS idx_memo_tags_tag ON memo_tags(tag); CREATE TABLE IF NOT EXISTS daily_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, feature TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_usage_date_feature ON daily_usage(date, feature); CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date); `) // Phase 13.2: RAG 테이블 s.exec(` CREATE TABLE IF NOT EXISTS rag_documents ( id TEXT PRIMARY KEY, file_name TEXT NOT NULL, file_path TEXT NOT NULL, file_type TEXT NOT NULL, chunk_count INTEGER NOT NULL DEFAULT 0, indexed INTEGER NOT NULL DEFAULT 0, indexed_at INTEGER, added_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rag_documents_added_at ON rag_documents(added_at); CREATE TABLE IF NOT EXISTS rag_chunks ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL, embedding TEXT NOT NULL, chunk_index INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id); `) // Phase 14: Meeting Mode 테이블 s.exec(` CREATE TABLE IF NOT EXISTS meeting_sessions ( id TEXT PRIMARY KEY, title TEXT, status TEXT NOT NULL DEFAULT 'recording', started_at INTEGER NOT NULL, ended_at INTEGER, duration_ms INTEGER, raw_transcript TEXT, minutes_markdown TEXT, minutes_json TEXT, stt_model TEXT, llm_model TEXT, stt_latency_ms INTEGER, llm_latency_ms INTEGER, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_meeting_sessions_created_at ON meeting_sessions(created_at DESC); CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status ON meeting_sessions(status); CREATE TABLE IF NOT EXISTS meeting_memos ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, content TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_meeting_memos_session_id ON meeting_memos(session_id); CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms); `) // Phase 14.5: meeting_documents 테이블 s.exec(` CREATE TABLE IF NOT EXISTS meeting_documents ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, template_type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', prompt_used TEXT, llm_model TEXT, llm_latency_ms INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id); `) // Phase 14.5: meeting_sessions에 edited_transcript 컬럼 try { const msCols = s.pragma('table_info(meeting_sessions)') as Array<{ name: string }> if (!msCols.some((c) => c.name === 'edited_transcript')) { s.exec('ALTER TABLE meeting_sessions ADD COLUMN edited_transcript TEXT') logger.info('Migrated: added edited_transcript column to meeting_sessions') } } catch (err) { logger.warn( `edited_transcript migration check failed: ${err instanceof Error ? err.message : String(err)}` ) } // Phase 15: history 테이블에 title 컬럼 try { const histCols = s.pragma('table_info(history)') as Array<{ name: string }> if (!histCols.some((c) => c.name === 'title')) { s.exec('ALTER TABLE history ADD COLUMN title TEXT') logger.info('Migrated: added title column to history') } } catch (err) { logger.warn( `title migration check failed: ${err instanceof Error ? err.message : String(err)}` ) } // Phase 12.2: history 테이블에 summary_text 컬럼 try { const columns = s.pragma('table_info(history)') as Array<{ name: string }> const hasSummaryText = columns.some((c) => c.name === 'summary_text') if (!hasSummaryText) { s.exec('ALTER TABLE history ADD COLUMN summary_text TEXT') logger.info('Migrated: added summary_text column to history') } } catch (err) { logger.warn( `summary_text migration check failed: ${err instanceof Error ? err.message : String(err)}` ) } } // ───────────────────────────────────────────────────────────── // Public API // ───────────────────────────────────────────────────────────── export interface OpenForUserResult { created: boolean dbPath: string } /** * 사용자별 DB 열기 — userData/users/${userId}/d3ro.db * 이미 다른 사용자 DB가 열려있으면 먼저 close 후 오픈. * 동일 사용자면 no-op. */ export function openForUser(userId: string): OpenForUserResult { if (!userId) { throw new D3ROError(ErrorCode.DBOpenFailed, 'openForUser requires a userId') } if (currentUserId === userId && sqlite && db) { logger.info(`Database already open for user: ${userId}`) return { created: false, dbPath: getUserDbPath(userId) } } // 다른 사용자가 열려있으면 먼저 닫기 if (sqlite) { closeCurrent() } // 레거시 DB는 첫 오픈 직전 silent archive archiveLegacyDbIfExists() const dbPath = getUserDbPath(userId) const dirPath = path.dirname(dbPath) const existed = fs.existsSync(dbPath) if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }) } logger.info(`Opening database for user ${userId} at: ${dbPath} (existed=${existed})`) try { sqlite = new Database(dbPath) applySchema(sqlite) db = drizzle(sqlite, { schema }) currentUserId = userId } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.error(`Failed to open database for user ${userId}: ${message}`) // 부분 상태 초기화 try { sqlite?.close() } catch { // ignore } sqlite = null db = null currentUserId = null throw new D3ROError(ErrorCode.DBOpenFailed, `Failed to open user DB: ${message}`) } logger.info(`Database opened for user: ${userId}`) return { created: !existed, dbPath } } /** * 익명 로컬 모드 DB 열기. 앱 시작 시 기본으로 호출되는 entry point. * 로그인 없이 바로 동작해야 하는 킬러 피처의 데이터 저장소. * * `userData/users/_local/d3ro.db`에 저장. 로그아웃 복귀 시 같은 경로로 재오픈. */ export function openLocal(): OpenForUserResult { return openForUser(LOCAL_USER_ID) } /** * 현재 열려있는 DB 닫기. 아무 것도 안 열려 있으면 no-op. */ export function closeCurrent(): void { if (!sqlite) return const prevUserId = currentUserId try { sqlite.close() } catch (err) { logger.warn( `Database close error: ${err instanceof Error ? err.message : String(err)}` ) } sqlite = null db = null currentUserId = null logger.info(`Database closed (user was: ${prevUserId ?? 'none'})`) } export function getDatabase(): BetterSQLite3Database { if (!db) { throw new D3ROError( ErrorCode.DBOpenFailed, 'Database not initialized. Sign in first (openForUser).' ) } return db } export function getCurrentUserId(): string | null { return currentUserId } export function isDatabaseOpen(): boolean { return db !== null } /** * 테스트/개발용 — 현재 오픈된 DB 경로 조회. */ export function getCurrentDbPath(): string | null { return currentUserId ? getUserDbPath(currentUserId) : null }