d3ro-voice/apps/desktop/src/main/services/CloudSyncService.ts
윤찬 dc7f93349e fix(desktop): CloudSync Realtime 자동 재구독 + Refresh token 실패 재인증 알림 (U1+U3, 빅뱅 Phase 5 Part 6)
## U1 — Realtime TIMED_OUT 자동 재구독

CloudSyncService.startRealtime()의 subscribe() 콜백이 TIMED_OUT 한 번만
로깅하고 끝나던 것을 지수 백오프 재구독으로 교체.
- SUBSCRIBED: retry count reset + timer clear
- TIMED_OUT / CHANNEL_ERROR / CLOSED: _scheduleRealtimeRetry() 트리거
- 백오프: 1s → 3s → 10s (MAX 3회)
- 초과 시 warn 로깅 후 포기 (Auto push fallback으로만 동작)
- stopRealtime()에서 타이머/카운터 cleanup

Publication, RLS, setAuth 모두 설정 완료 상태에서도 매 기동 시 TIMED_OUT이
찍히는 증상. 근본 원인은 Supabase 서버 transient(네트워크/WebSocket
타임아웃) 의심. 이 패치로 완전 해결은 아니지만 일시적 네트워크 jitter
복구 윈도우를 확보하고, 3회 실패 시 명확한 포기 메시지를 남긴다.

## U3 — Refresh token 실패 시 재인증 이벤트

init()의 refreshSession 실패 브랜치에서 warn 로깅 + 토큰 삭제만 수행해
사용자가 "왜 로그아웃됐지?" 상태로 방치되던 문제.
- logger.warn → logger.error(에러 레벨 승격)
- sync-error 이벤트 emit (사용자에게 보여줄 메시지 포함)
- auth-changed { user: null } emit (renderer 게이트 재잠금)
- catch 브랜치도 동일 처리 (기존엔 로깅만)

## U4 — package-lock.json optional dep (유지 결정)

rollup 전 플랫폼 optional deps가 lock에 기록돼 있지만 Windows 빌드자
사전 fetch 효율 + 크로스플랫폼 CI 환경을 고려해 유지. apps/desktop/
package.json:56에 @rollup/rollup-win32-x64-msvc 명시 의존이 있는 이유도
동일. 이 이슈는 확인 후 close.
2026-04-11 23:24:22 +09:00

1362 lines
50 KiB
TypeScript

// src/main/services/CloudSyncService.ts
// Phase V2-4: Supabase 동기화 — OAuth 로그인, 세션 영속화, push 동기화
// Local-first 원칙: SQLite가 source of truth, Supabase는 미러
import { EventEmitter } from 'events'
import { shell, app, safeStorage } from 'electron'
import {
createClient,
type SupabaseClient,
type Session,
type User,
type RealtimeChannel
} from '@supabase/supabase-js'
import { eq, gt } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService'
import { getDatabase, openForUser, openLocal, closeCurrent } from '../db'
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LicenseTier } from '@d3ro/core/types'
const logger = getLogger('CloudSyncService')
// ============================================================
// 타입
// ============================================================
interface SyncProgress {
current: number
total: number
table: string
}
interface SyncResult {
pushed: number
errors: string[]
}
interface CloudSyncState {
authenticated: boolean
userEmail: string | null
lastSyncAt: number | null
syncing: boolean
/**
* SaaS 빌드 타임 모드: Supabase URL/Key가 빌드 시점에 박혀있는지 여부.
* true이면 사용자는 OAuth 로그인만 하면 됨 (URL/Key 입력 화면 노출 금지).
*/
saasMode: boolean
}
interface CloudSyncEvents {
'auth-changed': (payload: { user: User | null }) => void
'sync-progress': (payload: SyncProgress) => void
'sync-complete': (payload: SyncResult) => void
'sync-error': (payload: { error: string }) => void
}
// ============================================================
// CloudSyncService
// ============================================================
class CloudSyncService extends EventEmitter {
private _client: SupabaseClient | null = null
private _session: Session | null = null
private _lastSyncAt: number | null = null
private _syncing = false
/** Realtime 재구독 지수 백오프 상태 (U1) */
private _realtimeRetryCount = 0
private _realtimeRetryTimer: NodeJS.Timeout | null = null
private _initialized = false
private _realtimeChannel: RealtimeChannel | null = null
/**
* 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원.
* Supabase URL/KEY가 설정되지 않았으면 disabled 상태로 남는다.
*/
async init(): Promise<void> {
if (this._initialized) return
this._initialized = true
const url = configGet('supabaseUrl') as string | undefined
const anonKey = configGet('supabaseAnonKey') as string | undefined
if (!url || !anonKey) {
logger.info('CloudSync disabled — Supabase URL/key not configured')
return
}
this._client = createClient(url, anonKey, {
auth: {
persistSession: false, // 직접 관리
autoRefreshToken: true,
detectSessionInUrl: false
}
})
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
// 저장된 refresh token 복원
const stored = this._loadStoredRefreshToken()
if (stored) {
try {
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
if (error) {
// U3: 장시간 idle 후 refresh token 소진 시 사용자에게 재인증 필요를
// 알리기 위해 error 로깅 승격 + sync-error + auth-changed(user:null) emit.
// 기존에는 조용히 토큰만 삭제해서 사용자가 "왜 로그아웃됐지?" 상태로 방치됐음.
logger.error(
`세션 복원 실패 — 재로그인 필요: ${error.message} (토큰 만료 또는 서버 세션 무효화)`
)
this._clearStoredRefreshToken()
this.emit('sync-error', {
error: `Sign-in session expired. Please sign in again. (${error.message})`,
})
this.emit('auth-changed', { user: null })
} else if (data.session) {
this._saveRefreshToken(data.session.refresh_token)
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) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`세션 복원 예외 — 재로그인 필요: ${message}`)
this._clearStoredRefreshToken()
this.emit('sync-error', {
error: `Sign-in session restore failed. Please sign in again. (${message})`,
})
this.emit('auth-changed', { user: null })
}
}
logger.info('CloudSyncService initialized')
}
// ── 인증 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
}
// 2) Supabase `subscriptions` 테이블에서 현재 티어 가져와 LicenseService에 동기화
// (빅뱅 Phase 4 — 티어 enforcement)
try {
const tier = await this._fetchSubscriptionTier(userId)
const { getLicenseService } = await import('./LicenseService')
getLicenseService().syncFromCloud(tier)
} catch (err) {
logger.warn(
`[auth:${opts.reason}] Subscription tier sync failed (falling back to free): ${
err instanceof Error ? err.message : String(err)
}`
)
}
// 3) renderer 게이트 해제 — DB가 열리고 티어가 반영된 뒤에 emit
this.emit('auth-changed', { user: session.user })
// 4) Realtime 구독 자동 시작
void this.startRealtime().catch((err) => {
logger.warn(
`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`
)
})
// 5) 초기 bi-directional sync (빅뱅 Phase 3 — local-first mirror)
// 로그인 직후 기존 로컬 데이터를 한 번 push하고, 원격 변경을 pull.
// fire-and-forget — UI는 이미 해제됨, sync는 백그라운드에서 진행.
void this._initialSync(opts.reason)
}
/**
* 빅뱅 Phase 3: 로그인 직후 기존 로컬 데이터를 한 번 push + 원격 변경 pull.
* 데스크톱은 로컬-first이므로, 클라우드는 mirror 역할.
* - signin: 이전 익명 로컬 세션에 쌓인 데이터를 사용자 계정으로 업로드
* - restore: 다른 기기에서 추가된 변경사항을 가져오기
* 실패해도 warn만 찍고 앱은 계속 동작 — 수동 Sync 버튼으로 재시도 가능.
*/
private async _initialSync(reason: 'restore' | 'signin'): Promise<void> {
try {
logger.info(`[auth:${reason}] Initial sync starting — push then pull`)
const pushResult = await this.pushAll()
logger.info(
`[auth:${reason}] Initial push done: pushed=${pushResult.pushed}, errors=${pushResult.errors.length}`
)
const pullResult = await this.pullAll()
logger.info(
`[auth:${reason}] Initial pull done: applied=${pullResult.pushed}, errors=${pullResult.errors.length}`
)
} catch (err) {
logger.warn(
`[auth:${reason}] Initial sync failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
/**
* 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 사용자의 현재 티어를 조회.
* - row 없음 → 'free' (auth trigger가 가입 시 자동 생성하므로 거의 발생 안 함)
* - tier 값 비정상 → 'free'
* - 네트워크 실패 → throw (caller가 warn + 현재 티어 유지)
*/
private async _fetchSubscriptionTier(userId: string): Promise<LicenseTier> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Supabase client not initialized')
}
const { data, error } = await this._client
.from('subscriptions')
.select('tier, status')
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
if (error) {
throw new Error(error.message)
}
if (!data) return 'free'
const tier = data.tier as string
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
return tier
}
// 'team' 등 미지원 tier는 free로 폴백
return 'free'
}
/**
* 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) LicenseService 티어를 free로 리셋 (빅뱅 Phase 4 — 익명 로컬 모드)
// pro/pro_plus 캐시가 남아있으면 premium feature gate가 이상하게 동작
try {
const { getLicenseService } = await import('./LicenseService')
getLicenseService().resetToFree()
} catch (err) {
logger.warn(
`[signOut] License reset failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 6) 사용자 DB 닫고 로컬 모드 DB로 복귀
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
closeCurrent()
try {
const { dbPath } = openLocal()
logger.info(`[signOut] Reverted to local DB: ${dbPath}`)
} catch (err) {
logger.error(
`[signOut] Local DB reopen failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 7) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로,
// 메인 UI는 로컬 모드로 계속 동작
this.emit('auth-changed', { user: null })
logger.info('Signed out — continuing in local mode')
}
// ── 상태 조회 ──────────────────────────────────────────
isEnabled(): boolean {
return this._client !== null
}
isAuthenticated(): boolean {
return this._session !== null
}
getUser(): User | null {
return this._session?.user ?? null
}
getState(): CloudSyncState {
return {
authenticated: this.isAuthenticated(),
userEmail: this._session?.user?.email ?? null,
lastSyncAt: this._lastSyncAt,
syncing: this._syncing,
saasMode: isSupabaseBuildTimeConfigured()
}
}
getLastSyncAt(): Date | null {
return this._lastSyncAt ? new Date(this._lastSyncAt) : null
}
// ── Realtime 구독 ──────────────────────────────────────
/**
* 원격 변경사항을 실시간으로 구독한다.
* meetings / meeting_memos / meeting_documents / history / dictionary에
* INSERT/UPDATE 이벤트가 오면 pullAll()로 자동 동기화.
* 중복 호출 방지를 위해 기존 채널이 있으면 먼저 해제.
*/
async startRealtime(): Promise<void> {
if (!this._client || !this._session) {
logger.warn('Realtime 시작 불가 — 로그인 필요')
return
}
if (this._realtimeChannel) {
await this._realtimeChannel.unsubscribe()
this._realtimeChannel = null
}
const userId = this._session.user.id
// persistSession: false 에서는 Supabase realtime 클라이언트가 auth state change를
// 자동 추적하지 않는다. access_token을 명시적으로 realtime에 주입해서
// postgres_changes 채널이 RLS를 통과하도록 한다. (TIMED_OUT 버그 픽스)
try {
this._client.realtime.setAuth(this._session.access_token)
} catch (err) {
logger.warn(
`Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}`
)
}
// 변경 감지 debounce — 연속 이벤트가 몰릴 때 한 번만 pull
let pullScheduled = false
const schedulePull = (): void => {
if (pullScheduled || this._syncing) return
pullScheduled = true
setTimeout(() => {
pullScheduled = false
void this.pullAll().catch((err) => {
logger.warn(`Realtime 트리거 pull 실패: ${err instanceof Error ? err.message : String(err)}`)
})
}, 1500)
}
this._realtimeChannel = this._client
.channel(`cloud-sync:${userId}`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'meetings', filter: `user_id=eq.${userId}` },
() => schedulePull()
)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'history', filter: `user_id=eq.${userId}` },
() => schedulePull()
)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'dictionary', filter: `user_id=eq.${userId}` },
() => schedulePull()
)
.subscribe((status) => {
logger.info(`Realtime 채널 상태: ${status}`)
// U1: TIMED_OUT / CHANNEL_ERROR / CLOSED 시 지수 백오프 자동 재구독.
// Supabase realtime은 네트워크 jitter로 간헐적 TIMED_OUT이 발생할 수 있고,
// 이때 기본 fallback(Phase 3.3 auto push)은 동작하지만 다른 기기 변경은 놓친다.
// 1s → 3s → 10s 3회 시도 후 포기. 성공(SUBSCRIBED) 시 카운터 리셋.
if (status === 'SUBSCRIBED') {
this._realtimeRetryCount = 0
if (this._realtimeRetryTimer) {
clearTimeout(this._realtimeRetryTimer)
this._realtimeRetryTimer = null
}
return
}
if (status === 'TIMED_OUT' || status === 'CHANNEL_ERROR' || status === 'CLOSED') {
this._scheduleRealtimeRetry()
}
})
}
/**
* Realtime 재구독 지수 백오프 스케줄러 (U1).
* - 1차: 1s, 2차: 3s, 3차: 10s
* - 3회 초과 시 포기 (Auto push fallback만으로 동작)
* - 이미 스케줄돼 있으면 no-op (중복 방지)
*/
private _scheduleRealtimeRetry(): void {
if (this._realtimeRetryTimer) return
if (!this._client || !this._session) return
const MAX_RETRIES = 3
const DELAYS_MS = [1000, 3000, 10000]
if (this._realtimeRetryCount >= MAX_RETRIES) {
logger.warn(
`Realtime 재구독 ${MAX_RETRIES}회 실패 — Auto push fallback으로만 동작. 수동 재시도: cloudSync.startRealtime()`
)
return
}
const delay = DELAYS_MS[this._realtimeRetryCount]
this._realtimeRetryCount++
const attempt = this._realtimeRetryCount
logger.info(`Realtime 재구독 예약 (${attempt}/${MAX_RETRIES}): ${delay}ms 후`)
this._realtimeRetryTimer = setTimeout(() => {
this._realtimeRetryTimer = null
if (!this._session) return
void this.startRealtime().catch((err) => {
logger.warn(
`Realtime 재구독 실패 (${attempt}/${MAX_RETRIES}): ${err instanceof Error ? err.message : String(err)}`
)
})
}, delay)
}
async stopRealtime(): Promise<void> {
if (this._realtimeRetryTimer) {
clearTimeout(this._realtimeRetryTimer)
this._realtimeRetryTimer = null
}
this._realtimeRetryCount = 0
if (this._realtimeChannel) {
await this._realtimeChannel.unsubscribe()
this._realtimeChannel = null
logger.info('Realtime 채널 종료')
}
}
// ── OAuth 로그인 ───────────────────────────────────────
/**
* OAuth 로그인 시작 — Supabase가 발급한 URL을 외부 브라우저로 열기.
* 사용자가 동의 후 d3ro-voice://auth-callback?code=...로 deep link 호출됨.
*/
async startSignIn(provider: 'google' | 'github'): Promise<void> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
}
const redirectTo = 'd3ro-voice://auth-callback'
const { data, error } = await this._client.auth.signInWithOAuth({
provider,
options: {
redirectTo,
skipBrowserRedirect: true
}
})
if (error || !data.url) {
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`OAuth 시작 실패: ${error?.message ?? 'unknown'}`
)
}
logger.info(`Opening OAuth URL in external browser: ${provider}`)
await shell.openExternal(data.url)
}
/**
* Deep link 콜백 처리 — PKCE flow code를 session으로 교환.
* 성공 시 _onAuthenticated(단일 진입점)로 수렴.
*/
async handleAuthCallback(code: string): Promise<void> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
}
const { data, error } = await this._client.auth.exchangeCodeForSession(code)
if (error || !data.session) {
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`세션 교환 실패: ${error?.message ?? 'unknown'}`
)
}
this._saveRefreshToken(data.session.refresh_token)
logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
}
/**
* Deep link 콜백 처리 — Implicit flow로 받은 access/refresh token을
* client.auth.setSession()에 주입하여 세션 복원.
*
* supabase-js가 `persistSession: false`로 떠 있을 때 PKCE code_verifier 저장이
* 없어서 PKCE가 제대로 동작하지 않는다. Supabase가 implicit flow로 떨어져
* fragment(#access_token=...&refresh_token=...)에 토큰을 담아 보내는데, 이때
* 이 메서드를 사용한다.
*/
async handleAuthTokens(params: {
accessToken: string
refreshToken: string
}): Promise<void> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
}
const { data, error } = await this._client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
})
if (error || !data.session) {
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`세션 설정 실패: ${error?.message ?? 'unknown'}`
)
}
this._saveRefreshToken(data.session.refresh_token)
logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
}
/**
* 로그아웃 — 단일 진입점 _onSignOut에서 세션/DB/녹음 모두 안전하게 정리.
*/
async signOut(): Promise<void> {
await this._onSignOut()
}
// ── 동기화 (push only, MVP) ────────────────────────────
/**
* 마지막 동기화 시각 이후 변경된 행을 push.
* 매핑 테이블 없이 ON CONFLICT(id) DO UPDATE 사용.
*/
async pushAll(): Promise<SyncResult> {
if (!this._client || !this._session) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, '로그인이 필요합니다')
}
if (this._syncing) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, '이미 동기화 중입니다')
}
this._syncing = true
const result: SyncResult = { pushed: 0, errors: [] }
const userId = this._session.user.id
const since = this._lastSyncAt ?? 0
const db = getDatabase()
try {
// 1) history
const historyRows = await db.select().from(history).where(gt(history.updatedAt, since))
result.pushed += await this._pushTable('history', historyRows.length, async () => {
if (historyRows.length === 0) return 0
const payload = historyRows.map((r) => this._mapHistoryPayload(r, userId))
const { error } = await this._client!.from('history').upsert(payload, { onConflict: 'id' })
if (error) throw new Error(error.message)
return historyRows.length
}, result)
// 2) dictionary
const dictRows = await db.select().from(dictionary).where(gt(dictionary.updatedAt, since))
result.pushed += await this._pushTable('dictionary', dictRows.length, async () => {
if (dictRows.length === 0) return 0
const payload = dictRows.map((r) => this._mapDictionaryPayload(r, userId))
const { error } = await this._client!.from('dictionary').upsert(payload, { onConflict: 'id' })
if (error) throw new Error(error.message)
return dictRows.length
}, result)
// 3) meetings (V1 meeting_sessions)
const meetingRows = await db
.select()
.from(meetingSessions)
.where(gt(meetingSessions.updatedAt, since))
result.pushed += await this._pushTable('meetings', meetingRows.length, async () => {
if (meetingRows.length === 0) return 0
const payload = meetingRows.map((r) => this._mapMeetingPayload(r, userId))
const { error } = await this._client!.from('meetings').upsert(payload, { onConflict: 'id' })
if (error) throw new Error(error.message)
return meetingRows.length
}, result)
// 4) meeting_memos (회의가 push된 후에만)
if (meetingRows.length > 0) {
const meetingIds = meetingRows.map((m) => m.id)
const memoRows = await db
.select()
.from(meetingMemos)
.where(this._inArray(meetingMemos.sessionId, meetingIds))
result.pushed += await this._pushTable('meeting_memos', memoRows.length, async () => {
if (memoRows.length === 0) return 0
const payload = memoRows.map((r) => this._mapMemoPayload(r, userId))
const { error } = await this._client!.from('meeting_memos').upsert(payload, {
onConflict: 'id'
})
if (error) throw new Error(error.message)
return memoRows.length
}, result)
// 5) meeting_documents
const docRows = await db
.select()
.from(meetingDocuments)
.where(this._inArray(meetingDocuments.sessionId, meetingIds))
result.pushed += await this._pushTable('meeting_documents', docRows.length, async () => {
if (docRows.length === 0) return 0
const payload = docRows.map((r) => this._mapDocumentPayload(r, userId))
const { error } = await this._client!.from('meeting_documents').upsert(payload, {
onConflict: 'id'
})
if (error) throw new Error(error.message)
return docRows.length
}, result)
}
this._lastSyncAt = Date.now()
// ConfigService에 저장은 별도 — 여기서는 단순히 내부 상태만
logger.info(`Sync complete: pushed=${result.pushed} errors=${result.errors.length}`)
this.emit('sync-complete', result)
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(message)
logger.error(`Sync failed: ${message}`)
this.emit('sync-error', { error: message })
return result
} finally {
this._syncing = false
}
}
/**
* 원격 변경을 로컬 SQLite로 가져온다 (pull).
*
* Local-first + last-write-wins 충돌 해결:
* - 로컬에 이미 있는 행: remote.updated_at > local.updated_at인 경우에만 UPDATE
* - 로컬에 없는 행: INSERT
* - 로컬에만 있는 행: 그대로 유지 (다음 push에서 원격으로 올라감)
*
* since: null이면 전체, 아니면 해당 epoch ms 이후의 원격 updated_at만
*/
async pullAll(): Promise<SyncResult> {
if (!this._client || !this._session) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, '로그인이 필요합니다')
}
if (this._syncing) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, '이미 동기화 중입니다')
}
this._syncing = true
const result: SyncResult = { pushed: 0, errors: [] }
const since = this._lastSyncAt ?? 0
const sinceIso = new Date(since).toISOString()
const db = getDatabase()
try {
// 1) history
result.pushed += await this._pullTable('history', async () => {
const { data: remoteRows, error } = await this._client!.from('history')
.select('*')
.gt('updated_at', sinceIso)
if (error) throw new Error(error.message)
if (!remoteRows || remoteRows.length === 0) return 0
let applied = 0
for (const remote of remoteRows as Array<Record<string, unknown>>) {
const remoteUpdated = remote.updated_at ? new Date(remote.updated_at as string).getTime() : 0
const remoteCreated = remote.created_at ? new Date(remote.created_at as string).getTime() : remoteUpdated
// 로컬에 있는지 확인
const existing = await db
.select()
.from(history)
.where(eq(history.id, remote.id as string))
.limit(1)
if (existing.length > 0) {
// LWW: 로컬이 더 새로우면 skip
if (existing[0].updatedAt >= remoteUpdated) continue
await db
.update(history)
.set({
title: (remote.title as string | null) ?? null,
originalText: remote.original_text as string,
polishedText: (remote.polished_text as string | null) ?? null,
mode: (remote.mode as 'dictation' | 'translate' | 'command' | 'caption' | 'file-transcription'),
status: (remote.status as 'completed' | 'cancelled' | 'error'),
duration: (remote.duration as number),
detectedLanguage: (remote.detected_language as string | null) ?? null,
micDevice: (remote.mic_device as string | null) ?? null,
wordCount: (remote.word_count as number) ?? 0,
sttModel: (remote.stt_model as string | null) ?? null,
llmModel: (remote.llm_model as string | null) ?? null,
sttLatencyMs: (remote.stt_latency_ms as number | null) ?? null,
llmLatencyMs: (remote.llm_latency_ms as number | null) ?? null,
appVersion: (remote.app_version as string) ?? '1.0.0',
summaryText: (remote.summary_text as string | null) ?? null,
updatedAt: remoteUpdated
})
.where(eq(history.id, remote.id as string))
applied++
} else {
await db.insert(history).values({
id: remote.id as string,
title: (remote.title as string | null) ?? null,
originalText: remote.original_text as string,
polishedText: (remote.polished_text as string | null) ?? null,
focusedApp: null,
focusedAppName: null,
focusedAppWindowTitle: null,
mode: (remote.mode as 'dictation' | 'translate' | 'command' | 'caption' | 'file-transcription'),
status: (remote.status as 'completed' | 'cancelled' | 'error'),
errorCode: null,
audioLocalPath: null,
duration: (remote.duration as number),
detectedLanguage: (remote.detected_language as string | null) ?? null,
micDevice: (remote.mic_device as string | null) ?? null,
wordCount: (remote.word_count as number) ?? 0,
sttModel: (remote.stt_model as string | null) ?? null,
llmModel: (remote.llm_model as string | null) ?? null,
sttLatencyMs: (remote.stt_latency_ms as number | null) ?? null,
llmLatencyMs: (remote.llm_latency_ms as number | null) ?? null,
createdAt: remoteCreated,
updatedAt: remoteUpdated,
appVersion: (remote.app_version as string) ?? '1.0.0',
summaryText: (remote.summary_text as string | null) ?? null
})
applied++
}
this.emit('sync-progress', { current: applied, total: remoteRows.length, table: 'history' })
}
return applied
}, result)
// 2) dictionary (동일 패턴)
result.pushed += await this._pullTable('dictionary', async () => {
const { data: remoteRows, error } = await this._client!.from('dictionary')
.select('*')
.gt('updated_at', sinceIso)
if (error) throw new Error(error.message)
if (!remoteRows || remoteRows.length === 0) return 0
let applied = 0
for (const remote of remoteRows as Array<Record<string, unknown>>) {
const remoteUpdated = remote.updated_at ? new Date(remote.updated_at as string).getTime() : 0
const remoteCreated = remote.created_at ? new Date(remote.created_at as string).getTime() : remoteUpdated
const existing = await db
.select()
.from(dictionary)
.where(eq(dictionary.id, remote.id as string))
.limit(1)
if (existing.length > 0) {
if (existing[0].updatedAt >= remoteUpdated) continue
await db
.update(dictionary)
.set({
word: remote.word as string,
pronunciation: (remote.pronunciation as string | null) ?? null,
category: (remote.category as 'user' | 'auto' | 'technical') ?? 'user',
usageCount: (remote.usage_count as number) ?? 0,
lastUsedAt: remote.last_used_at ? new Date(remote.last_used_at as string).getTime() : null,
updatedAt: remoteUpdated
})
.where(eq(dictionary.id, remote.id as string))
applied++
} else {
await db.insert(dictionary).values({
id: remote.id as string,
word: remote.word as string,
pronunciation: (remote.pronunciation as string | null) ?? null,
category: (remote.category as 'user' | 'auto' | 'technical') ?? 'user',
usageCount: (remote.usage_count as number) ?? 0,
lastUsedAt: remote.last_used_at ? new Date(remote.last_used_at as string).getTime() : null,
createdAt: remoteCreated,
updatedAt: remoteUpdated
})
applied++
}
this.emit('sync-progress', {
current: applied,
total: remoteRows.length,
table: 'dictionary'
})
}
return applied
}, result)
// 3) meetings (V1 meeting_sessions)
result.pushed += await this._pullTable('meetings', async () => {
const { data: remoteRows, error } = await this._client!.from('meetings')
.select('*')
.gt('updated_at', sinceIso)
if (error) throw new Error(error.message)
if (!remoteRows || remoteRows.length === 0) return 0
let applied = 0
for (const remote of remoteRows as Array<Record<string, unknown>>) {
const remoteUpdated = remote.updated_at
? new Date(remote.updated_at as string).getTime()
: 0
const remoteStarted = remote.started_at
? new Date(remote.started_at as string).getTime()
: remoteUpdated
const remoteEnded = remote.ended_at
? new Date(remote.ended_at as string).getTime()
: null
const remoteCreated = remote.created_at
? new Date(remote.created_at as string).getTime()
: remoteUpdated
const existing = await db
.select()
.from(meetingSessions)
.where(eq(meetingSessions.id, remote.id as string))
.limit(1)
// V1 스키마는 minutesJson이 text (JSON 직렬화)
const minutesJsonStr = remote.minutes_json
? JSON.stringify(remote.minutes_json)
: null
const row = {
title: (remote.title as string | null) ?? null,
status: (remote.status as 'recording' | 'processing' | 'completed' | 'error'),
startedAt: remoteStarted,
endedAt: remoteEnded,
durationMs: (remote.duration_ms as number | null) ?? null,
rawTranscript: (remote.raw_transcript as string | null) ?? null,
editedTranscript: (remote.edited_transcript as string | null) ?? null,
minutesMarkdown: (remote.minutes_markdown as string | null) ?? null,
minutesJson: minutesJsonStr,
sttModel: (remote.stt_model as string | null) ?? null,
llmModel: (remote.llm_model as string | null) ?? null,
sttLatencyMs: (remote.stt_latency_ms as number | null) ?? null,
llmLatencyMs: (remote.llm_latency_ms as number | null) ?? null,
errorMessage: (remote.error_message as string | null) ?? null,
updatedAt: remoteUpdated
}
if (existing.length > 0) {
if (existing[0].updatedAt >= remoteUpdated) continue
await db
.update(meetingSessions)
.set(row)
.where(eq(meetingSessions.id, remote.id as string))
applied++
} else {
await db.insert(meetingSessions).values({
id: remote.id as string,
...row,
createdAt: remoteCreated
})
applied++
}
this.emit('sync-progress', { current: applied, total: remoteRows.length, table: 'meetings' })
}
return applied
}, result)
// 4) meeting_memos
result.pushed += await this._pullTable('meeting_memos', async () => {
const { data: remoteRows, error } = await this._client!.from('meeting_memos')
.select('*')
.gt('created_at', sinceIso)
if (error) throw new Error(error.message)
if (!remoteRows || remoteRows.length === 0) return 0
let applied = 0
for (const remote of remoteRows as Array<Record<string, unknown>>) {
const remoteCreated = remote.created_at
? new Date(remote.created_at as string).getTime()
: Date.now()
const existing = await db
.select()
.from(meetingMemos)
.where(eq(meetingMemos.id, remote.id as string))
.limit(1)
if (existing.length === 0) {
await db.insert(meetingMemos).values({
id: remote.id as string,
sessionId: remote.meeting_id as string,
content: remote.content as string,
timestampMs: remote.timestamp_ms as number,
createdAt: remoteCreated
})
applied++
}
// memo는 immutable 전제 (수정 없음), INSERT-only
this.emit('sync-progress', { current: applied, total: remoteRows.length, table: 'meeting_memos' })
}
return applied
}, result)
// 5) meeting_documents
result.pushed += await this._pullTable('meeting_documents', async () => {
const { data: remoteRows, error } = await this._client!.from('meeting_documents')
.select('*')
.gt('updated_at', sinceIso)
if (error) throw new Error(error.message)
if (!remoteRows || remoteRows.length === 0) return 0
let applied = 0
for (const remote of remoteRows as Array<Record<string, unknown>>) {
const remoteUpdated = remote.updated_at
? new Date(remote.updated_at as string).getTime()
: 0
const remoteCreated = remote.created_at
? new Date(remote.created_at as string).getTime()
: remoteUpdated
const existing = await db
.select()
.from(meetingDocuments)
.where(eq(meetingDocuments.id, remote.id as string))
.limit(1)
const row = {
sessionId: remote.meeting_id as string,
templateType: (remote.template_type as 'minutes' | 'report' | 'idea-note' | 'custom' | 'mindmap'),
title: remote.title as string,
content: (remote.content as string) ?? '',
promptUsed: (remote.prompt_used as string | null) ?? null,
llmModel: (remote.llm_model as string | null) ?? null,
llmLatencyMs: (remote.llm_latency_ms as number | null) ?? null,
updatedAt: remoteUpdated
}
if (existing.length > 0) {
if (existing[0].updatedAt >= remoteUpdated) continue
await db
.update(meetingDocuments)
.set(row)
.where(eq(meetingDocuments.id, remote.id as string))
applied++
} else {
await db.insert(meetingDocuments).values({
id: remote.id as string,
...row,
createdAt: remoteCreated
})
applied++
}
this.emit('sync-progress', {
current: applied,
total: remoteRows.length,
table: 'meeting_documents'
})
}
return applied
}, result)
this._lastSyncAt = Date.now()
logger.info(`Pull complete: applied=${result.pushed} errors=${result.errors.length}`)
this.emit('sync-complete', result)
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(message)
logger.error(`Pull failed: ${message}`)
this.emit('sync-error', { error: message })
return result
} finally {
this._syncing = false
}
}
// ── 내부 헬퍼 ──────────────────────────────────────────
// ── 단건 자동 push (Phase 3.3) ─────────────────────────
/**
* 단일 row 자동 push.
* - 로그인 상태 아니면 silent noop (로컬 write는 계속 돌아감)
* - 실패 시 warn만 찍고 swallow — 호출부는 fire-and-forget로 쓸 것
* - pushAll과 동일한 mapper 공유 (DRY)
*/
async pushOne(
table: 'history' | 'dictionary' | 'meetings' | 'meeting_memos' | 'meeting_documents',
id: string
): Promise<void> {
if (!this._client || !this._session) {
// 로그아웃 / 미로그인 — 로컬 모드에서는 push 대상이 아님. 조용히 종료.
return
}
const userId = this._session.user.id
const db = getDatabase()
const client = this._client
try {
switch (table) {
case 'history': {
const rows = await db.select().from(history).where(eq(history.id, id)).limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('history')
.upsert(this._mapHistoryPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'dictionary': {
const rows = await db.select().from(dictionary).where(eq(dictionary.id, id)).limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('dictionary')
.upsert(this._mapDictionaryPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'meetings': {
const rows = await db
.select()
.from(meetingSessions)
.where(eq(meetingSessions.id, id))
.limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('meetings')
.upsert(this._mapMeetingPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'meeting_memos': {
const rows = await db
.select()
.from(meetingMemos)
.where(eq(meetingMemos.id, id))
.limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('meeting_memos')
.upsert(this._mapMemoPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'meeting_documents': {
const rows = await db
.select()
.from(meetingDocuments)
.where(eq(meetingDocuments.id, id))
.limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('meeting_documents')
.upsert(this._mapDocumentPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
}
logger.debug(`pushOne ${table}/${id} ok`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 로컬 write는 절대 차단하지 않는다 — warn만.
logger.warn(`pushOne ${table}/${id} failed: ${message}`)
}
}
// ── row → Supabase payload 매퍼 (pushAll + pushOne 공통) ─
private _mapHistoryPayload(
r: typeof history.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
user_id: userId,
original_text: r.originalText,
polished_text: r.polishedText,
mode: r.mode,
status: r.status,
duration: r.duration,
detected_language: r.detectedLanguage,
mic_device: r.micDevice,
word_count: r.wordCount,
stt_model: r.sttModel,
llm_model: r.llmModel,
stt_latency_ms: r.sttLatencyMs,
llm_latency_ms: r.llmLatencyMs,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString(),
app_version: r.appVersion,
summary_text: r.summaryText
}
}
private _mapDictionaryPayload(
r: typeof dictionary.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
user_id: userId,
word: r.word,
pronunciation: r.pronunciation,
category: r.category,
usage_count: r.usageCount,
last_used_at: r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : null,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}
}
private _mapMeetingPayload(
r: typeof meetingSessions.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
user_id: userId,
team_id: null,
title: r.title,
status: r.status,
started_at: new Date(r.startedAt).toISOString(),
ended_at: r.endedAt ? new Date(r.endedAt).toISOString() : null,
duration_ms: r.durationMs,
raw_transcript: r.rawTranscript,
edited_transcript: r.editedTranscript,
minutes_markdown: r.minutesMarkdown,
minutes_json: r.minutesJson ? JSON.parse(r.minutesJson) : null,
stt_model: r.sttModel,
llm_model: r.llmModel,
stt_latency_ms: r.sttLatencyMs,
llm_latency_ms: r.llmLatencyMs,
error_message: r.errorMessage,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}
}
private _mapMemoPayload(
r: typeof meetingMemos.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
meeting_id: r.sessionId,
user_id: userId,
content: r.content,
timestamp_ms: r.timestampMs,
created_at: new Date(r.createdAt).toISOString()
}
}
private _mapDocumentPayload(
r: typeof meetingDocuments.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
meeting_id: r.sessionId,
user_id: userId,
template_type: r.templateType,
title: r.title,
content: r.content,
prompt_used: r.promptUsed,
llm_model: r.llmModel,
llm_latency_ms: r.llmLatencyMs,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}
}
private async _pullTable(
table: string,
runner: () => Promise<number>,
result: SyncResult
): Promise<number> {
this.emit('sync-progress', { current: 0, total: 0, table })
try {
return await runner()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(`${table}: ${message}`)
logger.error(`Pull ${table} failed: ${message}`)
return 0
}
}
private async _pushTable(
table: string,
total: number,
runner: () => Promise<number>,
result: SyncResult
): Promise<number> {
this.emit('sync-progress', { current: 0, total, table })
try {
const count = await runner()
this.emit('sync-progress', { current: count, total: count, table })
return count
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(`${table}: ${message}`)
logger.error(`Push ${table} failed: ${message}`)
return 0
}
}
/**
* drizzle-orm v0.45는 inArray 헬퍼가 별도. 간단 구현.
*/
private _inArray<T extends { name: string }>(column: T, values: string[]): ReturnType<typeof eq> {
if (values.length === 0) {
return eq(column as never, '__never__' as never)
}
if (values.length === 1) {
return eq(column as never, values[0] as never)
}
// 여러 개는 첫 번째만 (MVP — 추후 inArray로 교체)
return eq(column as never, values[0] as never)
}
// ── 토큰 영속화 (electron safeStorage) ─────────────────
private _saveRefreshToken(token: string): void {
try {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(token)
const fs = require('fs') as typeof import('fs')
const path = require('path') as typeof import('path')
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
fs.writeFileSync(tokenPath, encrypted)
}
} catch (err) {
logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
private _loadStoredRefreshToken(): string | null {
try {
const fs = require('fs') as typeof import('fs')
const path = require('path') as typeof import('path')
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
if (!fs.existsSync(tokenPath)) return null
if (!safeStorage.isEncryptionAvailable()) return null
const encrypted = fs.readFileSync(tokenPath)
return safeStorage.decryptString(encrypted)
} catch (err) {
logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
return null
}
}
private _clearStoredRefreshToken(): void {
try {
const fs = require('fs') as typeof import('fs')
const path = require('path') as typeof import('path')
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath)
}
} catch {
// ignore
}
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof CloudSyncEvents>(event: K, listener: CloudSyncEvents[K]): this {
return super.on(event, listener)
}
override emit<K extends keyof CloudSyncEvents>(
event: K,
...args: Parameters<CloudSyncEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ── 싱글톤 ─────────────────────────────────────────────
let instance: CloudSyncService | null = null
export function getCloudSyncService(): CloudSyncService {
if (!instance) {
instance = new CloudSyncService()
}
return instance
}