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:
parent
5ad1d19c3a
commit
816f527592
4 changed files with 354 additions and 79 deletions
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
{ 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<void> {
|
|||
initConfigService()
|
||||
}
|
||||
|
||||
async function initDB(): Promise<void> {
|
||||
initDatabase()
|
||||
}
|
||||
|
||||
async function initLicense(): Promise<void> {
|
||||
initLicenseService()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue