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.
224 lines
8.4 KiB
TypeScript
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}`)
|
|
}
|
|
}
|