From 0a4f5aee6476633091683f83176422756726ca7b Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 27 Sep 2026 14:04:49 +0900 Subject: [PATCH] feat(desktop): two-way cloud sync with mobile and web Rewrites the desktop mirror as services/sync/SyncEngine: a persistent outbox, per-account server-clock keyset cursors with paging, pulls that never overwrite unsent local edits, deletions both ways through sync_tombstones and per-row failure isolation. It now covers history titles and favorites, dictionary, every meeting's memos and documents, memo tags, user commands and dictation/meeting templates, and registers the desktop as a device that the phone can disconnect. Fixes shipped defects: the first pull after sign-in fetched nothing, only the first meeting's children were pushed, team meetings leaked into the personal database and lost team_id on re-push, and Realtime never connected because Electron's Node 20 has no global WebSocket (ws is now the transport). Anonymous local-mode records are imported into the first account that signs in. The settings sync section is translated and shows pending/rejected changes; synced screens reload on app:dataChanged. --- apps/desktop/package.json | 4 +- apps/desktop/src/main/db/index.ts | 141 ++- apps/desktop/src/main/db/schema.ts | 36 +- .../src/main/ipc/cloud-sync-handlers.ts | 7 +- apps/desktop/src/main/ipc/history-handlers.ts | 16 +- .../src/main/services/CloudSyncService.ts | 990 +++++------------- .../src/main/services/ConfigService.ts | 1 + .../main/services/CustomInstructionService.ts | 37 +- .../main/services/DictationTemplateService.ts | 23 + .../src/main/services/DictionaryService.ts | 1 + .../src/main/services/HistoryService.ts | 28 +- .../services/MeetingDocTemplateService.ts | 23 + .../src/main/services/MeetingModeService.ts | 24 +- apps/desktop/src/main/services/MemoService.ts | 19 +- .../src/main/services/sync/SyncEngine.ts | 467 +++++++++ .../main/services/sync/device-registration.ts | 159 +++ .../src/main/services/sync/memo-tag-sync.ts | 137 +++ .../main/services/sync/realtime-transport.ts | 10 + .../services/sync/supabase-sync-remote.ts | 96 ++ .../src/main/services/sync/sync-adapters.ts | 857 +++++++++++++++ .../src/main/services/sync/sync-outbox.ts | 160 +++ .../src/main/services/sync/sync-types.ts | 141 +++ apps/desktop/src/preload/index.ts | 14 +- .../renderer/components/CloudSyncSection.tsx | 179 ++-- .../renderer/components/TemplateSection.tsx | 9 + .../components/meeting/MeetingDetailTabs.tsx | 12 +- .../components/shared/HistoryEntryCard.tsx | 26 +- .../src/renderer/pages/DictionaryPage.tsx | 4 + .../src/renderer/pages/HistoryPage.tsx | 14 + .../src/renderer/pages/MeetingModePage.tsx | 19 + apps/desktop/tests/helpers/createTestDb.ts | 155 +-- apps/desktop/tests/helpers/fakeSyncRemote.ts | 224 ++++ .../cross-device-sync.supabase.test.ts | 271 +++++ .../tests/main/sync/SyncEngine.test.ts | 383 +++++++ package-lock.json | 10 +- packages/core/src/ipc-channels.ts | 1 + packages/core/src/types.ts | 13 + packages/i18n/src/locales/de.json | 21 +- packages/i18n/src/locales/en.json | 26 +- packages/i18n/src/locales/es.json | 21 +- packages/i18n/src/locales/fr.json | 21 +- packages/i18n/src/locales/ja.json | 21 +- packages/i18n/src/locales/ko.json | 26 +- packages/i18n/src/locales/pt.json | 21 +- packages/i18n/src/locales/ru.json | 21 +- packages/i18n/src/locales/th.json | 21 +- packages/i18n/src/locales/vi.json | 21 +- packages/i18n/src/locales/zh-TW.json | 21 +- packages/i18n/src/locales/zh.json | 21 +- 49 files changed, 3940 insertions(+), 1033 deletions(-) create mode 100644 apps/desktop/src/main/services/sync/SyncEngine.ts create mode 100644 apps/desktop/src/main/services/sync/device-registration.ts create mode 100644 apps/desktop/src/main/services/sync/memo-tag-sync.ts create mode 100644 apps/desktop/src/main/services/sync/realtime-transport.ts create mode 100644 apps/desktop/src/main/services/sync/supabase-sync-remote.ts create mode 100644 apps/desktop/src/main/services/sync/sync-adapters.ts create mode 100644 apps/desktop/src/main/services/sync/sync-outbox.ts create mode 100644 apps/desktop/src/main/services/sync/sync-types.ts create mode 100644 apps/desktop/tests/helpers/fakeSyncRemote.ts create mode 100644 apps/desktop/tests/integration/cross-device-sync.supabase.test.ts create mode 100644 apps/desktop/tests/main/sync/SyncEngine.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 19c6e35..9df0179 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,6 +34,7 @@ "@types/node": "^22.13.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", "electron": "33.4.11", "electron-builder": "^26.8.1", @@ -67,7 +68,8 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tar": "^7.5.13", - "uiohook-napi": "^1.5.5" + "uiohook-napi": "^1.5.5", + "ws": "^8.22.0" }, "optionalDependencies": { "@rollup/rollup-win32-x64-msvc": "^4.60.1" diff --git a/apps/desktop/src/main/db/index.ts b/apps/desktop/src/main/db/index.ts index d46aa9a..fc3e39d 100644 --- a/apps/desktop/src/main/db/index.ts +++ b/apps/desktop/src/main/db/index.ts @@ -85,7 +85,7 @@ function archiveLegacyDbIfExists(): void { // 스키마 적용 (테이블 생성 + 인라인 ALTER 마이그레이션) // ───────────────────────────────────────────────────────────── -function applySchema(s: Database.Database): void { +export function applySchema(s: Database.Database): void { s.pragma('journal_mode = WAL') s.pragma('foreign_keys = ON') s.pragma('busy_timeout = 5000') @@ -378,6 +378,49 @@ function applySchema(s: Database.Database): void { `summary_text migration check failed: ${err instanceof Error ? err.message : String(err)}` ) } + + // 기기 간 동기화: 모바일과 같은 필드(즐겨찾기, 메모 수정 시각) + 로컬 outbox/커서 + addColumnIfMissing(s, 'history', 'is_favorite', 'INTEGER NOT NULL DEFAULT 0') + addColumnIfMissing(s, 'meeting_memos', 'updated_at', 'INTEGER') + s.exec(` + CREATE TABLE IF NOT EXISTS sync_outbox ( + entity TEXT NOT NULL, + row_id TEXT NOT NULL, + op TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + queued_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + PRIMARY KEY (entity, row_id) + ); + CREATE INDEX IF NOT EXISTS idx_sync_outbox_due ON sync_outbox(next_attempt_at); + + CREATE TABLE IF NOT EXISTS sync_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `) +} + +function addColumnIfMissing( + s: Database.Database, + table: string, + column: string, + definition: string +): void { + try { + const columns = s.pragma(`table_info(${table})`) as Array<{ name: string }> + if (!columns.some((c) => c.name === column)) { + s.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`) + logger.info(`Migrated: added ${column} column to ${table}`) + } + } catch (err) { + logger.warn( + `${table}.${column} migration check failed: ${err instanceof Error ? err.message : String(err)}` + ) + } } // ───────────────────────────────────────────────────────────── @@ -494,6 +537,102 @@ export function isDatabaseOpen(): boolean { return db !== null } +// ───────────────────────────────────────────────────────────── +// 익명 로컬 데이터 → 계정 DB 가져오기 +// ───────────────────────────────────────────────────────────── + +/** 가져올 테이블. 입력 텔레메트리·RAG 색인 등 기기 전용 데이터는 옮기지 않는다. */ +const IMPORTABLE_TABLES = [ + 'history', + 'dictionary', + 'memo_tags', + 'meeting_sessions', + 'meeting_memos', + 'meeting_documents', +] as const + +const LOCAL_CLAIM_KEY = 'claimedBy' +const IMPORT_DONE_KEY = 'localImport:v1' + +export interface LocalImportResult { + imported: number + tables: Record +} + +function tableColumns(s: Database.Database, schemaName: string, table: string): string[] { + const rows = s.pragma(`${schemaName}.table_info(${table})`) as Array<{ name: string }> + return rows.map((r) => r.name) +} + +/** + * 로그인 전(익명 로컬 모드)에 쌓인 기록을 현재 열린 계정 DB로 복사한다. + * - 계정 DB마다 한 번만(sync_state 플래그), 익명 DB는 처음 가져간 계정 하나만(claimedBy) 가져간다. + * - 원본은 지우지 않는다. 같은 id·같은 단어는 건너뛴다(INSERT OR IGNORE). + * - 옛 DB는 컬럼 순서가 다를 수 있어 양쪽에 모두 있는 컬럼만 이름으로 복사한다. + * 가져온 행은 이후 동기화의 최초 대조(backfill)가 서버로 올린다. + */ +export function importLocalModeData(): LocalImportResult | null { + if (!sqlite || !currentUserId || currentUserId === LOCAL_USER_ID) return null + const s = sqlite + const userId = currentUserId + const done = s.prepare('SELECT value FROM sync_state WHERE key = ?').get(IMPORT_DONE_KEY) as + | { value: string } + | undefined + if (done) return null + + const localPath = getUserDbPath(LOCAL_USER_ID) + const markDone = (): void => { + s.prepare( + 'INSERT OR REPLACE INTO sync_state(key, value, updated_at) VALUES (?, ?, ?)' + ).run(IMPORT_DONE_KEY, String(Date.now()), Date.now()) + } + if (!fs.existsSync(localPath)) { + markDone() + return null + } + + s.prepare('ATTACH DATABASE ? AS anon').run(localPath) + try { + s.exec(`CREATE TABLE IF NOT EXISTS anon.sync_state ( + key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL + )`) + const claim = s.prepare('SELECT value FROM anon.sync_state WHERE key = ?').get(LOCAL_CLAIM_KEY) as + | { value: string } + | undefined + if (claim && claim.value !== userId) { + logger.info(`Local-mode data already imported by another account; skipping for ${userId}`) + markDone() + return null + } + + const result: LocalImportResult = { imported: 0, tables: {} } + const copy = s.transaction(() => { + for (const table of IMPORTABLE_TABLES) { + const sourceColumns = tableColumns(s, 'anon', table) + if (sourceColumns.length === 0) continue + const targetColumns = new Set(tableColumns(s, 'main', table)) + const columns = sourceColumns.filter((c) => targetColumns.has(c)).map((c) => `"${c}"`) + if (columns.length === 0) continue + const list = columns.join(', ') + const info = s + .prepare(`INSERT OR IGNORE INTO main.${table} (${list}) SELECT ${list} FROM anon.${table}`) + .run() + result.tables[table] = info.changes + result.imported += info.changes + } + s.prepare( + 'INSERT OR REPLACE INTO anon.sync_state(key, value, updated_at) VALUES (?, ?, ?)' + ).run(LOCAL_CLAIM_KEY, userId, Date.now()) + markDone() + }) + copy() + logger.info(`Imported ${result.imported} local-mode row(s) into ${userId}: ${JSON.stringify(result.tables)}`) + return result + } finally { + s.exec('DETACH DATABASE anon') + } +} + /** * 테스트/개발용 — 현재 오픈된 DB 경로 조회. */ diff --git a/apps/desktop/src/main/db/schema.ts b/apps/desktop/src/main/db/schema.ts index c868dba..77a3371 100644 --- a/apps/desktop/src/main/db/schema.ts +++ b/apps/desktop/src/main/db/schema.ts @@ -1,7 +1,7 @@ // src/main/db/schema.ts // 설계서 03의 drizzle-orm 스키마 정의 -import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { sqliteTable, text, integer, real, index, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core' // ── history ────────────────────────────────────────────── export const history = sqliteTable( @@ -35,6 +35,8 @@ export const history = sqliteTable( appVersion: text('app_version').notNull().default('1.0.0'), /** Phase 12.2: 회의록 자동 요약 텍스트 (마크다운) */ summaryText: text('summary_text'), + /** 모바일과 공유하는 즐겨찾기 (Supabase history.is_favorite) */ + isFavorite: integer('is_favorite', { mode: 'boolean' }).notNull().default(false), }, (table) => [ index('idx_history_created_at').on(table.createdAt), @@ -192,6 +194,8 @@ export const meetingMemos = sqliteTable( content: text('content').notNull(), timestampMs: integer('timestamp_ms').notNull(), createdAt: integer('created_at').notNull(), + /** 모바일에서 수정한 메모를 커서로 가져오기 위한 수정 시각 (없으면 createdAt) */ + updatedAt: integer('updated_at'), }, (table) => [ index('idx_meeting_memos_session_id').on(table.sessionId), @@ -366,3 +370,33 @@ export type NewHistory = typeof history.$inferInsert export type Dictionary = typeof dictionary.$inferSelect export type NewDictionary = typeof dictionary.$inferInsert export type Stats = typeof stats.$inferSelect + +// ── sync_outbox / sync_state (기기 간 동기화) ──────────── +// outbox: 아직 서버에 반영되지 않은 로컬 변경. (entity,row_id)당 최신 연산 하나만 남는다. +// version은 enqueue마다 증가해, push 도중 들어온 새 변경을 push 완료 처리가 지우지 않게 한다. +export const syncOutbox = sqliteTable( + 'sync_outbox', + { + entity: text('entity').notNull(), + rowId: text('row_id').notNull(), + op: text('op', { enum: ['upsert', 'delete'] }).notNull(), + version: integer('version').notNull().default(1), + queuedAt: integer('queued_at').notNull(), + attempts: integer('attempts').notNull().default(0), + nextAttemptAt: integer('next_attempt_at').notNull().default(0), + lastError: text('last_error'), + }, + (table) => [ + primaryKey({ columns: [table.entity, table.rowId] }), + index('idx_sync_outbox_due').on(table.nextAttemptAt), + ] +) + +// sync_state: 사용자 DB별 커서·1회성 플래그. 사용자 DB 안에 있으므로 계정별로 자동 분리된다. +export const syncState = sqliteTable('sync_state', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: integer('updated_at').notNull(), +}) + +export type SyncOutboxRow = typeof syncOutbox.$inferSelect diff --git a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts index 33eae1f..2864595 100644 --- a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts +++ b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts @@ -6,7 +6,6 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { getCloudSyncService } from '../services/CloudSyncService' import { getLogger } from '../services/LoggerService' -import { configSet } from '../services/ConfigService' const logger = getLogger('cloud-sync-handlers') @@ -71,7 +70,6 @@ export function registerCloudSyncHandlers(): void { ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.PUSH_ALL, async () => { try { const result = await sync.pushAll() - configSet('cloudSyncLastAt', Date.now()) return ipcSuccess(result) } catch (e) { if (e instanceof D3ROError) { @@ -85,7 +83,6 @@ export function registerCloudSyncHandlers(): void { ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.PULL_ALL, async () => { try { const result = await sync.pullAll() - configSet('cloudSyncLastAt', Date.now()) return ipcSuccess(result) } catch (e) { if (e instanceof D3ROError) { @@ -117,6 +114,10 @@ export function registerCloudSyncHandlers(): void { sync.on('sync-error', (payload) => { broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_ERROR, payload) }) + // 다른 기기의 변경이 반영되면 앱 공통 새로고침 신호로 알린다 (이력·사전·회의·명령 화면이 구독). + sync.on('data-changed', (payload) => { + broadcast(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'cloud-sync', entities: payload.entities }) + }) logger.info('Cloud Sync IPC handlers registered') } diff --git a/apps/desktop/src/main/ipc/history-handlers.ts b/apps/desktop/src/main/ipc/history-handlers.ts index f2fe2a7..0142169 100644 --- a/apps/desktop/src/main/ipc/history-handlers.ts +++ b/apps/desktop/src/main/ipc/history-handlers.ts @@ -8,7 +8,8 @@ import type { HistoryQueryParams, HistoryGetByIdParams, HistoryDeleteParams, - HistorySearchParams + HistorySearchParams, + HistorySetFavoriteParams } from '@d3ro/core/types' export function registerHistoryHandlers(): void { @@ -53,6 +54,19 @@ export function registerHistoryHandlers(): void { } }) + ipcMain.handle(IPC_CHANNELS.HISTORY.SET_FAVORITE, async (_event, params: HistorySetFavoriteParams) => { + try { + const entry = getHistoryService().setFavorite(params.id, params.isFavorite === true) + if (!entry) { + return ipcError(ErrorCode.HistoryNotFound, `History entry not found: ${params.id}`) + } + return ipcSuccess(entry) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return ipcError(ErrorCode.DBWriteFailed, `Failed to update favorite: ${message}`) + } + }) + ipcMain.handle(IPC_CHANNELS.HISTORY.SEARCH, async (_event, params: HistorySearchParams) => { try { return ipcSuccess(getHistoryService().search(params)) diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 4c78f54..100579e 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -1,6 +1,7 @@ // src/main/services/CloudSyncService.ts -// Phase V2-4: Supabase 동기화 — OAuth 로그인, 세션 영속화, push 동기화 -// Local-first 원칙: SQLite가 source of truth, Supabase는 미러 +// Phase V2-4: Supabase 동기화 — OAuth 로그인, 세션 영속화, 기기 간 양방향 동기화 +// Local-first: 데스크톱은 SQLite에서 바로 동작하고, Supabase(모바일·웹의 정본)와 양방향으로 맞춘다. +// 실제 동기화는 services/sync/SyncEngine 이 한다 — 이 서비스는 인증·수명주기·트리거를 맡는다. import { EventEmitter } from 'events' import { shell, app, safeStorage } from 'electron' @@ -11,17 +12,43 @@ import { type User, type RealtimeChannel } from '@supabase/supabase-js' -import { eq, gt } from 'drizzle-orm' import { getLogger } from './LoggerService' -import { configGet } from './ConfigService' +import { configGet, configSet } from './ConfigService' import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@d3ro/core/supabase-config' -import { getDatabase, openForUser, openLocal, closeCurrent } from '../db' -import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' +import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalModeData } from '../db' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LicenseTier } from '@d3ro/core/types' +import { SyncEngine, type SyncStatus } from './sync/SyncEngine' +import { SupabaseSyncRemote } from './sync/supabase-sync-remote' +import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox' +import { checkInDesktopDevice, currentDeviceInfo, unregisterDesktopDevice } from './sync/device-registration' +import type { SyncEntity, SyncRunResult } from './sync/sync-types' +import { nodeRealtimeTransport } from './sync/realtime-transport' const logger = getLogger('CloudSyncService') +/** 로컬 변경을 모아서 보내는 지연 */ +const FLUSH_DEBOUNCE_MS = 1_500 +/** Realtime 이벤트가 몰릴 때 한 번만 가져오는 지연 */ +const PULL_DEBOUNCE_MS = 1_500 +/** 기기 생존 신호 + Realtime 누락 대비 전체 동기화 주기 */ +const HEARTBEAT_MS = 5 * 60_000 +/** sync_state 에 저장하는 이 사용자 DB의 등록 기기 id */ +const DEVICE_STATE_KEY = 'device:id' + +/** Realtime 변경 감시 대상. sync_tombstones = 다른 기기의 삭제 */ +const REALTIME_TABLES = [ + 'history', + 'dictionary', + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'memo_tags', + 'custom_instructions', + 'user_templates', + 'sync_tombstones', +] as const + // ============================================================ // 타입 // ============================================================ @@ -42,6 +69,10 @@ interface CloudSyncState { userEmail: string | null lastSyncAt: number | null syncing: boolean + /** 아직 서버에 올라가지 않은 로컬 변경 수 */ + pendingChanges: number + /** 서버가 거부해 자동 재시도를 멈춘 변경 수 (수동 업로드로 재시도) */ + failedChanges: number } interface CloudSyncEvents { @@ -49,6 +80,8 @@ interface CloudSyncEvents { 'sync-progress': (payload: SyncProgress) => void 'sync-complete': (payload: SyncResult) => void 'sync-error': (payload: { error: string }) => void + /** 다른 기기의 변경이 로컬에 반영됨 — 화면 새로고침 신호 */ + 'data-changed': (payload: { entities: SyncEntity[] }) => void } // ============================================================ @@ -65,6 +98,10 @@ class CloudSyncService extends EventEmitter { private _realtimeRetryTimer: NodeJS.Timeout | null = null private _initialized = false private _realtimeChannel: RealtimeChannel | null = null + private _engine: SyncEngine | null = null + private _flushTimer: NodeJS.Timeout | null = null + private _pullTimer: NodeJS.Timeout | null = null + private _heartbeatTimer: NodeJS.Timeout | null = null /** * 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원. @@ -79,7 +116,11 @@ class CloudSyncService extends EventEmitter { persistSession: false, // 직접 관리 autoRefreshToken: true, detectSessionInUrl: false - } + }, + // Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이 + // 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이 + // 실시간으로 오지 않던 원인. + realtime: { transport: nodeRealtimeTransport } }) this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null @@ -173,45 +214,52 @@ class CloudSyncService extends EventEmitter { ) } - // 3) renderer 게이트 해제 — DB가 열리고 티어가 반영된 뒤에 emit + // 3) 로그인 전 익명 로컬 모드에서 쌓인 기록을 이 계정 DB로 가져온다(최초 1회). + // 가져온 행은 아래 최초 대조가 서버로 올려 모바일에서도 보인다. + try { + importLocalModeData() + } catch (err) { + logger.warn( + `[auth:${opts.reason}] Local-mode import failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + + // 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다. + this._engine?.dispose() + this._engine = null + if (this._client) { + const engine = new SyncEngine({ remote: new SupabaseSyncRemote(this._client), userId }) + engine.on('progress', (payload) => this.emit('sync-progress', payload)) + this._engine = engine + } + + // 5) renderer 게이트 해제 — DB가 열리고 티어가 반영된 뒤에 emit this.emit('auth-changed', { user: session.user }) - // 4) Realtime 구독 자동 시작 + // 6) 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) - } + // 7) 이 데스크톱을 계정의 기기 목록에 등록(모바일 "연결된 기기"), 주기 점검 시작 + void this._checkInDevice(opts.reason) + this._startHeartbeat() - /** - * 빅뱅 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)}` - ) - } + // 8) 최초 동기화: 대조(1회) → 로컬 변경 push → 원격 변경·삭제 pull. + // fire-and-forget — UI는 이미 해제됨. 실패해도 outbox에 남아 다음 트리거에 재시도된다. + void this._runEngine('full') + .then((result) => { + logger.info( + `[auth:${opts.reason}] Initial sync done: pushed=${result.pushed} pulled=${result.pulled} deleted=${result.deleted} errors=${result.errors.length}` + ) + }) + .catch((err) => { + logger.warn( + `[auth:${opts.reason}] Initial sync failed: ${err instanceof Error ? err.message : String(err)}` + ) + }) } /** @@ -248,7 +296,7 @@ class CloudSyncService extends EventEmitter { * VoiceMode/MeetingMode/Caption 진행 중이면 먼저 멈추고, 그 다음 Realtime stop, * 그 다음 auth-changed(null) emit, 마지막으로 DB close. */ - private async _onSignOut(): Promise { + private async _onSignOut(options: { unregister: boolean } = { unregister: true }): Promise { // 1) 활성 녹음/세션 강제 종료 (DB close 전에 flush 기회 제공) try { const { getVoiceModeService } = await import('./VoiceModeService') @@ -286,10 +334,18 @@ class CloudSyncService extends EventEmitter { ) } - // 2) Realtime 구독 종료 + // 2) Realtime 구독·주기 작업 종료, 엔진 해제 await this.stopRealtime() + this._stopHeartbeat() + this._engine?.dispose() + this._engine = null - // 3) Supabase 세션 무효화 + // 3) 사용자가 직접 로그아웃하면 기기 목록에서도 빠진다(원격 해제로 로그아웃될 때는 이미 해제됨). + if (options.unregister && this._client && this._session) { + await unregisterDesktopDevice(this._client).catch(() => undefined) + } + + // 4) Supabase 세션 무효화 if (this._client && this._session) { try { await this._client.auth.signOut() @@ -475,11 +531,14 @@ class CloudSyncService extends EventEmitter { } getState(): CloudSyncState { + const status = this.getSyncStatus() return { authenticated: this.isAuthenticated(), userEmail: this._session?.user?.email ?? null, lastSyncAt: this._lastSyncAt, - syncing: this._syncing + syncing: this._syncing, + pendingChanges: status?.pending ?? 0, + failedChanges: status?.parked ?? 0, } } @@ -491,8 +550,8 @@ class CloudSyncService extends EventEmitter { /** * 원격 변경사항을 실시간으로 구독한다. - * meetings / meeting_memos / meeting_documents / history / dictionary에 - * INSERT/UPDATE 이벤트가 오면 pullAll()로 자동 동기화. + * 동기화 대상 테이블·삭제 기록(sync_tombstones)에 변경이 오면 debounce 후 pull, + * devices 변경이 오면 이 기기의 연결 해제 여부를 확인한다. * 중복 호출 방지를 위해 기존 채널이 있으면 먼저 해제. */ async startRealtime(): Promise { @@ -518,36 +577,21 @@ class CloudSyncService extends EventEmitter { ) } - // 변경 감지 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) + let channel = this._client.channel(`cloud-sync:${userId}`) + for (const table of REALTIME_TABLES) { + channel = channel.on( + 'postgres_changes', + { event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` }, + () => this._schedulePull() + ) } - - 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() - ) + // 이 계정의 기기 목록이 바뀌면(다른 기기에서 연결 해제 포함) 등록 상태를 다시 확인한다. + channel = channel.on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'devices', filter: `user_id=eq.${userId}` }, + () => void this._checkInDevice('heartbeat') + ) + this._realtimeChannel = channel .subscribe((status) => { logger.info(`Realtime 채널 상태: ${status}`) // U1: TIMED_OUT / CHANNEL_ERROR / CLOSED 시 지수 백오프 자동 재구독. @@ -713,701 +757,169 @@ class CloudSyncService extends EventEmitter { await this._onSignOut() } - // ── 동기화 (push only, MVP) ──────────────────────────── + // ── 동기화 ───────────────────────────────────────────── /** - * 마지막 동기화 시각 이후 변경된 행을 push. - * 매핑 테이블 없이 ON CONFLICT(id) DO UPDATE 사용. + * 로컬 변경 기록(outbox) + 짧은 지연 뒤 push. + * 로그인 상태가 아니면(익명 로컬 모드) 기록하지 않는다 — 로그인 시 최초 대조가 올린다. */ - 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 - } + pushOne(entity: SyncEntity, id: string): void { + this._recordChange(entity, id, 'upsert') } - /** - * 원격 변경을 로컬 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 - } + /** 로컬 삭제를 다른 기기로 전파한다. */ + pushDelete(entity: SyncEntity, id: string): void { + this._recordChange(entity, id, 'delete') } - // ── 내부 헬퍼 ────────────────────────────────────────── - - // ── 단건 자동 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 대상이 아님. 조용히 종료. + private _recordChange(entity: SyncEntity, id: string, op: 'upsert' | 'delete'): void { + if (!this._session || !this._engine || getCurrentUserId() !== this._session.user.id) return + try { + enqueueChange(entity, id, op) + } catch (err) { + // 로컬 write는 절대 막지 않는다 — 다음 최초 대조가 없는 행을 다시 찾는다. + logger.warn(`Sync enqueue ${entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}`) return } + this._scheduleFlush() + } - const userId = this._session.user.id - const db = getDatabase() - const client = this._client + private _scheduleFlush(delayMs = FLUSH_DEBOUNCE_MS): void { + if (this._flushTimer) return + this._flushTimer = setTimeout(() => { + this._flushTimer = null + void this._runEngine('flush').catch(() => undefined) + }, delayMs) + } + private _schedulePull(delayMs = PULL_DEBOUNCE_MS): void { + if (this._pullTimer) return + this._pullTimer = setTimeout(() => { + this._pullTimer = null + void this._runEngine('pull').catch(() => undefined) + }, delayMs) + } + + /** 수동 "업로드": 보관된 실패 항목도 다시 시도한다. */ + async pushAll(): Promise { + const result = await this._runEngine('flush', { releaseParked: true }) + return { pushed: result.pushed, errors: result.errors } + } + + /** 수동 "다운로드". */ + async pullAll(): Promise { + const result = await this._runEngine('pull') + return { pushed: result.pulled + result.deleted, errors: result.errors } + } + + private async _runEngine( + kind: 'full' | 'flush' | 'pull', + options: { releaseParked?: boolean } = {} + ): Promise { + const engine = this._engine + if (!engine || !this._session) { + throw new D3ROError(ErrorCode.LLMServerUnreachable, '로그인이 필요합니다') + } + this._syncing = true 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 - } + const result = + kind === 'full' + ? await engine.runFullSync() + : kind === 'flush' + ? await engine.flush(options) + : await engine.pull() + if (engine !== this._engine) return result + if (kind !== 'flush') { + this._lastSyncAt = Date.now() + configSet('cloudSyncLastAt', this._lastSyncAt) } - logger.debug(`pushOne ${table}/${id} ok`) + if (result.changed.length > 0) this.emit('data-changed', { entities: result.changed }) + this.emit('sync-complete', { pushed: result.pushed + result.pulled + result.deleted, errors: result.errors }) + if (result.errors.length > 0) { + logger.warn(`Sync ${kind} finished with ${result.errors.length} error(s): ${result.errors.slice(0, 3).join(' | ')}`) + } + return result } catch (err) { const message = err instanceof Error ? err.message : String(err) - // 로컬 write는 절대 차단하지 않는다 — warn만. - logger.warn(`pushOne ${table}/${id} failed: ${message}`) + logger.error(`Sync ${kind} failed: ${message}`) + this.emit('sync-error', { error: message }) + throw err + } finally { + this._syncing = false } } - // ── 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 }) + private async _checkInDevice(reason: 'signin' | 'restore' | 'heartbeat'): Promise { + const client = this._client + const session = this._session + if (!client || !session) return + let known: string | null = null 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 + known = getSyncState(DEVICE_STATE_KEY) + } catch { + known = null } - } - - 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 + const check = await checkInDesktopDevice( + client, + session.user.id, + reason, + currentDeviceInfo(app.getVersion()), + reason === 'signin' ? null : known + ) + if (check.status === 'active') { + setSyncState(DEVICE_STATE_KEY, check.deviceId) + } else if (check.status === 'revoked' && this._session === session) { + logger.warn('This desktop was disconnected from another device — signing out') + await this._onSignOut({ unregister: false }) + this.emit('sync-error', { + error: 'This computer was disconnected from your account on another device. Sign in again to keep syncing.', + }) + } } catch (err) { - const message = err instanceof Error ? err.message : String(err) - result.errors.push(`${table}: ${message}`) - logger.error(`Push ${table} failed: ${message}`) - return 0 + logger.warn(`Device check-in (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`) } } - /** - * drizzle-orm v0.45는 inArray 헬퍼가 별도. 간단 구현. - */ - private _inArray(column: T, values: string[]): ReturnType { - if (values.length === 0) { - return eq(column as never, '__never__' as never) + private _startHeartbeat(): void { + this._stopHeartbeat() + this._heartbeatTimer = setInterval(() => { + if (!this._session) return + void this._checkInDevice('heartbeat') + // 재구독을 포기한 채널(3회 실패)을 되살린다 — 네트워크가 돌아온 뒤에도 실시간이 끊겨 있지 않게. + if (this._realtimeChannel?.state !== 'joined') { + this._realtimeRetryCount = 0 + void this.startRealtime().catch(() => undefined) + } + // Realtime 이벤트를 놓쳤을 때의 안전망 + void this._runEngine('full').catch(() => undefined) + }, HEARTBEAT_MS) + } + + private _stopHeartbeat(): void { + if (this._heartbeatTimer) { + clearInterval(this._heartbeatTimer) + this._heartbeatTimer = null } - if (values.length === 1) { - return eq(column as never, values[0] as never) + if (this._flushTimer) { + clearTimeout(this._flushTimer) + this._flushTimer = null + } + if (this._pullTimer) { + clearTimeout(this._pullTimer) + this._pullTimer = null + } + } + + getSyncStatus(): SyncStatus | null { + if (!this._engine) return null + try { + return this._engine.getStatus() + } catch { + return null } - // 여러 개는 첫 번째만 (MVP — 추후 inArray로 교체) - return eq(column as never, values[0] as never) } // ── 토큰 영속화 (electron safeStorage) ───────────────── diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 58de637..ab944a4 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -157,6 +157,7 @@ const CONFIG_DEFAULTS: AppConfig = { supabaseUrl: '', supabaseAnonKey: '', cloudSyncLastAt: null, + deviceInstallationId: null, onboardingCompleted: false, // Phase 6/10+: AppConfig 키 기본값 (WS2 SSOT 강화 대응). // as never 제거 후 configGet이 이 키들을 반환 — 기존 사용자 config(0.1.x)에 diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index 7eae7f6..37f49e9 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -4,6 +4,7 @@ import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' +import { getCloudSyncService } from './CloudSyncService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { CustomInstruction } from '@d3ro/core/types' @@ -147,6 +148,7 @@ class CustomInstructionService { instructions.push(instruction) saveInstructions() + getCloudSyncService().pushOne('custom_instructions', instruction.id) logger.info(`Custom instruction created: "${instruction.name}"`) return instruction } @@ -164,6 +166,7 @@ class CustomInstructionService { } } else { instructions[index] = { ...existing, ...data, updatedAt: Date.now() } + getCloudSyncService().pushOne('custom_instructions', id) } saveInstructions() @@ -182,10 +185,33 @@ class CustomInstructionService { instructions.splice(index, 1) saveInstructions() + getCloudSyncService().pushDelete('custom_instructions', id) logger.info(`Custom instruction deleted: ${id}`) return true } + /** 동기화: 다른 기기에서 만든/고친 사용자 명령을 반영한다. outbox에 다시 넣지 않는다. */ + applyRemote(instruction: CustomInstruction): void { + const index = instructions.findIndex((i) => i.id === instruction.id) + if (index === -1) { + instructions.push(instruction) + } else if (!instructions[index].isBuiltin) { + instructions[index] = instruction + } else { + return + } + saveInstructions() + } + + /** 동기화: 다른 기기에서 지운 사용자 명령을 지운다. 프리셋은 건드리지 않는다. */ + removeRemote(id: string): boolean { + const index = instructions.findIndex((i) => i.id === id) + if (index === -1 || instructions[index].isBuiltin) return false + instructions.splice(index, 1) + saveInstructions() + return true + } + reorder(ids: string[]): void { const reordered: CustomInstruction[] = [] for (let i = 0; i < ids.length; i++) { @@ -202,8 +228,17 @@ class CustomInstructionService { } } - instructions = reordered + const previousOrder = new Map(instructions.map((i) => [i.id, i.order])) + const now = Date.now() + instructions = reordered.map((inst) => + previousOrder.get(inst.id) === inst.order ? inst : { ...inst, updatedAt: now } + ) saveInstructions() + for (const inst of instructions) { + if (!inst.isBuiltin && previousOrder.get(inst.id) !== inst.order) { + getCloudSyncService().pushOne('custom_instructions', inst.id) + } + } } resetBuiltins(): void { diff --git a/apps/desktop/src/main/services/DictationTemplateService.ts b/apps/desktop/src/main/services/DictationTemplateService.ts index a4a995b..93d3402 100644 --- a/apps/desktop/src/main/services/DictationTemplateService.ts +++ b/apps/desktop/src/main/services/DictationTemplateService.ts @@ -5,6 +5,7 @@ import { EventEmitter } from 'events' import Store from 'electron-store' import { getLogger } from './LoggerService' +import { getCloudSyncService } from './CloudSyncService' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -113,6 +114,7 @@ class DictationTemplateService extends EventEmitter { const templates = this.getAll() templates.push(template) this._store.set('templates', templates) + getCloudSyncService().pushOne('user_templates', template.id) return template } @@ -136,6 +138,7 @@ class DictationTemplateService extends EventEmitter { templates[idx] = updated this._store.set('templates', templates) + if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id) return updated } @@ -153,6 +156,26 @@ class DictationTemplateService extends EventEmitter { 'templates', templates.filter((t) => t.id !== id), ) + getCloudSyncService().pushDelete('user_templates', id) + } + + /** 동기화: 다른 기기의 사용자 템플릿을 반영한다. outbox에 다시 넣지 않는다. */ + applyRemote(template: DictationTemplate): void { + const templates = this.getAll() + const idx = templates.findIndex((t) => t.id === template.id) + if (idx === -1) templates.push(template) + else if (!templates[idx].isBuiltin) templates[idx] = template + else return + this._store.set('templates', templates) + } + + /** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */ + removeRemote(id: string): boolean { + const templates = this.getAll() + const target = templates.find((t) => t.id === id) + if (!target || target.isBuiltin) return false + this._store.set('templates', templates.filter((t) => t.id !== id)) + return true } // ── 세션 관리 ── diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index e8528b3..046801f 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -170,6 +170,7 @@ class DictionaryService { delete(id: string): boolean { const db = getDatabase() const result = db.delete(dictionary).where(eq(dictionary.id, id)).run() + if (result.changes > 0) getCloudSyncService().pushDelete('dictionary', id) return result.changes > 0 } diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index b41b6aa..ad0f23f 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -3,7 +3,7 @@ import { eq, desc, like, and, sql, count } from 'drizzle-orm' import { getDatabase } from '../db' -import { history, stats } from '../db/schema' +import { history, memoTags, stats } from '../db/schema' import type { History, NewHistory } from '../db/schema' import { getLogger } from './LoggerService' import { getCloudSyncService } from './CloudSyncService' @@ -26,6 +26,7 @@ class HistoryService { const entry: NewHistory = { id, + isFavorite: false, ...input, createdAt: now, updatedAt: now @@ -125,16 +126,40 @@ class HistoryService { delete(id: string): boolean { const db = getDatabase() + db.delete(memoTags).where(eq(memoTags.historyId, id)).run() const result = db.delete(history).where(eq(history.id, id)).run() + if (result.changes > 0) getCloudSyncService().pushDelete('history', id) return result.changes > 0 } + /** + * 사용자가 명시적으로 "전체 삭제"한 것이므로 다른 기기에도 전파한다. + * 로컬에 있던 행만 지운다 — 아직 내려받지 않은 원격 행까지 일괄 삭제하지 않는다. + */ deleteAll(): void { const db = getDatabase() + const ids = db.select({ id: history.id }).from(history).all().map((r) => r.id) + db.delete(memoTags).run() db.delete(history).run() + const sync = getCloudSyncService() + for (const id of ids) sync.pushDelete('history', id) logger.info('All history entries deleted') } + /** 즐겨찾기 토글 — 모바일 History 즐겨찾기와 같은 값(history.is_favorite)이다. */ + setFavorite(id: string, isFavorite: boolean): HistoryEntry | null { + const db = getDatabase() + const result = db + .update(history) + .set({ isFavorite, updatedAt: Date.now() }) + .where(eq(history.id, id)) + .run() + if (result.changes === 0) return null + getCloudSyncService().pushOne('history', id) + const row = db.select().from(history).where(eq(history.id, id)).get() + return row ? this._toEntry(row) : null + } + getStats(): StatsSummary { const db = getDatabase() const row = db.select().from(stats).where(eq(stats.id, 1)).get() @@ -261,6 +286,7 @@ class HistoryService { updatedAt: row.updatedAt, appVersion: row.appVersion, summaryText: row.summaryText ?? null, + isFavorite: row.isFavorite ?? false, } } } diff --git a/apps/desktop/src/main/services/MeetingDocTemplateService.ts b/apps/desktop/src/main/services/MeetingDocTemplateService.ts index dacd8ba..43dd5a9 100644 --- a/apps/desktop/src/main/services/MeetingDocTemplateService.ts +++ b/apps/desktop/src/main/services/MeetingDocTemplateService.ts @@ -4,6 +4,7 @@ import { EventEmitter } from 'events' import Store from 'electron-store' import { getLogger } from './LoggerService' +import { getCloudSyncService } from './CloudSyncService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { MeetingDocTemplate, @@ -188,6 +189,7 @@ class MeetingDocTemplateService extends EventEmitter { const templates = this.getAll() templates.push(template) this._store.set('templates', templates) + getCloudSyncService().pushOne('user_templates', template.id) logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`) return template } @@ -213,6 +215,7 @@ class MeetingDocTemplateService extends EventEmitter { templates[idx] = updated this._store.set('templates', templates) + if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id) logger.info(`회의 문서 템플릿 수정: ${params.id}`) return updated } @@ -237,9 +240,29 @@ class MeetingDocTemplateService extends EventEmitter { 'templates', templates.filter((t) => t.id !== id), ) + getCloudSyncService().pushDelete('user_templates', id) logger.info(`회의 문서 템플릿 삭제: ${id}`) } + /** 동기화: 다른 기기의 사용자 템플릿을 반영한다. outbox에 다시 넣지 않는다. */ + applyRemote(template: MeetingDocTemplate): void { + const templates = this.getAll() + const idx = templates.findIndex((t) => t.id === template.id) + if (idx === -1) templates.push(template) + else if (!templates[idx].isBuiltin) templates[idx] = template + else return + this._store.set('templates', templates) + } + + /** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */ + removeRemote(id: string): boolean { + const templates = this.getAll() + const target = templates.find((t) => t.id === id) + if (!target || target.isBuiltin) return false + this._store.set('templates', templates.filter((t) => t.id !== id)) + return true + } + private _ensureBuiltins(): void { const templates = this.getAll() let changed = false diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index 264681e..86eaf1b 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -211,6 +211,7 @@ class MeetingModeService extends EventEmitter { errorMessage: err instanceof Error ? err.message : String(err), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) this._setState('idle') throw err @@ -237,7 +238,7 @@ class MeetingModeService extends EventEmitter { // Phase 3.3: 부모 meeting row를 즉시 push (빈 상태로). // 자식 meeting_memos/meeting_documents의 RLS/FK가 부모 존재를 요구하므로, // 녹음 중에 들어오는 addMemo/generateDocument 푸시가 통과하려면 필수. - void getCloudSyncService().pushOne('meetings', sessionId) + getCloudSyncService().pushOne('meetings', sessionId) return { sessionId } } @@ -273,7 +274,7 @@ class MeetingModeService extends EventEmitter { logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`) // Phase 3.3: 자동 push (fire-and-forget) - void getCloudSyncService().pushOne('meeting_memos', memo.id) + getCloudSyncService().pushOne('meeting_memos', memo.id) return memo } @@ -317,6 +318,7 @@ class MeetingModeService extends EventEmitter { durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`) @@ -339,6 +341,7 @@ class MeetingModeService extends EventEmitter { status: 'processing', updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) // Step 1: 전사 텍스트 합산 this._sendProgress(sessionId, 'merging', 40) @@ -355,9 +358,7 @@ class MeetingModeService extends EventEmitter { sttModel: configGet('sttModelId') as string | undefined, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() - - // Phase 3.3: 최종 save 후 자동 push (fire-and-forget) - void getCloudSyncService().pushOne('meetings', sessionId) + getCloudSyncService().pushOne('meetings', sessionId) // Step 2.5: 오디오 WAV 파일 저장 (화자 구분용) if (this._audioBuffersForFile.length > 0) { @@ -400,6 +401,7 @@ class MeetingModeService extends EventEmitter { errorMessage: err instanceof Error ? err.message : String(err), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) this._resetSession() this._setState('idle') @@ -478,7 +480,10 @@ class MeetingModeService extends EventEmitter { } db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run() + db.delete(meetingDocuments).where(eq(meetingDocuments.sessionId, sessionId)).run() db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run() + // 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다. + getCloudSyncService().pushDelete('meetings', sessionId) logger.info(`회의 세션 삭제: ${sessionId}`) } @@ -490,6 +495,7 @@ class MeetingModeService extends EventEmitter { } db.update(meetingSessions).set({ title, updatedAt: Date.now() }) .where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) } // ── 내보내기 ── @@ -649,6 +655,7 @@ class MeetingModeService extends EventEmitter { editedTranscript, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) logger.info(`전사 수정 저장: sessionId=${sessionId}`) } @@ -733,7 +740,7 @@ class MeetingModeService extends EventEmitter { logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`) // Phase 3.3: 자동 push (fire-and-forget) - void getCloudSyncService().pushOne('meeting_documents', docId) + getCloudSyncService().pushOne('meeting_documents', docId) return { id: docId, @@ -779,6 +786,7 @@ class MeetingModeService extends EventEmitter { } db.update(meetingDocuments).set({ content, updatedAt: Date.now() }) .where(eq(meetingDocuments.id, documentId)).run() + getCloudSyncService().pushOne('meeting_documents', documentId) logger.info(`문서 수정: docId=${documentId}`) } @@ -789,6 +797,7 @@ class MeetingModeService extends EventEmitter { throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) } db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run() + getCloudSyncService().pushDelete('meeting_documents', documentId) logger.info(`문서 삭제: docId=${documentId}`) } @@ -1012,6 +1021,7 @@ class MeetingModeService extends EventEmitter { editedTranscript: result.text, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) logger.info(`Auto Polish 완료: sessionId=${sessionId}`) return result.text @@ -1163,6 +1173,7 @@ ${transcript}` editedTranscript: labeledLines.join('\n'), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`) } catch (err) { @@ -1211,6 +1222,7 @@ ${speakerHint} editedTranscript: result.text, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() + getCloudSyncService().pushOne('meetings', sessionId) } private _buildPdfHtml(session: MeetingSessionDetail): string { diff --git a/apps/desktop/src/main/services/MemoService.ts b/apps/desktop/src/main/services/MemoService.ts index 4fe1f2f..d576a7d 100644 --- a/apps/desktop/src/main/services/MemoService.ts +++ b/apps/desktop/src/main/services/MemoService.ts @@ -9,6 +9,8 @@ import { getDatabase } from '../db' import { memoTags, history } from '../db/schema' import type { MemoTagRow } from '../db/schema' import { getLogger } from './LoggerService' +import { getCloudSyncService } from './CloudSyncService' +import { memoTagKey, normalizeMemoTag } from './sync/memo-tag-sync' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { MemoTag, @@ -43,7 +45,7 @@ class MemoService { */ addTag(historyId: string, tag: string): MemoTag { const db = getDatabase() - const normalizedTag = tag.trim().toLowerCase() + const normalizedTag = normalizeMemoTag(tag) if (!normalizedTag) { throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Memo tag is empty') } @@ -74,6 +76,7 @@ class MemoService { }) .run() + getCloudSyncService().pushOne('memo_tags', memoTagKey(historyId, normalizedTag)) logger.info(`Tag added: "${normalizedTag}" → history ${historyId}`) return { @@ -90,7 +93,7 @@ class MemoService { */ removeTag(historyId: string, tag: string): void { const db = getDatabase() - const normalizedTag = tag.trim().toLowerCase() + const normalizedTag = normalizeMemoTag(tag) const result = db .delete(memoTags) @@ -104,6 +107,7 @@ class MemoService { ) } + getCloudSyncService().pushDelete('memo_tags', memoTagKey(historyId, normalizedTag)) logger.info(`Tag removed: "${normalizedTag}" from history ${historyId}`) } @@ -134,7 +138,7 @@ class MemoService { searchByTag(params: SearchByTagParams): HistoryPage { const db = getDatabase() const { tag, page, pageSize } = params - const normalizedTag = tag.trim().toLowerCase() + const normalizedTag = normalizeMemoTag(tag) const totalResult = db .select({ count: count() }) @@ -169,7 +173,8 @@ class MemoService { createdAt: history.createdAt, updatedAt: history.updatedAt, appVersion: history.appVersion, - summaryText: history.summaryText + summaryText: history.summaryText, + isFavorite: history.isFavorite }) .from(memoTags) .innerJoin(history, eq(memoTags.historyId, history.id)) @@ -196,7 +201,7 @@ class MemoService { // 태그별 히스토리 조회 const tagFilter = params.tag - ? eq(memoTags.tag, params.tag.trim().toLowerCase()) + ? eq(memoTags.tag, normalizeMemoTag(params.tag)) : undefined const dateConditions: ReturnType[] = [] @@ -333,6 +338,7 @@ class MemoService { updatedAt: number appVersion: string summaryText: string | null + isFavorite: boolean }): HistoryEntry { return { id: row.id, @@ -357,7 +363,8 @@ class MemoService { createdAt: row.createdAt, updatedAt: row.updatedAt, appVersion: row.appVersion, - summaryText: row.summaryText + summaryText: row.summaryText, + isFavorite: row.isFavorite } } } diff --git a/apps/desktop/src/main/services/sync/SyncEngine.ts b/apps/desktop/src/main/services/sync/SyncEngine.ts new file mode 100644 index 0000000..dac6cc9 --- /dev/null +++ b/apps/desktop/src/main/services/sync/SyncEngine.ts @@ -0,0 +1,467 @@ +// src/main/services/sync/SyncEngine.ts +// 데스크톱 SQLite ↔ Supabase 양방향 동기화 엔진. +// +// 원칙 +// - 로컬 변경은 outbox에 쌓였다가 push된다(오프라인·재시작에도 유실 없음). +// - pull은 서버 시각(updated_at) keyset 커서로 가져온다. 로컬 시계를 쓰지 않는다. +// - 아직 올라가지 않은 로컬 변경이 있는 행은 pull이 덮지 않는다. +// - 삭제는 sync_tombstones로 양방향 전파된다. +// - 커서·플래그는 사용자 DB 안(sync_state)에 있어 계정별로 분리된다. + +import { EventEmitter } from 'events' +import { getLogger } from '../LoggerService' +import { TABLE_ADAPTERS, adapterFor, type PushContext, type SyncAdapter } from './sync-adapters' +import { + fetchRemoteMemoTagKeys, + listLocalMemoTagKeys, + pushMemoTagAdds, + pushMemoTagRemovals, + reconcileMemoTags, +} from './memo-tag-sync' +import { + completeEntry, + dropEntry, + failEntry, + getSyncState, + listDueEntries, + outboxCounts, + pendingOps, + releaseParkedEntries, + setSyncState, + enqueueChange, + type OutboxEntry, +} from './sync-outbox' +import { + emptyRunResult, + isSyncEntity, + type PushOutcome, + type RemoteCursor, + type RemoteRow, + type SyncEntity, + type SyncRemote, + type SyncRunResult, +} from './sync-types' + +const logger = getLogger('SyncEngine') + +const PULL_PAGE_SIZE = 500 +/** + * 커서 되감기 폭. updated_at은 트랜잭션 시작 시각이라 늦게 커밋된 행이 커서 뒤에 끼어들 수 있다. + * 매 pull마다 이만큼 겹쳐 읽는다 — 반영은 멱등이라 다시 읽어도 안전하다. + */ +const CURSOR_OVERLAP_MS = 60_000 +const BACKFILL_FLAG = 'backfill:v1' +/** 되감은 커서의 id 하한. 행 테이블은 uuid, sync_tombstones는 bigserial이다. */ +const MIN_UUID = '00000000-0000-0000-0000-000000000000' +const MIN_SERIAL = '0' + +export interface SyncEngineOptions { + remote: SyncRemote + userId: string + now?: () => number +} + +export interface SyncStatus { + pending: number + parked: number + lastPullAt: number | null +} + +interface SyncEngineEvents { + progress: (payload: { current: number; total: number; table: string }) => void +} + +function cursorKey(entity: SyncEntity | 'tombstones'): string { + return `cursor:${entity}` +} + +function parseCursor(value: string | null): RemoteCursor | null { + if (!value) return null + try { + const parsed = JSON.parse(value) as Partial + return typeof parsed.ts === 'string' && typeof parsed.id === 'string' ? { ts: parsed.ts, id: parsed.id } : null + } catch { + return null + } +} + +/** 커서를 CURSOR_OVERLAP_MS만큼 되감은 시작점. */ +function rewind(cursor: RemoteCursor | null, minId: string): RemoteCursor | null { + if (!cursor) return null + const at = Date.parse(cursor.ts) + if (!Number.isFinite(at)) return null + return { ts: new Date(Math.max(0, at - CURSOR_OVERLAP_MS)).toISOString(), id: minId } +} + +function compareIds(a: string, b: string): number { + if (/^\d+$/.test(a) && /^\d+$/.test(b)) { + const x = BigInt(a) + const y = BigInt(b) + return x === y ? 0 : x > y ? 1 : -1 + } + return a === b ? 0 : a > b ? 1 : -1 +} + +/** 서버 timestamptz 문자열 비교. 같은 밀리초 안의 순서는 원문(마이크로초)으로 가린다. */ +function isAfter(a: RemoteCursor, b: RemoteCursor | null): boolean { + if (!b) return true + const x = Date.parse(a.ts) + const y = Date.parse(b.ts) + if (Number.isFinite(x) && Number.isFinite(y) && x !== y) return x > y + if (a.ts !== b.ts) return a.ts > b.ts + return compareIds(a.id, b.id) > 0 +} + +function rowCursor(row: RemoteRow, column: string): RemoteCursor | null { + const ts = row[column] + const id = row.id + if (typeof ts !== 'string' || (typeof id !== 'string' && typeof id !== 'number')) return null + return { ts, id: String(id) } +} + +export class SyncEngine extends EventEmitter { + private readonly remote: SyncRemote + private readonly userId: string + private readonly now: () => number + /** flush/pull/backfill을 한 줄로 세운다 — 서로 끼어들면 pending 판정이 흔들린다. */ + private queue: Promise = Promise.resolve() + private disposed = false + + constructor(options: SyncEngineOptions) { + super() + this.remote = options.remote + this.userId = options.userId + this.now = options.now ?? Date.now + } + + dispose(): void { + this.disposed = true + this.removeAllListeners() + } + + getStatus(): SyncStatus { + const counts = outboxCounts() + const lastPull = getSyncState('lastPullAt') + return { ...counts, lastPullAt: lastPull ? Number(lastPull) : null } + } + + /** 로그인 직후/복원 직후: 최초 1회 대조 → push → pull. */ + runFullSync(): Promise { + return this.serialize(async () => { + const result = emptyRunResult() + await this.backfillIfNeeded() + await this.flushInner(result) + await this.pullInner(result) + return result + }) + } + + flush(options: { releaseParked?: boolean } = {}): Promise { + return this.serialize(async () => { + if (options.releaseParked) releaseParkedEntries() + const result = emptyRunResult() + await this.backfillIfNeeded() + await this.flushInner(result) + return result + }) + } + + pull(): Promise { + return this.serialize(async () => { + const result = emptyRunResult() + await this.pullInner(result) + return result + }) + } + + private serialize(task: () => Promise): Promise { + const run = this.queue.then( + () => task(), + () => task() + ) + this.queue = run.catch(() => undefined) + return run + } + + // ── 최초 대조 ───────────────────────────────────────── + + /** + * 이 사용자 DB에서 처음 동기화할 때 한 번: 서버에 없거나 로컬이 더 새로운 행을 outbox에 넣는다. + * 서버에 같은 판이 이미 있으면 다시 올리지 않는다(다른 기기의 편집을 덮지 않는다). + */ + private async backfillIfNeeded(): Promise { + if (getSyncState(BACKFILL_FLAG) === 'done') return + const now = this.now() + let queued = 0 + for (const adapter of TABLE_ADAPTERS) { + const local = adapter.listLocalVersions() + if (local.length === 0) continue + const remoteVersions = await this.fetchRemoteVersions(adapter) + for (const row of local) { + const remoteUpdatedAt = remoteVersions.get(row.id) + if (remoteUpdatedAt === undefined || row.updatedAt > remoteUpdatedAt) { + enqueueChange(adapter.entity, row.id, 'upsert', now) + queued++ + } + } + } + const localTags = listLocalMemoTagKeys() + if (localTags.size > 0) { + const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId) + for (const key of localTags) { + if (!remoteTags.has(key)) { + enqueueChange('memo_tags', key, 'upsert', now) + queued++ + } + } + } + setSyncState(BACKFILL_FLAG, 'done', now) + logger.info(`Backfill queued ${queued} local change(s) for ${this.userId}`) + } + + private async fetchRemoteVersions(adapter: SyncAdapter): Promise> { + const versions = new Map() + let after: RemoteCursor | null = null + for (;;) { + const page = await this.remote.fetchPage({ + table: adapter.entity, + userId: this.userId, + cursorColumn: 'updated_at', + after, + limit: 1000, + columns: 'id,updated_at', + filters: adapter.pull?.filters, + }) + for (const row of page) { + if (typeof row.id === 'string' && typeof row.updated_at === 'string') { + const at = Date.parse(row.updated_at) + if (Number.isFinite(at)) versions.set(row.id, at) + } + } + if (page.length < 1000) break + const next = rowCursor(page[page.length - 1], 'updated_at') + if (!next) break + after = next + } + return versions + } + + // ── push ────────────────────────────────────────────── + + private async flushInner(result: SyncRunResult): Promise { + const due = listDueEntries(this.now()) + if (due.length === 0) return + const ctx: PushContext = { remote: this.remote, userId: this.userId } + const byEntity = new Map() + for (const entry of due) { + if (!isSyncEntity(entry.entity)) continue + const bucket = byEntity.get(entry.entity) ?? { upserts: [], deletes: [] } + ;(entry.op === 'delete' ? bucket.deletes : bucket.upserts).push(entry) + byEntity.set(entry.entity, bucket) + } + + const order: SyncEntity[] = [...TABLE_ADAPTERS.map((a) => a.entity), 'memo_tags'] + // 부모 먼저 upsert: history → … → memo_tags(이력 필요) + const upsertOrder: SyncEntity[] = [ + 'history', + 'dictionary', + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'custom_instructions', + 'user_templates', + 'memo_tags', + ] + let networkDown = false + for (const entity of upsertOrder) { + const bucket = byEntity.get(entity) + if (!bucket || bucket.upserts.length === 0 || networkDown) continue + const outcomes = + entity === 'memo_tags' + ? await pushMemoTagAdds(ctx, bucket.upserts.map((e) => e.rowId)) + : await this.requireAdapter(entity).push(ctx, bucket.upserts.map((e) => e.rowId)) + networkDown = this.settle(bucket.upserts, outcomes, result, entity) + } + // 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다) + for (const entity of [...order].reverse()) { + const bucket = byEntity.get(entity) + if (!bucket || bucket.deletes.length === 0 || networkDown) continue + const outcomes = + entity === 'memo_tags' + ? await pushMemoTagRemovals(ctx, bucket.deletes.map((e) => e.rowId)) + : await this.requireAdapter(entity).pushDeletes(ctx, bucket.deletes.map((e) => e.rowId)) + networkDown = this.settle(bucket.deletes, outcomes, result, entity) + } + } + + /** outbox를 결과대로 정리. 네트워크 계열 실패가 있었으면 true(이번 flush 중단). */ + private settle(entries: OutboxEntry[], outcomes: PushOutcome[], result: SyncRunResult, entity: SyncEntity): boolean { + const byId = new Map(outcomes.map((o) => [o.id, o.error])) + let networkDown = false + const now = this.now() + for (const entry of entries) { + // 결과가 없는 항목은 처리되지 않은 것 — 지우지 말고 다음 flush에 맡긴다. + if (!byId.has(entry.rowId)) continue + const error = byId.get(entry.rowId) ?? null + if (error === null) { + completeEntry(entry) + result.pushed++ + continue + } + failEntry(entry, error, now) + if (error.code === 'network') networkDown = true + result.errors.push(`${entity}/${entry.rowId}: ${error.message}`) + } + if (networkDown) logger.warn(`Push stopped on ${entity}: network unavailable`) + return networkDown + } + + private requireAdapter(entity: SyncEntity): SyncAdapter { + const adapter = adapterFor(entity) + if (!adapter) throw new Error(`No sync adapter for ${entity}`) + return adapter + } + + // ── pull ────────────────────────────────────────────── + + private async pullInner(result: SyncRunResult): Promise { + const changed = new Set(result.changed) + for (const adapter of TABLE_ADAPTERS) { + if (this.disposed || !adapter.pull) continue + try { + const applied = await this.pullEntity(adapter) + if (applied > 0) changed.add(adapter.entity) + result.pulled += applied + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + result.errors.push(`${adapter.entity}: ${message}`) + logger.warn(`Pull ${adapter.entity} failed: ${message}`) + } + } + + try { + const removed = await this.pullTombstones(changed) + result.deleted += removed + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + result.errors.push(`tombstones: ${message}`) + logger.warn(`Pull tombstones failed: ${message}`) + } + + try { + const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId) + const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now()) + if (tagChanges > 0) { + changed.add('memo_tags') + result.pulled += tagChanges + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + result.errors.push(`memo_tags: ${message}`) + logger.warn(`Reconcile memo_tags failed: ${message}`) + } + + result.changed = [...changed] + if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now())) + } + + private async pullEntity(adapter: SyncAdapter): Promise { + const saved = parseCursor(getSyncState(cursorKey(adapter.entity))) + let after = rewind(saved, MIN_UUID) + let newest = saved + let applied = 0 + let seen = 0 + for (;;) { + const page = await this.remote.fetchPage({ + table: adapter.entity, + userId: this.userId, + cursorColumn: 'updated_at', + after, + limit: PULL_PAGE_SIZE, + columns: adapter.pull?.columns, + filters: adapter.pull?.filters, + }) + if (page.length === 0) break + // 페이지마다 새로 읽는다: 페이지 사이에 사용자가 편집했을 수 있다. + const pending = pendingOps(adapter.entity) + for (const row of page) { + seen++ + const id = typeof row.id === 'string' ? row.id : null + if (id && !pending.has(id)) { + try { + if (adapter.applyRemote(row)) applied++ + } catch (err) { + logger.warn( + `Apply ${adapter.entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + const cursor = rowCursor(row, 'updated_at') + if (cursor && isAfter(cursor, newest)) newest = cursor + } + this.emit('progress', { current: seen, total: seen, table: adapter.entity }) + if (page.length < PULL_PAGE_SIZE) break + const next = rowCursor(page[page.length - 1], 'updated_at') + if (!next) break + after = next + } + if (newest && newest !== saved) setSyncState(cursorKey(adapter.entity), JSON.stringify(newest)) + return applied + } + + /** 다른 기기에서 지운 행을 로컬에서도 지운다. 원격 삭제는 로컬 미전송 변경보다 우선한다. */ + private async pullTombstones(changed: Set): Promise { + const saved = parseCursor(getSyncState(cursorKey('tombstones'))) + let after = rewind(saved, MIN_SERIAL) + let newest = saved + let removed = 0 + for (;;) { + const page = await this.remote.fetchPage({ + table: 'sync_tombstones', + userId: this.userId, + cursorColumn: 'deleted_at', + after, + limit: PULL_PAGE_SIZE, + columns: 'id,table_name,row_id,deleted_at', + }) + if (page.length === 0) break + for (const row of page) { + const entity = row.table_name + const rowId = row.row_id + // memo_tags는 서버 id라 로컬과 맞지 않는다 — 전체 대조가 처리한다. + if (isSyncEntity(entity) && entity !== 'memo_tags' && typeof rowId === 'string') { + const adapter = adapterFor(entity) + if (adapter) { + dropEntry(entity, rowId) + try { + if (adapter.deleteLocal(rowId)) { + removed++ + changed.add(entity) + } + } catch (err) { + logger.warn(`Tombstone ${entity}/${rowId} failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + const cursor = rowCursor(row, 'deleted_at') + if (cursor && isAfter(cursor, newest)) newest = cursor + } + if (page.length < PULL_PAGE_SIZE) break + const next = rowCursor(page[page.length - 1], 'deleted_at') + if (!next) break + after = next + } + if (newest && newest !== saved) setSyncState(cursorKey('tombstones'), JSON.stringify(newest)) + return removed + } + + // ── 타입 안전 이벤트 ────────────────────────────────── + + override on(event: K, listener: SyncEngineEvents[K]): this { + return super.on(event, listener) + } + + override emit(event: K, ...args: Parameters): boolean { + return super.emit(event, ...args) + } +} diff --git a/apps/desktop/src/main/services/sync/device-registration.ts b/apps/desktop/src/main/services/sync/device-registration.ts new file mode 100644 index 0000000..267ae07 --- /dev/null +++ b/apps/desktop/src/main/services/sync/device-registration.ts @@ -0,0 +1,159 @@ +// src/main/services/sync/device-registration.ts +// 데스크톱을 Supabase devices에 등록한다 — 모바일 "연결된 기기" 목록에 나타나고, +// 모바일에서 연결 해제(revoke_device)하면 데스크톱이 로그아웃된다. + +import os from 'os' +import type { SupabaseClient } from '@supabase/supabase-js' +import { configGet, configSet } from '../ConfigService' +import { getLogger } from '../LoggerService' +import { isUuid } from './sync-adapters' +import { toSyncRemoteError } from './sync-types' + +const logger = getLogger('DeviceRegistration') + +const DEVICE_COLUMNS = 'id,installation_id,platform,revoked_at' + +export type DesktopPlatform = 'windows' | 'macos' + +export type DeviceCheck = + /** 등록·갱신됨 */ + | { status: 'active'; deviceId: string } + /** 다른 기기에서 연결 해제되었거나 목록에서 삭제됨 — 로그아웃해야 한다 */ + | { status: 'revoked' } + /** 등록 대상 플랫폼이 아니다(서버 platform 목록에 없음) */ + | { status: 'unsupported' } + +export function desktopPlatform(platform: NodeJS.Platform = process.platform): DesktopPlatform | null { + if (platform === 'win32') return 'windows' + if (platform === 'darwin') return 'macos' + return null +} + +export interface DeviceInfo { + deviceName: string + appVersion: string + osVersion: string +} + +export function currentDeviceInfo(appVersion: string): DeviceInfo { + const name = os.hostname().trim() || 'Desktop' + return { + deviceName: name.slice(0, 120), + appVersion, + osVersion: `${os.type()} ${os.release()}`.slice(0, 120), + } +} + +function storedInstallationId(): string | null { + const value = configGet('deviceInstallationId') + return isUuid(value) ? value : null +} + +function newInstallationId(): string { + const id = crypto.randomUUID() + configSet('deviceInstallationId', id) + return id +} + +interface DeviceRow { + id: string + revoked_at: string | null +} + +function parseDevice(value: unknown): DeviceRow | null { + if (typeof value !== 'object' || value === null) return null + const row = value as Record + if (!isUuid(row.id)) return null + return { id: row.id, revoked_at: typeof row.revoked_at === 'string' ? row.revoked_at : null } +} + +/** + * 로그인/복원/주기 점검 때 호출한다. + * - signin: 해제된 기존 등록이 있으면 새 설치 id로 다시 등록한다(사용자가 방금 다시 로그인했다). + * - restore/heartbeat: 해제됐거나 목록에서 사라졌으면 'revoked'를 돌려준다. + */ +export async function checkInDesktopDevice( + client: SupabaseClient, + userId: string, + reason: 'signin' | 'restore' | 'heartbeat', + info: DeviceInfo, + knownDeviceId: string | null +): Promise { + const platform = desktopPlatform() + if (!platform) return { status: 'unsupported' } + + let installationId = storedInstallationId() ?? newInstallationId() + const existing = await client + .from('devices') + .select(DEVICE_COLUMNS) + .eq('user_id', userId) + .eq('installation_id', installationId) + .maybeSingle() + if (existing.error) throw toSyncRemoteError(existing.error) + let device = parseDevice(existing.data) + + if (device?.revoked_at || (!device && knownDeviceId !== null)) { + if (reason !== 'signin') { + logger.warn(`Desktop device ${device?.id ?? knownDeviceId} was disconnected from another device`) + return { status: 'revoked' } + } + // 방금 다시 로그인했다 — 해제는 영구라 새 설치 id로 등록한다. + installationId = newInstallationId() + device = null + } + + if (device) { + const updated = await client + .from('devices') + .update({ + device_name: info.deviceName, + app_version: info.appVersion, + os_version: info.osVersion, + last_seen_at: new Date().toISOString(), + }) + .eq('id', device.id) + .eq('user_id', userId) + .select(DEVICE_COLUMNS) + .maybeSingle() + if (updated.error) throw toSyncRemoteError(updated.error) + const row = parseDevice(updated.data) + if (!row || row.revoked_at) return reason === 'signin' ? register(client, userId, platform, info) : { status: 'revoked' } + return { status: 'active', deviceId: row.id } + } + + return register(client, userId, platform, info, installationId) +} + +async function register( + client: SupabaseClient, + userId: string, + platform: DesktopPlatform, + info: DeviceInfo, + installationId: string = newInstallationId() +): Promise { + const inserted = await client + .from('devices') + .insert({ + user_id: userId, + installation_id: installationId, + platform, + device_name: info.deviceName, + app_version: info.appVersion, + os_version: info.osVersion, + }) + .select(DEVICE_COLUMNS) + .single() + if (inserted.error) throw toSyncRemoteError(inserted.error) + const row = parseDevice(inserted.data) + if (!row) throw toSyncRemoteError(new Error('Device registration returned no row')) + logger.info(`Desktop device registered: ${row.id} (${platform})`) + return { status: 'active', deviceId: row.id } +} + +/** 사용자가 데스크톱에서 직접 로그아웃할 때: 목록에서 빠지도록 스스로 해제한다. */ +export async function unregisterDesktopDevice(client: SupabaseClient): Promise { + const installationId = storedInstallationId() + if (!installationId) return + const { error } = await client.rpc('unregister_current_device', { current_installation_id: installationId }) + if (error) logger.warn(`Device unregister failed: ${error.message}`) +} diff --git a/apps/desktop/src/main/services/sync/memo-tag-sync.ts b/apps/desktop/src/main/services/sync/memo-tag-sync.ts new file mode 100644 index 0000000..d3bedfa --- /dev/null +++ b/apps/desktop/src/main/services/sync/memo-tag-sync.ts @@ -0,0 +1,137 @@ +// src/main/services/sync/memo-tag-sync.ts +// 메모 태그는 서버가 id를 발급하고(모바일 RPC 계약), 태그 이름 변경은 행을 UPDATE 한다. +// 그래서 id·커서 대신 자연키 `${historyId}|${정규화 태그}` 로 전체 대조한다. 태그 수는 작다. + +import { eq } from 'drizzle-orm' +import { getDatabase } from '../../db' +import { history, memoTags } from '../../db/schema' +import { isUuid, type PushContext } from './sync-adapters' +import { toSyncRemoteError, type PushOutcome, type RemoteCursor, type SyncOp, type SyncRemote } from './sync-types' + +const SEPARATOR = '|' +const PAGE_SIZE = 1000 + +/** 서버 정규화와 같다: trim → 연속 공백 1칸 → 소문자. */ +export function normalizeMemoTag(tag: string): string { + return tag.trim().replace(/\s+/g, ' ').toLowerCase() +} + +export function memoTagKey(historyId: string, tag: string): string { + return `${historyId}${SEPARATOR}${normalizeMemoTag(tag)}` +} + +export function parseMemoTagKey(key: string): { historyId: string; tag: string } | null { + const index = key.indexOf(SEPARATOR) + if (index <= 0) return null + const historyId = key.slice(0, index) + const tag = key.slice(index + 1) + if (!isUuid(historyId) || tag.length === 0) return null + return { historyId, tag } +} + +export async function pushMemoTagAdds(ctx: PushContext, keys: string[]): Promise { + const outcomes: PushOutcome[] = [] + for (const key of keys) { + const parsed = parseMemoTagKey(key) + if (!parsed) { + outcomes.push({ id: key, error: null }) + continue + } + const stillLocal = getDatabase() + .select({ id: memoTags.id, historyId: memoTags.historyId, tag: memoTags.tag }) + .from(memoTags) + .where(eq(memoTags.historyId, parsed.historyId)) + .all() + .some((r) => normalizeMemoTag(r.tag) === parsed.tag) + if (!stillLocal) { + outcomes.push({ id: key, error: null }) + continue + } + try { + await ctx.remote.rpc('mobile_add_memo_tag_v1', { p_history_id: parsed.historyId, p_tag: parsed.tag }) + outcomes.push({ id: key, error: null }) + } catch (err) { + outcomes.push({ id: key, error: toSyncRemoteError(err) }) + } + } + return outcomes +} + +export async function pushMemoTagRemovals(ctx: PushContext, keys: string[]): Promise { + const outcomes: PushOutcome[] = [] + for (const key of keys) { + const parsed = parseMemoTagKey(key) + if (!parsed) { + outcomes.push({ id: key, error: null }) + continue + } + try { + // 이미 없으면 false — 목표 상태와 같으므로 성공이다. + await ctx.remote.rpc('mobile_remove_memo_tag_v1', { p_history_id: parsed.historyId, p_tag: parsed.tag }) + outcomes.push({ id: key, error: null }) + } catch (err) { + outcomes.push({ id: key, error: toSyncRemoteError(err) }) + } + } + return outcomes +} + +export function listLocalMemoTagKeys(): Set { + const rows = getDatabase().select({ historyId: memoTags.historyId, tag: memoTags.tag }).from(memoTags).all() + return new Set(rows.map((r) => memoTagKey(r.historyId, r.tag))) +} + +export async function fetchRemoteMemoTagKeys(remote: SyncRemote, userId: string): Promise> { + const keys = new Set() + let after: RemoteCursor | null = null + for (;;) { + const page = await remote.fetchPage({ + table: 'memo_tags', + userId, + cursorColumn: 'created_at', + after, + limit: PAGE_SIZE, + columns: 'id,history_id,normalized_tag,tag,created_at', + }) + for (const row of page) { + const tag = typeof row.normalized_tag === 'string' ? row.normalized_tag : typeof row.tag === 'string' ? row.tag : null + if (isUuid(row.history_id) && tag) keys.add(memoTagKey(row.history_id, tag)) + } + if (page.length < PAGE_SIZE) break + const last = page[page.length - 1] + if (typeof last.created_at !== 'string' || typeof last.id !== 'string') break + after = { ts: last.created_at, id: last.id } + } + return keys +} + +/** + * 원격 집합을 로컬에 맞춘다. 아직 올라가지 않은 로컬 변경(pending)은 건드리지 않는다. + * 반환값: 로컬이 바뀐 건수. + */ +export function reconcileMemoTags(remoteKeys: Set, pending: Map, now = Date.now()): number { + const db = getDatabase() + const localRows = db.select().from(memoTags).all() + const localByKey = new Map(localRows.map((r) => [memoTagKey(r.historyId, r.tag), r])) + let changed = 0 + + for (const key of remoteKeys) { + if (localByKey.has(key) || pending.get(key) === 'delete') continue + const parsed = parseMemoTagKey(key) + if (!parsed) continue + const hasHistory = db.select({ id: history.id }).from(history).where(eq(history.id, parsed.historyId)).get() + if (!hasHistory) continue + db.insert(memoTags) + .values({ id: crypto.randomUUID(), historyId: parsed.historyId, tag: parsed.tag, createdAt: now }) + .onConflictDoNothing() + .run() + changed++ + } + + for (const [key, row] of localByKey) { + if (remoteKeys.has(key) || pending.get(key) === 'upsert') continue + db.delete(memoTags).where(eq(memoTags.id, row.id)).run() + changed++ + } + return changed +} diff --git a/apps/desktop/src/main/services/sync/realtime-transport.ts b/apps/desktop/src/main/services/sync/realtime-transport.ts new file mode 100644 index 0000000..16c68d8 --- /dev/null +++ b/apps/desktop/src/main/services/sync/realtime-transport.ts @@ -0,0 +1,10 @@ +// src/main/services/sync/realtime-transport.ts +// Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없어 Supabase Realtime이 연결하지 못한다. +// ws는 브라우저 WebSocket API와 호환되지만 이벤트 핸들러 선언이 달라 타입만 한 번 맞춘다. + +import WebSocket from 'ws' +import type { SupabaseClientOptions } from '@supabase/supabase-js' + +type RealtimeTransport = NonNullable['realtime']>['transport']> + +export const nodeRealtimeTransport = WebSocket as unknown as RealtimeTransport diff --git a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts new file mode 100644 index 0000000..57c94be --- /dev/null +++ b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts @@ -0,0 +1,96 @@ +// src/main/services/sync/supabase-sync-remote.ts +// SyncRemote 의 Supabase(PostgREST) 구현. + +import type { SupabaseClient } from '@supabase/supabase-js' +import { + toSyncRemoteError, + type RemoteFilter, + type RemotePageRequest, + type RemoteRow, + type SyncRemote, +} from './sync-types' + +/** PostgREST or() 값은 따옴표로 감싸야 `+`, `:` 가 섞인 timestamptz를 안전하게 넘긴다. */ +function quote(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +/** ilike 패턴에서 와일드카드를 이스케이프해 대소문자만 무시하는 정확 일치로 만든다. */ +export function exactIlikePattern(value: string): string { + return value.replace(/[\\%_]/g, (ch) => `\\${ch}`) +} + +interface FilterableQuery { + eq(column: string, value: string | number | boolean): this + is(column: string, value: null): this + ilike(column: string, pattern: string): this +} + +function applyFilters(query: Q, filters: RemoteFilter[] | undefined): Q { + let q = query + for (const filter of filters ?? []) { + if (filter.op === 'eq') q = q.eq(filter.column, filter.value) + else if (filter.op === 'is') q = q.is(filter.column, null) + else q = q.ilike(filter.column, exactIlikePattern(filter.value)) + } + return q +} + +function rowsOf(data: unknown): RemoteRow[] { + if (!Array.isArray(data)) return [] + return data.filter((row): row is RemoteRow => typeof row === 'object' && row !== null) +} + +export class SupabaseSyncRemote implements SyncRemote { + constructor(private readonly client: SupabaseClient) {} + + async fetchPage(request: RemotePageRequest): Promise { + const { table, userId, cursorColumn, after, limit, columns, filters } = request + let query = this.client + .from(table) + .select(columns ?? '*') + .eq('user_id', userId) + query = applyFilters(query, filters) + if (after) { + query = query.or( + `${cursorColumn}.gt.${quote(after.ts)},and(${cursorColumn}.eq.${quote(after.ts)},id.gt.${quote(after.id)})` + ) + } + const { data, error } = await query + .order(cursorColumn, { ascending: true }) + .order('id', { ascending: true }) + .limit(limit) + if (error) throw toSyncRemoteError(error) + return rowsOf(data) + } + + async selectWhere( + table: string, + userId: string, + filters: RemoteFilter[], + columns?: string + ): Promise { + const query = applyFilters(this.client.from(table).select(columns ?? '*').eq('user_id', userId), filters) + const { data, error } = await query.limit(100) + if (error) throw toSyncRemoteError(error) + return rowsOf(data) + } + + async upsert(table: string, rows: RemoteRow[]): Promise { + if (rows.length === 0) return + const { error } = await this.client.from(table).upsert(rows, { onConflict: 'id' }) + if (error) throw toSyncRemoteError(error) + } + + async deleteByIds(table: string, userId: string, ids: string[]): Promise { + if (ids.length === 0) return + const { error } = await this.client.from(table).delete().eq('user_id', userId).in('id', ids) + if (error) throw toSyncRemoteError(error) + } + + async rpc(name: string, params: Record): Promise { + const { data, error } = await this.client.rpc(name, params) + if (error) throw toSyncRemoteError(error) + return data + } +} diff --git a/apps/desktop/src/main/services/sync/sync-adapters.ts b/apps/desktop/src/main/services/sync/sync-adapters.ts new file mode 100644 index 0000000..448c305 --- /dev/null +++ b/apps/desktop/src/main/services/sync/sync-adapters.ts @@ -0,0 +1,857 @@ +// src/main/services/sync/sync-adapters.ts +// 엔티티별 로컬 ↔ Supabase 매핑. 로컬 쓰기는 outbox에 다시 넣지 않는다(에코 방지). + +import { and, eq, ne } from 'drizzle-orm' +import { getDatabase } from '../../db' +import { + dictionary, + history, + meetingDocuments, + meetingMemos, + meetingSessions, + memoTags, +} from '../../db/schema' +import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types' +import { getCustomInstructionService } from '../CustomInstructionService' +import { getDictationTemplateService } from '../DictationTemplateService' +import { getMeetingDocTemplateService } from '../MeetingDocTemplateService' +import { dropEntry } from './sync-outbox' +import { + SyncRemoteError, + toSyncRemoteError, + type PushOutcome, + type RemoteFilter, + type RemoteRow, + type SyncEntity, + type SyncRemote, +} from './sync-types' + +export interface PushContext { + remote: SyncRemote + userId: string +} + +export interface LocalVersion { + id: string + updatedAt: number +} + +export interface SyncAdapter { + entity: SyncEntity + /** 커서 pull 설정. 없으면 커서 pull 대상이 아니다(memo_tags는 전체 대조). */ + pull: { columns?: string; filters?: RemoteFilter[] } | null + listLocalVersions(): LocalVersion[] + push(ctx: PushContext, ids: string[]): Promise + pushDeletes(ctx: PushContext, ids: string[]): Promise + /** 원격 행을 로컬에 반영. 반영했으면 true */ + applyRemote(row: RemoteRow): boolean + /** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */ + deleteLocal(id: string): boolean +} + +// ── 값 변환 ───────────────────────────────────────────── + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export function isUuid(value: unknown): value is string { + return typeof value === 'string' && UUID_PATTERN.test(value) +} + +function iso(ms: number): string { + return new Date(ms).toISOString() +} + +function nullableIso(ms: number | null): string | null { + return ms === null ? null : iso(ms) +} + +function ms(value: unknown, fallback: number): number { + if (typeof value !== 'string') return fallback + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function nullableMs(value: unknown): number | null { + if (typeof value !== 'string') return null + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function str(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function num(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value) + return null +} + +function int(value: number | null): number | null { + return value === null ? null : Math.round(value) +} + +function oneOf(value: unknown, allowed: readonly T[]): T | null { + return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : null +} + +// ── 공통 push 전략 ────────────────────────────────────── + +const BATCH_SIZE = 100 + +/** + * 배치 upsert 후, 재시도 불가 오류면 행 단위로 다시 보내 문제 행만 격리한다. + * 재시도 가능 오류(네트워크)는 배치 전체를 같은 오류로 돌려준다. + */ +async function upsertIsolated( + ctx: PushContext, + table: string, + payloads: Array<{ id: string; row: RemoteRow }>, + onRowConflict?: (id: string, error: SyncRemoteError) => Promise +): Promise { + const outcomes: PushOutcome[] = [] + for (let i = 0; i < payloads.length; i += BATCH_SIZE) { + const batch = payloads.slice(i, i + BATCH_SIZE) + try { + await ctx.remote.upsert(table, batch.map((p) => p.row)) + outcomes.push(...batch.map((p) => ({ id: p.id, error: null }))) + continue + } catch (err) { + const error = toSyncRemoteError(err) + if (error.retryable || batch.length === 1) { + if (!error.retryable && batch.length === 1 && onRowConflict) { + outcomes.push({ id: batch[0].id, error: await onRowConflict(batch[0].id, error) }) + } else { + outcomes.push(...batch.map((p) => ({ id: p.id, error }))) + } + continue + } + } + for (const payload of batch) { + try { + await ctx.remote.upsert(table, [payload.row]) + outcomes.push({ id: payload.id, error: null }) + } catch (err) { + const error = toSyncRemoteError(err) + outcomes.push({ + id: payload.id, + error: !error.retryable && onRowConflict ? await onRowConflict(payload.id, error) : error, + }) + } + } + } + return outcomes +} + +async function deleteRemote(ctx: PushContext, table: string, ids: string[]): Promise { + const valid = ids.filter(isUuid) + const outcomes: PushOutcome[] = ids.filter((id) => !isUuid(id)).map((id) => ({ id, error: null })) + try { + await ctx.remote.deleteByIds(table, ctx.userId, valid) + outcomes.push(...valid.map((id) => ({ id, error: null }))) + } catch (err) { + const error = toSyncRemoteError(err) + outcomes.push(...valid.map((id) => ({ id, error }))) + } + return outcomes +} + +/** 로컬에서 사라진 행은 보낼 것이 없다 — 성공으로 처리해 outbox에서 뺀다. */ +function missingAsDone(ids: string[], found: Set): PushOutcome[] { + return ids.filter((id) => !found.has(id)).map((id) => ({ id, error: null })) +} + +// ── history ───────────────────────────────────────────── + +const HISTORY_MODES = ['dictation', 'translate', 'command', 'caption', 'file-transcription'] as const +const HISTORY_STATUSES = ['completed', 'cancelled', 'error'] as const + +export function historyToRemote(r: typeof history.$inferSelect, userId: string): RemoteRow { + // focused_app*/mic 경로/오디오 경로는 데스크톱 로컬 맥락이라 올리지 않는다(창 제목 등 개인정보). + return { + id: r.id, + user_id: userId, + title: r.title, + 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: Math.round(r.wordCount), + stt_model: r.sttModel, + llm_model: r.llmModel, + stt_latency_ms: int(r.sttLatencyMs), + llm_latency_ms: int(r.llmLatencyMs), + app_version: r.appVersion, + summary_text: r.summaryText, + is_favorite: r.isFavorite, + created_at: iso(r.createdAt), + updated_at: iso(r.updatedAt), + } +} + +const historyAdapter: SyncAdapter = { + entity: 'history', + pull: {}, + listLocalVersions() { + return getDatabase().select({ id: history.id, updatedAt: history.updatedAt }).from(history).all() + }, + async push(ctx, ids) { + const db = getDatabase() + const rows = ids.flatMap((id) => db.select().from(history).where(eq(history.id, id)).all()) + const found = new Set(rows.map((r) => r.id)) + const outcomes = await upsertIsolated( + ctx, + 'history', + rows.map((r) => ({ id: r.id, row: historyToRemote(r, ctx.userId) })) + ) + return [...outcomes, ...missingAsDone(ids, found)] + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'history', ids) + }, + applyRemote(row) { + const id = row.id + const originalText = str(row.original_text) + const mode = oneOf(row.mode, HISTORY_MODES) + const status = oneOf(row.status, HISTORY_STATUSES) + if (!isUuid(id) || originalText === null || mode === null || status === null) return false + const updatedAt = ms(row.updated_at, Date.now()) + const values = { + title: str(row.title), + originalText, + polishedText: str(row.polished_text), + mode, + status, + duration: num(row.duration) ?? 0, + detectedLanguage: str(row.detected_language), + micDevice: str(row.mic_device), + wordCount: int(num(row.word_count)) ?? 0, + sttModel: str(row.stt_model), + llmModel: str(row.llm_model), + sttLatencyMs: int(num(row.stt_latency_ms)), + llmLatencyMs: int(num(row.llm_latency_ms)), + appVersion: str(row.app_version) ?? '1.0.0', + summaryText: str(row.summary_text), + isFavorite: row.is_favorite === true, + updatedAt, + } + getDatabase() + .insert(history) + .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) + .onConflictDoUpdate({ target: history.id, set: values }) + .run() + return true + }, + deleteLocal(id) { + const db = getDatabase() + db.delete(memoTags).where(eq(memoTags.historyId, id)).run() + return db.delete(history).where(eq(history.id, id)).run().changes > 0 + }, +} + +// ── dictionary ────────────────────────────────────────── + +const DICTIONARY_CATEGORIES = ['user', 'auto', 'technical'] as const + +export function dictionaryToRemote(r: typeof dictionary.$inferSelect, userId: string): RemoteRow { + return { + id: r.id, + user_id: userId, + word: r.word, + pronunciation: r.pronunciation, + category: r.category, + usage_count: Math.round(r.usageCount), + last_used_at: nullableIso(r.lastUsedAt), + created_at: iso(r.createdAt), + updated_at: iso(r.updatedAt), + } +} + +/** + * 서버는 (단어 대소문자 무시, 카테고리)가 유일하다. 데스크톱의 "API"와 모바일의 "api"가 + * 다른 id로 공존하면 push가 23505로 막힌다 — 서버 행을 채택하고 로컬 사본을 그 id로 바꾼다. + * 사용 횟수는 합친 값이 사라지지 않도록 큰 쪽을 남긴 뒤 다시 올린다. + */ +async function adoptRemoteDictionaryRow( + ctx: PushContext, + localId: string, + error: SyncRemoteError +): Promise { + if (error.code !== '23505') return error + const db = getDatabase() + const local = db.select().from(dictionary).where(eq(dictionary.id, localId)).get() + if (!local) return null + let matches: RemoteRow[] + try { + matches = await ctx.remote.selectWhere('dictionary', ctx.userId, [ + { column: 'word', op: 'ilike', value: local.word.trim() }, + { column: 'category', op: 'eq', value: local.category }, + ]) + } catch (err) { + return toSyncRemoteError(err) + } + const match = matches.find((m) => isUuid(m.id) && m.id !== localId) + if (!match || !isUuid(match.id)) return error + const remoteId = match.id + db.transaction((tx) => { + tx.delete(dictionary).where(eq(dictionary.id, localId)).run() + }) + dropEntry('dictionary', localId) + dictionaryAdapter.applyRemote(match) + const merged = db.select().from(dictionary).where(eq(dictionary.id, remoteId)).get() + if (merged && local.usageCount > merged.usageCount) { + db.update(dictionary) + .set({ usageCount: local.usageCount, lastUsedAt: local.lastUsedAt ?? merged.lastUsedAt, updatedAt: Date.now() }) + .where(eq(dictionary.id, remoteId)) + .run() + const refreshed = db.select().from(dictionary).where(eq(dictionary.id, remoteId)).get() + if (refreshed) { + try { + await ctx.remote.upsert('dictionary', [dictionaryToRemote(refreshed, ctx.userId)]) + } catch { + // 사용 횟수 보정은 다음 변경 때 다시 올라간다. + } + } + } + return null +} + +const dictionaryAdapter: SyncAdapter = { + entity: 'dictionary', + pull: {}, + listLocalVersions() { + return getDatabase().select({ id: dictionary.id, updatedAt: dictionary.updatedAt }).from(dictionary).all() + }, + async push(ctx, ids) { + const db = getDatabase() + const rows = ids.flatMap((id) => db.select().from(dictionary).where(eq(dictionary.id, id)).all()) + const found = new Set(rows.map((r) => r.id)) + const outcomes = await upsertIsolated( + ctx, + 'dictionary', + rows.map((r) => ({ id: r.id, row: dictionaryToRemote(r, ctx.userId) })), + (id, error) => adoptRemoteDictionaryRow(ctx, id, error) + ) + return [...outcomes, ...missingAsDone(ids, found)] + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'dictionary', ids) + }, + applyRemote(row) { + const id = row.id + const word = str(row.word) + const category = oneOf(row.category, DICTIONARY_CATEGORIES) ?? 'user' + if (!isUuid(id) || word === null) return false + const updatedAt = ms(row.updated_at, Date.now()) + const values = { + word, + pronunciation: str(row.pronunciation), + category, + usageCount: int(num(row.usage_count)) ?? 0, + lastUsedAt: nullableMs(row.last_used_at), + updatedAt, + } + const db = getDatabase() + // 로컬 유일 인덱스(word, category)를 다른 id가 점유하고 있으면 서버 행이 이긴다. + const clash = db + .select({ id: dictionary.id }) + .from(dictionary) + .where(and(eq(dictionary.word, word), eq(dictionary.category, category), ne(dictionary.id, id))) + .all() + for (const other of clash) { + db.delete(dictionary).where(eq(dictionary.id, other.id)).run() + dropEntry('dictionary', other.id) + } + db.insert(dictionary) + .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) + .onConflictDoUpdate({ target: dictionary.id, set: values }) + .run() + return true + }, + deleteLocal(id) { + return getDatabase().delete(dictionary).where(eq(dictionary.id, id)).run().changes > 0 + }, +} + +// ── meetings ──────────────────────────────────────────── + +const MEETING_STATUSES = ['recording', 'processing', 'completed', 'error'] as const + +function parseJsonOrNull(text: string | null): unknown { + if (text === null) return null + try { + return JSON.parse(text) as unknown + } catch { + return null + } +} + +export function meetingToRemote(r: typeof meetingSessions.$inferSelect, userId: string): RemoteRow { + // team_id는 보내지 않는다: 모바일/웹에서 팀에 공유한 회의를 데스크톱 수정이 개인 회의로 되돌리면 안 된다. + return { + id: r.id, + user_id: userId, + title: r.title, + status: r.status, + started_at: iso(r.startedAt), + ended_at: nullableIso(r.endedAt), + duration_ms: int(r.durationMs), + raw_transcript: r.rawTranscript, + edited_transcript: r.editedTranscript, + minutes_markdown: r.minutesMarkdown, + minutes_json: parseJsonOrNull(r.minutesJson), + stt_model: r.sttModel, + llm_model: r.llmModel, + stt_latency_ms: int(r.sttLatencyMs), + llm_latency_ms: int(r.llmLatencyMs), + error_message: r.errorMessage, + created_at: iso(r.createdAt), + updated_at: iso(r.updatedAt), + } +} + +function deleteMeetingLocally(id: string): boolean { + const db = getDatabase() + db.delete(meetingMemos).where(eq(meetingMemos.sessionId, id)).run() + db.delete(meetingDocuments).where(eq(meetingDocuments.sessionId, id)).run() + return db.delete(meetingSessions).where(eq(meetingSessions.id, id)).run().changes > 0 +} + +const meetingsAdapter: SyncAdapter = { + entity: 'meetings', + pull: {}, + listLocalVersions() { + return getDatabase() + .select({ id: meetingSessions.id, updatedAt: meetingSessions.updatedAt }) + .from(meetingSessions) + .all() + }, + async push(ctx, ids) { + const db = getDatabase() + const rows = ids.flatMap((id) => db.select().from(meetingSessions).where(eq(meetingSessions.id, id)).all()) + const found = new Set(rows.map((r) => r.id)) + const outcomes = await upsertIsolated( + ctx, + 'meetings', + rows.map((r) => ({ id: r.id, row: meetingToRemote(r, ctx.userId) })) + ) + return [...outcomes, ...missingAsDone(ids, found)] + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'meetings', ids) + }, + applyRemote(row) { + const id = row.id + const status = oneOf(row.status, MEETING_STATUSES) + if (!isUuid(id) || status === null) return false + const updatedAt = ms(row.updated_at, Date.now()) + const values = { + title: str(row.title), + status, + startedAt: ms(row.started_at, updatedAt), + endedAt: nullableMs(row.ended_at), + durationMs: int(num(row.duration_ms)), + rawTranscript: str(row.raw_transcript), + editedTranscript: str(row.edited_transcript), + minutesMarkdown: str(row.minutes_markdown), + minutesJson: row.minutes_json === null || row.minutes_json === undefined ? null : JSON.stringify(row.minutes_json), + sttModel: str(row.stt_model), + llmModel: str(row.llm_model), + sttLatencyMs: int(num(row.stt_latency_ms)), + llmLatencyMs: int(num(row.llm_latency_ms)), + errorMessage: str(row.error_message), + updatedAt, + } + getDatabase() + .insert(meetingSessions) + .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) + .onConflictDoUpdate({ target: meetingSessions.id, set: values }) + .run() + return true + }, + deleteLocal: deleteMeetingLocally, +} + +// ── meeting_memos ─────────────────────────────────────── + +function hasLocalMeeting(id: string): boolean { + return ( + getDatabase().select({ id: meetingSessions.id }).from(meetingSessions).where(eq(meetingSessions.id, id)).get() !== + undefined + ) +} + +export function meetingMemoToRemote(r: typeof meetingMemos.$inferSelect, userId: string): RemoteRow { + return { + id: r.id, + meeting_id: r.sessionId, + user_id: userId, + content: r.content, + timestamp_ms: Math.round(r.timestampMs), + created_at: iso(r.createdAt), + updated_at: iso(r.updatedAt ?? r.createdAt), + } +} + +const meetingMemosAdapter: SyncAdapter = { + entity: 'meeting_memos', + pull: {}, + listLocalVersions() { + return getDatabase() + .select({ id: meetingMemos.id, createdAt: meetingMemos.createdAt, updatedAt: meetingMemos.updatedAt }) + .from(meetingMemos) + .all() + .map((r) => ({ id: r.id, updatedAt: r.updatedAt ?? r.createdAt })) + }, + async push(ctx, ids) { + const db = getDatabase() + const rows = ids.flatMap((id) => db.select().from(meetingMemos).where(eq(meetingMemos.id, id)).all()) + const found = new Set(rows.map((r) => r.id)) + const outcomes = await upsertIsolated( + ctx, + 'meeting_memos', + rows.map((r) => ({ id: r.id, row: meetingMemoToRemote(r, ctx.userId) })) + ) + return [...outcomes, ...missingAsDone(ids, found)] + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'meeting_memos', ids) + }, + applyRemote(row) { + const id = row.id + const meetingId = row.meeting_id + const content = str(row.content) + // 남의 팀 회의에 내가 단 메모는 부모 회의가 로컬에 없다 — 고아 행을 만들지 않는다. + if (!isUuid(id) || !isUuid(meetingId) || content === null || !hasLocalMeeting(meetingId)) return false + const createdAt = ms(row.created_at, Date.now()) + const values = { + sessionId: meetingId, + content, + timestampMs: int(num(row.timestamp_ms)) ?? 0, + updatedAt: ms(row.updated_at, createdAt), + } + getDatabase() + .insert(meetingMemos) + .values({ id, ...values, createdAt }) + .onConflictDoUpdate({ target: meetingMemos.id, set: values }) + .run() + return true + }, + deleteLocal(id) { + return getDatabase().delete(meetingMemos).where(eq(meetingMemos.id, id)).run().changes > 0 + }, +} + +// ── meeting_documents ─────────────────────────────────── + +const DOCUMENT_TYPES = ['minutes', 'report', 'idea-note', 'custom', 'mindmap'] as const + +export function meetingDocumentToRemote(r: typeof meetingDocuments.$inferSelect, userId: string): RemoteRow { + 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: int(r.llmLatencyMs), + created_at: iso(r.createdAt), + updated_at: iso(r.updatedAt), + } +} + +const meetingDocumentsAdapter: SyncAdapter = { + entity: 'meeting_documents', + pull: {}, + listLocalVersions() { + return getDatabase() + .select({ id: meetingDocuments.id, updatedAt: meetingDocuments.updatedAt }) + .from(meetingDocuments) + .all() + }, + async push(ctx, ids) { + const db = getDatabase() + const rows = ids.flatMap((id) => db.select().from(meetingDocuments).where(eq(meetingDocuments.id, id)).all()) + const found = new Set(rows.map((r) => r.id)) + const outcomes = await upsertIsolated( + ctx, + 'meeting_documents', + rows.map((r) => ({ id: r.id, row: meetingDocumentToRemote(r, ctx.userId) })) + ) + return [...outcomes, ...missingAsDone(ids, found)] + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'meeting_documents', ids) + }, + applyRemote(row) { + const id = row.id + const meetingId = row.meeting_id + const templateType = oneOf(row.template_type, DOCUMENT_TYPES) + const title = str(row.title) + if (!isUuid(id) || !isUuid(meetingId) || templateType === null || title === null) return false + if (!hasLocalMeeting(meetingId)) return false + const updatedAt = ms(row.updated_at, Date.now()) + const values = { + sessionId: meetingId, + templateType, + title, + content: str(row.content) ?? '', + promptUsed: str(row.prompt_used), + llmModel: str(row.llm_model), + llmLatencyMs: int(num(row.llm_latency_ms)), + updatedAt, + } + getDatabase() + .insert(meetingDocuments) + .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) + .onConflictDoUpdate({ target: meetingDocuments.id, set: values }) + .run() + return true + }, + deleteLocal(id) { + return getDatabase().delete(meetingDocuments).where(eq(meetingDocuments.id, id)).run().changes > 0 + }, +} + +// ── custom_instructions ───────────────────────────────── +// 데스크톱 프리셋(builtin-*)과 서버 프리셋(builtin_key)은 각자 따로 있다. 사용자가 만든 명령만 맞춘다. + +export function instructionToRemote(i: CustomInstruction, userId: string): RemoteRow { + return { + id: i.id, + user_id: userId, + name: i.name.trim().slice(0, 80), + description: (i.description ?? '').trim().slice(0, 240), + prompt: i.prompt.trim().slice(0, 4000), + icon: (i.icon || 'sparkles').slice(0, 32), + sort_order: Math.max(0, Math.round(i.order)), + created_at: iso(i.createdAt), + } +} + +const customInstructionsAdapter: SyncAdapter = { + entity: 'custom_instructions', + pull: { filters: [{ column: 'builtin_key', op: 'is', value: null }] }, + listLocalVersions() { + return getCustomInstructionService() + .getAll() + .filter((i) => !i.isBuiltin && isUuid(i.id)) + .map((i) => ({ id: i.id, updatedAt: i.updatedAt })) + }, + async push(ctx, ids) { + const service = getCustomInstructionService() + const items = ids + .map((id) => service.getById(id)) + .filter((i): i is CustomInstruction => i !== null && !i.isBuiltin && isUuid(i.id)) + const found = new Set(items.map((i) => i.id)) + const outcomes = await upsertIsolated( + ctx, + 'custom_instructions', + items.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) })) + ) + return [...outcomes, ...missingAsDone(ids, found)] + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'custom_instructions', ids) + }, + applyRemote(row) { + const id = row.id + const name = str(row.name) + const prompt = str(row.prompt) + if (!isUuid(id) || name === null || prompt === null || row.builtin_key !== null) return false + const updatedAt = ms(row.updated_at, Date.now()) + getCustomInstructionService().applyRemote({ + id, + name, + description: str(row.description) ?? '', + prompt, + icon: str(row.icon) ?? 'Extension', + isBuiltin: false, + order: int(num(row.sort_order)) ?? 0, + createdAt: ms(row.created_at, updatedAt), + updatedAt, + }) + return true + }, + deleteLocal(id) { + return getCustomInstructionService().removeRemote(id) + }, +} + +// ── user_templates (받아쓰기 + 회의 문서) ─────────────── + +function sanitizeFields(fields: TemplateField[]): TemplateField[] { + // 서버는 정확히 이 6개 키만 받는다. + return fields.map((f) => ({ + id: f.id, + name: f.name, + label: f.label, + promptText: f.promptText, + required: f.required, + maxDurationSec: Math.round(f.maxDurationSec), + })) +} + +function parseFields(value: unknown): TemplateField[] | null { + if (!Array.isArray(value)) return null + const fields: TemplateField[] = [] + for (const item of value) { + if (typeof item !== 'object' || item === null) return null + const f = item as Record + const id = str(f.id) + const name = str(f.name) + const label = str(f.label) + const promptText = str(f.promptText) + const maxDurationSec = num(f.maxDurationSec) + if (id === null || name === null || label === null || promptText === null || maxDurationSec === null) return null + fields.push({ id, name, label, promptText, required: f.required === true, maxDurationSec }) + } + return fields +} + +type LocalTemplate = + | { kind: 'dictation'; template: DictationTemplate } + | { kind: 'meeting_document'; template: MeetingDocTemplate } + +function findLocalTemplate(id: string): LocalTemplate | null { + const dictation = getDictationTemplateService().getById(id) + if (dictation && !dictation.isBuiltin) return { kind: 'dictation', template: dictation } + const meeting = getMeetingDocTemplateService().getById(id) + if (meeting && !meeting.isBuiltin) return { kind: 'meeting_document', template: meeting } + return null +} + +export function templateToRpcParams(local: LocalTemplate): Record { + if (local.kind === 'dictation') { + const t = local.template + return { + p_id: t.id, + p_template_kind: 'dictation', + p_name: t.name, + p_description: t.description, + p_fields: sanitizeFields(t.fields), + p_output_format: t.outputFormat, + p_system_prompt: null, + } + } + const t = local.template + return { + p_id: t.id, + p_template_kind: 'meeting_document', + p_name: t.name, + p_description: t.description, + p_fields: [], + p_output_format: null, + p_system_prompt: t.systemPrompt, + } +} + +const userTemplatesAdapter: SyncAdapter = { + entity: 'user_templates', + pull: { filters: [{ column: 'is_builtin', op: 'eq', value: false }] }, + listLocalVersions() { + const dictation = getDictationTemplateService().getAll() + const meeting = getMeetingDocTemplateService().getAll() + return [...dictation, ...meeting] + .filter((t) => !t.isBuiltin && isUuid(t.id)) + .map((t) => ({ id: t.id, updatedAt: t.updatedAt })) + }, + async push(ctx, ids) { + const outcomes: PushOutcome[] = [] + for (const id of ids) { + const local = isUuid(id) ? findLocalTemplate(id) : null + if (!local) { + outcomes.push({ id, error: null }) + continue + } + try { + await ctx.remote.rpc('sync_upsert_user_template_v1', templateToRpcParams(local)) + outcomes.push({ id, error: null }) + } catch (err) { + outcomes.push({ id, error: toSyncRemoteError(err) }) + } + } + return outcomes + }, + async pushDeletes(ctx, ids) { + const outcomes: PushOutcome[] = [] + for (const id of ids) { + if (!isUuid(id)) { + outcomes.push({ id, error: null }) + continue + } + try { + await ctx.remote.rpc('sync_delete_user_template_v1', { p_id: id }) + outcomes.push({ id, error: null }) + } catch (err) { + outcomes.push({ id, error: toSyncRemoteError(err) }) + } + } + return outcomes + }, + applyRemote(row) { + const id = row.id + const name = str(row.name) + if (!isUuid(id) || name === null || row.is_builtin === true) return false + const updatedAt = ms(row.updated_at, Date.now()) + const createdAt = ms(row.created_at, updatedAt) + const description = str(row.description) ?? '' + if (row.template_kind === 'dictation') { + const fields = parseFields(row.fields) + const outputFormat = str(row.output_format) + if (fields === null || outputFormat === null) return false + getDictationTemplateService().applyRemote({ + id, + name, + description, + fields, + outputFormat, + isBuiltin: false, + createdAt, + updatedAt, + }) + return true + } + if (row.template_kind === 'meeting_document') { + const systemPrompt = str(row.system_prompt) + if (systemPrompt === null) return false + getMeetingDocTemplateService().applyRemote({ + id, + name, + description, + templateType: 'custom', + systemPrompt, + isBuiltin: false, + createdAt, + updatedAt, + }) + return true + } + return false + }, + deleteLocal(id) { + const a = getDictationTemplateService().removeRemote(id) + const b = getMeetingDocTemplateService().removeRemote(id) + return a || b + }, +} + +/** push 순서: 부모(이력·회의)가 자식(태그·메모·문서)보다 먼저. 삭제는 역순. */ +export const TABLE_ADAPTERS: readonly SyncAdapter[] = [ + historyAdapter, + dictionaryAdapter, + meetingsAdapter, + meetingMemosAdapter, + meetingDocumentsAdapter, + customInstructionsAdapter, + userTemplatesAdapter, +] + +export function adapterFor(entity: SyncEntity): SyncAdapter | null { + return TABLE_ADAPTERS.find((a) => a.entity === entity) ?? null +} diff --git a/apps/desktop/src/main/services/sync/sync-outbox.ts b/apps/desktop/src/main/services/sync/sync-outbox.ts new file mode 100644 index 0000000..69b6e96 --- /dev/null +++ b/apps/desktop/src/main/services/sync/sync-outbox.ts @@ -0,0 +1,160 @@ +// src/main/services/sync/sync-outbox.ts +// 로컬 변경 대기열(outbox)과 동기화 상태(커서·1회성 플래그). +// 둘 다 현재 열린 사용자 DB 안에 있으므로 계정 전환 시 자동으로 분리된다. + +import { and, asc, eq, lte, sql } from 'drizzle-orm' +import { getDatabase } from '../../db' +import { syncOutbox, syncState, type SyncOutboxRow } from '../../db/schema' +import type { SyncEntity, SyncOp } from './sync-types' + +/** 재시도 불가 오류가 이 횟수를 넘으면 자동 재시도를 멈추고 보관(parked)한다. */ +export const MAX_NON_RETRYABLE_ATTEMPTS = 5 +const PARKED_AT = Number.MAX_SAFE_INTEGER +const RETRY_BASE_MS = 5_000 +const RETRY_MAX_MS = 10 * 60_000 +const NON_RETRYABLE_DELAY_MS = 60 * 60_000 + +export interface OutboxEntry { + entity: SyncEntity + rowId: string + op: SyncOp + version: number + attempts: number +} + +/** + * 로컬 변경을 기록한다. 같은 행의 이전 대기 연산은 최신 연산으로 덮는다 + * (upsert 뒤 delete면 delete만 남는다). version을 올려 진행 중인 push가 + * 새 변경을 완료 처리하지 못하게 한다. + */ +export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now = Date.now()): void { + getDatabase() + .insert(syncOutbox) + .values({ entity, rowId, op, version: 1, queuedAt: now, attempts: 0, nextAttemptAt: 0, lastError: null }) + .onConflictDoUpdate({ + target: [syncOutbox.entity, syncOutbox.rowId], + set: { + op, + version: sql`${syncOutbox.version} + 1`, + queuedAt: now, + attempts: 0, + nextAttemptAt: 0, + lastError: null, + }, + }) + .run() +} + +export function listDueEntries(now = Date.now()): OutboxEntry[] { + return getDatabase() + .select() + .from(syncOutbox) + .where(lte(syncOutbox.nextAttemptAt, now)) + .orderBy(asc(syncOutbox.queuedAt)) + .all() + .map(toEntry) +} + +/** push 성공. 그 사이 새 변경이 들어왔으면(version 불일치) 남겨 둔다. */ +export function completeEntry(entry: OutboxEntry): void { + getDatabase() + .delete(syncOutbox) + .where( + and( + eq(syncOutbox.entity, entry.entity), + eq(syncOutbox.rowId, entry.rowId), + eq(syncOutbox.version, entry.version) + ) + ) + .run() +} + +export function failEntry( + entry: OutboxEntry, + error: { message: string; retryable: boolean }, + now = Date.now() +): void { + const attempts = entry.attempts + 1 + let nextAttemptAt: number + if (error.retryable) { + nextAttemptAt = now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 10)) + } else if (attempts >= MAX_NON_RETRYABLE_ATTEMPTS) { + nextAttemptAt = PARKED_AT + } else { + nextAttemptAt = now + NON_RETRYABLE_DELAY_MS + } + getDatabase() + .update(syncOutbox) + .set({ attempts, nextAttemptAt, lastError: error.message.slice(0, 500) }) + .where( + and( + eq(syncOutbox.entity, entry.entity), + eq(syncOutbox.rowId, entry.rowId), + eq(syncOutbox.version, entry.version) + ) + ) + .run() +} + +/** 원격 삭제가 로컬 대기 변경보다 우선할 때 등, 대기 연산을 버린다. */ +export function dropEntry(entity: SyncEntity, rowId: string): void { + getDatabase() + .delete(syncOutbox) + .where(and(eq(syncOutbox.entity, entity), eq(syncOutbox.rowId, rowId))) + .run() +} + +/** 엔티티별 대기 중인 row id → 연산. pull이 로컬 미전송 변경을 덮지 않도록 확인할 때 쓴다. */ +export function pendingOps(entity: SyncEntity): Map { + const rows = getDatabase() + .select({ rowId: syncOutbox.rowId, op: syncOutbox.op }) + .from(syncOutbox) + .where(eq(syncOutbox.entity, entity)) + .all() + return new Map(rows.map((r) => [r.rowId, r.op])) +} + +export function outboxCounts(): { pending: number; parked: number } { + const rows = getDatabase() + .select({ + parked: sql`coalesce(sum(case when ${syncOutbox.nextAttemptAt} >= ${PARKED_AT} then 1 else 0 end), 0)`, + total: sql`count(*)`, + }) + .from(syncOutbox) + .get() + const total = rows?.total ?? 0 + const parked = rows?.parked ?? 0 + return { pending: total - parked, parked } +} + +/** 보관된 항목을 다시 시도 대상으로 돌린다 (수동 동기화 버튼). */ +export function releaseParkedEntries(): void { + getDatabase() + .update(syncOutbox) + .set({ attempts: 0, nextAttemptAt: 0 }) + .where(eq(syncOutbox.nextAttemptAt, PARKED_AT)) + .run() +} + +export function getSyncState(key: string): string | null { + const row = getDatabase().select().from(syncState).where(eq(syncState.key, key)).get() + return row?.value ?? null +} + +export function setSyncState(key: string, value: string, now = Date.now()): void { + getDatabase() + .insert(syncState) + .values({ key, value, updatedAt: now }) + .onConflictDoUpdate({ target: syncState.key, set: { value, updatedAt: now } }) + .run() +} + +function toEntry(row: SyncOutboxRow): OutboxEntry { + return { + entity: row.entity as SyncEntity, + rowId: row.rowId, + op: row.op, + version: row.version, + attempts: row.attempts, + } +} diff --git a/apps/desktop/src/main/services/sync/sync-types.ts b/apps/desktop/src/main/services/sync/sync-types.ts new file mode 100644 index 0000000..1b32c17 --- /dev/null +++ b/apps/desktop/src/main/services/sync/sync-types.ts @@ -0,0 +1,141 @@ +// src/main/services/sync/sync-types.ts +// 기기 간 동기화 공통 타입. 데스크톱 SQLite ↔ Supabase(모바일·웹이 직접 읽고 쓰는 정본). + +/** 동기화 대상. 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다. */ +export const SYNC_ENTITIES = [ + 'history', + 'dictionary', + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'memo_tags', + 'custom_instructions', + 'user_templates', +] as const + +export type SyncEntity = (typeof SYNC_ENTITIES)[number] + +export type SyncOp = 'upsert' | 'delete' + +export function isSyncEntity(value: unknown): value is SyncEntity { + return typeof value === 'string' && (SYNC_ENTITIES as readonly string[]).includes(value) +} + +/** + * 서버 시각 기준 keyset 커서. ts는 PostgREST가 준 문자열을 그대로 보관한다 + * (마이크로초 정밀도 — Date로 바꾸면 같은 밀리초 안의 행을 놓친다). + */ +export interface RemoteCursor { + ts: string + id: string +} + +export type RemoteRow = Record + +export type RemoteFilter = + | { column: string; op: 'eq'; value: string | number | boolean } + | { column: string; op: 'is'; value: null } + | { column: string; op: 'ilike'; value: string } + +export interface RemotePageRequest { + table: string + userId: string + cursorColumn: string + after: RemoteCursor | null + limit: number + columns?: string + filters?: RemoteFilter[] +} + +/** + * Supabase 접근 추상화. 엔진은 이 인터페이스만 보므로 테스트에서 메모리 구현으로 바꿔 끼운다. + * 실패는 항상 SyncRemoteError 로 던진다. + */ +export interface SyncRemote { + fetchPage(request: RemotePageRequest): Promise + selectWhere(table: string, userId: string, filters: RemoteFilter[], columns?: string): Promise + upsert(table: string, rows: RemoteRow[]): Promise + deleteByIds(table: string, userId: string, ids: string[]): Promise + rpc(name: string, params: Record): Promise +} + +export class SyncRemoteError extends Error { + constructor( + message: string, + /** Postgres SQLSTATE / PostgREST 코드 / 'network' */ + readonly code: string, + /** 네트워크·서버 일시 장애면 true. 제약 위반처럼 다시 보내도 같은 결과면 false */ + readonly retryable: boolean + ) { + super(message) + this.name = 'SyncRemoteError' + } +} + +/** PostgREST/Postgres 오류를 재시도 가능 여부와 함께 정규화한다. */ +export function toSyncRemoteError(error: unknown): SyncRemoteError { + if (error instanceof SyncRemoteError) return error + const candidate = (typeof error === 'object' && error !== null ? error : {}) as { + code?: unknown + message?: unknown + status?: unknown + } + const code = typeof candidate.code === 'string' ? candidate.code : '' + const message = + typeof candidate.message === 'string' + ? candidate.message + : error instanceof Error + ? error.message + : String(error) + const lower = message.toLowerCase() + + if ( + error instanceof TypeError || + lower.includes('fetch failed') || + lower.includes('network') || + lower.includes('econnreset') || + lower.includes('etimedout') || + lower.includes('enotfound') || + lower.includes('socket hang up') + ) { + return new SyncRemoteError(message, 'network', true) + } + + // 23503: 부모(회의·이력)가 아직 서버에 없음 — 부모 push 뒤 다시 보내면 된다. + // P0002: RPC가 대상 행을 못 찾음 — 이력 push 전의 메모 태그 등. 역시 나중에 다시. + // 40001/40P01: 직렬화 실패·교착. 57014: 문장 타임아웃. PT409: 템플릿 revision 경합. + const retryableCodes = new Set(['23503', 'P0002', '40001', '40P01', '57014', '53300', 'PT409']) + if (retryableCodes.has(code)) return new SyncRemoteError(message, code, true) + + const status = typeof candidate.status === 'number' ? candidate.status : 0 + // PGRST0xx: DB 연결 실패. PGRST3xx/401: 토큰 만료 — 자동 갱신 뒤 다시 보내면 된다. + if ( + status >= 500 || + status === 401 || + code.startsWith('PGRST0') || + code.startsWith('PGRST3') || + code === '' + ) { + // 코드 없는 오류는 대개 게이트웨이/네트워크 계층이다. + return new SyncRemoteError(message, code || 'unknown', true) + } + return new SyncRemoteError(message, code, false) +} + +export interface PushOutcome { + id: string + error: SyncRemoteError | null +} + +export interface SyncRunResult { + pushed: number + pulled: number + deleted: number + errors: string[] + /** 서버 변경을 반영해 로컬 데이터가 바뀐 엔티티 — 렌더러 새로고침 신호 */ + changed: SyncEntity[] +} + +export function emptyRunResult(): SyncRunResult { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cf2207b..d5dc00f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -66,6 +66,7 @@ import type { HistoryEntry, HistoryDeleteParams, HistorySearchParams, + HistorySetFavoriteParams, DictionaryQueryParams, DictionaryPage, DictionaryEntry, @@ -408,6 +409,8 @@ const electronAPI = { delete: (params: HistoryDeleteParams) => invoke(IPC_CHANNELS.HISTORY.DELETE, params), deleteAll: () => invoke(IPC_CHANNELS.HISTORY.DELETE_ALL), + setFavorite: (params: HistorySetFavoriteParams) => + invoke(IPC_CHANNELS.HISTORY.SET_FAVORITE, params), search: (params: HistorySearchParams) => invoke(IPC_CHANNELS.HISTORY.SEARCH, params), onAdded: (cb: (e: HistoryEntry) => void): Unsubscribe => @@ -469,8 +472,13 @@ const electronAPI = { // ── App 이벤트 (실시간 UI 갱신) ──────────────────────── app: { - onDataChanged: (cb: (data: { type: string; activeId?: string }) => void): Unsubscribe => - on('app:dataChanged', cb), + /** + * 로컬 데이터가 바뀌었다 — 화면을 다시 불러온다. + * type 'cloud-sync'는 다른 기기(모바일·웹)의 변경이 반영된 것이고 entities에 바뀐 종류가 온다. + */ + onDataChanged: ( + cb: (data: { type: string; activeId?: string; entities?: string[] }) => void + ): Unsubscribe => on(IPC_CHANNELS.APP.DATA_CHANGED, cb), }, // ── Phase 10: Memo Tags ───────────────────────────────── @@ -802,6 +810,8 @@ const electronAPI = { userEmail: string | null lastSyncAt: number | null syncing: boolean + pendingChanges: number + failedChanges: number }>(IPC_CHANNELS.CLOUD_SYNC.GET_STATE), signIn: (params: { provider: 'google' | 'github' }) => invoke<{ started: boolean }>(IPC_CHANNELS.CLOUD_SYNC.SIGN_IN, params), diff --git a/apps/desktop/src/renderer/components/CloudSyncSection.tsx b/apps/desktop/src/renderer/components/CloudSyncSection.tsx index 1e19b2a..caf957f 100644 --- a/apps/desktop/src/renderer/components/CloudSyncSection.tsx +++ b/apps/desktop/src/renderer/components/CloudSyncSection.tsx @@ -1,72 +1,64 @@ // src/renderer/components/CloudSyncSection.tsx // Phase V2-4: Settings에 표시되는 Cloud Sync 섹션 -// 로그인 상태 / Sync Now 버튼 / 마지막 동기화 / 환경변수 설정 +// 로그인 상태 / 지금 동기화 / 마지막 동기화 / 올릴 변경·거부된 변경 건수 -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material' -import { Cloud, CloudCheck, Globe, GitBranch } from 'lucide-react' +import { Cloud, CloudCheck, Globe, GitBranch, RefreshCw } from 'lucide-react' import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' interface CloudSyncState { authenticated: boolean userEmail: string | null lastSyncAt: number | null syncing: boolean + pendingChanges: number + failedChanges: number } -interface SyncProgress { - current: number - total: number - table: string +const INITIAL_STATE: CloudSyncState = { + authenticated: false, + userEmail: null, + lastSyncAt: null, + syncing: false, + pendingChanges: 0, + failedChanges: 0, } export function CloudSyncSection(): React.ReactElement { - const [state, setState] = useState({ - authenticated: false, - userEmail: null, - lastSyncAt: null, - syncing: false - }) - const [progress, setProgress] = useState(null) + const { t, formatDate } = useI18n() + const [state, setState] = useState(INITIAL_STATE) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [busy, setBusy] = useState(false) + const refreshState = useCallback(async () => { + const r = await window.electronAPI.cloudSync.getState() + if (r.success) setState(r.data) + }, []) + // 초기 상태 로드 + 이벤트 구독 useEffect(() => { - void window.electronAPI.cloudSync.getState().then((r) => { - if (r.success) setState(r.data) - }) + void refreshState() - const unsubAuth = window.electronAPI.cloudSync.onAuthChanged((payload) => { - setState((prev) => ({ - ...prev, - authenticated: payload.user !== null, - userEmail: payload.user?.email ?? null - })) + const unsubAuth = window.electronAPI.cloudSync.onAuthChanged(() => { + void refreshState() }) - const unsubProgress = window.electronAPI.cloudSync.onSyncProgress((p) => { - setProgress(p) - }) - const unsubComplete = window.electronAPI.cloudSync.onSyncComplete((p) => { - setProgress(null) - setInfo(`동기화 완료: ${p.pushed}건${p.errors.length > 0 ? ` (오류 ${p.errors.length})` : ''}`) - setBusy(false) - setState((prev) => ({ ...prev, syncing: false, lastSyncAt: Date.now() })) + // 백그라운드 동기화(로그인 직후·실시간·주기)도 여기로 온다 — 건수만 갱신하고 알림은 띄우지 않는다. + const unsubComplete = window.electronAPI.cloudSync.onSyncComplete(() => { + void refreshState() }) const unsubError = window.electronAPI.cloudSync.onSyncError((p) => { - setProgress(null) setError(p.error) - setBusy(false) - setState((prev) => ({ ...prev, syncing: false })) + void refreshState() }) return () => { unsubAuth() - unsubProgress() unsubComplete() unsubError() } - }, []) + }, [refreshState]) async function handleSignIn(provider: 'google' | 'github'): Promise { setError(null) @@ -76,7 +68,7 @@ export function CloudSyncSection(): React.ReactElement { if (!r.success) { setError(r.error.message) } else { - setInfo('브라우저에서 로그인을 완료해주세요...') + setInfo(t('cloudSync.browserPending')) } } finally { setBusy(false) @@ -87,52 +79,54 @@ export function CloudSyncSection(): React.ReactElement { setBusy(true) try { await window.electronAPI.cloudSync.signOut() - setInfo('로그아웃되었습니다') + setInfo(t('cloudSync.signedOut')) } finally { setBusy(false) + void refreshState() } } - async function handleSync(): Promise { + /** 올리기(거부된 항목 재시도 포함) → 내려받기 */ + async function handleSyncNow(): Promise { setError(null) + setInfo(null) setBusy(true) - setState((prev) => ({ ...prev, syncing: true })) try { - await window.electronAPI.cloudSync.pushAll() + const pushed = await window.electronAPI.cloudSync.pushAll() + const pulled = await window.electronAPI.cloudSync.pullAll() + const count = (pushed.success ? pushed.data.pushed : 0) + (pulled.success ? pulled.data.pushed : 0) + const errors = (pushed.success ? pushed.data.errors.length : 1) + (pulled.success ? pulled.data.errors.length : 1) + if (!pushed.success) setError(pushed.error.message) + else if (!pulled.success) setError(pulled.error.message) + setInfo(errors > 0 ? t('cloudSync.doneWithErrors', { count, errors }) : t('cloudSync.done', { count })) } finally { - // 완료/에러 이벤트로 setBusy(false) 처리됨 - } - } - - async function handlePull(): Promise { - setError(null) - setBusy(true) - setState((prev) => ({ ...prev, syncing: true })) - try { - await window.electronAPI.cloudSync.pullAll() - } finally { - // 완료/에러 이벤트로 setBusy(false) 처리됨 + setBusy(false) + void refreshState() } } + const syncing = busy || state.syncing const lastSyncText = state.lastSyncAt - ? new Date(state.lastSyncAt).toLocaleString('ko-KR') - : '없음' + ? formatDate(state.lastSyncAt, { dateStyle: 'medium', timeStyle: 'short' }) + : t('cloudSync.never') return ( - + {state.authenticated ? ( ) : ( )} - Cloud Sync + {t('cloudSync.title')} + + {t('cloudSync.description')} + {!state.authenticated && ( - - OAuth 로그인 + + {t('cloudSync.signIn')} - diff --git a/apps/desktop/src/renderer/components/TemplateSection.tsx b/apps/desktop/src/renderer/components/TemplateSection.tsx index 759bff2..ededb74 100644 --- a/apps/desktop/src/renderer/components/TemplateSection.tsx +++ b/apps/desktop/src/renderer/components/TemplateSection.tsx @@ -37,6 +37,15 @@ export function TemplateSection(): React.ReactElement { useEffect(() => { loadData() }, [loadData]) + // 다른 기기에서 바뀐 템플릿이 반영되면 다시 불러온다. + useEffect( + () => + window.electronAPI.app.onDataChanged((data) => { + if (data.type === 'cloud-sync' && data.entities?.includes('user_templates')) loadData() + }), + [loadData], + ) + useEffect(() => { const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => { setSession(data) diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 1d6133a..56bc073 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -134,10 +134,16 @@ export function MeetingDetailTabs({ [detail.rawTranscript, detail.editedTranscript], ) - // 템플릿 로드 + // 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시) useEffect(() => { - window.electronAPI.meetingDocTemplate.getAll().then((resp) => { - if (resp.success) setTemplates(resp.data) + const load = (): void => { + window.electronAPI.meetingDocTemplate.getAll().then((resp) => { + if (resp.success) setTemplates(resp.data) + }) + } + load() + return window.electronAPI.app.onDataChanged((data) => { + if (data.type === 'cloud-sync' && data.entities?.includes('user_templates')) load() }) }, []) diff --git a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx index 0f77bbf..45b3f22 100644 --- a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx +++ b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react' import { Box, IconButton, Tooltip, Chip, Collapse } from '@mui/material' -import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2 } from 'lucide-react' +import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2, Star } from 'lucide-react' import { MetalCard, Led, TactileBadge } from '@d3ro/ui/components/ds' import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' @@ -15,6 +15,8 @@ export interface HistoryEntryCardProps { entry: HistoryEntry onCopy?: (text: string) => void onDelete?: (id: string) => void + /** 즐겨찾기 토글 — 모바일·웹과 공유되는 값 */ + onToggleFavorite?: (entry: HistoryEntry) => void showTags?: boolean onTagClick?: (tag: string) => void } @@ -23,6 +25,7 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, + onToggleFavorite, showTags = false, onTagClick, }: HistoryEntryCardProps): React.ReactElement { @@ -160,6 +163,27 @@ export function HistoryEntryCard({ {/* Quick Action Buttons */} + {onToggleFavorite && ( + + { + e.stopPropagation() + onToggleFavorite(entry) + }} + sx={{ + p: 0.6, + color: entry.isFavorite ? d3roPalette.tag.orange : d3roPalette.text.inactive, + bgcolor: entry.isFavorite ? d3roPalette.tag.orangeBg : 'transparent', + '&:hover': { color: d3roPalette.tag.orange, bgcolor: d3roPalette.tag.orangeBg }, + }} + > + + + + )} { loadData() + // 다른 기기(모바일·웹)에서 바뀐 사전이 반영되면 다시 불러온다. + return window.electronAPI.app.onDataChanged((data) => { + if (data.type === 'cloud-sync' && data.entities?.includes('dictionary')) loadData() + }) }, [loadData]) const openAdd = () => { diff --git a/apps/desktop/src/renderer/pages/HistoryPage.tsx b/apps/desktop/src/renderer/pages/HistoryPage.tsx index 94f5578..64c8dad 100644 --- a/apps/desktop/src/renderer/pages/HistoryPage.tsx +++ b/apps/desktop/src/renderer/pages/HistoryPage.tsx @@ -86,6 +86,19 @@ export function HistoryPage(): React.ReactElement { [loadData], ) + const handleToggleFavorite = useCallback(async (entry: HistoryEntry) => { + const next = !entry.isFavorite + const apply = (value: boolean): void => + setData((prev) => + prev + ? { ...prev, entries: prev.entries.map((e) => (e.id === entry.id ? { ...e, isFavorite: value } : e)) } + : prev, + ) + apply(next) + const result = await window.electronAPI.history.setFavorite({ id: entry.id, isFavorite: next }) + if (!result.success) apply(entry.isFavorite) + }, []) + const handleTagClick = useCallback((tag: string) => { setActiveTag((prev) => (prev === tag ? null : tag)) setSearch('') @@ -232,6 +245,7 @@ export function HistoryPage(): React.ReactElement { entry={entry} onCopy={handleCopy} onDelete={handleDelete} + onToggleFavorite={handleToggleFavorite} showTags onTagClick={handleTagClick} /> diff --git a/apps/desktop/src/renderer/pages/MeetingModePage.tsx b/apps/desktop/src/renderer/pages/MeetingModePage.tsx index d43f553..4d346a5 100644 --- a/apps/desktop/src/renderer/pages/MeetingModePage.tsx +++ b/apps/desktop/src/renderer/pages/MeetingModePage.tsx @@ -197,6 +197,25 @@ export function MeetingModePage(): React.ReactElement { if (view === 'list') loadSessions() }, [view, loadSessions]) + // 다른 기기(모바일·웹)의 회의 변경이 반영되면: 목록은 다시 불러오고, + // 보고 있던 회의가 지워졌으면 목록으로 돌아간다(편집 중인 상세는 덮어쓰지 않는다). + useEffect( + () => + window.electronAPI.app.onDataChanged((data) => { + if (data.type !== 'cloud-sync') return + const entities = data.entities ?? [] + if (!entities.some((e) => e === 'meetings' || e === 'meeting_memos' || e === 'meeting_documents')) return + if (view === 'list') { + loadSessions() + } else if (view === 'detail' && detail) { + window.electronAPI.meetingMode.getSession({ sessionId: detail.id }).then((resp) => { + if (!resp.success) setView('list') + }) + } + }), + [view, detail, loadSessions], + ) + // ── IPC 이벤트 구독 ── useEffect(() => { const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => { diff --git a/apps/desktop/tests/helpers/createTestDb.ts b/apps/desktop/tests/helpers/createTestDb.ts index 0189cc9..7753044 100644 --- a/apps/desktop/tests/helpers/createTestDb.ts +++ b/apps/desktop/tests/helpers/createTestDb.ts @@ -1,9 +1,10 @@ // tests/helpers/createTestDb.ts -// in-memory SQLite + drizzle-orm 스키마 적용 (src/main/db/index.ts applySchema 와 동일) +// in-memory SQLite + drizzle-orm 스키마 적용 (src/main/db/index.ts applySchema 그대로) import Database from 'better-sqlite3' import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import * as schema from '../../src/main/db/schema' +import { applySchema } from '../../src/main/db' /** * 테스트용 in-memory SQLite DB를 생성한다. @@ -16,156 +17,8 @@ export function createTestDb(): { } { const sqlite = new Database(':memory:') - sqlite.pragma('journal_mode = WAL') - sqlite.pragma('foreign_keys = ON') - sqlite.pragma('busy_timeout = 5000') - - sqlite.exec(` - CREATE TABLE IF NOT EXISTS history ( - id TEXT PRIMARY KEY, - original_text TEXT NOT NULL, - polished_text TEXT, - focused_app TEXT, - focused_app_name TEXT, - focused_app_window_title TEXT, - mode TEXT NOT NULL DEFAULT 'dictation', - status TEXT NOT NULL DEFAULT 'completed', - error_code TEXT, - audio_local_path TEXT, - duration REAL NOT NULL, - detected_language TEXT, - mic_device TEXT, - word_count INTEGER NOT NULL DEFAULT 0, - stt_model TEXT, - llm_model TEXT, - stt_latency_ms INTEGER, - llm_latency_ms INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - app_version TEXT NOT NULL DEFAULT '1.0.0', - title TEXT, - summary_text TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_history_status ON history(status); - - CREATE TABLE IF NOT EXISTS dictionary ( - id TEXT PRIMARY KEY, - word TEXT NOT NULL, - pronunciation TEXT, - category TEXT NOT NULL DEFAULT 'user', - usage_count INTEGER NOT NULL DEFAULT 0, - last_used_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category); - CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at); - CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC); - - CREATE TABLE IF NOT EXISTS stats ( - id INTEGER PRIMARY KEY CHECK (id = 1), - total_duration REAL NOT NULL DEFAULT 0, - total_words INTEGER NOT NULL DEFAULT 0, - session_count INTEGER NOT NULL DEFAULT 0, - streak_days INTEGER NOT NULL DEFAULT 0, - last_session_at INTEGER, - last_updated INTEGER NOT NULL - ); - - INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated) - VALUES (1, 0, 0, 0, 0, ${Date.now()}); - - CREATE TABLE IF NOT EXISTS memo_tags ( - id TEXT PRIMARY KEY, - history_id TEXT NOT NULL, - tag TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_memo_tags_unique ON memo_tags(history_id, tag); - CREATE INDEX IF NOT EXISTS idx_memo_tags_history_id ON memo_tags(history_id); - CREATE INDEX IF NOT EXISTS idx_memo_tags_tag ON memo_tags(tag); - - CREATE TABLE IF NOT EXISTS daily_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - date TEXT NOT NULL, - feature TEXT NOT NULL, - count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_usage_date_feature ON daily_usage(date, feature); - CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date); - - CREATE TABLE IF NOT EXISTS rag_documents ( - id TEXT PRIMARY KEY, - file_name TEXT NOT NULL, - file_path TEXT NOT NULL, - file_type TEXT NOT NULL, - chunk_count INTEGER NOT NULL DEFAULT 0, - indexed INTEGER NOT NULL DEFAULT 0, - indexed_at INTEGER, - added_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_rag_documents_added_at ON rag_documents(added_at); - - CREATE TABLE IF NOT EXISTS rag_chunks ( - id TEXT PRIMARY KEY, - document_id TEXT NOT NULL, - content TEXT NOT NULL, - embedding TEXT NOT NULL, - chunk_index INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id); - - CREATE TABLE IF NOT EXISTS meeting_sessions ( - id TEXT PRIMARY KEY, - title TEXT, - status TEXT NOT NULL DEFAULT 'recording', - started_at INTEGER NOT NULL, - ended_at INTEGER, - duration_ms INTEGER, - raw_transcript TEXT, - minutes_markdown TEXT, - minutes_json TEXT, - stt_model TEXT, - llm_model TEXT, - stt_latency_ms INTEGER, - llm_latency_ms INTEGER, - error_message TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - edited_transcript TEXT - ); - CREATE INDEX IF NOT EXISTS idx_meeting_sessions_created_at ON meeting_sessions(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status ON meeting_sessions(status); - - CREATE TABLE IF NOT EXISTS meeting_memos ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - content TEXT NOT NULL, - timestamp_ms INTEGER NOT NULL, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_meeting_memos_session_id ON meeting_memos(session_id); - CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms); - - CREATE TABLE IF NOT EXISTS meeting_documents ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - template_type TEXT NOT NULL, - title TEXT NOT NULL, - content TEXT NOT NULL DEFAULT '', - prompt_used TEXT, - llm_model TEXT, - llm_latency_ms INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id); - `) + // 운영과 같은 스키마 정본을 쓴다 — 손으로 복제한 DDL은 컬럼 추가 때마다 어긋났다. + applySchema(sqlite) const db = drizzle(sqlite, { schema }) diff --git a/apps/desktop/tests/helpers/fakeSyncRemote.ts b/apps/desktop/tests/helpers/fakeSyncRemote.ts new file mode 100644 index 0000000..8806739 --- /dev/null +++ b/apps/desktop/tests/helpers/fakeSyncRemote.ts @@ -0,0 +1,224 @@ +// tests/helpers/fakeSyncRemote.ts +// Supabase 동작을 흉내 내는 메모리 SyncRemote. +// - INSERT는 보낸 updated_at 유지, UPDATE는 서버 시각으로 갱신(moddatetime) +// - 삭제 시 sync_tombstones 기록, meetings 삭제는 메모·문서로 cascade +// - dictionary (lower(word), category) 유일, memo_tags/템플릿 RPC + +import type { + RemoteFilter, + RemotePageRequest, + RemoteRow, + SyncRemote, +} from '../../src/main/services/sync/sync-types' +import { SyncRemoteError } from '../../src/main/services/sync/sync-types' + +export class FakeSyncRemote implements SyncRemote { + readonly tables = new Map() + private clock = Date.parse('2026-09-27T00:00:00.000Z') + private tombstoneSeq = 0 + networkDown = false + /** 특정 행 upsert를 거부시키는 훅 (재시도 불가 오류 시뮬레이션) */ + rejectRow: ((table: string, row: RemoteRow) => SyncRemoteError | null) | null = null + readonly calls: string[] = [] + + constructor(readonly userId: string) {} + + now(): string { + this.clock += 1000 + return new Date(this.clock).toISOString() + } + + rows(table: string): RemoteRow[] { + let rows = this.tables.get(table) + if (!rows) { + rows = [] + this.tables.set(table, rows) + } + return rows + } + + find(table: string, id: string): RemoteRow | undefined { + return this.rows(table).find((r) => r.id === id) + } + + // ── 모바일/웹이 서버에 직접 쓰는 것을 흉내 ── + + mobileInsert(table: string, row: RemoteRow): RemoteRow { + const at = this.now() + const full = { user_id: this.userId, created_at: at, updated_at: at, ...row } + this.rows(table).push(full) + return full + } + + mobileUpdate(table: string, id: string, patch: RemoteRow): void { + const row = this.find(table, id) + if (!row) throw new Error(`no ${table}/${id}`) + Object.assign(row, patch, { updated_at: this.now() }) + } + + mobileDelete(table: string, id: string): void { + this.removeRow(table, id) + } + + // ── SyncRemote ── + + private guard(): void { + if (this.networkDown) throw new SyncRemoteError('fetch failed', 'network', true) + } + + private matches(row: RemoteRow, filters: RemoteFilter[] | undefined): boolean { + for (const f of filters ?? []) { + const v = row[f.column] + if (f.op === 'eq' && v !== f.value) return false + if (f.op === 'is' && v !== null && v !== undefined) return false + if (f.op === 'ilike' && (typeof v !== 'string' || v.toLowerCase() !== f.value.toLowerCase())) return false + } + return true + } + + async fetchPage(request: RemotePageRequest): Promise { + this.guard() + this.calls.push(`fetch:${request.table}`) + const col = request.cursorColumn + const key = (r: RemoteRow): [number, string] => [Date.parse(String(r[col])), String(r.id)] + const after = request.after ? ([Date.parse(request.after.ts), request.after.id] as [number, string]) : null + const cmpId = (a: string, b: string): number => + /^\d+$/.test(a) && /^\d+$/.test(b) ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0 + const sorted = this.rows(request.table) + .filter((r) => r.user_id === request.userId && this.matches(r, request.filters)) + .sort((a, b) => { + const [ta, ia] = key(a) + const [tb, ib] = key(b) + return ta !== tb ? ta - tb : cmpId(ia, ib) + }) + .filter((r) => { + if (!after) return true + const [t, i] = key(r) + return t > after[0] || (t === after[0] && cmpId(i, after[1]) > 0) + }) + return sorted.slice(0, request.limit).map((r) => ({ ...r })) + } + + async selectWhere(table: string, userId: string, filters: RemoteFilter[]): Promise { + this.guard() + return this.rows(table) + .filter((r) => r.user_id === userId && this.matches(r, filters)) + .map((r) => ({ ...r })) + } + + async upsert(table: string, rows: RemoteRow[]): Promise { + this.guard() + this.calls.push(`upsert:${table}:${rows.length}`) + // 배치는 원자적이다: 하나라도 거부되면 전체 실패 + for (const row of rows) { + const rejected = this.rejectRow?.(table, row) + if (rejected) throw rejected + if (table === 'dictionary') { + const clash = this.rows(table).find( + (r) => + r.id !== row.id && + String(r.word).trim().toLowerCase() === String(row.word).trim().toLowerCase() && + r.category === row.category + ) + if (clash) throw new SyncRemoteError('duplicate key value', '23505', false) + } + if ((table === 'meeting_memos' || table === 'meeting_documents') && !this.find('meetings', String(row.meeting_id))) { + throw new SyncRemoteError('violates foreign key', '23503', true) + } + } + for (const row of rows) { + const existing = this.find(table, String(row.id)) + if (existing) { + const changed = Object.keys(row).some((k) => k !== 'updated_at' && existing[k] !== row[k]) + Object.assign(existing, row, { updated_at: this.now() }) + if (table === 'history' && changed) existing.revision = Number(existing.revision ?? 1) + 1 + } else { + const at = this.now() + this.rows(table).push({ revision: 1, created_at: at, ...row, updated_at: row.updated_at ?? at }) + } + } + } + + async deleteByIds(table: string, userId: string, ids: string[]): Promise { + this.guard() + this.calls.push(`delete:${table}:${ids.length}`) + for (const id of ids) { + const row = this.find(table, id) + if (row && row.user_id === userId) this.removeRow(table, id) + } + } + + private removeRow(table: string, id: string): void { + const rows = this.rows(table) + const index = rows.findIndex((r) => r.id === id) + if (index === -1) return + const [row] = rows.splice(index, 1) + this.rows('sync_tombstones').push({ + id: ++this.tombstoneSeq, + user_id: row.user_id, + table_name: table, + row_id: id, + deleted_at: this.now(), + }) + if (table === 'meetings') { + for (const child of ['meeting_memos', 'meeting_documents']) { + for (const c of this.rows(child).filter((r) => r.meeting_id === id)) this.removeRow(child, String(c.id)) + } + } + if (table === 'history') { + for (const t of this.rows('memo_tags').filter((r) => r.history_id === id)) this.removeRow('memo_tags', String(t.id)) + } + } + + async rpc(name: string, params: Record): Promise { + this.guard() + this.calls.push(`rpc:${name}`) + if (name === 'mobile_add_memo_tag_v1') { + const historyId = String(params.p_history_id) + if (!this.find('history', historyId)) throw new SyncRemoteError('history_not_found', 'P0002', true) + const normalized = String(params.p_tag).trim().replace(/\s+/g, ' ').toLowerCase() + const existing = this.rows('memo_tags').find((r) => r.history_id === historyId && r.normalized_tag === normalized) + if (existing) return existing + return this.mobileInsert('memo_tags', { + id: crypto.randomUUID(), + history_id: historyId, + tag: String(params.p_tag), + normalized_tag: normalized, + }) + } + if (name === 'mobile_remove_memo_tag_v1') { + const historyId = String(params.p_history_id) + const normalized = String(params.p_tag).trim().replace(/\s+/g, ' ').toLowerCase() + const row = this.rows('memo_tags').find((r) => r.history_id === historyId && r.normalized_tag === normalized) + if (!row) return false + this.removeRow('memo_tags', String(row.id)) + return true + } + if (name === 'sync_upsert_user_template_v1') { + const id = String(params.p_id) + const existing = this.find('user_templates', id) + const fields = { + template_kind: params.p_template_kind, + name: params.p_name, + description: params.p_description, + fields: params.p_fields, + output_format: params.p_output_format, + system_prompt: params.p_system_prompt, + template_type: params.p_template_kind === 'meeting_document' ? 'custom' : null, + is_builtin: false, + } + if (existing) { + Object.assign(existing, fields, { revision: Number(existing.revision) + 1, updated_at: this.now() }) + return existing + } + return this.mobileInsert('user_templates', { id, revision: 1, ...fields }) + } + if (name === 'sync_delete_user_template_v1') { + const id = String(params.p_id) + if (!this.find('user_templates', id)) return false + this.removeRow('user_templates', id) + return true + } + throw new Error(`unknown rpc ${name}`) + } +} diff --git a/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts b/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts new file mode 100644 index 0000000..ada74bf --- /dev/null +++ b/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts @@ -0,0 +1,271 @@ +// 데스크톱 동기화 엔진 ↔ 실제 Supabase(로컬 스택) 통합 검증. +// 모바일 앱이 쓰는 것과 같은 테이블·RPC·RLS를 상대로, 모바일 역할 클라이언트와 데스크톱 엔진이 +// 서로의 생성·수정·삭제를 주고받는지 확인한다. +// +// 실행: 로컬 스택(`supabase start`, server/) 기동 후 +// D3RO_SYNC_IT_SUPABASE_URL=http://127.0.0.1:55321 \ +// D3RO_SYNC_IT_ANON_KEY= D3RO_SYNC_IT_SERVICE_KEY= \ +// vitest run tests/integration/cross-device-sync.supabase.test.ts +// 환경변수가 없으면 건너뛴다(CI 기본). + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { nodeRealtimeTransport } from '../../src/main/services/sync/realtime-transport' +import { createTestDb } from '../helpers/createTestDb' +import { bindTestDatabase, unbindTestDatabase } from '../../src/main/db' +import { dictionary, history, meetingMemos, meetingSessions, memoTags } from '../../src/main/db/schema' +import { initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService' +import { + getCustomInstructionService, + resetCustomInstructionServiceForTests, +} from '../../src/main/services/CustomInstructionService' +import { + getDictationTemplateService, + resetDictationTemplateServiceForTests, +} from '../../src/main/services/DictationTemplateService' +import { resetMeetingDocTemplateServiceForTests } from '../../src/main/services/MeetingDocTemplateService' +import { SyncEngine } from '../../src/main/services/sync/SyncEngine' +import { SupabaseSyncRemote } from '../../src/main/services/sync/supabase-sync-remote' +import { enqueueChange, outboxCounts } from '../../src/main/services/sync/sync-outbox' +import { memoTagKey } from '../../src/main/services/sync/memo-tag-sync' +import { checkInDesktopDevice } from '../../src/main/services/sync/device-registration' + +const URL = process.env.D3RO_SYNC_IT_SUPABASE_URL +const ANON = process.env.D3RO_SYNC_IT_ANON_KEY +const SERVICE = process.env.D3RO_SYNC_IT_SERVICE_KEY +const enabled = Boolean(URL && ANON && SERVICE) + +// 데스크톱 CloudSyncService와 같은 클라이언트 설정 (Node 20에는 전역 WebSocket이 없다) +const noPersist = { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + realtime: { transport: nodeRealtimeTransport }, +} + +async function signedInClient(email: string, password: string): Promise { + const client = createClient(URL!, ANON!, noPersist) + const { error } = await client.auth.signInWithPassword({ email, password }) + if (error) throw error + return client +} + +function must(result: { data: T; error: { message: string } | null }): T { + if (result.error) throw new Error(result.error.message) + return result.data +} + +describe.skipIf(!enabled)('cross-device sync against local Supabase', () => { + let admin: SupabaseClient + let desktop: SupabaseClient + let mobile: SupabaseClient + let userId: string + let testDb: ReturnType + let engine: SyncEngine + + beforeAll(async () => { + admin = createClient(URL!, SERVICE!, noPersist) + const email = `sync-it-${Date.now()}@example.com` + const password = `Sync-it-${crypto.randomUUID()}` + const created = await admin.auth.admin.createUser({ email, password, email_confirm: true }) + if (created.error || !created.data.user) throw created.error ?? new Error('no user') + userId = created.data.user.id + desktop = await signedInClient(email, password) + mobile = await signedInClient(email, password) + + testDb = createTestDb() + bindTestDatabase(testDb.db, userId) + initInMemoryConfig() + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + getCustomInstructionService().initialize() + engine = new SyncEngine({ remote: new SupabaseSyncRemote(desktop), userId }) + }) + + afterAll(async () => { + engine?.dispose() + unbindTestDatabase() + resetInMemoryConfig() + testDb?.close() + if (userId) await admin.auth.admin.deleteUser(userId) + }) + + it('양방향 최초 동기화 — 모바일 기존 데이터 수신 + 데스크톱 데이터 업로드', async () => { + const db = testDb.db + const now = Date.now() + + // 모바일이 먼저 만들어 둔 것 + const phoneHistory = crypto.randomUUID() + must(await mobile.from('history').insert({ id: phoneHistory, user_id: userId, original_text: 'from phone', duration: 2, is_favorite: true })) + await mobile.rpc('mobile_add_memo_tag_v1', { p_history_id: phoneHistory, p_tag: 'Phone Tag' }).throwOnError() + must(await mobile.from('dictionary').insert({ id: crypto.randomUUID(), user_id: userId, word: 'kubernetes', category: 'user' })) + const phoneMeeting = crypto.randomUUID() + must(await mobile.from('meetings').insert({ id: phoneMeeting, user_id: userId, title: 'phone meeting', status: 'completed' })) + must(await mobile.from('meeting_memos').insert({ id: crypto.randomUUID(), meeting_id: phoneMeeting, user_id: userId, content: 'phone memo', timestamp_ms: 3 })) + must(await mobile.from('custom_instructions').insert({ user_id: userId, name: 'Phone command', prompt: 'Be brief', icon: 'sparkles' })) + await mobile + .rpc('create_user_template_v1', { + p_template_kind: 'meeting_document', + p_name: 'Phone template', + p_description: null, + p_fields: [], + p_output_format: null, + p_system_prompt: 'Summarise', + }) + .throwOnError() + + // 데스크톱에만 있는 것 + const deskHistory = crypto.randomUUID() + db.insert(history).values({ id: deskHistory, originalText: 'from desktop', duration: 1.5, createdAt: now, updatedAt: now }).run() + db.insert(memoTags).values({ id: crypto.randomUUID(), historyId: deskHistory, tag: 'desk', createdAt: now }).run() + db.insert(dictionary).values({ id: crypto.randomUUID(), word: 'Kubernetes', category: 'user', usageCount: 4, createdAt: now, updatedAt: now }).run() + const m1 = crypto.randomUUID() + const m2 = crypto.randomUUID() + for (const m of [m1, m2]) { + db.insert(meetingSessions).values({ id: m, title: `desk ${m.slice(0, 4)}`, status: 'completed', startedAt: now, createdAt: now, updatedAt: now }).run() + db.insert(meetingMemos).values({ id: crypto.randomUUID(), sessionId: m, content: 'desk memo', timestampMs: 1, createdAt: now }).run() + } + const deskCommand = getCustomInstructionService().create({ name: 'Desk command', description: '', prompt: 'Rewrite formally' }) + const deskTemplate = getDictationTemplateService().create({ + name: 'Desk dictation', + description: 'd', + fields: [{ id: 'field0', name: 'body', label: 'Body', promptText: 'Say it', required: true, maxDurationSec: 60 }], + outputFormat: '{{body}}', + }) + + const result = await engine.runFullSync() + expect(result.errors).toEqual([]) + expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) + + // 서버(모바일이 보는 것) + const remoteHistory = must(await mobile.from('history').select('id').eq('user_id', userId)) + expect(remoteHistory.map((r) => r.id).sort()).toEqual([phoneHistory, deskHistory].sort()) + const remoteMemos = must(await mobile.from('meeting_memos').select('meeting_id').eq('user_id', userId)) + expect(new Set(remoteMemos.map((r) => r.meeting_id))).toEqual(new Set([phoneMeeting, m1, m2])) + const remoteTags = must(await mobile.rpc('mobile_list_memo_tags_v1')) as Array<{ normalized_tag: string }> + expect(remoteTags.map((r) => r.normalized_tag).sort()).toEqual(['desk', 'phone tag']) + const remoteDict = must(await mobile.from('dictionary').select('word').eq('user_id', userId)) + expect(remoteDict).toHaveLength(1) + const remoteCommands = must(await mobile.from('custom_instructions').select('id,name').eq('user_id', userId).is('builtin_key', null)) + expect(remoteCommands.map((r) => r.name).sort()).toEqual(['Desk command', 'Phone command']) + expect(remoteCommands.some((r) => r.id === deskCommand.id)).toBe(true) + const remoteTemplate = must(await mobile.from('user_templates').select('id,name').eq('id', deskTemplate.id).maybeSingle()) + expect(remoteTemplate?.name).toBe('Desk dictation') + + // 데스크톱(로컬) + const local = db.select().from(history).where(eq(history.id, phoneHistory)).get() + expect(local?.isFavorite).toBe(true) + expect(db.select().from(memoTags).all().map((t) => t.tag).sort()).toEqual(['desk', 'phone tag']) + expect(db.select().from(dictionary).all()).toHaveLength(1) + expect(db.select().from(meetingMemos).where(eq(meetingMemos.sessionId, phoneMeeting)).all()).toHaveLength(1) + expect(getCustomInstructionService().getAll().some((i) => i.name === 'Phone command')).toBe(true) + }) + + it('모바일 수정·삭제 → 데스크톱, 데스크톱 삭제 → 모바일', async () => { + const db = testDb.db + const rows = must(await mobile.from('history').select('id,original_text,revision').eq('user_id', userId)) + const phone = rows.find((r) => r.original_text === 'from phone')! + const desk = rows.find((r) => r.original_text === 'from desktop')! + + // 모바일식 낙관적 동시성 수정 + must( + await mobile + .from('history') + .update({ original_text: 'phone edited', revision: Number(phone.revision) + 1 }) + .eq('id', phone.id) + .eq('revision', phone.revision) + ) + must(await mobile.from('meetings').delete().eq('user_id', userId).eq('title', 'phone meeting')) + await engine.pull() + expect(db.select().from(history).where(eq(history.id, phone.id)).get()?.originalText).toBe('phone edited') + expect(db.select().from(meetingSessions).all().map((m) => m.title)).not.toContain('phone meeting') + + // 데스크톱 즐겨찾기 → 서버 revision 증가(모바일 충돌 감지) + db.update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, desk.id)).run() + enqueueChange('history', desk.id, 'upsert') + await engine.flush() + const after = must(await mobile.from('history').select('is_favorite,revision').eq('id', desk.id).single()) + expect(after.is_favorite).toBe(true) + expect(Number(after.revision)).toBeGreaterThan(Number(desk.revision)) + + // 데스크톱 삭제 + db.delete(memoTags).where(eq(memoTags.historyId, desk.id)).run() + db.delete(history).where(eq(history.id, desk.id)).run() + enqueueChange('history', desk.id, 'delete') + enqueueChange('memo_tags', memoTagKey(desk.id, 'desk'), 'delete') + const flushed = await engine.flush() + expect(flushed.errors).toEqual([]) + const gone = must(await mobile.from('history').select('id').eq('id', desk.id)) + expect(gone).toEqual([]) + }) + + it('모바일 데이터 내보내기가 동기화된 데이터로도 성공한다 (v1 키 계약)', async () => { + const exported = must(await mobile.rpc('export_account_portability')) as Array<{ canonical_payload: string }> + const payload = JSON.parse(exported[0].canonical_payload) as { + datasets: { meeting_memos: Array>; meetings: Array> } + } + for (const memo of payload.datasets.meeting_memos) { + expect(Object.keys(memo).sort()).toEqual(['content', 'created_at', 'id', 'meeting_id', 'timestamp_ms', 'user_id']) + } + for (const meeting of payload.datasets.meetings) { + expect(meeting).not.toHaveProperty('attendees') + } + }) + + it('모바일 생성·삭제가 Realtime(행 + 삭제 기록)으로 데스크톱에 즉시 알려진다', async () => { + const session = must(await desktop.auth.getSession()).session + desktop.realtime.setAuth(session!.access_token) + const seen: string[] = [] + const channel = desktop + .channel(`it-sync:${userId}`) + .on('postgres_changes', { event: '*', schema: 'public', table: 'history', filter: `user_id=eq.${userId}` }, (p) => + seen.push(`history:${p.eventType}`) + ) + .on( + 'postgres_changes', + { event: 'INSERT', schema: 'public', table: 'sync_tombstones', filter: `user_id=eq.${userId}` }, + () => seen.push('tombstone') + ) + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('realtime subscribe timeout')), 15_000) + channel.subscribe((status, err) => { + if (status !== 'SUBSCRIBED') seen.push(`status:${status}:${err?.message ?? ''}`) + if (status === 'SUBSCRIBED') { + clearTimeout(timer) + resolve() + } + }) + }) + // 구독 직후에는 서버의 변경 감시 등록이 끝나지 않았을 수 있다. + await new Promise((r) => setTimeout(r, 1500)) + const id = crypto.randomUUID() + must(await mobile.from('history').insert({ id, user_id: userId, original_text: 'rt', duration: 1 })) + await new Promise((r) => setTimeout(r, 1000)) + // 필터 채널에는 DELETE 이벤트가 오지 않는다(Supabase 제약) — 삭제는 sync_tombstones INSERT로 전달된다. + must(await mobile.from('history').delete().eq('id', id)) + const deadline = Date.now() + 15_000 + while (Date.now() < deadline && !(seen.includes('history:INSERT') && seen.includes('tombstone'))) { + await new Promise((r) => setTimeout(r, 200)) + } + await desktop.removeChannel(channel) + expect(seen).toContain('history:INSERT') + expect(seen).toContain('tombstone') + }, 45_000) + + it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => { + const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' } + const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null) + expect(first.status).toBe('active') + const listed = must(await mobile.from('devices').select('id,platform,device_name').eq('user_id', userId)) + expect(listed.some((d) => d.device_name === 'IT-DESKTOP')).toBe(true) + + const deviceId = first.status === 'active' ? first.deviceId : '' + await mobile.rpc('revoke_device', { target_device_id: deviceId }).throwOnError() + const after = await checkInDesktopDevice(desktop, userId, 'heartbeat', info, deviceId) + expect(after.status).toBe('revoked') + + // 같은 계정으로 다시 로그인하면 새 설치 id로 재등록된다 + const again = await checkInDesktopDevice(desktop, userId, 'signin', info, deviceId) + expect(again.status).toBe('active') + }) +}) diff --git a/apps/desktop/tests/main/sync/SyncEngine.test.ts b/apps/desktop/tests/main/sync/SyncEngine.test.ts new file mode 100644 index 0000000..221f1e1 --- /dev/null +++ b/apps/desktop/tests/main/sync/SyncEngine.test.ts @@ -0,0 +1,383 @@ +// 데스크톱 ↔ Supabase(모바일·웹) 양방향 동기화 엔진 — 서버 흉내 원격으로 실제 SQLite에 대해 검증한다. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { createTestDb } from '../../helpers/createTestDb' +import { FakeSyncRemote } from '../../helpers/fakeSyncRemote' +import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' +import { + dictionary, + history, + meetingDocuments, + meetingMemos, + meetingSessions, + memoTags, +} from '../../../src/main/db/schema' +import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { + getCustomInstructionService, + resetCustomInstructionServiceForTests, +} from '../../../src/main/services/CustomInstructionService' +import { + getDictationTemplateService, + resetDictationTemplateServiceForTests, +} from '../../../src/main/services/DictationTemplateService' +import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService' +import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' +import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox' +import { memoTagKey } from '../../../src/main/services/sync/memo-tag-sync' +import { SyncRemoteError } from '../../../src/main/services/sync/sync-types' + +const USER = '11111111-1111-4111-8111-111111111111' +const OTHER = '22222222-2222-4222-8222-222222222222' + +function uuid(): string { + return crypto.randomUUID() +} + +let testDb: ReturnType +let remote: FakeSyncRemote +let engine: SyncEngine + +function db(): ReturnType['db'] { + return testDb.db +} + +function insertLocalHistory(text: string, at = Date.now()): string { + const id = uuid() + db().insert(history).values({ id, originalText: text, duration: 1.5, createdAt: at, updatedAt: at }).run() + return id +} + +function insertLocalMeeting(title: string, at = Date.now()): string { + const id = uuid() + db() + .insert(meetingSessions) + .values({ id, title, status: 'completed', startedAt: at, createdAt: at, updatedAt: at }) + .run() + return id +} + +function localHistoryIds(): string[] { + return db().select({ id: history.id }).from(history).all().map((r) => r.id).sort() +} + +beforeEach(() => { + testDb = createTestDb() + bindTestDatabase(testDb.db, USER) + initInMemoryConfig() + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + getCustomInstructionService().initialize() + remote = new FakeSyncRemote(USER) + engine = new SyncEngine({ remote, userId: USER }) +}) + +afterEach(() => { + engine.dispose() + unbindTestDatabase() + resetInMemoryConfig() + testDb.close() +}) + +describe('최초 동기화', () => { + it('모바일에서 만든 기존 기록을 내려받고, 데스크톱 기록을 올린다', async () => { + const mobileId = uuid() + remote.mobileInsert('history', { id: mobileId, original_text: 'from phone', duration: 2, mode: 'dictation', status: 'completed', is_favorite: true }) + const desktopId = insertLocalHistory('from desktop') + + const result = await engine.runFullSync() + + expect(result.errors).toEqual([]) + expect(localHistoryIds()).toEqual([mobileId, desktopId].sort()) + expect(remote.find('history', desktopId)?.original_text).toBe('from desktop') + const pulled = db().select().from(history).where(eq(history.id, mobileId)).get() + expect(pulled?.isFavorite).toBe(true) + expect(result.changed).toContain('history') + expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) + }) + + it('서버와 같은 판은 다시 올리지 않는다 (다른 기기의 편집을 덮지 않는다)', async () => { + const id = insertLocalHistory('same', Date.parse('2026-09-26T00:00:00Z')) + remote.mobileInsert('history', { + id, + original_text: 'edited on phone', + duration: 1.5, + mode: 'dictation', + status: 'completed', + }) + await engine.runFullSync() + expect(remote.calls.filter((c) => c.startsWith('upsert:history'))).toEqual([]) + expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('edited on phone') + }) + + it('500행이 넘어도 페이지를 넘겨 전부 가져온다', async () => { + for (let i = 0; i < 1203; i++) { + remote.mobileInsert('history', { id: uuid(), original_text: `r${i}`, duration: 1, mode: 'dictation', status: 'completed' }) + } + await engine.runFullSync() + expect(localHistoryIds()).toHaveLength(1203) + }) + + it('다른 사용자(팀) 행은 가져오지 않는다', async () => { + remote.rows('history').push({ + id: uuid(), + user_id: OTHER, + original_text: 'not mine', + duration: 1, + mode: 'dictation', + status: 'completed', + created_at: remote.now(), + updated_at: remote.now(), + }) + await engine.runFullSync() + expect(localHistoryIds()).toEqual([]) + }) +}) + +describe('변경·삭제 전파', () => { + it('모바일에서 수정/삭제한 것이 데스크톱에 반영된다', async () => { + const a = uuid() + const b = uuid() + remote.mobileInsert('history', { id: a, original_text: 'a', duration: 1, mode: 'dictation', status: 'completed' }) + remote.mobileInsert('history', { id: b, original_text: 'b', duration: 1, mode: 'dictation', status: 'completed' }) + await engine.runFullSync() + + remote.mobileUpdate('history', a, { original_text: 'a edited', title: 'renamed' }) + remote.mobileDelete('history', b) + const result = await engine.pull() + + expect(result.deleted).toBe(1) + expect(localHistoryIds()).toEqual([a]) + const row = db().select().from(history).where(eq(history.id, a)).get() + expect(row?.originalText).toBe('a edited') + expect(row?.title).toBe('renamed') + }) + + it('데스크톱 삭제가 서버로 전파된다', async () => { + const id = insertLocalHistory('bye') + await engine.runFullSync() + expect(remote.find('history', id)).toBeDefined() + + db().delete(history).where(eq(history.id, id)).run() + enqueueChange('history', id, 'delete') + await engine.flush() + + expect(remote.find('history', id)).toBeUndefined() + // 자기 삭제 기록을 다시 받아도 문제없다 + const pulled = await engine.pull() + expect(pulled.errors).toEqual([]) + }) + + it('아직 올리지 않은 로컬 변경은 pull이 덮지 않는다', async () => { + const id = uuid() + remote.mobileInsert('history', { id, original_text: 'v1', duration: 1, mode: 'dictation', status: 'completed' }) + await engine.runFullSync() + + db().update(history).set({ originalText: 'local v2', updatedAt: Date.now() }).where(eq(history.id, id)).run() + enqueueChange('history', id, 'upsert') + remote.mobileUpdate('history', id, { original_text: 'remote v2' }) + await engine.pull() + expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('local v2') + + await engine.flush() + expect(remote.find('history', id)?.original_text).toBe('local v2') + }) + + it('원격 삭제는 로컬 미전송 변경보다 우선한다', async () => { + const id = uuid() + remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' }) + await engine.runFullSync() + enqueueChange('history', id, 'upsert') + remote.mobileDelete('history', id) + await engine.pull() + expect(localHistoryIds()).toEqual([]) + expect(pendingOps('history').size).toBe(0) + }) + + it('데스크톱이 내용을 바꿔 올리면 서버 revision이 오른다 (모바일 충돌 감지)', async () => { + const id = uuid() + remote.mobileInsert('history', { id, original_text: 'x', duration: 1.5, mode: 'dictation', status: 'completed', revision: 1 }) + await engine.runFullSync() + db().update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, id)).run() + enqueueChange('history', id, 'upsert') + await engine.flush() + expect(remote.find('history', id)?.is_favorite).toBe(true) + expect(Number(remote.find('history', id)?.revision)).toBeGreaterThan(1) + }) +}) + +describe('회의', () => { + it('여러 회의의 메모·문서를 모두 올린다 (첫 회의만 올리던 회귀)', async () => { + const m1 = insertLocalMeeting('one') + const m2 = insertLocalMeeting('two') + const now = Date.now() + for (const m of [m1, m2]) { + db().insert(meetingMemos).values({ id: uuid(), sessionId: m, content: `memo ${m}`, timestampMs: 5, createdAt: now }).run() + db() + .insert(meetingDocuments) + .values({ id: uuid(), sessionId: m, templateType: 'minutes', title: 't', content: 'c', createdAt: now, updatedAt: now }) + .run() + } + const result = await engine.runFullSync() + expect(result.errors).toEqual([]) + expect(remote.rows('meeting_memos')).toHaveLength(2) + expect(remote.rows('meeting_documents')).toHaveLength(2) + }) + + it('팀에 공유된 회의를 데스크톱이 수정해도 team_id를 지우지 않는다', async () => { + const id = insertLocalMeeting('shared') + await engine.runFullSync() + const teamId = uuid() + remote.mobileUpdate('meetings', id, { team_id: teamId }) + await engine.pull() + db().update(meetingSessions).set({ title: 'renamed', updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run() + enqueueChange('meetings', id, 'upsert') + await engine.flush() + expect(remote.find('meetings', id)?.team_id).toBe(teamId) + expect(remote.find('meetings', id)?.title).toBe('renamed') + }) + + it('모바일에서 수정한 회의 메모를 가져오고, 회의 삭제는 메모·문서까지 지운다', async () => { + const meeting = uuid() + const memo = uuid() + remote.mobileInsert('meetings', { id: meeting, title: 'phone meeting', status: 'completed', started_at: remote.now() }) + remote.mobileInsert('meeting_memos', { id: memo, meeting_id: meeting, content: 'first', timestamp_ms: 10 }) + await engine.runFullSync() + remote.mobileUpdate('meeting_memos', memo, { content: 'edited' }) + await engine.pull() + expect(db().select().from(meetingMemos).where(eq(meetingMemos.id, memo)).get()?.content).toBe('edited') + + remote.mobileDelete('meetings', meeting) + await engine.pull() + expect(db().select().from(meetingSessions).all()).toEqual([]) + expect(db().select().from(meetingMemos).all()).toEqual([]) + }) + + it('남의 팀 회의에 단 내 메모는 부모 없는 고아 행으로 만들지 않는다', async () => { + const foreignMeeting = uuid() + remote.rows('meetings').push({ id: foreignMeeting, user_id: OTHER, status: 'completed', created_at: remote.now(), updated_at: remote.now() }) + remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: foreignMeeting, content: 'mine', timestamp_ms: 1 }) + const result = await engine.runFullSync() + expect(result.errors).toEqual([]) + expect(db().select().from(meetingMemos).all()).toEqual([]) + }) +}) + +describe('사전', () => { + it('대소문자만 다른 단어가 서버에 있으면 서버 행을 채택한다', async () => { + const remoteId = uuid() + remote.mobileInsert('dictionary', { id: remoteId, word: 'api', category: 'user', usage_count: 1 }) + const localId = uuid() + const now = Date.now() + db().insert(dictionary).values({ id: localId, word: 'API', category: 'user', usageCount: 7, createdAt: now, updatedAt: now }).run() + + const result = await engine.runFullSync() + + expect(result.errors).toEqual([]) + const rows = db().select().from(dictionary).all() + expect(rows.map((r) => r.id)).toEqual([remoteId]) + expect(rows[0].usageCount).toBe(7) + expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) + }) +}) + +describe('실패 처리', () => { + it('네트워크가 끊기면 변경을 보관했다가 다음에 올린다', async () => { + await engine.runFullSync() + const id = insertLocalHistory('offline') + enqueueChange('history', id, 'upsert') + remote.networkDown = true + const failed = await engine.flush() + expect(failed.pushed).toBe(0) + expect(outboxCounts().pending).toBe(1) + + remote.networkDown = false + // 백오프 시각을 지난 것으로 본다 + const later = new SyncEngine({ remote, userId: USER, now: () => Date.now() + 60_000 }) + await later.flush() + later.dispose() + expect(remote.find('history', id)).toBeDefined() + expect(outboxCounts().pending).toBe(0) + }) + + it('서버가 거부한 행만 격리하고 나머지는 올린다', async () => { + const good = insertLocalHistory('good') + const bad = insertLocalHistory('bad') + remote.rejectRow = (table, row) => + table === 'history' && row.id === bad ? new SyncRemoteError('check violation', '23514', false) : null + const result = await engine.runFullSync() + expect(remote.find('history', good)).toBeDefined() + expect(remote.find('history', bad)).toBeUndefined() + expect(result.errors.some((e) => e.includes(bad))).toBe(true) + expect(outboxCounts().pending).toBe(1) + }) +}) + +describe('메모 태그', () => { + it('데스크톱 태그를 올리고 모바일 태그·삭제를 반영한다', async () => { + const h = uuid() + remote.mobileInsert('history', { id: h, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' }) + await engine.runFullSync() + + db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'work', createdAt: Date.now() }).run() + enqueueChange('memo_tags', memoTagKey(h, 'work'), 'upsert') + await engine.flush() + expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['work']) + + await remote.rpc('mobile_add_memo_tag_v1', { p_history_id: h, p_tag: 'Idea Board' }) + await remote.rpc('mobile_remove_memo_tag_v1', { p_history_id: h, p_tag: 'work' }) + await engine.pull() + expect(db().select().from(memoTags).all().map((r) => r.tag)).toEqual(['idea board']) + }) + + it('이력이 아직 서버에 없으면 태그는 대기했다가 이력 뒤에 올라간다', async () => { + const h = insertLocalHistory('tagged') + db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'later', createdAt: Date.now() }).run() + const result = await engine.runFullSync() + expect(result.errors).toEqual([]) + expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['later']) + expect(db().select().from(memoTags).all()).toHaveLength(1) + }) +}) + +describe('명령·템플릿', () => { + it('사용자 명령을 양방향으로 맞추고 프리셋은 건드리지 않는다', async () => { + const service = getCustomInstructionService() + const created = service.create({ name: 'Tone down', description: 'd', prompt: 'Make it calm' }) + enqueueChange('custom_instructions', created.id, 'upsert') + const phone = uuid() + remote.mobileInsert('custom_instructions', { id: phone, builtin_key: null, name: 'From phone', description: '', prompt: 'p', icon: 'sparkles', sort_order: 3 }) + remote.mobileInsert('custom_instructions', { id: uuid(), builtin_key: 'summarize', name: 'Summarize', description: '', prompt: 'p', icon: 'x', sort_order: 0 }) + + await engine.runFullSync() + + expect(remote.find('custom_instructions', created.id)?.prompt).toBe('Make it calm') + expect(service.getById(phone)?.name).toBe('From phone') + expect(service.getAll().filter((i) => i.name === 'Summarize')).toEqual([]) + expect(remote.rows('custom_instructions').filter((r) => String(r.id).startsWith('builtin-'))).toEqual([]) + + remote.mobileDelete('custom_instructions', phone) + await engine.pull() + expect(service.getById(phone)).toBeNull() + }) + + it('받아쓰기 템플릿을 같은 id로 올리고 모바일 수정을 반영한다', async () => { + const service = getDictationTemplateService() + const template = service.create({ + name: 'Standup', + description: '', + fields: [{ id: 'field0', name: 'done', label: 'Done', promptText: 'What did you do?', required: true, maxDurationSec: 60 }], + outputFormat: '{{done}}', + }) + enqueueChange('user_templates', template.id, 'upsert') + await engine.runFullSync() + expect(remote.find('user_templates', template.id)?.name).toBe('Standup') + + remote.mobileUpdate('user_templates', template.id, { name: 'Daily standup' }) + await engine.pull() + expect(service.getById(template.id)?.name).toBe('Daily standup') + expect(service.getAll().filter((t) => t.isBuiltin).length).toBeGreaterThan(0) + }) +}) diff --git a/package-lock.json b/package-lock.json index 157808c..2076eb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,7 +137,8 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tar": "^7.5.13", - "uiohook-napi": "^1.5.5" + "uiohook-napi": "^1.5.5", + "ws": "^8.22.0" }, "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", @@ -146,6 +147,7 @@ "@types/node": "^22.13.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", "electron": "33.4.11", "electron-builder": "^26.8.1", @@ -17031,9 +17033,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts index 6cabd61..2eeca49 100644 --- a/packages/core/src/ipc-channels.ts +++ b/packages/core/src/ipc-channels.ts @@ -145,6 +145,7 @@ export const IPC_CHANNELS = { GET_BY_ID: 'history:getById', DELETE: 'history:delete', DELETE_ALL: 'history:deleteAll', + SET_FAVORITE: 'history:setFavorite', SEARCH: 'history:search', EXPORT: 'history:export', // Main → Renderer events diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1ef06ac..2f5baa7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -479,6 +479,12 @@ export interface AppConfig { supabaseAnonKey: string /** V2-4: 마지막 동기화 epoch ms */ cloudSyncLastAt: number | null + /** + * 이 설치본의 기기 식별자(uuid). Supabase devices.installation_id — + * 모바일 "연결된 기기" 목록에 이 데스크톱이 나타나고 원격 연결 해제 대상이 된다. + * 원격 해제된 id는 재사용할 수 없어(서버 규칙) 재로그인 때 새로 발급한다. + */ + deviceInstallationId: string | null /** 빅뱅 Phase 2: 첫 실행 온보딩 완료 여부 (로컬 모드 entry point) */ onboardingCompleted: boolean /** Phase 6: Custom instructions (CustomInstructionService) */ @@ -622,6 +628,8 @@ export interface HistoryEntry { appVersion: string /** Phase 12.2: 회의록 요약 마크다운 */ summaryText: string | null + /** 모바일·웹과 공유하는 즐겨찾기 (Supabase history.is_favorite) */ + isFavorite: boolean } export interface HistoryQueryParams { @@ -647,6 +655,11 @@ export interface HistoryDeleteParams { id: string } +export interface HistorySetFavoriteParams { + id: string + isFavorite: boolean +} + export interface HistorySearchParams { query: string page: number diff --git a/packages/i18n/src/locales/de.json b/packages/i18n/src/locales/de.json index 0a51d8e..faf9f58 100644 --- a/packages/i18n/src/locales/de.json +++ b/packages/i18n/src/locales/de.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "Die lokale KI korrigiert Leerzeichen, Satzzeichen und falsch verstandene Wörter anhand der umgebenden Zeilen.", "settings.captionModel": "Modell für Live-Untertitel", "settings.captionModel.same": "Wie beim Diktieren", - "settings.captionModel.desc": "Live-Untertitel werden mit einem eigenen Modell erkannt, das zusätzlich zum Diktiermodell geladen wird." + "settings.captionModel.desc": "Live-Untertitel werden mit einem eigenen Modell erkannt, das zusätzlich zum Diktiermodell geladen wird.", + "history.favorite": "Favorit", + "history.unfavorite": "Favorit entfernen", + "cloudSync.title": "Cloud-Synchronisierung", + "cloudSync.description": "Hält Verlauf, Wörterbuch, Meetings, Memo-Tags, Befehle und Vorlagen mit Mobil und Web abgeglichen. Was du auf einem Gerät löschst, wird überall gelöscht.", + "cloudSync.signIn": "Anmelden", + "cloudSync.signedInAs": "Angemeldet als {{email}}", + "cloudSync.noEmail": "keine E-Mail", + "cloudSync.lastSync": "Zuletzt synchronisiert: {{time}}", + "cloudSync.never": "noch nicht", + "cloudSync.upToDate": "Alle Änderungen sind hochgeladen", + "cloudSync.pending": "{{count}} Änderung(en) warten auf Upload", + "cloudSync.failed": "{{count}} Änderung(en) vom Server abgelehnt – „Jetzt synchronisieren“ versucht es erneut", + "cloudSync.syncNow": "Jetzt synchronisieren", + "cloudSync.syncing": "Wird synchronisiert…", + "cloudSync.signOut": "Abmelden", + "cloudSync.browserPending": "Schließe die Anmeldung im Browser ab.", + "cloudSync.signedOut": "Abgemeldet. Die Einträge auf diesem Computer bleiben erhalten.", + "cloudSync.done": "Synchronisierung abgeschlossen ({{count}} übernommen)", + "cloudSync.doneWithErrors": "Synchronisierung abgeschlossen ({{count}} übernommen, {{errors}} fehlgeschlagen – nächster Lauf versucht es erneut)" } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 03c3b7e..05742f0 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -1903,5 +1903,29 @@ "settings.captionRefine.desc": "The local AI fixes spacing, punctuation and misheard words in finished captions using the surrounding lines.", "settings.captionModel": "Live caption model", "settings.captionModel.same": "Same as dictation", - "settings.captionModel.desc": "Recognises live captions with a separate model, loaded alongside the dictation model." + "settings.captionModel.desc": "Recognises live captions with a separate model, loaded alongside the dictation model.", + "history.favorite": "Favorite", + "history.unfavorite": "Remove favorite", + "cloudSync.title": "Cloud sync", + "cloudSync.description": "Keeps history, dictionary, meetings, memo tags, commands and templates in step with mobile and web. Deleting on one device deletes everywhere.", + "cloudSync.signIn": "Sign in", + "cloudSync.signedInAs": "Signed in as {{email}}", + "cloudSync.noEmail": "no email", + "cloudSync.lastSync": "Last synced: {{time}}", + "cloudSync.never": "not yet", + "cloudSync.upToDate": "Every change is uploaded", + "cloudSync.pending": "{{count}} change(s) waiting to upload", + "cloudSync.failed": "{{count}} change(s) rejected by the server — Sync now tries them again", + "cloudSync.syncNow": "Sync now", + "cloudSync.syncing": "Syncing…", + "cloudSync.signOut": "Sign out", + "cloudSync.browserPending": "Finish signing in in your browser.", + "cloudSync.signedOut": "Signed out. Records on this computer stay where they are.", + "cloudSync.done": "Sync complete ({{count}} applied)", + "cloudSync.doneWithErrors": "Sync complete ({{count}} applied, {{errors}} failed — retried on the next sync)", + "mobile.devices.platform.android": "Android", + "mobile.devices.platform.ios": "iPhone / iPad", + "mobile.devices.platform.web": "Web browser", + "mobile.devices.platform.windows": "Windows desktop app", + "mobile.devices.platform.macos": "macOS desktop app" } diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index cc884b0..c6c1b9c 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "La IA local corrige espacios, puntuación y palabras mal oídas usando las líneas cercanas.", "settings.captionModel": "Modelo de subtítulos en vivo", "settings.captionModel.same": "Igual que el dictado", - "settings.captionModel.desc": "Reconoce los subtítulos en vivo con un modelo aparte, cargado junto al de dictado." + "settings.captionModel.desc": "Reconoce los subtítulos en vivo con un modelo aparte, cargado junto al de dictado.", + "history.favorite": "Favorito", + "history.unfavorite": "Quitar de favoritos", + "cloudSync.title": "Sincronización en la nube", + "cloudSync.description": "Mantiene el historial, el diccionario, las reuniones, las etiquetas, los comandos y las plantillas al día con el móvil y la web. Lo que borres en un dispositivo se borra en todos.", + "cloudSync.signIn": "Iniciar sesión", + "cloudSync.signedInAs": "Sesión iniciada como {{email}}", + "cloudSync.noEmail": "sin correo", + "cloudSync.lastSync": "Última sincronización: {{time}}", + "cloudSync.never": "todavía no", + "cloudSync.upToDate": "Todos los cambios están subidos", + "cloudSync.pending": "{{count}} cambio(s) pendientes de subir", + "cloudSync.failed": "El servidor rechazó {{count}} cambio(s): «Sincronizar ahora» los reintenta", + "cloudSync.syncNow": "Sincronizar ahora", + "cloudSync.syncing": "Sincronizando…", + "cloudSync.signOut": "Cerrar sesión", + "cloudSync.browserPending": "Termina de iniciar sesión en el navegador.", + "cloudSync.signedOut": "Sesión cerrada. Los registros de este equipo se conservan.", + "cloudSync.done": "Sincronización completa ({{count}} aplicados)", + "cloudSync.doneWithErrors": "Sincronización completa ({{count}} aplicados, {{errors}} fallidos: se reintentan en la próxima)" } diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index 3a5c8d3..3f9a511 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "L’IA locale corrige les espaces, la ponctuation et les mots mal entendus d’après les lignes voisines.", "settings.captionModel": "Modèle des sous-titres en direct", "settings.captionModel.same": "Identique à la dictée", - "settings.captionModel.desc": "Les sous-titres en direct utilisent un modèle distinct, chargé en plus de celui de la dictée." + "settings.captionModel.desc": "Les sous-titres en direct utilisent un modèle distinct, chargé en plus de celui de la dictée.", + "history.favorite": "Favori", + "history.unfavorite": "Retirer des favoris", + "cloudSync.title": "Synchronisation cloud", + "cloudSync.description": "Synchronise l'historique, le dictionnaire, les réunions, les étiquettes, les commandes et les modèles avec le mobile et le web. Une suppression sur un appareil s'applique partout.", + "cloudSync.signIn": "Se connecter", + "cloudSync.signedInAs": "Connecté en tant que {{email}}", + "cloudSync.noEmail": "sans e-mail", + "cloudSync.lastSync": "Dernière synchronisation : {{time}}", + "cloudSync.never": "pas encore", + "cloudSync.upToDate": "Toutes les modifications sont envoyées", + "cloudSync.pending": "{{count}} modification(s) en attente d’envoi", + "cloudSync.failed": "Le serveur a refusé {{count}} modification(s) — « Synchroniser » les renvoie", + "cloudSync.syncNow": "Synchroniser", + "cloudSync.syncing": "Synchronisation…", + "cloudSync.signOut": "Se déconnecter", + "cloudSync.browserPending": "Terminez la connexion dans votre navigateur.", + "cloudSync.signedOut": "Déconnecté. Les enregistrements de cet ordinateur sont conservés.", + "cloudSync.done": "Synchronisation terminée ({{count}} appliqués)", + "cloudSync.doneWithErrors": "Synchronisation terminée ({{count}} appliqués, {{errors}} en échec — réessayés à la prochaine)" } diff --git a/packages/i18n/src/locales/ja.json b/packages/i18n/src/locales/ja.json index ab56694..9b17f5a 100644 --- a/packages/i18n/src/locales/ja.json +++ b/packages/i18n/src/locales/ja.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "確定した字幕の区切り・句読点・聞き間違いを、ローカルAIが前後の文脈に合わせて直します。", "settings.captionModel": "リアルタイム字幕モデル", "settings.captionModel.same": "音声入力と同じ", - "settings.captionModel.desc": "リアルタイム字幕だけを別のモデルで認識します。音声入力モデルと同時に読み込まれます。" + "settings.captionModel.desc": "リアルタイム字幕だけを別のモデルで認識します。音声入力モデルと同時に読み込まれます。", + "history.favorite": "お気に入り", + "history.unfavorite": "お気に入りを解除", + "cloudSync.title": "クラウド同期", + "cloudSync.description": "履歴・辞書・会議・メモタグ・コマンド・テンプレートをモバイルとウェブに自動で同期します。どの端末で削除しても他の端末からも削除されます。", + "cloudSync.signIn": "ログイン", + "cloudSync.signedInAs": "{{email}} でログイン中", + "cloudSync.noEmail": "メールなし", + "cloudSync.lastSync": "最終同期: {{time}}", + "cloudSync.never": "まだありません", + "cloudSync.upToDate": "すべての変更をアップロード済み", + "cloudSync.pending": "アップロード待ちの変更 {{count}} 件", + "cloudSync.failed": "サーバーに拒否された変更 {{count}} 件 —「今すぐ同期」で再試行します", + "cloudSync.syncNow": "今すぐ同期", + "cloudSync.syncing": "同期中…", + "cloudSync.signOut": "ログアウト", + "cloudSync.browserPending": "ブラウザでログインを完了してください。", + "cloudSync.signedOut": "ログアウトしました。このコンピューターの記録はそのまま残ります。", + "cloudSync.done": "同期完了({{count}} 件反映)", + "cloudSync.doneWithErrors": "同期完了({{count}} 件反映、{{errors}} 件失敗 — 次回の同期で再試行)" } diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index cd2c7e4..b444058 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -1910,5 +1910,29 @@ "settings.captionRefine.desc": "확정된 자막을 로컬 AI가 앞뒤 문맥에 맞게 띄어쓰기·문장부호·잘못 들은 단어를 고칩니다.", "settings.captionModel": "실시간 자막 모델", "settings.captionModel.same": "받아쓰기와 같게", - "settings.captionModel.desc": "실시간 자막만 다른 모델로 인식합니다. 받아쓰기 모델과 함께 GPU 메모리에 올라갑니다." + "settings.captionModel.desc": "실시간 자막만 다른 모델로 인식합니다. 받아쓰기 모델과 함께 GPU 메모리에 올라갑니다.", + "history.favorite": "즐겨찾기", + "history.unfavorite": "즐겨찾기 해제", + "cloudSync.title": "클라우드 동기화", + "cloudSync.description": "기록·사전·회의·메모 태그·명령·템플릿을 모바일과 웹에 자동으로 맞춥니다. 어느 기기에서 지워도 다른 기기에서 함께 지워집니다.", + "cloudSync.signIn": "로그인", + "cloudSync.signedInAs": "{{email}} 계정으로 로그인됨", + "cloudSync.noEmail": "이메일 없음", + "cloudSync.lastSync": "마지막 동기화: {{time}}", + "cloudSync.never": "아직 없음", + "cloudSync.upToDate": "모든 변경이 올라갔습니다", + "cloudSync.pending": "올릴 변경 {{count}}건 대기 중", + "cloudSync.failed": "서버가 거부한 변경 {{count}}건 — \"지금 동기화\"로 다시 시도합니다", + "cloudSync.syncNow": "지금 동기화", + "cloudSync.syncing": "동기화 중…", + "cloudSync.signOut": "로그아웃", + "cloudSync.browserPending": "브라우저에서 로그인을 마쳐 주세요.", + "cloudSync.signedOut": "로그아웃했습니다. 이 컴퓨터의 기록은 그대로 남아 있습니다.", + "cloudSync.done": "동기화 완료 ({{count}}건 반영)", + "cloudSync.doneWithErrors": "동기화 완료 ({{count}}건 반영, {{errors}}건 실패 — 다음 동기화 때 다시 시도)", + "mobile.devices.platform.android": "Android", + "mobile.devices.platform.ios": "iPhone·iPad", + "mobile.devices.platform.web": "웹 브라우저", + "mobile.devices.platform.windows": "Windows 데스크톱 앱", + "mobile.devices.platform.macos": "macOS 데스크톱 앱" } diff --git a/packages/i18n/src/locales/pt.json b/packages/i18n/src/locales/pt.json index f7651b8..8d1839e 100644 --- a/packages/i18n/src/locales/pt.json +++ b/packages/i18n/src/locales/pt.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "A IA local corrige espaços, pontuação e palavras mal ouvidas usando as linhas próximas.", "settings.captionModel": "Modelo de legendas ao vivo", "settings.captionModel.same": "Igual ao ditado", - "settings.captionModel.desc": "Reconhece as legendas ao vivo com um modelo separado, carregado junto com o do ditado." + "settings.captionModel.desc": "Reconhece as legendas ao vivo com um modelo separado, carregado junto com o do ditado.", + "history.favorite": "Favorito", + "history.unfavorite": "Remover dos favoritos", + "cloudSync.title": "Sincronização na nuvem", + "cloudSync.description": "Mantém histórico, dicionário, reuniões, etiquetas, comandos e modelos sincronizados com o celular e a web. O que você apaga em um dispositivo é apagado em todos.", + "cloudSync.signIn": "Entrar", + "cloudSync.signedInAs": "Conectado como {{email}}", + "cloudSync.noEmail": "sem e-mail", + "cloudSync.lastSync": "Última sincronização: {{time}}", + "cloudSync.never": "ainda não", + "cloudSync.upToDate": "Todas as alterações foram enviadas", + "cloudSync.pending": "{{count}} alteração(ões) aguardando envio", + "cloudSync.failed": "O servidor recusou {{count}} alteração(ões) — \"Sincronizar agora\" tenta de novo", + "cloudSync.syncNow": "Sincronizar agora", + "cloudSync.syncing": "Sincronizando…", + "cloudSync.signOut": "Sair", + "cloudSync.browserPending": "Conclua o login no navegador.", + "cloudSync.signedOut": "Você saiu. Os registros deste computador continuam aqui.", + "cloudSync.done": "Sincronização concluída ({{count}} aplicados)", + "cloudSync.doneWithErrors": "Sincronização concluída ({{count}} aplicados, {{errors}} com falha — tentados de novo na próxima)" } diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 6cf3cb0..95d8d51 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "Локальный ИИ исправляет пробелы, пунктуацию и ослышки в готовых субтитрах по соседним строкам.", "settings.captionModel": "Модель живых субтитров", "settings.captionModel.same": "Как для диктовки", - "settings.captionModel.desc": "Живые субтитры распознаются отдельной моделью, которая загружается вместе с моделью диктовки." + "settings.captionModel.desc": "Живые субтитры распознаются отдельной моделью, которая загружается вместе с моделью диктовки.", + "history.favorite": "В избранное", + "history.unfavorite": "Убрать из избранного", + "cloudSync.title": "Облачная синхронизация", + "cloudSync.description": "Синхронизирует историю, словарь, встречи, теги, команды и шаблоны с мобильным приложением и вебом. Удаление на одном устройстве удаляет везде.", + "cloudSync.signIn": "Войти", + "cloudSync.signedInAs": "Вы вошли как {{email}}", + "cloudSync.noEmail": "нет почты", + "cloudSync.lastSync": "Последняя синхронизация: {{time}}", + "cloudSync.never": "ещё нет", + "cloudSync.upToDate": "Все изменения отправлены", + "cloudSync.pending": "Ожидают отправки: {{count}}", + "cloudSync.failed": "Сервер отклонил изменений: {{count}} — «Синхронизировать» повторит попытку", + "cloudSync.syncNow": "Синхронизировать", + "cloudSync.syncing": "Синхронизация…", + "cloudSync.signOut": "Выйти", + "cloudSync.browserPending": "Завершите вход в браузере.", + "cloudSync.signedOut": "Вы вышли. Записи на этом компьютере сохранены.", + "cloudSync.done": "Синхронизация завершена (применено: {{count}})", + "cloudSync.doneWithErrors": "Синхронизация завершена (применено: {{count}}, ошибок: {{errors}} — повтор при следующей синхронизации)" } diff --git a/packages/i18n/src/locales/th.json b/packages/i18n/src/locales/th.json index fb6f61c..3e52924 100644 --- a/packages/i18n/src/locales/th.json +++ b/packages/i18n/src/locales/th.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "AI ในเครื่องจะแก้เว้นวรรค เครื่องหมายวรรคตอน และคำที่ได้ยินผิด โดยดูจากบรรทัดรอบข้าง", "settings.captionModel": "โมเดลคำบรรยายสด", "settings.captionModel.same": "เหมือนการพิมพ์ด้วยเสียง", - "settings.captionModel.desc": "ใช้โมเดลแยกสำหรับคำบรรยายสด โดยโหลดคู่กับโมเดลพิมพ์ด้วยเสียง" + "settings.captionModel.desc": "ใช้โมเดลแยกสำหรับคำบรรยายสด โดยโหลดคู่กับโมเดลพิมพ์ด้วยเสียง", + "history.favorite": "รายการโปรด", + "history.unfavorite": "นำออกจากรายการโปรด", + "cloudSync.title": "ซิงก์คลาวด์", + "cloudSync.description": "ซิงก์ประวัติ พจนานุกรม การประชุม แท็กบันทึก คำสั่ง และเทมเพลตกับมือถือและเว็บโดยอัตโนมัติ ลบบนอุปกรณ์หนึ่งจะลบทุกที่", + "cloudSync.signIn": "เข้าสู่ระบบ", + "cloudSync.signedInAs": "เข้าสู่ระบบในชื่อ {{email}}", + "cloudSync.noEmail": "ไม่มีอีเมล", + "cloudSync.lastSync": "ซิงก์ล่าสุด: {{time}}", + "cloudSync.never": "ยังไม่เคย", + "cloudSync.upToDate": "อัปโหลดการเปลี่ยนแปลงครบแล้ว", + "cloudSync.pending": "รออัปโหลด {{count}} รายการ", + "cloudSync.failed": "เซิร์ฟเวอร์ปฏิเสธ {{count}} รายการ — \"ซิงก์ตอนนี้\" จะลองใหม่", + "cloudSync.syncNow": "ซิงก์ตอนนี้", + "cloudSync.syncing": "กำลังซิงก์…", + "cloudSync.signOut": "ออกจากระบบ", + "cloudSync.browserPending": "โปรดเข้าสู่ระบบให้เสร็จในเบราว์เซอร์", + "cloudSync.signedOut": "ออกจากระบบแล้ว ข้อมูลบนคอมพิวเตอร์นี้ยังอยู่ครบ", + "cloudSync.done": "ซิงก์เสร็จแล้ว ({{count}} รายการ)", + "cloudSync.doneWithErrors": "ซิงก์เสร็จแล้ว ({{count}} รายการ, ล้มเหลว {{errors}} — จะลองใหม่รอบหน้า)" } diff --git a/packages/i18n/src/locales/vi.json b/packages/i18n/src/locales/vi.json index b1e4bc9..bb33f40 100644 --- a/packages/i18n/src/locales/vi.json +++ b/packages/i18n/src/locales/vi.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "AI cục bộ sửa khoảng trắng, dấu câu và từ nghe nhầm dựa trên các dòng xung quanh.", "settings.captionModel": "Mô hình phụ đề trực tiếp", "settings.captionModel.same": "Giống đọc chính tả", - "settings.captionModel.desc": "Phụ đề trực tiếp dùng mô hình riêng, được tải cùng mô hình đọc chính tả." + "settings.captionModel.desc": "Phụ đề trực tiếp dùng mô hình riêng, được tải cùng mô hình đọc chính tả.", + "history.favorite": "Yêu thích", + "history.unfavorite": "Bỏ yêu thích", + "cloudSync.title": "Đồng bộ đám mây", + "cloudSync.description": "Tự động đồng bộ lịch sử, từ điển, cuộc họp, thẻ ghi chú, lệnh và mẫu với di động và web. Xóa trên một thiết bị sẽ xóa ở mọi nơi.", + "cloudSync.signIn": "Đăng nhập", + "cloudSync.signedInAs": "Đã đăng nhập: {{email}}", + "cloudSync.noEmail": "không có email", + "cloudSync.lastSync": "Đồng bộ lần cuối: {{time}}", + "cloudSync.never": "chưa có", + "cloudSync.upToDate": "Mọi thay đổi đã được tải lên", + "cloudSync.pending": "{{count}} thay đổi đang chờ tải lên", + "cloudSync.failed": "Máy chủ từ chối {{count}} thay đổi — \"Đồng bộ ngay\" sẽ thử lại", + "cloudSync.syncNow": "Đồng bộ ngay", + "cloudSync.syncing": "Đang đồng bộ…", + "cloudSync.signOut": "Đăng xuất", + "cloudSync.browserPending": "Hãy hoàn tất đăng nhập trên trình duyệt.", + "cloudSync.signedOut": "Đã đăng xuất. Dữ liệu trên máy này vẫn được giữ nguyên.", + "cloudSync.done": "Đồng bộ xong (áp dụng {{count}})", + "cloudSync.doneWithErrors": "Đồng bộ xong ({{count}} áp dụng, {{errors}} lỗi — sẽ thử lại lần sau)" } diff --git a/packages/i18n/src/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json index 37dce4f..be1cf36 100644 --- a/packages/i18n/src/locales/zh-TW.json +++ b/packages/i18n/src/locales/zh-TW.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "本機 AI 會依上下文修正已確定字幕的空格、標點與聽錯的詞。", "settings.captionModel": "即時字幕模型", "settings.captionModel.same": "與聽寫相同", - "settings.captionModel.desc": "僅即時字幕使用獨立模型辨識,會與聽寫模型一同載入。" + "settings.captionModel.desc": "僅即時字幕使用獨立模型辨識,會與聽寫模型一同載入。", + "history.favorite": "收藏", + "history.unfavorite": "取消收藏", + "cloudSync.title": "雲端同步", + "cloudSync.description": "自動與行動裝置和網頁同步歷史、字典、會議、備忘標籤、指令和範本。在任一裝置刪除,其他裝置也會一併刪除。", + "cloudSync.signIn": "登入", + "cloudSync.signedInAs": "已登入:{{email}}", + "cloudSync.noEmail": "無電子郵件", + "cloudSync.lastSync": "上次同步:{{time}}", + "cloudSync.never": "尚未同步", + "cloudSync.upToDate": "所有變更皆已上傳", + "cloudSync.pending": "{{count}} 項變更等待上傳", + "cloudSync.failed": "伺服器拒絕了 {{count}} 項變更 —「立即同步」會重試", + "cloudSync.syncNow": "立即同步", + "cloudSync.syncing": "同步中…", + "cloudSync.signOut": "登出", + "cloudSync.browserPending": "請在瀏覽器中完成登入。", + "cloudSync.signedOut": "已登出。這台電腦上的紀錄會保留。", + "cloudSync.done": "同步完成(已套用 {{count}} 項)", + "cloudSync.doneWithErrors": "同步完成(已套用 {{count}} 項,{{errors}} 項失敗 — 下次同步時重試)" } diff --git a/packages/i18n/src/locales/zh.json b/packages/i18n/src/locales/zh.json index 262f4eb..2e1508f 100644 --- a/packages/i18n/src/locales/zh.json +++ b/packages/i18n/src/locales/zh.json @@ -522,5 +522,24 @@ "settings.captionRefine.desc": "本地 AI 会根据上下文修正已确定字幕的空格、标点和听错的词。", "settings.captionModel": "实时字幕模型", "settings.captionModel.same": "与听写相同", - "settings.captionModel.desc": "仅实时字幕使用单独的模型识别,会与听写模型一同加载。" + "settings.captionModel.desc": "仅实时字幕使用单独的模型识别,会与听写模型一同加载。", + "history.favorite": "收藏", + "history.unfavorite": "取消收藏", + "cloudSync.title": "云同步", + "cloudSync.description": "自动与移动端和网页同步历史、词典、会议、备忘标签、命令和模板。在任一设备上删除,其他设备也会一并删除。", + "cloudSync.signIn": "登录", + "cloudSync.signedInAs": "已登录:{{email}}", + "cloudSync.noEmail": "无邮箱", + "cloudSync.lastSync": "上次同步:{{time}}", + "cloudSync.never": "尚未同步", + "cloudSync.upToDate": "所有更改均已上传", + "cloudSync.pending": "{{count}} 项更改等待上传", + "cloudSync.failed": "服务器拒绝了 {{count}} 项更改 —「立即同步」会重试", + "cloudSync.syncNow": "立即同步", + "cloudSync.syncing": "正在同步…", + "cloudSync.signOut": "退出登录", + "cloudSync.browserPending": "请在浏览器中完成登录。", + "cloudSync.signedOut": "已退出登录。本机上的记录会保留。", + "cloudSync.done": "同步完成(已应用 {{count}} 项)", + "cloudSync.doneWithErrors": "同步完成(已应用 {{count}} 项,{{errors}} 项失败 — 下次同步时重试)" }