d3ro-voice/apps/desktop/src/main/services/sync/sync-adapters.ts

1040 lines
37 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,
ragDocuments,
} from '../../db/schema'
import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types'
import { getCustomInstructionService, instructionLimitViolation } from '../CustomInstructionService'
import { getDictationTemplateService } from '../DictationTemplateService'
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
import { getRAGService } from '../RAGService'
import { deleteHistoryLocally } from '../history-deletion'
import { purgeRemoteAudio } from './audio-sync'
import { pushMeetingSegments } from './transcript-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
/**
* 이 작업이 아직 유효한지(엔진이 해제되지 않았고 같은 사용자 DB가 열려 있는지).
* 네트워크를 기다린 뒤 로컬에 쓰는 어댑터는 쓰기 직전에 확인한다.
*/
isCurrent?: () => boolean
}
/** 엔진이 해제됐거나 사용자 DB가 바뀌어 작업을 멈춘다 — 로컬에 아무것도 쓰지 않는다. */
export class SyncAbortedError extends Error {
constructor() {
super('Sync aborted: engine disposed or user DB switched')
this.name = 'SyncAbortedError'
}
}
export function assertCurrent(ctx: PushContext): void {
if (ctx.isCurrent && !ctx.isCurrent()) throw new SyncAbortedError()
}
/**
* 원격 행 반영 결과. 'deferred' = 지금은 반영할 수 없지만(부모 회의가 아직 없음, 청크가 아직 없음)
* 나중에 다시 시도해야 한다 — 엔진이 id를 기억했다가 다음 pull에서 다시 가져온다.
*/
export type ApplyResult = boolean | 'deferred'
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): ApplyResult | Promise<ApplyResult>
/** 원격 삭제를 로컬에 반영. 지운 행이 있으면 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 은 보내지 않는다: 서버 시각(INSERT 기본값·트리거)이 keyset 커서의 기준이다.
// 기기 시계·오프라인 편집 시각을 실으면 다른 기기의 커서 뒤로 들어가 영영 내려받히지 않는다.
}
}
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) {
// 녹음 WAV까지 함께 지운다(폰에서 지운 기록의 목소리가 디스크에 남지 않게).
return deleteHistoryLocally({ ids: [id] }).deleted > 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),
}
}
/**
* 서버는 (단어 대소문자 무시, 카테고리)가 유일하다. 데스크톱의 "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),
}
}
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) }))
)
// 회의 행이 올라간 뒤 전사 구간을 맞춘다 — 폰은 구간이 있으면 구간을 그린다.
const byId = new Map(rows.map((r) => [r.id, r]))
for (const outcome of outcomes) {
const row = byId.get(outcome.id)
if (outcome.error !== null || !row) continue
try {
await pushMeetingSegments(ctx, row)
} catch (err) {
outcome.error = toSyncRemoteError(err)
}
}
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),
}
}
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) return false
// 부모 회의가 아직 로컬에 없으면 고아 행을 만들지 않고 미룬다(회의 pull이 실패했을 수 있다).
// 남의 팀 회의에 단 내 메모는 끝내 부모가 오지 않는다 — 엔진이 몇 번 재시도 후 포기한다.
if (!hasLocalMeeting(meetingId)) return 'deferred'
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),
}
}
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 'deferred'
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)은 각자 따로 있다. 사용자가 만든 명령만 맞춘다.
/**
* 서버 CHECK(이름 80·설명 240·프롬프트 4000자)를 넘는 명령은 잘라 보내지 않는다 — 잘린 사본이 다음 pull에서
* 원본을 덮는다. 재시도 불가 오류로 돌려 outbox가 보관(parked)하게 한다.
*/
export function instructionPushError(i: CustomInstruction): SyncRemoteError | null {
const field = instructionLimitViolation(i)
return field === null
? null
: new SyncRemoteError(`custom instruction ${field} exceeds the server length limit`, '22001', false)
}
export function instructionToRemote(i: CustomInstruction, userId: string): RemoteRow {
return {
id: i.id,
user_id: userId,
name: i.name.trim(),
description: (i.description ?? '').trim(),
prompt: i.prompt,
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 tooLong: PushOutcome[] = []
const sendable: CustomInstruction[] = []
for (const item of items) {
const error = instructionPushError(item)
if (error) tooLong.push({ id: item.id, error })
else sendable.push(item)
}
const outcomes = await upsertIsolated(
ctx,
'custom_instructions',
sendable.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) }))
)
return [...outcomes, ...tooLong, ...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,
}
}
/**
* sync_upsert_user_template_v1 의 P0002(template_not_found)는 "이 id의 템플릿이 다른 계정 소유"라는 뜻이다.
* 다시 보내도 같은 결과라 재시도 불가로 바꿔 outbox가 보관하게 한다(전역 P0002는 재시도 가능으로 남긴다).
*/
export function templateUpsertError(err: unknown): SyncRemoteError {
const error = toSyncRemoteError(err)
return error.code === 'P0002' ? new SyncRemoteError(error.message, error.code, false) : error
}
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: templateUpsertError(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
}
/**
* 서버 청크가 문서 행의 chunk_count만큼 빠짐없이(chunk_index 0..n-1) 올라와 있으면 원문 배열을,
* 아직 업로드 중이거나 업로드가 중간에 실패했거나 조회가 잘렸으면 null을 돌려준다.
* chunk_count를 모르는 행(옛 클라이언트)은 0부터 이어지는지만 본다.
*/
export function completeKnowledgeChunks(chunkRows: readonly RemoteRow[], expectedCount: number | null): string[] | null {
if (chunkRows.length === 0) return null
if (expectedCount !== null && chunkRows.length !== expectedCount) return null
const byIndex = new Map<number, string>()
for (const c of chunkRows) {
const index = num(c.chunk_index)
const content = str(c.content)
if (index === null || content === null || byIndex.has(index)) return null
byIndex.set(index, content)
}
const chunks: string[] = []
for (let i = 0; i < byIndex.size; i++) {
const content = byIndex.get(i)
if (content === undefined) return null
chunks.push(content)
}
return chunks
}
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')
// 네트워크를 기다리는 사이 로그아웃·계정 전환이 있었으면 다른 사용자 DB에 쓰지 않는다.
assertCurrent(ctx)
// 다른 기기가 문서 행을 올리고 청크를 아직(또는 일부만) 올렸을 수 있다 — 다음 pull에서 다시 본다.
// 일부만 받아 저장하면 이후 pull은 "이미 있음"으로 건너뛰어 잘린 문서가 영구히 남는다.
const chunks = completeKnowledgeChunks(chunkRows, num(row.chunk_count))
if (chunks === null) return 'deferred'
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
}