비즈니스 모델 수정: - 무료 로컬 모드 = 사용자 onboarding funnel 입구 (킬러 피처, 회원가입 0) - 유료 클라우드 SaaS = 수익화 (OAuth 로그인 + pro + premium 모델 + sync) - 이전 세션 SaaS [2] OAuth 강제 게이트 철회 db/index.ts: - LOCAL_USER_ID = '_local' 상수 export - openLocal() 헬퍼 — userData/users/_local/d3ro.db - isLocalMode() 헬퍼 bootstrap.ts: - database step 복원 (critical). openLocal() 호출로 앱 시작 시 로컬 DB 자동 오픈 — 로그인 없이 즉시 메인 UI 진입 가능 CloudSyncService._onSignOut(): - closeCurrent() 후 openLocal() 호출 — 로그아웃 시 로컬 모드 DB로 복귀 - 앱은 계속 로컬 모드로 동작 (entry point 철학) App.tsx: - AuthGate 상태머신 제거 (loading/login-required/authenticated/legacy) - LoginScreen import 제거, 항상 AppLayout 렌더 - LoginScreen.tsx 파일은 유지 (추후 Welcome 화면 재활용 가능) 검증: - desktop tsc --noEmit ✅ - desktop build ✅ - dev 런타임: [bootstrap] local database opened → Main window shown - users/_local/d3ro.db 파일 생성 확인
409 lines
15 KiB
TypeScript
409 lines
15 KiB
TypeScript
// 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<typeof schema> | 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<typeof schema> {
|
|
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
|
|
}
|