feat(desktop): SaaS [3] 사용자별 SQLite 격리 (빅뱅 Phase 1)

db/index.ts 전면 재설계:
- initDatabase/closeDatabase 폐기
- openForUser(userId), closeCurrent, getDatabase, getCurrentUserId 신 API
- 경로: userData/users/${userId}/d3ro.db
- applySchema() 추출 — 새 user DB마다 테이블 + ALTER 마이그레이션 재실행
- legacy d3ro-voice.db는 silent archive (archive-legacy-ISO.db로 rename만)

CloudSyncService 인증 lifecycle SSOT:
- _onAuthenticated(session, reason) 단일 진입점
  init() 세션 복원, handleAuthCallback() 신규 로그인 모두 수렴
  DB open 실패 시 세션 롤백 + sync-error emit
- _onSignOut() 안전 종료 시퀀스
  VoiceMode cancel → MeetingMode stopRecording → Caption stop →
  Realtime stop → auth.signOut → token clear →
  auth-changed(null) emit → closeCurrent
- VoiceMode/MeetingMode/Caption은 dynamic import로 순환 의존 회피

bootstrap.ts:
- database step 제거 (DB는 _onAuthenticated에서만 열림)
- cloud-sync step이 마지막 유지 (세션 복원 → DB open → auth-changed)
This commit is contained in:
윤찬 2026-04-11 09:58:00 +09:00
parent 5ad1d19c3a
commit 816f527592
4 changed files with 354 additions and 79 deletions

View file

@ -1,30 +1,81 @@
// src/main/db/index.ts
// 설계서 03의 DB 초기화 코드
// 사용자별 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')
let db: BetterSQLite3Database<typeof schema> | null = null
// ─────────────────────────────────────────────────────────────
// 모듈 상태
// ─────────────────────────────────────────────────────────────
let currentUserId: string | null = null
let sqlite: Database.Database | null = null
let db: BetterSQLite3Database<typeof schema> | null = null
export function initDatabase(): BetterSQLite3Database<typeof schema> {
const dbPath = path.join(app.getPath('userData'), 'd3ro-voice.db')
logger.info(`Initializing database at: ${dbPath}`)
// ─────────────────────────────────────────────────────────────
// 경로 헬퍼
// ─────────────────────────────────────────────────────────────
sqlite = new Database(dbPath)
/** 사용자별 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')
}
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
sqlite.pragma('busy_timeout = 5000')
/** 레거시(V1) 단일 DB 파일 경로. 한 번도 정식 서비스되지 않았으므로 발견 시 silent archive. */
function getLegacyDbPath(): string {
return path.join(app.getPath('userData'), 'd3ro-voice.db')
}
// 테이블 생성 (마이그레이션 대신 직접 생성)
sqlite.exec(`
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,
@ -105,8 +156,8 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date);
`)
// Phase 13.2: RAG 테이블 생성
sqlite.exec(`
// Phase 13.2: RAG 테이블
s.exec(`
CREATE TABLE IF NOT EXISTS rag_documents (
id TEXT PRIMARY KEY,
file_name TEXT NOT NULL,
@ -129,8 +180,8 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id);
`)
// Phase 14: Meeting Mode 테이블 생성
sqlite.exec(`
// Phase 14: Meeting Mode 테이블
s.exec(`
CREATE TABLE IF NOT EXISTS meeting_sessions (
id TEXT PRIMARY KEY,
title TEXT,
@ -163,8 +214,8 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
`)
// Phase 14.5: meeting_documents 테이블 + edited_transcript 컬럼
sqlite.exec(`
// Phase 14.5: meeting_documents 테이블
s.exec(`
CREATE TABLE IF NOT EXISTS meeting_documents (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
@ -180,58 +231,154 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
`)
// Phase 14.5: meeting_sessions에 edited_transcript 컬럼 마이그레이션
// Phase 14.5: meeting_sessions에 edited_transcript 컬럼
try {
const msCols = sqlite.pragma('table_info(meeting_sessions)') as Array<{ name: string }>
const msCols = s.pragma('table_info(meeting_sessions)') as Array<{ name: string }>
if (!msCols.some((c) => c.name === 'edited_transcript')) {
sqlite.exec('ALTER TABLE meeting_sessions ADD COLUMN edited_transcript TEXT')
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)
logger.warn(
`edited_transcript migration check failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// Phase 15: history 테이블에 title 컬럼 마이그레이션
// Phase 15: history 테이블에 title 컬럼
try {
const histCols = sqlite.pragma('table_info(history)') as Array<{ name: string }>
const histCols = s.pragma('table_info(history)') as Array<{ name: string }>
if (!histCols.some((c) => c.name === 'title')) {
sqlite.exec('ALTER TABLE history ADD COLUMN title TEXT')
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)
logger.warn(
`title migration check failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션
// Phase 12.2: history 테이블에 summary_text 컬럼
try {
const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }>
const columns = s.pragma('table_info(history)') as Array<{ name: string }>
const hasSummaryText = columns.some((c) => c.name === 'summary_text')
if (!hasSummaryText) {
sqlite.exec('ALTER TABLE history ADD COLUMN summary_text TEXT')
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)
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')
}
db = drizzle(sqlite, { schema })
if (currentUserId === userId && sqlite && db) {
logger.info(`Database already open for user: ${userId}`)
return { created: false, dbPath: getUserDbPath(userId) }
}
logger.info('Database initialized')
return db
// 다른 사용자가 열려있으면 먼저 닫기
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 . 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 Error('Database not initialized. Call initDatabase() first.')
throw new D3ROError(
ErrorCode.DBOpenFailed,
'Database not initialized. Sign in first (openForUser).'
)
}
return db
}
export function closeDatabase(): void {
if (sqlite) {
sqlite.close()
sqlite = null
db = null
logger.info('Database closed')
}
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
}