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
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
224
apps/desktop/tests/helpers/fakeSyncRemote.ts
Normal file
224
apps/desktop/tests/helpers/fakeSyncRemote.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue