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:
Yun Chan 2026-09-27 14:04:49 +09:00
parent b5c9ff9f31
commit 0a4f5aee64
49 changed files with 3940 additions and 1033 deletions

View file

@ -211,6 +211,7 @@ class MeetingModeService extends EventEmitter {
errorMessage: err instanceof Error ? err.message : String(err),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
this._setState('idle')
throw err
@ -237,7 +238,7 @@ class MeetingModeService extends EventEmitter {
// Phase 3.3: 부모 meeting row를 즉시 push (빈 상태로).
// 자식 meeting_memos/meeting_documents의 RLS/FK가 부모 존재를 요구하므로,
// 녹음 중에 들어오는 addMemo/generateDocument 푸시가 통과하려면 필수.
void getCloudSyncService().pushOne('meetings', sessionId)
getCloudSyncService().pushOne('meetings', sessionId)
return { sessionId }
}
@ -273,7 +274,7 @@ class MeetingModeService extends EventEmitter {
logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`)
// Phase 3.3: 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('meeting_memos', memo.id)
getCloudSyncService().pushOne('meeting_memos', memo.id)
return memo
}
@ -317,6 +318,7 @@ class MeetingModeService extends EventEmitter {
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
@ -339,6 +341,7 @@ class MeetingModeService extends EventEmitter {
status: 'processing',
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
// Step 1: 전사 텍스트 합산
this._sendProgress(sessionId, 'merging', 40)
@ -355,9 +358,7 @@ class MeetingModeService extends EventEmitter {
sttModel: configGet('sttModelId') as string | undefined,
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
// Phase 3.3: 최종 save 후 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('meetings', sessionId)
getCloudSyncService().pushOne('meetings', sessionId)
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
if (this._audioBuffersForFile.length > 0) {
@ -400,6 +401,7 @@ class MeetingModeService extends EventEmitter {
errorMessage: err instanceof Error ? err.message : String(err),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
this._resetSession()
this._setState('idle')
@ -478,7 +480,10 @@ class MeetingModeService extends EventEmitter {
}
db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run()
db.delete(meetingDocuments).where(eq(meetingDocuments.sessionId, sessionId)).run()
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
getCloudSyncService().pushDelete('meetings', sessionId)
logger.info(`회의 세션 삭제: ${sessionId}`)
}
@ -490,6 +495,7 @@ class MeetingModeService extends EventEmitter {
}
db.update(meetingSessions).set({ title, updatedAt: Date.now() })
.where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
}
// ── 내보내기 ──
@ -649,6 +655,7 @@ class MeetingModeService extends EventEmitter {
editedTranscript,
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
}
@ -733,7 +740,7 @@ class MeetingModeService extends EventEmitter {
logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`)
// Phase 3.3: 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('meeting_documents', docId)
getCloudSyncService().pushOne('meeting_documents', docId)
return {
id: docId,
@ -779,6 +786,7 @@ class MeetingModeService extends EventEmitter {
}
db.update(meetingDocuments).set({ content, updatedAt: Date.now() })
.where(eq(meetingDocuments.id, documentId)).run()
getCloudSyncService().pushOne('meeting_documents', documentId)
logger.info(`문서 수정: docId=${documentId}`)
}
@ -789,6 +797,7 @@ class MeetingModeService extends EventEmitter {
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
}
db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run()
getCloudSyncService().pushDelete('meeting_documents', documentId)
logger.info(`문서 삭제: docId=${documentId}`)
}
@ -1012,6 +1021,7 @@ class MeetingModeService extends EventEmitter {
editedTranscript: result.text,
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
return result.text
@ -1163,6 +1173,7 @@ ${transcript}`
editedTranscript: labeledLines.join('\n'),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`)
} catch (err) {
@ -1211,6 +1222,7 @@ ${speakerHint}
editedTranscript: result.text,
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
}
private _buildPdfHtml(session: MeetingSessionDetail): string {