The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.
Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
359 lines
14 KiB
TypeScript
359 lines
14 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
|
|
/** 업로드된 저장소 객체: `${bucket}/${key}` → 바이트 */
|
|
readonly objects = new Map<string, Uint8Array>()
|
|
readonly invoked: string[] = []
|
|
/** 특정 행 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 selectChildren(
|
|
table: string,
|
|
parentColumn: string,
|
|
parentId: string,
|
|
_columns: string,
|
|
orderBy: string
|
|
): Promise<RemoteRow[]> {
|
|
this.guard()
|
|
return this.rows(table)
|
|
.filter((r) => r[parentColumn] === parentId)
|
|
.sort((a, b) => Number(a[orderBy]) - Number(b[orderBy]))
|
|
.map((r) => ({ ...r }))
|
|
}
|
|
|
|
async insert(table: string, rows: RemoteRow[]): Promise<void> {
|
|
this.guard()
|
|
this.calls.push(`insert:${table}:${rows.length}`)
|
|
for (const row of rows) {
|
|
if (table === 'user_settings' && this.rows(table).some((r) => r.user_id === row.user_id)) {
|
|
throw new SyncRemoteError('duplicate key value', '23505', false)
|
|
}
|
|
const at = this.now()
|
|
this.rows(table).push({ id: crypto.randomUUID(), revision: 1, created_at: at, updated_at: at, ...row })
|
|
}
|
|
}
|
|
|
|
async updateMatching(
|
|
table: string,
|
|
userId: string,
|
|
match: Record<string, string | number>,
|
|
patch: RemoteRow
|
|
): Promise<number> {
|
|
this.guard()
|
|
this.calls.push(`update:${table}`)
|
|
const rows = this.rows(table).filter(
|
|
(r) => r.user_id === userId && Object.entries(match).every(([k, v]) => r[k] === v)
|
|
)
|
|
for (const row of rows) Object.assign(row, patch, { updated_at: this.now() })
|
|
return rows.length
|
|
}
|
|
|
|
async deleteChildren(table: string, parentColumn: string, parentId: string): Promise<void> {
|
|
this.guard()
|
|
const rows = this.rows(table)
|
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][parentColumn] === parentId) rows.splice(i, 1)
|
|
}
|
|
|
|
async deleteChildrenFrom(
|
|
table: string,
|
|
parentColumn: string,
|
|
parentId: string,
|
|
indexColumn: string,
|
|
fromIndex: number
|
|
): Promise<void> {
|
|
this.guard()
|
|
const rows = this.rows(table)
|
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
if (rows[i][parentColumn] === parentId && Number(rows[i][indexColumn]) >= fromIndex) rows.splice(i, 1)
|
|
}
|
|
}
|
|
|
|
/** 서버 프리셋 기본 프롬프트 (마이그레이션 0036 과 같은 문구) */
|
|
static readonly BUILTIN_DEFAULTS: Record<string, string> = {
|
|
translate_en: 'Translate the following text into natural English. Return only the translation.',
|
|
summarize: 'Summarize the following text in no more than three concise lines.',
|
|
formal: 'Rewrite the following text in a formal business style while preserving its meaning. Return only the rewritten text.',
|
|
explain_code: 'Explain the following code in Korean, including its responsibilities and important caveats.',
|
|
}
|
|
|
|
private ensureBuiltins(): void {
|
|
for (const [key, prompt] of Object.entries(FakeSyncRemote.BUILTIN_DEFAULTS)) {
|
|
if (!this.rows('custom_instructions').some((r) => r.builtin_key === key)) {
|
|
this.mobileInsert('custom_instructions', { id: crypto.randomUUID(), builtin_key: key, name: key, prompt, icon: 'x', sort_order: 10 })
|
|
}
|
|
}
|
|
}
|
|
|
|
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
|
this.guard()
|
|
this.invoked.push(`${name}:${String(body.document_id ?? '')}`)
|
|
return { indexed: true }
|
|
}
|
|
|
|
async uploadObject(bucket: string, key: string, bytes: Uint8Array): Promise<void> {
|
|
this.guard()
|
|
if (!key.startsWith(`${this.userId}/`)) throw new SyncRemoteError('row-level security', '42501', false)
|
|
this.objects.set(`${bucket}/${key}`, bytes)
|
|
}
|
|
|
|
async removeObjects(bucket: string, keys: string[]): Promise<void> {
|
|
this.guard()
|
|
for (const key of keys) this.objects.delete(`${bucket}/${key}`)
|
|
}
|
|
|
|
async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): 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)
|
|
}
|
|
}
|
|
const conflictColumns = onConflict.split(',')
|
|
for (const row of rows) {
|
|
const existing =
|
|
onConflict === 'id'
|
|
? this.find(table, String(row.id))
|
|
: this.rows(table).find((r) => conflictColumns.every((c) => r[c] === row[c]))
|
|
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({ id: crypto.randomUUID(), 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))
|
|
}
|
|
if (table === 'knowledge_documents') {
|
|
const chunks = this.rows('knowledge_chunks')
|
|
for (let i = chunks.length - 1; i >= 0; i--) if (chunks[i].document_id === id) chunks.splice(i, 1)
|
|
}
|
|
}
|
|
|
|
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 === 'set_active_custom_instruction') {
|
|
const instructionId = params.instruction_id
|
|
if (instructionId !== null && !this.find('custom_instructions', String(instructionId))) {
|
|
throw new SyncRemoteError('instruction_not_found', 'P0002', true)
|
|
}
|
|
const row = this.rows('user_settings').find((r) => r.user_id === this.userId)
|
|
if (row) Object.assign(row, { active_instruction_id: instructionId, revision: Number(row.revision) + 1, updated_at: this.now() })
|
|
else this.mobileInsert('user_settings', { active_instruction_id: instructionId, revision: 1 })
|
|
return row
|
|
}
|
|
if (name === 'sync_set_builtin_instruction_prompt_v1') {
|
|
this.ensureBuiltins()
|
|
const key = String(params.p_builtin_key)
|
|
const fallback = FakeSyncRemote.BUILTIN_DEFAULTS[key]
|
|
if (!fallback) throw new SyncRemoteError('invalid_builtin_key', '22023', false)
|
|
const next = typeof params.p_prompt === 'string' && params.p_prompt.trim() ? params.p_prompt.trim() : fallback
|
|
const row = this.rows('custom_instructions').find((r) => r.builtin_key === key)!
|
|
if (row.prompt !== next) Object.assign(row, { prompt: next, revision: Number(row.revision) + 1, updated_at: this.now() })
|
|
return row
|
|
}
|
|
if (name === 'sync_list_builtin_instructions_v1') {
|
|
return this.rows('custom_instructions')
|
|
.filter((r) => typeof r.builtin_key === 'string')
|
|
.map((r) => ({
|
|
builtin_key: r.builtin_key,
|
|
prompt: r.prompt,
|
|
is_default: r.prompt === FakeSyncRemote.BUILTIN_DEFAULTS[String(r.builtin_key)],
|
|
updated_at: r.updated_at,
|
|
}))
|
|
}
|
|
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}`)
|
|
}
|
|
}
|