d3ro-voice/apps/desktop/src/main/services/sync/sync-adapters.ts
Yun Chan 9a8f7e6aa6 feat(desktop): sync knowledge, recordings and shared settings; play any recording
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.
2026-09-27 14:44:56 +09:00

951 lines
33 KiB
TypeScript

// 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<PushOutcome[]>
pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
/** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */
applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise<boolean>
/** 원격 삭제를 로컬에 반영. 지운 행이 있으면 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<T extends string>(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<SyncRemoteError | null>
): Promise<PushOutcome[]> {
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<PushOutcome[]> {
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<string>): 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<SyncRemoteError | null> {
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<string, unknown>
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<string, unknown> {
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<SyncRemoteError | null> {
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
}