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

@ -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
const INITIAL_STATE: CloudSyncState = {
authenticated: false,
userEmail: null,
lastSyncAt: null,
syncing: false,
pendingChanges: 0,
failedChanges: 0,
}
export function CloudSyncSection(): React.ReactElement {
const [state, setState] = useState<CloudSyncState>({
authenticated: false,
userEmail: null,
lastSyncAt: null,
syncing: false
})
const [progress, setProgress] = useState<SyncProgress | null>(null)
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>
<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}
</Box>
<Box
sx={{
height: 4,
bgcolor: d3roPalette.bg.inset,
borderRadius: 2,
overflow: 'hidden'
}}
>
<Box
sx={{
height: '100%',
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`,
bgcolor: d3roPalette.accent.main,
transition: 'width 200ms'
}}
/>
</Box>
<Stack spacing={0.5}>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12 }}>
{t('cloudSync.lastSync', { time: lastSyncText })}
</Box>
)}
<Box
sx={{
fontSize: 12,
color: state.pendingChanges > 0 ? d3roPalette.tag.orange : d3roPalette.tag.green,
}}
>
{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,10 +134,16 @@ export function MeetingDetailTabs({
[detail.rawTranscript, detail.editedTranscript],
)
// 템플릿 로드
// 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시)
useEffect(() => {
window.electronAPI.meetingDocTemplate.getAll().then((resp) => {
if (resp.success) setTemplates(resp.data)
const load = (): void => {
window.electronAPI.meetingDocTemplate.getAll().then((resp) => {
if (resp.success) setTemplates(resp.data)
})
}
load()
return window.electronAPI.app.onDataChanged((data) => {
if (data.type === 'cloud-sync' && data.entities?.includes('user_templates')) load()
})
}, [])

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