## 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.
1362 lines
50 KiB
TypeScript
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
|
|
}
|