feat(desktop): two-way cloud sync with mobile and web
Rewrites the desktop mirror as services/sync/SyncEngine: a persistent outbox, per-account server-clock keyset cursors with paging, pulls that never overwrite unsent local edits, deletions both ways through sync_tombstones and per-row failure isolation. It now covers history titles and favorites, dictionary, every meeting's memos and documents, memo tags, user commands and dictation/meeting templates, and registers the desktop as a device that the phone can disconnect. Fixes shipped defects: the first pull after sign-in fetched nothing, only the first meeting's children were pushed, team meetings leaked into the personal database and lost team_id on re-push, and Realtime never connected because Electron's Node 20 has no global WebSocket (ws is now the transport). Anonymous local-mode records are imported into the first account that signs in. The settings sync section is translated and shows pending/rejected changes; synced screens reload on app:dataChanged.
This commit is contained in:
parent
b5c9ff9f31
commit
0a4f5aee64
49 changed files with 3940 additions and 1033 deletions
857
apps/desktop/src/main/services/sync/sync-adapters.ts
Normal file
857
apps/desktop/src/main/services/sync/sync-adapters.ts
Normal file
|
|
@ -0,0 +1,857 @@
|
|||
// 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,
|
||||
} 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 { 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): 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)]
|
||||
},
|
||||
pushDeletes(ctx, 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.transaction((tx) => {
|
||||
tx.delete(dictionary).where(eq(dictionary.id, localId)).run()
|
||||
})
|
||||
dropEntry('dictionary', localId)
|
||||
dictionaryAdapter.applyRemote(match)
|
||||
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)]
|
||||
},
|
||||
pushDeletes(ctx, 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
|
||||
},
|
||||
}
|
||||
|
||||
/** push 순서: 부모(이력·회의)가 자식(태그·메모·문서)보다 먼저. 삭제는 역순. */
|
||||
export const TABLE_ADAPTERS: readonly SyncAdapter[] = [
|
||||
historyAdapter,
|
||||
dictionaryAdapter,
|
||||
meetingsAdapter,
|
||||
meetingMemosAdapter,
|
||||
meetingDocumentsAdapter,
|
||||
customInstructionsAdapter,
|
||||
userTemplatesAdapter,
|
||||
]
|
||||
|
||||
export function adapterFor(entity: SyncEntity): SyncAdapter | null {
|
||||
return TABLE_ADAPTERS.find((a) => a.entity === entity) ?? null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue