// 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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { await this._onSignOut() } // ── 동기화 (push only, MVP) ──────────────────────────── /** * 마지막 동기화 시각 이후 변경된 행을 push. * 매핑 테이블 없이 ON CONFLICT(id) DO UPDATE 사용. */ async pushAll(): Promise { 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 { 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>) { 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>) { 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>) { 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>) { 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>) { 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 { 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 { 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 { 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 { 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 { 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 { 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, result: SyncResult ): Promise { 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, result: SyncResult ): Promise { 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(column: T, values: string[]): ReturnType { 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(event: K, listener: CloudSyncEvents[K]): this { return super.on(event, listener) } override emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } } // ── 싱글톤 ───────────────────────────────────────────── let instance: CloudSyncService | null = null export function getCloudSyncService(): CloudSyncService { if (!instance) { instance = new CloudSyncService() } return instance }