fix(desktop+supabase): 로컬 ID UUID 통일 + Realtime publication (빅뱅 Phase 5 Part 2)

SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1).

## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치
- 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..."
- 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK.
  빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서).
- 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지).
  14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체.
  nanoid 의존성 + electron.vite.config exclude 제거.
  drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능).

## Bug 5: supabase_realtime publication 누락
- 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT
- 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가.
  데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음.
- 픽스: 20260411000002_realtime_publication.sql 신규.
  pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/
  meeting_documents/history/dictionary 5개). supabase db push 적용.

## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스)
- 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token)
  명시 호출 (채널 구성 이전).
- ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요.
  블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지.

## 실증 결과
- A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈
- B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0
- C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode
- D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존
- E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건)

검증: desktop tsc --noEmit , dev 재기동 , push 최초 성공 
This commit is contained in:
윤찬 2026-04-11 18:46:22 +09:00
parent c8338ec458
commit e55687d298
19 changed files with 132 additions and 38 deletions

View file

@ -33,7 +33,7 @@ export default defineConfig(({ mode }) => {
main: {
plugins: [
externalizeDepsPlugin({
exclude: ['nanoid', 'electron-store', ...workspaceExclude]
exclude: ['electron-store', ...workspaceExclude]
})
],
resolve: { alias: sharedAlias },

View file

@ -61,7 +61,6 @@
"electron-log": "^5.2.0",
"electron-store": "^10.0.0",
"fluent-ffmpeg": "^2.1.3",
"nanoid": "^5.1.7",
"node-record-lpcm16": "^1.0.1",
"pdf-parse": "^2.4.5",
"react": "^19.0.0",

View file

@ -3,7 +3,6 @@
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
import { EventEmitter } from 'events'
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
import { getSoundEffectService } from './SoundEffectService'
@ -127,7 +126,7 @@ class CaptionService extends EventEmitter {
await sttService.initialize(modelId)
// 세션 초기화
this._sessionId = nanoid()
this._sessionId = crypto.randomUUID()
this._sessionStartedAt = Date.now()
this._segments = []
this._audioBuffers = []
@ -350,7 +349,7 @@ class CaptionService extends EventEmitter {
}
const segment: CaptionSegment = {
id: nanoid(),
id: crypto.randomUUID(),
text: result.text.trim(),
timestamp: Date.now(),
isFinal: true,
@ -423,7 +422,7 @@ class CaptionService extends EventEmitter {
if (!result.text || result.text.trim().length === 0) return
const segment: CaptionSegment = {
id: nanoid(),
id: crypto.randomUUID(),
text: result.text.trim(),
timestamp: Date.now(),
isFinal: true,

View file

@ -2,7 +2,6 @@
// Phase 10.4: Multi-LLM Chain 서비스.
// LLMChain을 electron-store에 저장하고, 체인을 순차 실행한다.
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getCustomInstructionService } from './CustomInstructionService'
@ -87,7 +86,7 @@ class ChainService {
create(params: CreateChainParams): LLMChain {
const now = Date.now()
const chain: LLMChain = {
id: nanoid(),
id: crypto.randomUUID(),
name: params.name,
steps: params.steps,
createdAt: now,

View file

@ -371,6 +371,17 @@ class CloudSyncService extends EventEmitter {
const userId = this._session.user.id
// persistSession: false 에서는 Supabase realtime 클라이언트가 auth state change를
// 자동 추적하지 않는다. access_token을 명시적으로 realtime에 주입해서
// postgres_changes 채널이 RLS를 통과하도록 한다. (TIMED_OUT 버그 픽스)
try {
this._client.realtime.setAuth(this._session.access_token)
} catch (err) {
logger.warn(
`Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}`
)
}
// 변경 감지 debounce — 연속 이벤트가 몰릴 때 한 번만 pull
let pullScheduled = false
const schedulePull = (): void => {

View file

@ -2,7 +2,6 @@
// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조.
// electron-store에 저장, 프리셋 5개 기본 제공.
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
@ -141,7 +140,7 @@ class CustomInstructionService {
create(input: CreateInput): CustomInstruction {
const now = Date.now()
const instruction: CustomInstruction = {
id: nanoid(),
id: crypto.randomUUID(),
name: input.name,
description: input.description,
prompt: input.prompt,

View file

@ -3,7 +3,6 @@
// 템플릿 CRUD + 세션 상태 머신 (필드별 음성 입력)
import { EventEmitter } from 'events'
import { nanoid } from 'nanoid'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
import { getMainWindow } from '../windows/WindowManager'
@ -103,7 +102,7 @@ class DictationTemplateService extends EventEmitter {
create(params: CreateTemplateParams): DictationTemplate {
const template: DictationTemplate = {
id: nanoid(),
id: crypto.randomUUID(),
name: params.name,
description: params.description,
fields: params.fields,

View file

@ -2,7 +2,6 @@
// 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현.
import { eq, like, desc, count, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getDatabase } from '../db'
import { dictionary } from '../db/schema'
import type { Dictionary, NewDictionary } from '../db/schema'
@ -22,7 +21,7 @@ class DictionaryService {
add(params: DictionaryAddParams): DictionaryEntry {
const db = getDatabase()
const now = Date.now()
const id = nanoid()
const id = crypto.randomUUID()
const entry: NewDictionary = {
id,

View file

@ -5,7 +5,6 @@
import { EventEmitter } from 'events'
import path from 'path'
import fs from 'fs'
import { nanoid } from 'nanoid'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { getLocalSTTService } from './LocalSTTService'
@ -89,7 +88,7 @@ class FileTranscriptionService extends EventEmitter {
if (err instanceof D3ROError) throw err
}
this._jobId = nanoid()
this._jobId = crypto.randomUUID()
this._cancelled = false
this._tempDir = path.join(app.getPath('temp'), `d3ro-ft-${this._jobId}`)
fs.mkdirSync(this._tempDir, { recursive: true })

View file

@ -2,7 +2,6 @@
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getDatabase } from '../db'
import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema'
@ -21,7 +20,7 @@ class HistoryService {
create(input: Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>): HistoryEntry {
const db = getDatabase()
const now = Date.now()
const id = nanoid()
const id = crypto.randomUUID()
const entry: NewHistory = {
id,

View file

@ -2,7 +2,6 @@
// Phase 14.5: 회의 문서 템플릿 서비스 (electron-store 기반)
import { EventEmitter } from 'events'
import { nanoid } from 'nanoid'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -176,7 +175,7 @@ class MeetingDocTemplateService extends EventEmitter {
create(params: CreateMeetingDocTemplateParams): MeetingDocTemplate {
const template: MeetingDocTemplate = {
id: nanoid(),
id: crypto.randomUUID(),
name: params.name,
description: params.description,
templateType: 'custom' as MeetingDocTemplateType,

View file

@ -5,7 +5,6 @@
import { EventEmitter } from 'events'
import { BrowserWindow, Notification, dialog } from 'electron'
import fs from 'fs'
import { nanoid } from 'nanoid'
import { eq, desc, sql } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
@ -117,7 +116,7 @@ class MeetingModeService extends EventEmitter {
this._setState('recording')
this._meetingModeActive = true
const sessionId = nanoid()
const sessionId = crypto.randomUUID()
const now = Date.now()
this._sessionId = sessionId
@ -209,7 +208,7 @@ class MeetingModeService extends EventEmitter {
}
const memo: MeetingMemo = {
id: nanoid(),
id: crypto.randomUUID(),
sessionId: this._sessionId,
content,
timestampMs: Date.now() - this._sessionStartedAt,
@ -638,7 +637,7 @@ class MeetingModeService extends EventEmitter {
sendProgress(85)
const now = Date.now()
const docId = nanoid()
const docId = crypto.randomUUID()
db.insert(meetingDocuments).values({
id: docId,
sessionId: params.sessionId,

View file

@ -2,7 +2,6 @@
// Phase 10.3: 음성 메모 태그 시스템. 히스토리 항목에 태그를 부착하고 태그별 검색/내보내기를 지원한다.
import { eq, and, desc, count, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { app } from 'electron'
import path from 'path'
import fs from 'fs'
@ -60,7 +59,7 @@ class MemoService {
)
}
const id = nanoid()
const id = crypto.randomUUID()
const now = Date.now()
db.insert(memoTags)

View file

@ -5,7 +5,6 @@
import { EventEmitter } from 'events'
import path from 'path'
import fs from 'fs'
import { nanoid } from 'nanoid'
import { eq } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { getLocalLLMService } from './LocalLLMService'
@ -99,7 +98,7 @@ class RAGService extends EventEmitter {
}
const fileName = path.basename(filePath)
const docId = nanoid()
const docId = crypto.randomUUID()
// 텍스트 추출
let content: string
@ -265,7 +264,7 @@ ${context}`
const embedding = await this._embed(chunks[i])
db.insert(ragChunks).values({
id: nanoid(),
id: crypto.randomUUID(),
documentId: docId,
content: chunks[i],
embedding: JSON.stringify(embedding),

View file

@ -5,7 +5,6 @@
import { EventEmitter } from 'events'
import { exec } from 'child_process'
import { shell } from 'electron'
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { getLocalLLMService } from './LocalLLMService'
import { configGet } from './ConfigService'
@ -147,7 +146,7 @@ class VoiceActionService extends EventEmitter {
// 안전장치
if (!plan.safe) {
const entry: VoiceActionHistoryEntry = {
id: nanoid(),
id: crypto.randomUUID(),
userText: text,
plan,
executed: false,
@ -237,7 +236,7 @@ class VoiceActionService extends EventEmitter {
}
const entry: VoiceActionHistoryEntry = {
id: nanoid(),
id: crypto.randomUUID(),
userText,
plan,
executed: true,

View file

@ -2,7 +2,6 @@
// Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택.
// electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공.
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import type {
@ -224,7 +223,7 @@ class VoiceCommandService {
// 새 rule 생성
const maxPriority = this.rules.reduce((max, r) => Math.max(max, r.priority), -1)
this.rules.push({
id: nanoid(),
id: crypto.randomUUID(),
instructionId,
keywords,
enabled: true,
@ -260,7 +259,7 @@ class VoiceCommandService {
for (const entry of DEFAULT_KEYWORDS) {
this.rules.push({
id: nanoid(),
id: crypto.randomUUID(),
instructionId: entry.instructionId,
keywords: [...entry.keywords],
enabled: true,

View file

@ -3,7 +3,6 @@
// 싱글톤 + EventEmitter. 대화 히스토리 최근 10턴 유지.
import { EventEmitter } from 'events'
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { getLocalLLMService } from './LocalLLMService'
import { getLocalSTTService } from './LocalSTTService'
@ -213,7 +212,7 @@ class VoiceConversationService extends EventEmitter {
private async _processUserMessage(text: string): Promise<void> {
// 사용자 메시지 추가
const userMsg: ConversationMessage = {
id: nanoid(),
id: crypto.randomUUID(),
role: 'user',
content: text,
timestamp: Date.now(),
@ -229,7 +228,7 @@ class VoiceConversationService extends EventEmitter {
const llmService = getLocalLLMService()
const chatMessages = this._buildChatMessages()
const assistantMsgId = nanoid()
const assistantMsgId = crypto.randomUUID()
let accumulated = ''
const ttsSentences: string[] = []
let sentenceBuffer = ''