feat(desktop): two-way cloud sync with mobile and web

Rewrites the desktop mirror as services/sync/SyncEngine: a persistent
outbox, per-account server-clock keyset cursors with paging, pulls that never
overwrite unsent local edits, deletions both ways through sync_tombstones and
per-row failure isolation. It now covers history titles and favorites,
dictionary, every meeting's memos and documents, memo tags, user commands and
dictation/meeting templates, and registers the desktop as a device that the
phone can disconnect.

Fixes shipped defects: the first pull after sign-in fetched nothing, only
the first meeting's children were pushed, team meetings leaked into the
personal database and lost team_id on re-push, and Realtime never connected
because Electron's Node 20 has no global WebSocket (ws is now the transport).
Anonymous local-mode records are imported into the first account that signs
in. The settings sync section is translated and shows pending/rejected
changes; synced screens reload on app:dataChanged.
This commit is contained in:
Yun Chan 2026-09-27 14:04:49 +09:00
parent b5c9ff9f31
commit 0a4f5aee64
49 changed files with 3940 additions and 1033 deletions

View file

@ -1,9 +1,10 @@
// tests/helpers/createTestDb.ts
// in-memory SQLite + drizzle-orm 스키마 적용 (src/main/db/index.ts applySchema 와 동일)
// in-memory SQLite + drizzle-orm 스키마 적용 (src/main/db/index.ts applySchema 그대로)
import Database from 'better-sqlite3'
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import * as schema from '../../src/main/db/schema'
import { applySchema } from '../../src/main/db'
/**
* 테스트용 in-memory SQLite DB를 생성한다.
@ -16,156 +17,8 @@ export function createTestDb(): {
} {
const sqlite = new Database(':memory:')
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
sqlite.pragma('busy_timeout = 5000')
sqlite.exec(`
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
original_text TEXT NOT NULL,
polished_text TEXT,
focused_app TEXT,
focused_app_name TEXT,
focused_app_window_title TEXT,
mode TEXT NOT NULL DEFAULT 'dictation',
status TEXT NOT NULL DEFAULT 'completed',
error_code TEXT,
audio_local_path TEXT,
duration REAL NOT NULL,
detected_language TEXT,
mic_device TEXT,
word_count INTEGER NOT NULL DEFAULT 0,
stt_model TEXT,
llm_model TEXT,
stt_latency_ms INTEGER,
llm_latency_ms INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
app_version TEXT NOT NULL DEFAULT '1.0.0',
title TEXT,
summary_text TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_status ON history(status);
CREATE TABLE IF NOT EXISTS dictionary (
id TEXT PRIMARY KEY,
word TEXT NOT NULL,
pronunciation TEXT,
category TEXT NOT NULL DEFAULT 'user',
usage_count INTEGER NOT NULL DEFAULT 0,
last_used_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category);
CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at);
CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC);
CREATE TABLE IF NOT EXISTS stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_duration REAL NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
session_count INTEGER NOT NULL DEFAULT 0,
streak_days INTEGER NOT NULL DEFAULT 0,
last_session_at INTEGER,
last_updated INTEGER NOT NULL
);
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
VALUES (1, 0, 0, 0, 0, ${Date.now()});
CREATE TABLE IF NOT EXISTS memo_tags (
id TEXT PRIMARY KEY,
history_id TEXT NOT NULL,
tag TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_memo_tags_unique ON memo_tags(history_id, tag);
CREATE INDEX IF NOT EXISTS idx_memo_tags_history_id ON memo_tags(history_id);
CREATE INDEX IF NOT EXISTS idx_memo_tags_tag ON memo_tags(tag);
CREATE TABLE IF NOT EXISTS daily_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
feature TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_usage_date_feature ON daily_usage(date, feature);
CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date);
CREATE TABLE IF NOT EXISTS rag_documents (
id TEXT PRIMARY KEY,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_type TEXT NOT NULL,
chunk_count INTEGER NOT NULL DEFAULT 0,
indexed INTEGER NOT NULL DEFAULT 0,
indexed_at INTEGER,
added_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rag_documents_added_at ON rag_documents(added_at);
CREATE TABLE IF NOT EXISTS rag_chunks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL,
chunk_index INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id);
CREATE TABLE IF NOT EXISTS meeting_sessions (
id TEXT PRIMARY KEY,
title TEXT,
status TEXT NOT NULL DEFAULT 'recording',
started_at INTEGER NOT NULL,
ended_at INTEGER,
duration_ms INTEGER,
raw_transcript TEXT,
minutes_markdown TEXT,
minutes_json TEXT,
stt_model TEXT,
llm_model TEXT,
stt_latency_ms INTEGER,
llm_latency_ms INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
edited_transcript TEXT
);
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_created_at ON meeting_sessions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status ON meeting_sessions(status);
CREATE TABLE IF NOT EXISTS meeting_memos (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meeting_memos_session_id ON meeting_memos(session_id);
CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
CREATE TABLE IF NOT EXISTS meeting_documents (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
template_type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
prompt_used TEXT,
llm_model TEXT,
llm_latency_ms INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
`)
// 운영과 같은 스키마 정본을 쓴다 — 손으로 복제한 DDL은 컬럼 추가 때마다 어긋났다.
applySchema(sqlite)
const db = drizzle(sqlite, { schema })

View file

@ -0,0 +1,224 @@
// tests/helpers/fakeSyncRemote.ts
// Supabase 동작을 흉내 내는 메모리 SyncRemote.
// - INSERT는 보낸 updated_at 유지, UPDATE는 서버 시각으로 갱신(moddatetime)
// - 삭제 시 sync_tombstones 기록, meetings 삭제는 메모·문서로 cascade
// - dictionary (lower(word), category) 유일, memo_tags/템플릿 RPC
import type {
RemoteFilter,
RemotePageRequest,
RemoteRow,
SyncRemote,
} from '../../src/main/services/sync/sync-types'
import { SyncRemoteError } from '../../src/main/services/sync/sync-types'
export class FakeSyncRemote implements SyncRemote {
readonly tables = new Map<string, RemoteRow[]>()
private clock = Date.parse('2026-09-27T00:00:00.000Z')
private tombstoneSeq = 0
networkDown = false
/** 특정 행 upsert를 거부시키는 훅 (재시도 불가 오류 시뮬레이션) */
rejectRow: ((table: string, row: RemoteRow) => SyncRemoteError | null) | null = null
readonly calls: string[] = []
constructor(readonly userId: string) {}
now(): string {
this.clock += 1000
return new Date(this.clock).toISOString()
}
rows(table: string): RemoteRow[] {
let rows = this.tables.get(table)
if (!rows) {
rows = []
this.tables.set(table, rows)
}
return rows
}
find(table: string, id: string): RemoteRow | undefined {
return this.rows(table).find((r) => r.id === id)
}
// ── 모바일/웹이 서버에 직접 쓰는 것을 흉내 ──
mobileInsert(table: string, row: RemoteRow): RemoteRow {
const at = this.now()
const full = { user_id: this.userId, created_at: at, updated_at: at, ...row }
this.rows(table).push(full)
return full
}
mobileUpdate(table: string, id: string, patch: RemoteRow): void {
const row = this.find(table, id)
if (!row) throw new Error(`no ${table}/${id}`)
Object.assign(row, patch, { updated_at: this.now() })
}
mobileDelete(table: string, id: string): void {
this.removeRow(table, id)
}
// ── SyncRemote ──
private guard(): void {
if (this.networkDown) throw new SyncRemoteError('fetch failed', 'network', true)
}
private matches(row: RemoteRow, filters: RemoteFilter[] | undefined): boolean {
for (const f of filters ?? []) {
const v = row[f.column]
if (f.op === 'eq' && v !== f.value) return false
if (f.op === 'is' && v !== null && v !== undefined) return false
if (f.op === 'ilike' && (typeof v !== 'string' || v.toLowerCase() !== f.value.toLowerCase())) return false
}
return true
}
async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
this.guard()
this.calls.push(`fetch:${request.table}`)
const col = request.cursorColumn
const key = (r: RemoteRow): [number, string] => [Date.parse(String(r[col])), String(r.id)]
const after = request.after ? ([Date.parse(request.after.ts), request.after.id] as [number, string]) : null
const cmpId = (a: string, b: string): number =>
/^\d+$/.test(a) && /^\d+$/.test(b) ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0
const sorted = this.rows(request.table)
.filter((r) => r.user_id === request.userId && this.matches(r, request.filters))
.sort((a, b) => {
const [ta, ia] = key(a)
const [tb, ib] = key(b)
return ta !== tb ? ta - tb : cmpId(ia, ib)
})
.filter((r) => {
if (!after) return true
const [t, i] = key(r)
return t > after[0] || (t === after[0] && cmpId(i, after[1]) > 0)
})
return sorted.slice(0, request.limit).map((r) => ({ ...r }))
}
async selectWhere(table: string, userId: string, filters: RemoteFilter[]): Promise<RemoteRow[]> {
this.guard()
return this.rows(table)
.filter((r) => r.user_id === userId && this.matches(r, filters))
.map((r) => ({ ...r }))
}
async upsert(table: string, rows: RemoteRow[]): Promise<void> {
this.guard()
this.calls.push(`upsert:${table}:${rows.length}`)
// 배치는 원자적이다: 하나라도 거부되면 전체 실패
for (const row of rows) {
const rejected = this.rejectRow?.(table, row)
if (rejected) throw rejected
if (table === 'dictionary') {
const clash = this.rows(table).find(
(r) =>
r.id !== row.id &&
String(r.word).trim().toLowerCase() === String(row.word).trim().toLowerCase() &&
r.category === row.category
)
if (clash) throw new SyncRemoteError('duplicate key value', '23505', false)
}
if ((table === 'meeting_memos' || table === 'meeting_documents') && !this.find('meetings', String(row.meeting_id))) {
throw new SyncRemoteError('violates foreign key', '23503', true)
}
}
for (const row of rows) {
const existing = this.find(table, String(row.id))
if (existing) {
const changed = Object.keys(row).some((k) => k !== 'updated_at' && existing[k] !== row[k])
Object.assign(existing, row, { updated_at: this.now() })
if (table === 'history' && changed) existing.revision = Number(existing.revision ?? 1) + 1
} else {
const at = this.now()
this.rows(table).push({ revision: 1, created_at: at, ...row, updated_at: row.updated_at ?? at })
}
}
}
async deleteByIds(table: string, userId: string, ids: string[]): Promise<void> {
this.guard()
this.calls.push(`delete:${table}:${ids.length}`)
for (const id of ids) {
const row = this.find(table, id)
if (row && row.user_id === userId) this.removeRow(table, id)
}
}
private removeRow(table: string, id: string): void {
const rows = this.rows(table)
const index = rows.findIndex((r) => r.id === id)
if (index === -1) return
const [row] = rows.splice(index, 1)
this.rows('sync_tombstones').push({
id: ++this.tombstoneSeq,
user_id: row.user_id,
table_name: table,
row_id: id,
deleted_at: this.now(),
})
if (table === 'meetings') {
for (const child of ['meeting_memos', 'meeting_documents']) {
for (const c of this.rows(child).filter((r) => r.meeting_id === id)) this.removeRow(child, String(c.id))
}
}
if (table === 'history') {
for (const t of this.rows('memo_tags').filter((r) => r.history_id === id)) this.removeRow('memo_tags', String(t.id))
}
}
async rpc(name: string, params: Record<string, unknown>): Promise<unknown> {
this.guard()
this.calls.push(`rpc:${name}`)
if (name === 'mobile_add_memo_tag_v1') {
const historyId = String(params.p_history_id)
if (!this.find('history', historyId)) throw new SyncRemoteError('history_not_found', 'P0002', true)
const normalized = String(params.p_tag).trim().replace(/\s+/g, ' ').toLowerCase()
const existing = this.rows('memo_tags').find((r) => r.history_id === historyId && r.normalized_tag === normalized)
if (existing) return existing
return this.mobileInsert('memo_tags', {
id: crypto.randomUUID(),
history_id: historyId,
tag: String(params.p_tag),
normalized_tag: normalized,
})
}
if (name === 'mobile_remove_memo_tag_v1') {
const historyId = String(params.p_history_id)
const normalized = String(params.p_tag).trim().replace(/\s+/g, ' ').toLowerCase()
const row = this.rows('memo_tags').find((r) => r.history_id === historyId && r.normalized_tag === normalized)
if (!row) return false
this.removeRow('memo_tags', String(row.id))
return true
}
if (name === 'sync_upsert_user_template_v1') {
const id = String(params.p_id)
const existing = this.find('user_templates', id)
const fields = {
template_kind: params.p_template_kind,
name: params.p_name,
description: params.p_description,
fields: params.p_fields,
output_format: params.p_output_format,
system_prompt: params.p_system_prompt,
template_type: params.p_template_kind === 'meeting_document' ? 'custom' : null,
is_builtin: false,
}
if (existing) {
Object.assign(existing, fields, { revision: Number(existing.revision) + 1, updated_at: this.now() })
return existing
}
return this.mobileInsert('user_templates', { id, revision: 1, ...fields })
}
if (name === 'sync_delete_user_template_v1') {
const id = String(params.p_id)
if (!this.find('user_templates', id)) return false
this.removeRow('user_templates', id)
return true
}
throw new Error(`unknown rpc ${name}`)
}
}