From 816f52759225c60f058c6d1d48e2d0d054780a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 09:58:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20SaaS=20[3]=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EB=B3=84=20SQLite=20=EA=B2=A9=EB=A6=AC=20(?= =?UTF-8?q?=EB=B9=85=EB=B1=85=20Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/src/main/bootstrap.ts | 9 +- apps/desktop/src/main/db/index.ts | 227 +++++++++++++++--- .../src/main/services/CloudSyncService.ts | 153 +++++++++--- memory/project_status.md | 44 +++- 4 files changed, 354 insertions(+), 79 deletions(-) diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index aabce21..81a16b8 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -15,7 +15,6 @@ import { getSoundEffectService } from './services/SoundEffectService' import { getAutoLaunchService } from './services/AutoLaunchService' import { getAudioCaptureService } from './services/AudioCaptureService' import { initLicenseService } from './services/LicenseService' -import { initDatabase } from './db' import { createMainWindow, getMainWindow, @@ -46,7 +45,8 @@ export async function bootstrap(): Promise { const steps: BootstrapStep[] = [ { name: 'logger', critical: false, fn: initLogger }, { name: 'config', critical: false, fn: initConfig }, - { name: 'database', critical: true, fn: initDB }, + // Phase 1 (SaaS 빅뱅): DB는 더 이상 bootstrap 타이밍에 열지 않는다. + // CloudSyncService._onAuthenticated(session)가 사용자별 DB를 연다. { name: 'license', critical: false, fn: initLicense }, { name: 'create-windows', critical: true, fn: createWindows }, { name: 'tray', critical: false, fn: initTray }, @@ -61,6 +61,7 @@ export async function bootstrap(): Promise { { name: 'llm-polling', critical: false, fn: initLLMPolling }, { name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring }, { name: 'meeting-mode', critical: false, fn: initMeetingMode }, + // cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit. { name: 'cloud-sync', critical: false, fn: initCloudSync }, ] @@ -90,10 +91,6 @@ async function initConfig(): Promise { initConfigService() } -async function initDB(): Promise { - initDatabase() -} - async function initLicense(): Promise { initLicenseService() } diff --git a/apps/desktop/src/main/db/index.ts b/apps/desktop/src/main/db/index.ts index 29a26bf..7676158 100644 --- a/apps/desktop/src/main/db/index.ts +++ b/apps/desktop/src/main/db/index.ts @@ -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 | null = null +// ───────────────────────────────────────────────────────────── +// 모듈 상태 +// ───────────────────────────────────────────────────────────── + +let currentUserId: string | null = null let sqlite: Database.Database | null = null +let db: BetterSQLite3Database | null = null -export function initDatabase(): BetterSQLite3Database { - 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 { 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 { 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 { 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 { 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 { 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 } diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 0d1d3f5..049135e 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -14,7 +14,7 @@ import { import { eq, gt } from 'drizzle-orm' import { getLogger } from './LoggerService' import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService' -import { getDatabase } from '../db' +import { getDatabase, openForUser, closeCurrent } from '../db' import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -90,6 +90,8 @@ class CloudSyncService extends EventEmitter { } }) + this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null + // 저장된 refresh token 복원 const stored = this._loadStoredRefreshToken() if (stored) { @@ -99,26 +101,130 @@ class CloudSyncService extends EventEmitter { logger.warn(`Stored session refresh failed: ${error.message}`) this._clearStoredRefreshToken() } else if (data.session) { - this._session = data.session this._saveRefreshToken(data.session.refresh_token) - logger.info(`Restored session for user: ${data.session.user.email ?? data.session.user.id}`) - this.emit('auth-changed', { user: data.session.user }) + logger.info( + `Restored session for user: ${data.session.user.email ?? data.session.user.id}` + ) + // 단일 진입점으로 수렴 — DB open + auth-changed emit + Realtime 시작 + await this._onAuthenticated(data.session, { reason: 'restore' }) } } catch (err) { logger.warn(`Session restore error: ${err instanceof Error ? err.message : String(err)}`) } } - this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null + logger.info('CloudSyncService initialized') + } - // 복원된 세션이 있으면 Realtime 구독 자동 시작 - if (this._session) { - void this.startRealtime().catch((err) => { - logger.warn(`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`) - }) + // ── 인증 lifecycle 단일 진입점 ───────────────────────── + + /** + * 세션이 인증된 직후 호출되는 단일 진입점. + * - 사용자별 DB 오픈 + * - auth-changed emit (renderer 게이트 해제) + * - Realtime 구독 시작 + * init()의 세션 복원과 handleAuthCallback()의 신규 로그인 모두 여기로 수렴한다. + */ + private async _onAuthenticated( + session: Session, + opts: { reason: 'restore' | 'signin' } + ): Promise { + this._session = session + const userId = session.user.id + + // 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리) + try { + const { created, dbPath } = openForUser(userId) + logger.info( + `[auth:${opts.reason}] DB opened for ${userId}: ${dbPath} (created=${created})` + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.error(`[auth:${opts.reason}] DB open failed for ${userId}: ${message}`) + // DB를 열지 못하면 로그인 상태를 유지해서는 안 된다 — 즉시 signOut + this._session = null + this._clearStoredRefreshToken() + this.emit('sync-error', { error: `Database open failed: ${message}` }) + this.emit('auth-changed', { user: null }) + return } - logger.info('CloudSyncService initialized') + // 2) renderer 게이트 해제 — DB가 열린 뒤에 emit + this.emit('auth-changed', { user: session.user }) + + // 3) Realtime 구독 자동 시작 + void this.startRealtime().catch((err) => { + logger.warn( + `Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}` + ) + }) + } + + /** + * signOut 전 in-flight 세션/녹음을 안전하게 중단시키는 단일 진입점. + * VoiceMode/MeetingMode/Caption 진행 중이면 먼저 멈추고, 그 다음 Realtime stop, + * 그 다음 auth-changed(null) emit, 마지막으로 DB close. + */ + private async _onSignOut(): Promise { + // 1) 활성 녹음/세션 강제 종료 (DB close 전에 flush 기회 제공) + try { + const { getVoiceModeService } = await import('./VoiceModeService') + const voiceMode = getVoiceModeService() + if (voiceMode.isActive) { + voiceMode.cancelSession() + logger.info('[signOut] VoiceMode active session cancelled') + } + } catch (err) { + logger.warn( + `[signOut] VoiceMode cancel failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + + try { + const { getMeetingModeService } = await import('./MeetingModeService') + const meetingMode = getMeetingModeService() + if (meetingMode.isMeetingModeActive()) { + await meetingMode.stopRecording() + logger.info('[signOut] MeetingMode recording stopped') + } + } catch (err) { + logger.warn( + `[signOut] MeetingMode stop failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + + try { + const { getCaptionService } = await import('./CaptionService') + const captionService = getCaptionService() + await captionService.stop() + } catch (err) { + logger.warn( + `[signOut] Caption stop failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + + // 2) Realtime 구독 종료 + await this.stopRealtime() + + // 3) Supabase 세션 무효화 + if (this._client && this._session) { + try { + await this._client.auth.signOut() + } catch (err) { + logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`) + } + } + + // 4) in-memory 상태 + 저장된 토큰 clear + this._session = null + this._clearStoredRefreshToken() + + // 5) renderer에 auth-changed(null) emit — 게이트가 LoginScreen으로 복귀 + this.emit('auth-changed', { user: null }) + + // 6) DB close — 여기 이후엔 getDatabase() throw + closeCurrent() + logger.info('Signed out and database closed') } // ── 상태 조회 ────────────────────────────────────────── @@ -246,6 +352,7 @@ class CloudSyncService extends EventEmitter { /** * Deep link 콜백 처리 — code를 session으로 교환. + * 성공 시 _onAuthenticated(단일 진입점)로 수렴. */ async handleAuthCallback(code: string): Promise { if (!this._client) { @@ -260,35 +367,17 @@ class CloudSyncService extends EventEmitter { ) } - this._session = data.session this._saveRefreshToken(data.session.refresh_token) logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`) - this.emit('auth-changed', { user: data.session.user }) - // 로그인 직후 Realtime 구독 자동 시작 - void this.startRealtime().catch((err) => { - logger.warn(`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`) - }) + await this._onAuthenticated(data.session, { reason: 'signin' }) } /** - * 로그아웃 — 세션/토큰/Realtime 모두 폐기. + * 로그아웃 — 단일 진입점 _onSignOut에서 세션/DB/녹음 모두 안전하게 정리. */ async signOut(): Promise { - await this.stopRealtime() - - if (this._client && this._session) { - try { - await this._client.auth.signOut() - } catch (err) { - logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`) - } - } - - this._session = null - this._clearStoredRefreshToken() - logger.info('Signed out') - this.emit('auth-changed', { user: null }) + await this._onSignOut() } // ── 동기화 (push only, MVP) ──────────────────────────── diff --git a/memory/project_status.md b/memory/project_status.md index 19ccd50..d14fe1c 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -166,7 +166,49 @@ V2 6차 고도화 (2026-04-11): - `!saasMode` (legacy)일 때만 기존 입력 화면 유지 - 검증: 이전엔 항상 떴던 `CloudSync disabled — Supabase URL/key not configured` 로그 사라짐 -### SaaS [3] 사용자별 SQLite 격리 — **다음 세션(빅뱅) 예정** +### SaaS [3] 사용자별 SQLite 격리 — ✅ **완료 (2026-04-11, 빅뱅 Phase 1)** + +- **`apps/desktop/src/main/db/index.ts` 전면 재설계** + - 구 `initDatabase()` / `closeDatabase()` 폐기 (이름 차원에서 제거) + - 새 API: `openForUser(userId)`, `closeCurrent()`, `getDatabase()`, `getCurrentUserId()`, `isDatabaseOpen()`, `getCurrentDbPath()` + - 경로: `userData/users/${userId}/d3ro.db` (userId path-separator/.. 가드) + - `applySchema(s)`로 테이블 + 인라인 ALTER 마이그레이션 추출 — 새 DB 생성 시마다 재실행 + - 모듈 상태: `currentUserId | null`, `sqlite`, `db` — closeCurrent 후 모두 null + - 같은 userId 재호출 시 no-op, 다른 userId면 먼저 close 후 재오픈 + - `openForUser` 실패 시 부분 상태 정리 + `D3ROError(DBOpenFailed)` throw +- **legacy DB 정책** + - V1 `userData/d3ro-voice.db`는 한 번도 정식 서비스 안 됨 → `archiveLegacyDbIfExists()`로 첫 `openForUser` 직전 silent rename (`d3ro-voice.legacy-${ISO timestamp}.db`) + - 파일 삭제 없음, dialog 없음, 데이터 이전 없음 ("클린하게" 요청) +- **`CloudSyncService` 인증 lifecycle 단일 진입점** (SSOT) + - `_onAuthenticated(session, { reason: 'restore' | 'signin' })`: + 1. `this._session = session` + 2. `openForUser(userId)` — 실패 시 세션 무효화 + `auth-changed(null)` + `sync-error` emit 후 return + 3. `auth-changed(user)` emit (DB가 열린 뒤에) + 4. `startRealtime()` 자동 시작 + - `init()` 세션 복원, `handleAuthCallback(code)` 신규 로그인 모두 이 함수로 수렴 + - `_onSignOut()`: + 1. VoiceMode active → `cancelSession()` (녹음 진행 중이면 강제 취소) + 2. MeetingMode active → `stopRecording()` (await) + 3. Caption → `stop()` (await) + 4. `stopRealtime()` + 5. Supabase `auth.signOut()` + 6. `_session = null` + `_clearStoredRefreshToken()` + 7. `auth-changed(null)` emit — 렌더러 LoginScreen 복귀 + 8. `closeCurrent()` — 이 시점 이후 `getDatabase()`는 throw + - `signOut()` public 메서드는 `_onSignOut()` 위임 + - `import` 동적 변경: VoiceMode/MeetingMode/Caption 모두 dynamic import로 순환 의존 회피 +- **`bootstrap.ts` 순서 변경** + - `database` step 완전 제거 (critical 2번째였던 자리 삭제) + - `cloud-sync` step은 마지막 유지 — 세션 복원 성공 시 DB가 여기서 열림 + - `initDatabase` import 삭제 +- **검증** + - desktop `tsc --noEmit` ✅ + - desktop `npm run build` ✅ (main bundle 997kB, vite 경고는 기존 동적 import 관련 pre-existing) + - runtime (dev 띄워서 LoginScreen → OAuth → user DB 생성 확인) — 사용자 수동 검증 대기 +- **남은 작업 (Phase 1 follow-up)** + - 사용자 A → signOut → 사용자 B 로그인 시 A 데이터 안 보이는지 실측 + - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 + - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 ## 빅뱅 다음 세션 목표 (2026-04-11 wrap-up)