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
383
apps/desktop/tests/main/sync/SyncEngine.test.ts
Normal file
383
apps/desktop/tests/main/sync/SyncEngine.test.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
// 데스크톱 ↔ Supabase(모바일·웹) 양방향 동기화 엔진 — 서버 흉내 원격으로 실제 SQLite에 대해 검증한다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import {
|
||||
dictionary,
|
||||
history,
|
||||
meetingDocuments,
|
||||
meetingMemos,
|
||||
meetingSessions,
|
||||
memoTags,
|
||||
} from '../../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import {
|
||||
getCustomInstructionService,
|
||||
resetCustomInstructionServiceForTests,
|
||||
} from '../../../src/main/services/CustomInstructionService'
|
||||
import {
|
||||
getDictationTemplateService,
|
||||
resetDictationTemplateServiceForTests,
|
||||
} from '../../../src/main/services/DictationTemplateService'
|
||||
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
||||
import { memoTagKey } from '../../../src/main/services/sync/memo-tag-sync'
|
||||
import { SyncRemoteError } from '../../../src/main/services/sync/sync-types'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: FakeSyncRemote
|
||||
let engine: SyncEngine
|
||||
|
||||
function db(): ReturnType<typeof createTestDb>['db'] {
|
||||
return testDb.db
|
||||
}
|
||||
|
||||
function insertLocalHistory(text: string, at = Date.now()): string {
|
||||
const id = uuid()
|
||||
db().insert(history).values({ id, originalText: text, duration: 1.5, createdAt: at, updatedAt: at }).run()
|
||||
return id
|
||||
}
|
||||
|
||||
function insertLocalMeeting(title: string, at = Date.now()): string {
|
||||
const id = uuid()
|
||||
db()
|
||||
.insert(meetingSessions)
|
||||
.values({ id, title, status: 'completed', startedAt: at, createdAt: at, updatedAt: at })
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
function localHistoryIds(): string[] {
|
||||
return db().select({ id: history.id }).from(history).all().map((r) => r.id).sort()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
resetCustomInstructionServiceForTests()
|
||||
resetDictationTemplateServiceForTests()
|
||||
resetMeetingDocTemplateServiceForTests()
|
||||
getCustomInstructionService().initialize()
|
||||
remote = new FakeSyncRemote(USER)
|
||||
engine = new SyncEngine({ remote, userId: USER })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
engine.dispose()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
describe('최초 동기화', () => {
|
||||
it('모바일에서 만든 기존 기록을 내려받고, 데스크톱 기록을 올린다', async () => {
|
||||
const mobileId = uuid()
|
||||
remote.mobileInsert('history', { id: mobileId, original_text: 'from phone', duration: 2, mode: 'dictation', status: 'completed', is_favorite: true })
|
||||
const desktopId = insertLocalHistory('from desktop')
|
||||
|
||||
const result = await engine.runFullSync()
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(localHistoryIds()).toEqual([mobileId, desktopId].sort())
|
||||
expect(remote.find('history', desktopId)?.original_text).toBe('from desktop')
|
||||
const pulled = db().select().from(history).where(eq(history.id, mobileId)).get()
|
||||
expect(pulled?.isFavorite).toBe(true)
|
||||
expect(result.changed).toContain('history')
|
||||
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
||||
})
|
||||
|
||||
it('서버와 같은 판은 다시 올리지 않는다 (다른 기기의 편집을 덮지 않는다)', async () => {
|
||||
const id = insertLocalHistory('same', Date.parse('2026-09-26T00:00:00Z'))
|
||||
remote.mobileInsert('history', {
|
||||
id,
|
||||
original_text: 'edited on phone',
|
||||
duration: 1.5,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
})
|
||||
await engine.runFullSync()
|
||||
expect(remote.calls.filter((c) => c.startsWith('upsert:history'))).toEqual([])
|
||||
expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('edited on phone')
|
||||
})
|
||||
|
||||
it('500행이 넘어도 페이지를 넘겨 전부 가져온다', async () => {
|
||||
for (let i = 0; i < 1203; i++) {
|
||||
remote.mobileInsert('history', { id: uuid(), original_text: `r${i}`, duration: 1, mode: 'dictation', status: 'completed' })
|
||||
}
|
||||
await engine.runFullSync()
|
||||
expect(localHistoryIds()).toHaveLength(1203)
|
||||
})
|
||||
|
||||
it('다른 사용자(팀) 행은 가져오지 않는다', async () => {
|
||||
remote.rows('history').push({
|
||||
id: uuid(),
|
||||
user_id: OTHER,
|
||||
original_text: 'not mine',
|
||||
duration: 1,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
created_at: remote.now(),
|
||||
updated_at: remote.now(),
|
||||
})
|
||||
await engine.runFullSync()
|
||||
expect(localHistoryIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('변경·삭제 전파', () => {
|
||||
it('모바일에서 수정/삭제한 것이 데스크톱에 반영된다', async () => {
|
||||
const a = uuid()
|
||||
const b = uuid()
|
||||
remote.mobileInsert('history', { id: a, original_text: 'a', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
remote.mobileInsert('history', { id: b, original_text: 'b', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
|
||||
remote.mobileUpdate('history', a, { original_text: 'a edited', title: 'renamed' })
|
||||
remote.mobileDelete('history', b)
|
||||
const result = await engine.pull()
|
||||
|
||||
expect(result.deleted).toBe(1)
|
||||
expect(localHistoryIds()).toEqual([a])
|
||||
const row = db().select().from(history).where(eq(history.id, a)).get()
|
||||
expect(row?.originalText).toBe('a edited')
|
||||
expect(row?.title).toBe('renamed')
|
||||
})
|
||||
|
||||
it('데스크톱 삭제가 서버로 전파된다', async () => {
|
||||
const id = insertLocalHistory('bye')
|
||||
await engine.runFullSync()
|
||||
expect(remote.find('history', id)).toBeDefined()
|
||||
|
||||
db().delete(history).where(eq(history.id, id)).run()
|
||||
enqueueChange('history', id, 'delete')
|
||||
await engine.flush()
|
||||
|
||||
expect(remote.find('history', id)).toBeUndefined()
|
||||
// 자기 삭제 기록을 다시 받아도 문제없다
|
||||
const pulled = await engine.pull()
|
||||
expect(pulled.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('아직 올리지 않은 로컬 변경은 pull이 덮지 않는다', async () => {
|
||||
const id = uuid()
|
||||
remote.mobileInsert('history', { id, original_text: 'v1', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
|
||||
db().update(history).set({ originalText: 'local v2', updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
remote.mobileUpdate('history', id, { original_text: 'remote v2' })
|
||||
await engine.pull()
|
||||
expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('local v2')
|
||||
|
||||
await engine.flush()
|
||||
expect(remote.find('history', id)?.original_text).toBe('local v2')
|
||||
})
|
||||
|
||||
it('원격 삭제는 로컬 미전송 변경보다 우선한다', async () => {
|
||||
const id = uuid()
|
||||
remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
remote.mobileDelete('history', id)
|
||||
await engine.pull()
|
||||
expect(localHistoryIds()).toEqual([])
|
||||
expect(pendingOps('history').size).toBe(0)
|
||||
})
|
||||
|
||||
it('데스크톱이 내용을 바꿔 올리면 서버 revision이 오른다 (모바일 충돌 감지)', async () => {
|
||||
const id = uuid()
|
||||
remote.mobileInsert('history', { id, original_text: 'x', duration: 1.5, mode: 'dictation', status: 'completed', revision: 1 })
|
||||
await engine.runFullSync()
|
||||
db().update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
await engine.flush()
|
||||
expect(remote.find('history', id)?.is_favorite).toBe(true)
|
||||
expect(Number(remote.find('history', id)?.revision)).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('회의', () => {
|
||||
it('여러 회의의 메모·문서를 모두 올린다 (첫 회의만 올리던 회귀)', async () => {
|
||||
const m1 = insertLocalMeeting('one')
|
||||
const m2 = insertLocalMeeting('two')
|
||||
const now = Date.now()
|
||||
for (const m of [m1, m2]) {
|
||||
db().insert(meetingMemos).values({ id: uuid(), sessionId: m, content: `memo ${m}`, timestampMs: 5, createdAt: now }).run()
|
||||
db()
|
||||
.insert(meetingDocuments)
|
||||
.values({ id: uuid(), sessionId: m, templateType: 'minutes', title: 't', content: 'c', createdAt: now, updatedAt: now })
|
||||
.run()
|
||||
}
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(remote.rows('meeting_memos')).toHaveLength(2)
|
||||
expect(remote.rows('meeting_documents')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('팀에 공유된 회의를 데스크톱이 수정해도 team_id를 지우지 않는다', async () => {
|
||||
const id = insertLocalMeeting('shared')
|
||||
await engine.runFullSync()
|
||||
const teamId = uuid()
|
||||
remote.mobileUpdate('meetings', id, { team_id: teamId })
|
||||
await engine.pull()
|
||||
db().update(meetingSessions).set({ title: 'renamed', updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run()
|
||||
enqueueChange('meetings', id, 'upsert')
|
||||
await engine.flush()
|
||||
expect(remote.find('meetings', id)?.team_id).toBe(teamId)
|
||||
expect(remote.find('meetings', id)?.title).toBe('renamed')
|
||||
})
|
||||
|
||||
it('모바일에서 수정한 회의 메모를 가져오고, 회의 삭제는 메모·문서까지 지운다', async () => {
|
||||
const meeting = uuid()
|
||||
const memo = uuid()
|
||||
remote.mobileInsert('meetings', { id: meeting, title: 'phone meeting', status: 'completed', started_at: remote.now() })
|
||||
remote.mobileInsert('meeting_memos', { id: memo, meeting_id: meeting, content: 'first', timestamp_ms: 10 })
|
||||
await engine.runFullSync()
|
||||
remote.mobileUpdate('meeting_memos', memo, { content: 'edited' })
|
||||
await engine.pull()
|
||||
expect(db().select().from(meetingMemos).where(eq(meetingMemos.id, memo)).get()?.content).toBe('edited')
|
||||
|
||||
remote.mobileDelete('meetings', meeting)
|
||||
await engine.pull()
|
||||
expect(db().select().from(meetingSessions).all()).toEqual([])
|
||||
expect(db().select().from(meetingMemos).all()).toEqual([])
|
||||
})
|
||||
|
||||
it('남의 팀 회의에 단 내 메모는 부모 없는 고아 행으로 만들지 않는다', async () => {
|
||||
const foreignMeeting = uuid()
|
||||
remote.rows('meetings').push({ id: foreignMeeting, user_id: OTHER, status: 'completed', created_at: remote.now(), updated_at: remote.now() })
|
||||
remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: foreignMeeting, content: 'mine', timestamp_ms: 1 })
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(db().select().from(meetingMemos).all()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('사전', () => {
|
||||
it('대소문자만 다른 단어가 서버에 있으면 서버 행을 채택한다', async () => {
|
||||
const remoteId = uuid()
|
||||
remote.mobileInsert('dictionary', { id: remoteId, word: 'api', category: 'user', usage_count: 1 })
|
||||
const localId = uuid()
|
||||
const now = Date.now()
|
||||
db().insert(dictionary).values({ id: localId, word: 'API', category: 'user', usageCount: 7, createdAt: now, updatedAt: now }).run()
|
||||
|
||||
const result = await engine.runFullSync()
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
const rows = db().select().from(dictionary).all()
|
||||
expect(rows.map((r) => r.id)).toEqual([remoteId])
|
||||
expect(rows[0].usageCount).toBe(7)
|
||||
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('실패 처리', () => {
|
||||
it('네트워크가 끊기면 변경을 보관했다가 다음에 올린다', async () => {
|
||||
await engine.runFullSync()
|
||||
const id = insertLocalHistory('offline')
|
||||
enqueueChange('history', id, 'upsert')
|
||||
remote.networkDown = true
|
||||
const failed = await engine.flush()
|
||||
expect(failed.pushed).toBe(0)
|
||||
expect(outboxCounts().pending).toBe(1)
|
||||
|
||||
remote.networkDown = false
|
||||
// 백오프 시각을 지난 것으로 본다
|
||||
const later = new SyncEngine({ remote, userId: USER, now: () => Date.now() + 60_000 })
|
||||
await later.flush()
|
||||
later.dispose()
|
||||
expect(remote.find('history', id)).toBeDefined()
|
||||
expect(outboxCounts().pending).toBe(0)
|
||||
})
|
||||
|
||||
it('서버가 거부한 행만 격리하고 나머지는 올린다', async () => {
|
||||
const good = insertLocalHistory('good')
|
||||
const bad = insertLocalHistory('bad')
|
||||
remote.rejectRow = (table, row) =>
|
||||
table === 'history' && row.id === bad ? new SyncRemoteError('check violation', '23514', false) : null
|
||||
const result = await engine.runFullSync()
|
||||
expect(remote.find('history', good)).toBeDefined()
|
||||
expect(remote.find('history', bad)).toBeUndefined()
|
||||
expect(result.errors.some((e) => e.includes(bad))).toBe(true)
|
||||
expect(outboxCounts().pending).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('메모 태그', () => {
|
||||
it('데스크톱 태그를 올리고 모바일 태그·삭제를 반영한다', async () => {
|
||||
const h = uuid()
|
||||
remote.mobileInsert('history', { id: h, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
|
||||
db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'work', createdAt: Date.now() }).run()
|
||||
enqueueChange('memo_tags', memoTagKey(h, 'work'), 'upsert')
|
||||
await engine.flush()
|
||||
expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['work'])
|
||||
|
||||
await remote.rpc('mobile_add_memo_tag_v1', { p_history_id: h, p_tag: 'Idea Board' })
|
||||
await remote.rpc('mobile_remove_memo_tag_v1', { p_history_id: h, p_tag: 'work' })
|
||||
await engine.pull()
|
||||
expect(db().select().from(memoTags).all().map((r) => r.tag)).toEqual(['idea board'])
|
||||
})
|
||||
|
||||
it('이력이 아직 서버에 없으면 태그는 대기했다가 이력 뒤에 올라간다', async () => {
|
||||
const h = insertLocalHistory('tagged')
|
||||
db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'later', createdAt: Date.now() }).run()
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['later'])
|
||||
expect(db().select().from(memoTags).all()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('명령·템플릿', () => {
|
||||
it('사용자 명령을 양방향으로 맞추고 프리셋은 건드리지 않는다', async () => {
|
||||
const service = getCustomInstructionService()
|
||||
const created = service.create({ name: 'Tone down', description: 'd', prompt: 'Make it calm' })
|
||||
enqueueChange('custom_instructions', created.id, 'upsert')
|
||||
const phone = uuid()
|
||||
remote.mobileInsert('custom_instructions', { id: phone, builtin_key: null, name: 'From phone', description: '', prompt: 'p', icon: 'sparkles', sort_order: 3 })
|
||||
remote.mobileInsert('custom_instructions', { id: uuid(), builtin_key: 'summarize', name: 'Summarize', description: '', prompt: 'p', icon: 'x', sort_order: 0 })
|
||||
|
||||
await engine.runFullSync()
|
||||
|
||||
expect(remote.find('custom_instructions', created.id)?.prompt).toBe('Make it calm')
|
||||
expect(service.getById(phone)?.name).toBe('From phone')
|
||||
expect(service.getAll().filter((i) => i.name === 'Summarize')).toEqual([])
|
||||
expect(remote.rows('custom_instructions').filter((r) => String(r.id).startsWith('builtin-'))).toEqual([])
|
||||
|
||||
remote.mobileDelete('custom_instructions', phone)
|
||||
await engine.pull()
|
||||
expect(service.getById(phone)).toBeNull()
|
||||
})
|
||||
|
||||
it('받아쓰기 템플릿을 같은 id로 올리고 모바일 수정을 반영한다', async () => {
|
||||
const service = getDictationTemplateService()
|
||||
const template = service.create({
|
||||
name: 'Standup',
|
||||
description: '',
|
||||
fields: [{ id: 'field0', name: 'done', label: 'Done', promptText: 'What did you do?', required: true, maxDurationSec: 60 }],
|
||||
outputFormat: '{{done}}',
|
||||
})
|
||||
enqueueChange('user_templates', template.id, 'upsert')
|
||||
await engine.runFullSync()
|
||||
expect(remote.find('user_templates', template.id)?.name).toBe('Standup')
|
||||
|
||||
remote.mobileUpdate('user_templates', template.id, { name: 'Daily standup' })
|
||||
await engine.pull()
|
||||
expect(service.getById(template.id)?.name).toBe('Daily standup')
|
||||
expect(service.getAll().filter((t) => t.isBuiltin).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue