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.
This commit is contained in:
Yun Chan 2026-09-27 14:04:49 +09:00
parent b5c9ff9f31
commit 0a4f5aee64
49 changed files with 3940 additions and 1033 deletions

View file

@ -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"

View file

@ -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<string, number>
}
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 경로 조회.
*/

View file

@ -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

View file

@ -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')
}

View file

@ -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))

File diff suppressed because it is too large Load diff

View file

@ -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)에

View file

@ -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 {

View file

@ -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
}
// ── 세션 관리 ──

View file

@ -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
}

View file

@ -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,
}
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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<typeof sql>[] = []
@ -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
}
}
}

View file

@ -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<RemoteCursor>
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<unknown> = 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<SyncRunResult> {
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<SyncRunResult> {
return this.serialize(async () => {
if (options.releaseParked) releaseParkedEntries()
const result = emptyRunResult()
await this.backfillIfNeeded()
await this.flushInner(result)
return result
})
}
pull(): Promise<SyncRunResult> {
return this.serialize(async () => {
const result = emptyRunResult()
await this.pullInner(result)
return result
})
}
private serialize<T>(task: () => Promise<T>): Promise<T> {
const run = this.queue.then(
() => task(),
() => task()
)
this.queue = run.catch(() => undefined)
return run
}
// ── 최초 대조 ─────────────────────────────────────────
/**
* 이 사용자 DB에서 처음 동기화할 때 한 번: 서버에 없거나 로컬이 더 새로운 행을 outbox에 넣는다.
* 서버에 같은 판이 이미 있으면 다시 올리지 않는다(다른 기기의 편집을 덮지 않는다).
*/
private async backfillIfNeeded(): Promise<void> {
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<Map<string, number>> {
const versions = new Map<string, number>()
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<void> {
const due = listDueEntries(this.now())
if (due.length === 0) return
const ctx: PushContext = { remote: this.remote, userId: this.userId }
const byEntity = new Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>()
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<void> {
const changed = new Set<SyncEntity>(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<number> {
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<SyncEntity>): Promise<number> {
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<K extends keyof SyncEngineEvents>(event: K, listener: SyncEngineEvents[K]): this {
return super.on(event, listener)
}
override emit<K extends keyof SyncEngineEvents>(event: K, ...args: Parameters<SyncEngineEvents[K]>): boolean {
return super.emit(event, ...args)
}
}

View file

@ -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<string, unknown>
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<DeviceCheck> {
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<DeviceCheck> {
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<void> {
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}`)
}

View file

@ -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<PushOutcome[]> {
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<PushOutcome[]> {
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<string> {
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<Set<string>> {
const keys = new Set<string>()
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<string>, pending: Map<string, SyncOp>, 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
}

View file

@ -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<NonNullable<SupabaseClientOptions<'public'>['realtime']>['transport']>
export const nodeRealtimeTransport = WebSocket as unknown as RealtimeTransport

View file

@ -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<Q extends FilterableQuery>(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<RemoteRow[]> {
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<RemoteRow[]> {
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<void> {
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<void> {
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<string, unknown>): Promise<unknown> {
const { data, error } = await this.client.rpc(name, params)
if (error) throw toSyncRemoteError(error)
return data
}
}

View file

@ -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<PushOutcome[]>
pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
/** 원격 행을 로컬에 반영. 반영했으면 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<T extends string>(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<SyncRemoteError | null>
): Promise<PushOutcome[]> {
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<PushOutcome[]> {
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<string>): 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<SyncRemoteError | null> {
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<string, unknown>
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<string, unknown> {
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
}

View file

@ -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<string, SyncOp> {
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<number>`coalesce(sum(case when ${syncOutbox.nextAttemptAt} >= ${PARKED_AT} then 1 else 0 end), 0)`,
total: sql<number>`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,
}
}

View file

@ -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<string, unknown>
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<RemoteRow[]>
selectWhere(table: string, userId: string, filters: RemoteFilter[], columns?: string): Promise<RemoteRow[]>
upsert(table: string, rows: RemoteRow[]): Promise<void>
deleteByIds(table: string, userId: string, ids: string[]): Promise<void>
rpc(name: string, params: Record<string, unknown>): Promise<unknown>
}
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: [] }
}

View file

@ -66,6 +66,7 @@ import type {
HistoryEntry,
HistoryDeleteParams,
HistorySearchParams,
HistorySetFavoriteParams,
DictionaryQueryParams,
DictionaryPage,
DictionaryEntry,
@ -408,6 +409,8 @@ const electronAPI = {
delete: (params: HistoryDeleteParams) =>
invoke<void>(IPC_CHANNELS.HISTORY.DELETE, params),
deleteAll: () => invoke<void>(IPC_CHANNELS.HISTORY.DELETE_ALL),
setFavorite: (params: HistorySetFavoriteParams) =>
invoke<HistoryEntry>(IPC_CHANNELS.HISTORY.SET_FAVORITE, params),
search: (params: HistorySearchParams) =>
invoke<HistoryPage>(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),

View file

@ -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
}
export function CloudSyncSection(): React.ReactElement {
const [state, setState] = useState<CloudSyncState>({
const INITIAL_STATE: CloudSyncState = {
authenticated: false,
userEmail: null,
lastSyncAt: null,
syncing: false
})
const [progress, setProgress] = useState<SyncProgress | null>(null)
syncing: false,
pendingChanges: 0,
failedChanges: 0,
}
export function CloudSyncSection(): React.ReactElement {
const { t, formatDate } = useI18n()
const [state, setState] = useState<CloudSyncState>(INITIAL_STATE)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(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<void> {
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<void> {
/** 올리기(거부된 항목 재시도 포함) → 내려받기 */
async function handleSyncNow(): Promise<void> {
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<void> {
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 (
<Box sx={{ p: 3, bgcolor: d3roPalette.bg.elevated, borderRadius: 2 }}>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
{state.authenticated ? (
<CloudCheck size={24} style={{ color: d3roPalette.tag.green }} />
) : (
<Cloud size={24} style={{ color: d3roPalette.text.label }} />
)}
<Box sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>Cloud Sync</Box>
<Box sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>{t('cloudSync.title')}</Box>
</Stack>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, lineHeight: 1.6, mb: 2 }}>
{t('cloudSync.description')}
</Box>
{!state.authenticated && (
<Stack spacing={2}>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}>OAuth 로그인</Box>
<Stack spacing={1}>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>{t('cloudSync.signIn')}</Box>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
@ -155,57 +149,42 @@ export function CloudSyncSection(): React.ReactElement {
)}
{state.authenticated && (
<Stack spacing={2}>
<Stack spacing={1.5}>
<Box sx={{ color: d3roPalette.text.primary, fontSize: 13 }}>
로그인됨: <strong>{state.userEmail ?? '(이메일 없음)'}</strong>
{t('cloudSync.signedInAs', { email: state.userEmail ?? t('cloudSync.noEmail') })}
</Box>
<Stack spacing={0.5}>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12 }}>
마지막 동기화: {lastSyncText}
</Box>
{progress && (
<Box>
<Box sx={{ color: d3roPalette.text.label, fontSize: 12, mb: 0.5 }}>
{progress.table}: {progress.current}/{progress.total}
{t('cloudSync.lastSync', { time: lastSyncText })}
</Box>
<Box
sx={{
height: 4,
bgcolor: d3roPalette.bg.inset,
borderRadius: 2,
overflow: 'hidden'
fontSize: 12,
color: state.pendingChanges > 0 ? d3roPalette.tag.orange : d3roPalette.tag.green,
}}
>
<Box
sx={{
height: '100%',
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`,
bgcolor: d3roPalette.accent.main,
transition: 'width 200ms'
}}
/>
{state.pendingChanges > 0
? t('cloudSync.pending', { count: state.pendingChanges })
: t('cloudSync.upToDate')}
</Box>
{state.failedChanges > 0 && (
<Box sx={{ fontSize: 12, color: d3roPalette.tag.red }}>
{t('cloudSync.failed', { count: state.failedChanges })}
</Box>
)}
</Stack>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
onClick={() => void handleSync()}
disabled={busy || state.syncing}
startIcon={state.syncing ? <CircularProgress size={16} /> : null}
onClick={() => void handleSyncNow()}
disabled={syncing}
startIcon={syncing ? <CircularProgress size={16} /> : <RefreshCw size={16} />}
>
{state.syncing ? '동기화 중...' : 'Push'}
</Button>
<Button
variant="outlined"
onClick={() => void handlePull()}
disabled={busy || state.syncing}
>
Pull
{syncing ? t('cloudSync.syncing') : t('cloudSync.syncNow')}
</Button>
<Button variant="outlined" onClick={() => void handleSignOut()} disabled={busy} color="warning">
로그아웃
{t('cloudSync.signOut')}
</Button>
</Stack>
</Stack>

View file

@ -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)

View file

@ -134,11 +134,17 @@ export function MeetingDetailTabs({
[detail.rawTranscript, detail.editedTranscript],
)
// 템플릿 로드
// 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시)
useEffect(() => {
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()
})
}, [])
// Sync action items from primary document

View file

@ -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 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{onToggleFavorite && (
<Tooltip title={entry.isFavorite ? t('history.unfavorite') : t('history.favorite')}>
<IconButton
size="small"
aria-pressed={entry.isFavorite}
aria-label={entry.isFavorite ? t('history.unfavorite') : t('history.favorite')}
onClick={(e) => {
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 },
}}
>
<Star size={14} fill={entry.isFavorite ? 'currentColor' : 'none'} />
</IconButton>
</Tooltip>
)}
<Tooltip title={copied ? t('common.copied') : t('common.copy')}>
<IconButton
size="small"

View file

@ -36,6 +36,10 @@ export function DictionaryPage(): React.ReactElement {
useEffect(() => {
loadData()
// 다른 기기(모바일·웹)에서 바뀐 사전이 반영되면 다시 불러온다.
return window.electronAPI.app.onDataChanged((data) => {
if (data.type === 'cloud-sync' && data.entities?.includes('dictionary')) loadData()
})
}, [loadData])
const openAdd = () => {

View file

@ -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}
/>

View file

@ -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) => {

View file

@ -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 })

View file

@ -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<string, RemoteRow[]>()
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<RemoteRow[]> {
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<RemoteRow[]> {
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<void> {
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<void> {
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<string, unknown>): Promise<unknown> {
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}`)
}
}

View file

@ -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=<publishable> D3RO_SYNC_IT_SERVICE_KEY=<secret> \
// 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<SupabaseClient> {
const client = createClient(URL!, ANON!, noPersist)
const { error } = await client.auth.signInWithPassword({ email, password })
if (error) throw error
return client
}
function must<T>(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<typeof createTestDb>
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<Record<string, unknown>>; meetings: Array<Record<string, unknown>> }
}
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<void>((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')
})
})

View file

@ -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<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
function db(): ReturnType<typeof createTestDb>['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)
})
})

10
package-lock.json generated
View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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)"
}

View file

@ -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"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -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}} 件失敗 — 次回の同期で再試行)"
}

View file

@ -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 데스크톱 앱"
}

View file

@ -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)"
}

View file

@ -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}} — повтор при следующей синхронизации)"
}

View file

@ -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}} — จะลองใหม่รอบหน้า)"
}

View file

@ -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)"
}

View file

@ -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}} 項失敗 — 下次同步時重試)"
}

View file

@ -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}} 项失败 — 下次同步时重试)"
}