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

@ -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<void> {
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<void> {
// 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<void> {
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<void> {
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) ────────────────────────────