d3ro-voice/apps/desktop/tests/helpers/fakeSyncRemote.ts
Yun Chan 0a4f5aee64 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.
2026-09-27 14:04:49 +09:00

224 lines
8.4 KiB
TypeScript

// 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}`)
}
}