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:
parent
b5c9ff9f31
commit
0a4f5aee64
49 changed files with 3940 additions and 1033 deletions
|
|
@ -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 경로 조회.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)에
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ── 세션 관리 ──
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
467
apps/desktop/src/main/services/sync/SyncEngine.ts
Normal file
467
apps/desktop/src/main/services/sync/SyncEngine.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
159
apps/desktop/src/main/services/sync/device-registration.ts
Normal file
159
apps/desktop/src/main/services/sync/device-registration.ts
Normal 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}`)
|
||||
}
|
||||
137
apps/desktop/src/main/services/sync/memo-tag-sync.ts
Normal file
137
apps/desktop/src/main/services/sync/memo-tag-sync.ts
Normal 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
|
||||
}
|
||||
10
apps/desktop/src/main/services/sync/realtime-transport.ts
Normal file
10
apps/desktop/src/main/services/sync/realtime-transport.ts
Normal 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
|
||||
96
apps/desktop/src/main/services/sync/supabase-sync-remote.ts
Normal file
96
apps/desktop/src/main/services/sync/supabase-sync-remote.ts
Normal 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
|
||||
}
|
||||
}
|
||||
857
apps/desktop/src/main/services/sync/sync-adapters.ts
Normal file
857
apps/desktop/src/main/services/sync/sync-adapters.ts
Normal 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
|
||||
}
|
||||
160
apps/desktop/src/main/services/sync/sync-outbox.ts
Normal file
160
apps/desktop/src/main/services/sync/sync-outbox.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
141
apps/desktop/src/main/services/sync/sync-types.ts
Normal file
141
apps/desktop/src/main/services/sync/sync-types.ts
Normal 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: [] }
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue