Knowledge documents travel as source-text chunks; each surface embeds them with its own model, the server index is requested through embed-chunks, and documents from the phone are stored without a file and indexed from their chunks. Chunk text is now kept when local embedding fails, so reindexing no longer needs the original file. Recordings upload to the mobile storage contract (audio bucket under the user's folder plus an audio_files row, 50 MiB cap, a Settings > Cloud toggle) and are removed with their record. The history card gains a play button that uses the local file or, for phone recordings, a signed URL. Language (ko/en), system/light/dark theme, auto-polish and the active user command follow the phone's user_settings with its revision rule; changes that arrive from the phone reach the open window.
309 lines
12 KiB
TypeScript
309 lines
12 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 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_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}`)
|
|
}
|
|
}
|