// src/main/services/sync/sync-adapters.ts // 엔티티별 로컬 ↔ Supabase 매핑. 로컬 쓰기는 outbox에 다시 넣지 않는다(에코 방지). import { and, eq, ne } from 'drizzle-orm' import { getDatabase } from '../../db' import { dictionary, history, meetingDocuments, meetingMemos, meetingSessions, memoTags, ragDocuments, } from '../../db/schema' import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types' import { getCustomInstructionService } from '../CustomInstructionService' import { getDictationTemplateService } from '../DictationTemplateService' import { getMeetingDocTemplateService } from '../MeetingDocTemplateService' import { getRAGService } from '../RAGService' import { purgeRemoteAudio } from './audio-sync' import { dropEntry } from './sync-outbox' import { SyncRemoteError, toSyncRemoteError, type PushOutcome, type RemoteFilter, type RemoteRow, type SyncEntity, type SyncRemote, } from './sync-types' export interface PushContext { remote: SyncRemote userId: string } export interface LocalVersion { id: string updatedAt: number } export interface SyncAdapter { entity: SyncEntity /** 커서 pull 설정. 없으면 커서 pull 대상이 아니다(memo_tags는 전체 대조). */ pull: { columns?: string; filters?: RemoteFilter[] } | null listLocalVersions(): LocalVersion[] push(ctx: PushContext, ids: string[]): Promise pushDeletes(ctx: PushContext, ids: string[]): Promise /** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */ applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise /** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */ deleteLocal(id: string): boolean } // ── 값 변환 ───────────────────────────────────────────── const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i export function isUuid(value: unknown): value is string { return typeof value === 'string' && UUID_PATTERN.test(value) } function iso(ms: number): string { return new Date(ms).toISOString() } function nullableIso(ms: number | null): string | null { return ms === null ? null : iso(ms) } function ms(value: unknown, fallback: number): number { if (typeof value !== 'string') return fallback const parsed = Date.parse(value) return Number.isFinite(parsed) ? parsed : fallback } function nullableMs(value: unknown): number | null { if (typeof value !== 'string') return null const parsed = Date.parse(value) return Number.isFinite(parsed) ? parsed : null } function str(value: unknown): string | null { return typeof value === 'string' ? value : null } function num(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return value if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value) return null } function int(value: number | null): number | null { return value === null ? null : Math.round(value) } function oneOf(value: unknown, allowed: readonly T[]): T | null { return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : null } // ── 공통 push 전략 ────────────────────────────────────── const BATCH_SIZE = 100 /** * 배치 upsert 후, 재시도 불가 오류면 행 단위로 다시 보내 문제 행만 격리한다. * 재시도 가능 오류(네트워크)는 배치 전체를 같은 오류로 돌려준다. */ async function upsertIsolated( ctx: PushContext, table: string, payloads: Array<{ id: string; row: RemoteRow }>, onRowConflict?: (id: string, error: SyncRemoteError) => Promise ): Promise { const outcomes: PushOutcome[] = [] for (let i = 0; i < payloads.length; i += BATCH_SIZE) { const batch = payloads.slice(i, i + BATCH_SIZE) try { await ctx.remote.upsert(table, batch.map((p) => p.row)) outcomes.push(...batch.map((p) => ({ id: p.id, error: null }))) continue } catch (err) { const error = toSyncRemoteError(err) if (error.retryable || batch.length === 1) { if (!error.retryable && batch.length === 1 && onRowConflict) { outcomes.push({ id: batch[0].id, error: await onRowConflict(batch[0].id, error) }) } else { outcomes.push(...batch.map((p) => ({ id: p.id, error }))) } continue } } for (const payload of batch) { try { await ctx.remote.upsert(table, [payload.row]) outcomes.push({ id: payload.id, error: null }) } catch (err) { const error = toSyncRemoteError(err) outcomes.push({ id: payload.id, error: !error.retryable && onRowConflict ? await onRowConflict(payload.id, error) : error, }) } } } return outcomes } async function deleteRemote(ctx: PushContext, table: string, ids: string[]): Promise { const valid = ids.filter(isUuid) const outcomes: PushOutcome[] = ids.filter((id) => !isUuid(id)).map((id) => ({ id, error: null })) try { await ctx.remote.deleteByIds(table, ctx.userId, valid) outcomes.push(...valid.map((id) => ({ id, error: null }))) } catch (err) { const error = toSyncRemoteError(err) outcomes.push(...valid.map((id) => ({ id, error }))) } return outcomes } /** 로컬에서 사라진 행은 보낼 것이 없다 — 성공으로 처리해 outbox에서 뺀다. */ function missingAsDone(ids: string[], found: Set): PushOutcome[] { return ids.filter((id) => !found.has(id)).map((id) => ({ id, error: null })) } // ── history ───────────────────────────────────────────── const HISTORY_MODES = ['dictation', 'translate', 'command', 'caption', 'file-transcription'] as const const HISTORY_STATUSES = ['completed', 'cancelled', 'error'] as const export function historyToRemote(r: typeof history.$inferSelect, userId: string): RemoteRow { // focused_app*/mic 경로/오디오 경로는 데스크톱 로컬 맥락이라 올리지 않는다(창 제목 등 개인정보). return { id: r.id, user_id: userId, title: r.title, original_text: r.originalText, polished_text: r.polishedText, mode: r.mode, status: r.status, duration: r.duration, detected_language: r.detectedLanguage, mic_device: r.micDevice, word_count: Math.round(r.wordCount), stt_model: r.sttModel, llm_model: r.llmModel, stt_latency_ms: int(r.sttLatencyMs), llm_latency_ms: int(r.llmLatencyMs), app_version: r.appVersion, summary_text: r.summaryText, is_favorite: r.isFavorite, created_at: iso(r.createdAt), updated_at: iso(r.updatedAt), } } const historyAdapter: SyncAdapter = { entity: 'history', pull: {}, listLocalVersions() { return getDatabase().select({ id: history.id, updatedAt: history.updatedAt }).from(history).all() }, async push(ctx, ids) { const db = getDatabase() const rows = ids.flatMap((id) => db.select().from(history).where(eq(history.id, id)).all()) const found = new Set(rows.map((r) => r.id)) const outcomes = await upsertIsolated( ctx, 'history', rows.map((r) => ({ id: r.id, row: historyToRemote(r, ctx.userId) })) ) return [...outcomes, ...missingAsDone(ids, found)] }, async pushDeletes(ctx, ids) { // 지운 기록의 녹음 파일도 저장소에서 지운다(행은 SET NULL로 남아 고아가 된다). await purgeRemoteAudio(ctx, 'history_id', ids) return deleteRemote(ctx, 'history', ids) }, applyRemote(row) { const id = row.id const originalText = str(row.original_text) const mode = oneOf(row.mode, HISTORY_MODES) const status = oneOf(row.status, HISTORY_STATUSES) if (!isUuid(id) || originalText === null || mode === null || status === null) return false const updatedAt = ms(row.updated_at, Date.now()) const values = { title: str(row.title), originalText, polishedText: str(row.polished_text), mode, status, duration: num(row.duration) ?? 0, detectedLanguage: str(row.detected_language), micDevice: str(row.mic_device), wordCount: int(num(row.word_count)) ?? 0, sttModel: str(row.stt_model), llmModel: str(row.llm_model), sttLatencyMs: int(num(row.stt_latency_ms)), llmLatencyMs: int(num(row.llm_latency_ms)), appVersion: str(row.app_version) ?? '1.0.0', summaryText: str(row.summary_text), isFavorite: row.is_favorite === true, updatedAt, } getDatabase() .insert(history) .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) .onConflictDoUpdate({ target: history.id, set: values }) .run() return true }, deleteLocal(id) { const db = getDatabase() db.delete(memoTags).where(eq(memoTags.historyId, id)).run() return db.delete(history).where(eq(history.id, id)).run().changes > 0 }, } // ── dictionary ────────────────────────────────────────── const DICTIONARY_CATEGORIES = ['user', 'auto', 'technical'] as const export function dictionaryToRemote(r: typeof dictionary.$inferSelect, userId: string): RemoteRow { return { id: r.id, user_id: userId, word: r.word, pronunciation: r.pronunciation, category: r.category, usage_count: Math.round(r.usageCount), last_used_at: nullableIso(r.lastUsedAt), created_at: iso(r.createdAt), updated_at: iso(r.updatedAt), } } /** * 서버는 (단어 대소문자 무시, 카테고리)가 유일하다. 데스크톱의 "API"와 모바일의 "api"가 * 다른 id로 공존하면 push가 23505로 막힌다 — 서버 행을 채택하고 로컬 사본을 그 id로 바꾼다. * 사용 횟수는 합친 값이 사라지지 않도록 큰 쪽을 남긴 뒤 다시 올린다. */ async function adoptRemoteDictionaryRow( ctx: PushContext, localId: string, error: SyncRemoteError ): Promise { if (error.code !== '23505') return error const db = getDatabase() const local = db.select().from(dictionary).where(eq(dictionary.id, localId)).get() if (!local) return null let matches: RemoteRow[] try { matches = await ctx.remote.selectWhere('dictionary', ctx.userId, [ { column: 'word', op: 'ilike', value: local.word.trim() }, { column: 'category', op: 'eq', value: local.category }, ]) } catch (err) { return toSyncRemoteError(err) } const match = matches.find((m) => isUuid(m.id) && m.id !== localId) if (!match || !isUuid(match.id)) return error const remoteId = match.id db.delete(dictionary).where(eq(dictionary.id, localId)).run() dropEntry('dictionary', localId) void dictionaryAdapter.applyRemote(match, ctx) const merged = db.select().from(dictionary).where(eq(dictionary.id, remoteId)).get() if (merged && local.usageCount > merged.usageCount) { db.update(dictionary) .set({ usageCount: local.usageCount, lastUsedAt: local.lastUsedAt ?? merged.lastUsedAt, updatedAt: Date.now() }) .where(eq(dictionary.id, remoteId)) .run() const refreshed = db.select().from(dictionary).where(eq(dictionary.id, remoteId)).get() if (refreshed) { try { await ctx.remote.upsert('dictionary', [dictionaryToRemote(refreshed, ctx.userId)]) } catch { // 사용 횟수 보정은 다음 변경 때 다시 올라간다. } } } return null } const dictionaryAdapter: SyncAdapter = { entity: 'dictionary', pull: {}, listLocalVersions() { return getDatabase().select({ id: dictionary.id, updatedAt: dictionary.updatedAt }).from(dictionary).all() }, async push(ctx, ids) { const db = getDatabase() const rows = ids.flatMap((id) => db.select().from(dictionary).where(eq(dictionary.id, id)).all()) const found = new Set(rows.map((r) => r.id)) const outcomes = await upsertIsolated( ctx, 'dictionary', rows.map((r) => ({ id: r.id, row: dictionaryToRemote(r, ctx.userId) })), (id, error) => adoptRemoteDictionaryRow(ctx, id, error) ) return [...outcomes, ...missingAsDone(ids, found)] }, pushDeletes(ctx, ids) { return deleteRemote(ctx, 'dictionary', ids) }, applyRemote(row) { const id = row.id const word = str(row.word) const category = oneOf(row.category, DICTIONARY_CATEGORIES) ?? 'user' if (!isUuid(id) || word === null) return false const updatedAt = ms(row.updated_at, Date.now()) const values = { word, pronunciation: str(row.pronunciation), category, usageCount: int(num(row.usage_count)) ?? 0, lastUsedAt: nullableMs(row.last_used_at), updatedAt, } const db = getDatabase() // 로컬 유일 인덱스(word, category)를 다른 id가 점유하고 있으면 서버 행이 이긴다. const clash = db .select({ id: dictionary.id }) .from(dictionary) .where(and(eq(dictionary.word, word), eq(dictionary.category, category), ne(dictionary.id, id))) .all() for (const other of clash) { db.delete(dictionary).where(eq(dictionary.id, other.id)).run() dropEntry('dictionary', other.id) } db.insert(dictionary) .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) .onConflictDoUpdate({ target: dictionary.id, set: values }) .run() return true }, deleteLocal(id) { return getDatabase().delete(dictionary).where(eq(dictionary.id, id)).run().changes > 0 }, } // ── meetings ──────────────────────────────────────────── const MEETING_STATUSES = ['recording', 'processing', 'completed', 'error'] as const function parseJsonOrNull(text: string | null): unknown { if (text === null) return null try { return JSON.parse(text) as unknown } catch { return null } } export function meetingToRemote(r: typeof meetingSessions.$inferSelect, userId: string): RemoteRow { // team_id는 보내지 않는다: 모바일/웹에서 팀에 공유한 회의를 데스크톱 수정이 개인 회의로 되돌리면 안 된다. return { id: r.id, user_id: userId, title: r.title, status: r.status, started_at: iso(r.startedAt), ended_at: nullableIso(r.endedAt), duration_ms: int(r.durationMs), raw_transcript: r.rawTranscript, edited_transcript: r.editedTranscript, minutes_markdown: r.minutesMarkdown, minutes_json: parseJsonOrNull(r.minutesJson), stt_model: r.sttModel, llm_model: r.llmModel, stt_latency_ms: int(r.sttLatencyMs), llm_latency_ms: int(r.llmLatencyMs), error_message: r.errorMessage, created_at: iso(r.createdAt), updated_at: iso(r.updatedAt), } } function deleteMeetingLocally(id: string): boolean { const db = getDatabase() db.delete(meetingMemos).where(eq(meetingMemos.sessionId, id)).run() db.delete(meetingDocuments).where(eq(meetingDocuments.sessionId, id)).run() return db.delete(meetingSessions).where(eq(meetingSessions.id, id)).run().changes > 0 } const meetingsAdapter: SyncAdapter = { entity: 'meetings', pull: {}, listLocalVersions() { return getDatabase() .select({ id: meetingSessions.id, updatedAt: meetingSessions.updatedAt }) .from(meetingSessions) .all() }, async push(ctx, ids) { const db = getDatabase() const rows = ids.flatMap((id) => db.select().from(meetingSessions).where(eq(meetingSessions.id, id)).all()) const found = new Set(rows.map((r) => r.id)) const outcomes = await upsertIsolated( ctx, 'meetings', rows.map((r) => ({ id: r.id, row: meetingToRemote(r, ctx.userId) })) ) return [...outcomes, ...missingAsDone(ids, found)] }, async pushDeletes(ctx, ids) { await purgeRemoteAudio(ctx, 'meeting_id', ids) return deleteRemote(ctx, 'meetings', ids) }, applyRemote(row) { const id = row.id const status = oneOf(row.status, MEETING_STATUSES) if (!isUuid(id) || status === null) return false const updatedAt = ms(row.updated_at, Date.now()) const values = { title: str(row.title), status, startedAt: ms(row.started_at, updatedAt), endedAt: nullableMs(row.ended_at), durationMs: int(num(row.duration_ms)), rawTranscript: str(row.raw_transcript), editedTranscript: str(row.edited_transcript), minutesMarkdown: str(row.minutes_markdown), minutesJson: row.minutes_json === null || row.minutes_json === undefined ? null : JSON.stringify(row.minutes_json), sttModel: str(row.stt_model), llmModel: str(row.llm_model), sttLatencyMs: int(num(row.stt_latency_ms)), llmLatencyMs: int(num(row.llm_latency_ms)), errorMessage: str(row.error_message), updatedAt, } getDatabase() .insert(meetingSessions) .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) .onConflictDoUpdate({ target: meetingSessions.id, set: values }) .run() return true }, deleteLocal: deleteMeetingLocally, } // ── meeting_memos ─────────────────────────────────────── function hasLocalMeeting(id: string): boolean { return ( getDatabase().select({ id: meetingSessions.id }).from(meetingSessions).where(eq(meetingSessions.id, id)).get() !== undefined ) } export function meetingMemoToRemote(r: typeof meetingMemos.$inferSelect, userId: string): RemoteRow { return { id: r.id, meeting_id: r.sessionId, user_id: userId, content: r.content, timestamp_ms: Math.round(r.timestampMs), created_at: iso(r.createdAt), updated_at: iso(r.updatedAt ?? r.createdAt), } } const meetingMemosAdapter: SyncAdapter = { entity: 'meeting_memos', pull: {}, listLocalVersions() { return getDatabase() .select({ id: meetingMemos.id, createdAt: meetingMemos.createdAt, updatedAt: meetingMemos.updatedAt }) .from(meetingMemos) .all() .map((r) => ({ id: r.id, updatedAt: r.updatedAt ?? r.createdAt })) }, async push(ctx, ids) { const db = getDatabase() const rows = ids.flatMap((id) => db.select().from(meetingMemos).where(eq(meetingMemos.id, id)).all()) const found = new Set(rows.map((r) => r.id)) const outcomes = await upsertIsolated( ctx, 'meeting_memos', rows.map((r) => ({ id: r.id, row: meetingMemoToRemote(r, ctx.userId) })) ) return [...outcomes, ...missingAsDone(ids, found)] }, pushDeletes(ctx, ids) { return deleteRemote(ctx, 'meeting_memos', ids) }, applyRemote(row) { const id = row.id const meetingId = row.meeting_id const content = str(row.content) // 남의 팀 회의에 내가 단 메모는 부모 회의가 로컬에 없다 — 고아 행을 만들지 않는다. if (!isUuid(id) || !isUuid(meetingId) || content === null || !hasLocalMeeting(meetingId)) return false const createdAt = ms(row.created_at, Date.now()) const values = { sessionId: meetingId, content, timestampMs: int(num(row.timestamp_ms)) ?? 0, updatedAt: ms(row.updated_at, createdAt), } getDatabase() .insert(meetingMemos) .values({ id, ...values, createdAt }) .onConflictDoUpdate({ target: meetingMemos.id, set: values }) .run() return true }, deleteLocal(id) { return getDatabase().delete(meetingMemos).where(eq(meetingMemos.id, id)).run().changes > 0 }, } // ── meeting_documents ─────────────────────────────────── const DOCUMENT_TYPES = ['minutes', 'report', 'idea-note', 'custom', 'mindmap'] as const export function meetingDocumentToRemote(r: typeof meetingDocuments.$inferSelect, userId: string): RemoteRow { return { id: r.id, meeting_id: r.sessionId, user_id: userId, template_type: r.templateType, title: r.title, content: r.content, prompt_used: r.promptUsed, llm_model: r.llmModel, llm_latency_ms: int(r.llmLatencyMs), created_at: iso(r.createdAt), updated_at: iso(r.updatedAt), } } const meetingDocumentsAdapter: SyncAdapter = { entity: 'meeting_documents', pull: {}, listLocalVersions() { return getDatabase() .select({ id: meetingDocuments.id, updatedAt: meetingDocuments.updatedAt }) .from(meetingDocuments) .all() }, async push(ctx, ids) { const db = getDatabase() const rows = ids.flatMap((id) => db.select().from(meetingDocuments).where(eq(meetingDocuments.id, id)).all()) const found = new Set(rows.map((r) => r.id)) const outcomes = await upsertIsolated( ctx, 'meeting_documents', rows.map((r) => ({ id: r.id, row: meetingDocumentToRemote(r, ctx.userId) })) ) return [...outcomes, ...missingAsDone(ids, found)] }, pushDeletes(ctx, ids) { return deleteRemote(ctx, 'meeting_documents', ids) }, applyRemote(row) { const id = row.id const meetingId = row.meeting_id const templateType = oneOf(row.template_type, DOCUMENT_TYPES) const title = str(row.title) if (!isUuid(id) || !isUuid(meetingId) || templateType === null || title === null) return false if (!hasLocalMeeting(meetingId)) return false const updatedAt = ms(row.updated_at, Date.now()) const values = { sessionId: meetingId, templateType, title, content: str(row.content) ?? '', promptUsed: str(row.prompt_used), llmModel: str(row.llm_model), llmLatencyMs: int(num(row.llm_latency_ms)), updatedAt, } getDatabase() .insert(meetingDocuments) .values({ id, ...values, createdAt: ms(row.created_at, updatedAt) }) .onConflictDoUpdate({ target: meetingDocuments.id, set: values }) .run() return true }, deleteLocal(id) { return getDatabase().delete(meetingDocuments).where(eq(meetingDocuments.id, id)).run().changes > 0 }, } // ── custom_instructions ───────────────────────────────── // 데스크톱 프리셋(builtin-*)과 서버 프리셋(builtin_key)은 각자 따로 있다. 사용자가 만든 명령만 맞춘다. export function instructionToRemote(i: CustomInstruction, userId: string): RemoteRow { return { id: i.id, user_id: userId, name: i.name.trim().slice(0, 80), description: (i.description ?? '').trim().slice(0, 240), prompt: i.prompt.trim().slice(0, 4000), icon: (i.icon || 'sparkles').slice(0, 32), sort_order: Math.max(0, Math.round(i.order)), created_at: iso(i.createdAt), } } const customInstructionsAdapter: SyncAdapter = { entity: 'custom_instructions', pull: { filters: [{ column: 'builtin_key', op: 'is', value: null }] }, listLocalVersions() { return getCustomInstructionService() .getAll() .filter((i) => !i.isBuiltin && isUuid(i.id)) .map((i) => ({ id: i.id, updatedAt: i.updatedAt })) }, async push(ctx, ids) { const service = getCustomInstructionService() const items = ids .map((id) => service.getById(id)) .filter((i): i is CustomInstruction => i !== null && !i.isBuiltin && isUuid(i.id)) const found = new Set(items.map((i) => i.id)) const outcomes = await upsertIsolated( ctx, 'custom_instructions', items.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) })) ) return [...outcomes, ...missingAsDone(ids, found)] }, pushDeletes(ctx, ids) { return deleteRemote(ctx, 'custom_instructions', ids) }, applyRemote(row) { const id = row.id const name = str(row.name) const prompt = str(row.prompt) if (!isUuid(id) || name === null || prompt === null || row.builtin_key !== null) return false const updatedAt = ms(row.updated_at, Date.now()) getCustomInstructionService().applyRemote({ id, name, description: str(row.description) ?? '', prompt, icon: str(row.icon) ?? 'Extension', isBuiltin: false, order: int(num(row.sort_order)) ?? 0, createdAt: ms(row.created_at, updatedAt), updatedAt, }) return true }, deleteLocal(id) { return getCustomInstructionService().removeRemote(id) }, } // ── user_templates (받아쓰기 + 회의 문서) ─────────────── function sanitizeFields(fields: TemplateField[]): TemplateField[] { // 서버는 정확히 이 6개 키만 받는다. return fields.map((f) => ({ id: f.id, name: f.name, label: f.label, promptText: f.promptText, required: f.required, maxDurationSec: Math.round(f.maxDurationSec), })) } function parseFields(value: unknown): TemplateField[] | null { if (!Array.isArray(value)) return null const fields: TemplateField[] = [] for (const item of value) { if (typeof item !== 'object' || item === null) return null const f = item as Record const id = str(f.id) const name = str(f.name) const label = str(f.label) const promptText = str(f.promptText) const maxDurationSec = num(f.maxDurationSec) if (id === null || name === null || label === null || promptText === null || maxDurationSec === null) return null fields.push({ id, name, label, promptText, required: f.required === true, maxDurationSec }) } return fields } type LocalTemplate = | { kind: 'dictation'; template: DictationTemplate } | { kind: 'meeting_document'; template: MeetingDocTemplate } function findLocalTemplate(id: string): LocalTemplate | null { const dictation = getDictationTemplateService().getById(id) if (dictation && !dictation.isBuiltin) return { kind: 'dictation', template: dictation } const meeting = getMeetingDocTemplateService().getById(id) if (meeting && !meeting.isBuiltin) return { kind: 'meeting_document', template: meeting } return null } export function templateToRpcParams(local: LocalTemplate): Record { if (local.kind === 'dictation') { const t = local.template return { p_id: t.id, p_template_kind: 'dictation', p_name: t.name, p_description: t.description, p_fields: sanitizeFields(t.fields), p_output_format: t.outputFormat, p_system_prompt: null, } } const t = local.template return { p_id: t.id, p_template_kind: 'meeting_document', p_name: t.name, p_description: t.description, p_fields: [], p_output_format: null, p_system_prompt: t.systemPrompt, } } const userTemplatesAdapter: SyncAdapter = { entity: 'user_templates', pull: { filters: [{ column: 'is_builtin', op: 'eq', value: false }] }, listLocalVersions() { const dictation = getDictationTemplateService().getAll() const meeting = getMeetingDocTemplateService().getAll() return [...dictation, ...meeting] .filter((t) => !t.isBuiltin && isUuid(t.id)) .map((t) => ({ id: t.id, updatedAt: t.updatedAt })) }, async push(ctx, ids) { const outcomes: PushOutcome[] = [] for (const id of ids) { const local = isUuid(id) ? findLocalTemplate(id) : null if (!local) { outcomes.push({ id, error: null }) continue } try { await ctx.remote.rpc('sync_upsert_user_template_v1', templateToRpcParams(local)) outcomes.push({ id, error: null }) } catch (err) { outcomes.push({ id, error: toSyncRemoteError(err) }) } } return outcomes }, async pushDeletes(ctx, ids) { const outcomes: PushOutcome[] = [] for (const id of ids) { if (!isUuid(id)) { outcomes.push({ id, error: null }) continue } try { await ctx.remote.rpc('sync_delete_user_template_v1', { p_id: id }) outcomes.push({ id, error: null }) } catch (err) { outcomes.push({ id, error: toSyncRemoteError(err) }) } } return outcomes }, applyRemote(row) { const id = row.id const name = str(row.name) if (!isUuid(id) || name === null || row.is_builtin === true) return false const updatedAt = ms(row.updated_at, Date.now()) const createdAt = ms(row.created_at, updatedAt) const description = str(row.description) ?? '' if (row.template_kind === 'dictation') { const fields = parseFields(row.fields) const outputFormat = str(row.output_format) if (fields === null || outputFormat === null) return false getDictationTemplateService().applyRemote({ id, name, description, fields, outputFormat, isBuiltin: false, createdAt, updatedAt, }) return true } if (row.template_kind === 'meeting_document') { const systemPrompt = str(row.system_prompt) if (systemPrompt === null) return false getMeetingDocTemplateService().applyRemote({ id, name, description, templateType: 'custom', systemPrompt, isBuiltin: false, createdAt, updatedAt, }) return true } return false }, deleteLocal(id) { const a = getDictationTemplateService().removeRemote(id) const b = getMeetingDocTemplateService().removeRemote(id) return a || b }, } // ── knowledge_documents ───────────────────────────────── // 원문 청크만 옮긴다. 임베딩은 기기마다 모델·차원이 달라(데스크톱 nomic 768 vs 서버 1536) 각자 만든다. const KNOWLEDGE_TYPES = ['txt', 'md', 'pdf', 'docx'] as const const CHUNK_BATCH = 200 async function pushKnowledgeDocument(ctx: PushContext, id: string): Promise { const db = getDatabase() const doc = db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get() if (!doc) return null const chunks = getRAGService().getStoredChunks(id) if (chunks.length === 0) return null try { await ctx.remote.upsert('knowledge_documents', [ { id: doc.id, user_id: ctx.userId, title: doc.fileName.slice(0, 300), file_name: doc.fileName, file_type: doc.fileType, chunk_count: chunks.length, indexed: false, indexed_at: null, created_at: iso(doc.addedAt), }, ]) await ctx.remote.deleteChildren('knowledge_chunks', 'document_id', doc.id) for (let i = 0; i < chunks.length; i += CHUNK_BATCH) { await ctx.remote.insert( 'knowledge_chunks', chunks.slice(i, i + CHUNK_BATCH).map((content, offset) => ({ document_id: doc.id, chunk_index: i + offset, content, })) ) } } catch (err) { return toSyncRemoteError(err) } // 모바일·웹 검색용 서버 임베딩. 공급자 키가 없거나 한도에 걸려도 원문은 이미 올라갔다 — // 모바일에서 "색인" 을 다시 누를 수 있으므로 여기서 실패로 되돌리지 않는다. try { await ctx.remote.invokeFunction('embed-chunks', { document_id: doc.id }) } catch { // 의도적 무시(위 주석) } return null } const knowledgeAdapter: SyncAdapter = { entity: 'knowledge_documents', pull: { columns: 'id,title,file_name,file_type,chunk_count,created_at,updated_at' }, listLocalVersions() { return getDatabase() .select({ id: ragDocuments.id, addedAt: ragDocuments.addedAt }) .from(ragDocuments) .all() .filter((r) => isUuid(r.id)) .map((r) => ({ id: r.id, updatedAt: r.addedAt })) }, async push(ctx, ids) { const outcomes: PushOutcome[] = [] for (const id of ids) outcomes.push({ id, error: await pushKnowledgeDocument(ctx, id) }) return outcomes }, pushDeletes(ctx, ids) { return deleteRemote(ctx, 'knowledge_documents', ids) }, async applyRemote(row, ctx) { const id = row.id if (!isUuid(id)) return false const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get() if (exists) return false const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index') const chunks = chunkRows.map((c) => str(c.content)).filter((c): c is string => c !== null) return getRAGService().applyRemoteDocument({ id, fileName: str(row.file_name) ?? str(row.title) ?? 'document', fileType: oneOf(row.file_type, KNOWLEDGE_TYPES) ?? 'txt', chunks, addedAt: ms(row.created_at, Date.now()), }) }, deleteLocal(id) { return getRAGService().removeRemote(id) }, } /** push 순서: 부모(이력·회의)가 자식(태그·메모·문서)보다 먼저. 삭제는 역순. */ export const TABLE_ADAPTERS: readonly SyncAdapter[] = [ historyAdapter, dictionaryAdapter, meetingsAdapter, meetingMemosAdapter, meetingDocumentsAdapter, customInstructionsAdapter, userTemplatesAdapter, knowledgeAdapter, ] export function adapterFor(entity: SyncEntity): SyncAdapter | null { return TABLE_ADAPTERS.find((a) => a.entity === entity) ?? null }