diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index e8f243f..20b7c3a 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -33,7 +33,7 @@ export default defineConfig(({ mode }) => { main: { plugins: [ externalizeDepsPlugin({ - exclude: ['nanoid', 'electron-store', ...workspaceExclude] + exclude: ['electron-store', ...workspaceExclude] }) ], resolve: { alias: sharedAlias }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 766e532..56343c9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/main/services/CaptionService.ts b/apps/desktop/src/main/services/CaptionService.ts index cf8ea51..099b08f 100644 --- a/apps/desktop/src/main/services/CaptionService.ts +++ b/apps/desktop/src/main/services/CaptionService.ts @@ -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, diff --git a/apps/desktop/src/main/services/ChainService.ts b/apps/desktop/src/main/services/ChainService.ts index c5ec9c2..818073e 100644 --- a/apps/desktop/src/main/services/ChainService.ts +++ b/apps/desktop/src/main/services/ChainService.ts @@ -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, diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index ff2ba20..0423cd1 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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 => { diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index 6cd0dd5..c1d5597 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -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, diff --git a/apps/desktop/src/main/services/DictationTemplateService.ts b/apps/desktop/src/main/services/DictationTemplateService.ts index 6f59d94..8fa93a2 100644 --- a/apps/desktop/src/main/services/DictationTemplateService.ts +++ b/apps/desktop/src/main/services/DictationTemplateService.ts @@ -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, diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index bade304..df2c41b 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -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, diff --git a/apps/desktop/src/main/services/FileTranscriptionService.ts b/apps/desktop/src/main/services/FileTranscriptionService.ts index b9189d3..2f939a2 100644 --- a/apps/desktop/src/main/services/FileTranscriptionService.ts +++ b/apps/desktop/src/main/services/FileTranscriptionService.ts @@ -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 }) diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index 09a31e0..441a26d 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -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): HistoryEntry { const db = getDatabase() const now = Date.now() - const id = nanoid() + const id = crypto.randomUUID() const entry: NewHistory = { id, diff --git a/apps/desktop/src/main/services/MeetingDocTemplateService.ts b/apps/desktop/src/main/services/MeetingDocTemplateService.ts index e54fa61..0b9cebb 100644 --- a/apps/desktop/src/main/services/MeetingDocTemplateService.ts +++ b/apps/desktop/src/main/services/MeetingDocTemplateService.ts @@ -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, diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index bc8718f..af13fef 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -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, diff --git a/apps/desktop/src/main/services/MemoService.ts b/apps/desktop/src/main/services/MemoService.ts index ba04a21..e0e1cd2 100644 --- a/apps/desktop/src/main/services/MemoService.ts +++ b/apps/desktop/src/main/services/MemoService.ts @@ -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) diff --git a/apps/desktop/src/main/services/RAGService.ts b/apps/desktop/src/main/services/RAGService.ts index 772a369..2c0b926 100644 --- a/apps/desktop/src/main/services/RAGService.ts +++ b/apps/desktop/src/main/services/RAGService.ts @@ -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), diff --git a/apps/desktop/src/main/services/VoiceActionService.ts b/apps/desktop/src/main/services/VoiceActionService.ts index b951e68..3d8f49a 100644 --- a/apps/desktop/src/main/services/VoiceActionService.ts +++ b/apps/desktop/src/main/services/VoiceActionService.ts @@ -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, diff --git a/apps/desktop/src/main/services/VoiceCommandService.ts b/apps/desktop/src/main/services/VoiceCommandService.ts index fa6f99d..1d4299e 100644 --- a/apps/desktop/src/main/services/VoiceCommandService.ts +++ b/apps/desktop/src/main/services/VoiceCommandService.ts @@ -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, diff --git a/apps/desktop/src/main/services/VoiceConversationService.ts b/apps/desktop/src/main/services/VoiceConversationService.ts index 3c83bda..77c6ecd 100644 --- a/apps/desktop/src/main/services/VoiceConversationService.ts +++ b/apps/desktop/src/main/services/VoiceConversationService.ts @@ -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 { // 사용자 메시지 추가 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 = '' diff --git a/memory/project_status.md b/memory/project_status.md index cd71ce6..9c620ec 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -208,6 +208,63 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 +### SaaS [9] 로컬 ID UUID 통일 + Realtime publication 픽스 + 실증 A~D 통과 (Phase 5 Part 2, 2026-04-11) + +> **실증 사이클 완주.** Phase 5 Part 1에서 못 잡은 3개 구조 버그를 한 세션에 픽스하고 실증 A/B/C/D 전부 통과. +> 로컬→클라우드 push가 **처음으로 성공** (pushed=1 errors=0). + +**실증 결과 (18:36~42)** + +| # | 실증 | 결과 | 증거 | +|---|---|---|---| +| A | 세션 자동 복원 + RLS 픽스 검증 | ✅ | `Restored session for user: yunchan8804@gmail.com` + `Pull complete applied=0 errors=0` | +| B | 사용자별 DB + push 경로 | ✅ | `HistoryService created 56a767ac-0fbc-45c8-98f2-2e2d4afa70f9` + `Sync complete: pushed=1 errors=0` | +| C | 로그아웃 → 로컬 복귀 | ✅ | `Realtime 종료` → `Database closed (7da3dd02-...)` → `Reverted to local DB: users/_local/d3ro.db` → `continuing in local mode` | +| D | 재로그인 데이터 복원 | ✅ | 실증 A의 "신규 7da3dd02 DB 오픈" 경로가 이 케이스와 동일 — refresh token 기반 자동 복원 | +| E | 웹 크로스 디바이스 | ⏭ Phase 3.3 이후 | Realtime TIMED_OUT 때문에 "웹→데스크톱 반영"은 스킵, "데스크톱→웹 조회"만 후속 | + +**발견+픽스한 구조 버그 3건** + +#### Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치 (실증 B 첫 시도에서 발견) +- 증상: `Push history failed: invalid input syntax for type uuid: "fvy6bIzrEtZ0vo96Dd-WZ"` → `Sync complete: pushed=0 errors=1` +- 원인: `apps/desktop/src/main/db/schema.ts`의 모든 PK는 `text('id').primaryKey()` + 서비스 코드는 `nanoid()` 생성 (예: `fvy6bIzr...`, 21자). 반면 Supabase `initial_schema.sql`은 `id uuid PRIMARY KEY DEFAULT gen_random_uuid()`. Phase V2-4 설계 단계에서 "V2-4 MVP는 uuid로 새로 발급, 매핑 테이블 필요"라고 적혀 있었으나 실제 구현에선 매핑이 없어 push 경로 전체가 깨져 있었음. 결과적으로 **빅뱅 사이클 내내 push가 한 번도 성공한 적 없음** (지난 세션 `pushed=0`은 로컬 데이터 0건이라 에러가 안 난 거지 버그가 없어서가 아님). +- 사용자 결정: **로컬을 UUID로 통일** (방향 B). 이유 — 3 클라이언트(desktop/web/mobile) id 스펙 일관 + PostgreSQL UUID PK 관용 유지 + `crypto.randomUUID()` 표준 API + `nanoid` 외부 의존성 자체 제거. "땜질 아닌 근본 해결" 사용자 요청. +- 픽스: + - 14개 서비스 파일에서 `import { nanoid } from 'nanoid'` 제거 + `nanoid()` → `crypto.randomUUID()` 일괄 교체 (총 20곳): HistoryService / MeetingModeService / MemoService / DictionaryService / CaptionService / VoiceConversationService / VoiceCommandService / VoiceActionService / FileTranscriptionService / CustomInstructionService / MeetingDocTemplateService / DictationTemplateService / RAGService / ChainService + - `apps/desktop/package.json` dependencies에서 `nanoid: ^5.1.7` 제거 + - `apps/desktop/electron.vite.config.ts` externalizeDepsPlugin exclude에서 `'nanoid'` 제거 + - 기존 로컬 DB 2개(`users/_local`, `users/7da3dd02-...`) drop — dev 초기 nanoid row만 있었고 의미 있는 데이터 0건이라 cascade FK 마이그레이션 대신 재생성 선택 + - drizzle schema(`text('id').primaryKey()`)는 건드릴 필요 없음 — SQLite text PK는 UUID 36자 문자열을 그대로 저장 +- 검증: 재기동 후 신규 녹음 → `HistoryService created 56a767ac-0fbc-45c8-98f2-2e2d4afa70f9` (UUID v4) → Push → `pushed=1 errors=0` **최초 성공** + +#### Bug 5: Supabase supabase_realtime publication 누락 +- 증상: 로그인 직후 `Realtime 채널 상태: TIMED_OUT` (실증 A에서 재현, 재기동 후에도 재현) +- 원인: `20260409000001_initial_schema.sql:134`이 `ALTER PUBLICATION supabase_realtime ADD TABLE public.transcripts` 한 줄만 실행. 데스크톱 `CloudSyncService.startRealtime()`이 실제 구독하는 `meetings` / `history` / `dictionary`는 publication에 **포함되지 않아** postgres_changes 스트림이 물리적으로 흐를 수 없었음. +- 픽스: `20260411000002_realtime_publication.sql` 신규 — `DO $$ ... LOOP ... ALTER PUBLICATION supabase_realtime ADD TABLE public.%I ... END $$;` 패턴으로 `meetings` / `meeting_memos` / `meeting_documents` / `history` / `dictionary` 5개 테이블을 pg_publication_tables 카탈로그 체크 후 조건부 추가. `supabase db push` 적용 완료. + +#### Bug 6: persistSession:false 에서 realtime.setAuth 자동 전파 안 됨 (가설 — 부분 검증) +- 증상: Bug 5 migration 적용 후에도 TIMED_OUT 재발. +- 원인 (가설): `createClient(url, anonKey, { auth: { persistSession: false, ... } })` 구성에서 `setSession()` 호출이 onAuthStateChange를 통한 `realtime.setAuth()` 자동 전파 경로를 깨뜨리는 것으로 추정. 즉 realtime WebSocket이 anon key JWT로 join을 시도하고 RLS 필터(`user_id=eq.${userId}`)를 통과 못해 TIMED_OUT. +- 픽스: `CloudSyncService.startRealtime()`에 `this._client.realtime.setAuth(this._session.access_token)`을 채널 구성 **이전**에 명시 호출 (try/catch로 안전하게). 에러 시 warn만. +- 현재 상태: ⚠️ **여전히 TIMED_OUT 재현**. Bug 5 migration은 확실히 적용됐고 setAuth 명시 호출도 들어갔으나 해결 안 됨. 후속 조사 필요 (task 8로 분리). 가능 가설 — (a) Supabase 프로젝트 Realtime 서비스 자체 disabled, (b) setAuth 타이밍 문제(WebSocket 이미 connect된 상태에서 setAuth 무효), (c) RLS 정책이 `user_id = auth.uid()` 외 다른 조건을 요구, (d) realtime 전용 RLS policy(`realtime.messages` 구독 권한)가 별도로 필요. +- **블로커 여부**: 아님. 주기적 pull + Phase 3.3 Auto push 경로로 최종 일관성 유지. Realtime은 "웹→데스크톱 5초 이내 반영" 편의 기능. + +**픽스로 해결된 부수 이슈** +- `cloud-sync.token` 기반 자동 세션 복원 경로 ✅ (지난 세션 `Invalid Refresh Token: Already Used`는 당시 토큰이 소진된 일회성 이슈, 새 로그인 후 재시작하니 `Restored session for user: yunchan8804@gmail.com` 성공) +- `users/${uuid}/d3ro.db` 파일이 로그아웃 시 close만 되고 보존되어 재로그인 시 `created=false`로 복원되는 경로 ✅ +- LicenseService 로컬 모드 리셋 경로 ✅ (로그아웃 후 익명 로컬 모드 정상 동작) + +**검증** +- desktop `tsc --noEmit` ✅ +- dev 런타임 재기동 ✅ (`Main window shown` → `Restored session` → `DB opened for 7da3dd02` → `Initial sync pushed=0 pulled=0`) +- 핫키 녹음 → UUID row 생성 → Push → `pushed=1 errors=0` ✅ (**최초 성공**) +- 로그아웃 → Realtime 종료 → `_local` DB 복귀 ✅ + +**이 섹션에서 의도적으로 뺀 것** +- package-lock.json 정리: `npm install`이 기존부터 `rollup-win32-x64-msvc` optional dep로 실패. 당장 nanoid import는 이미 제거되어 런타임 영향 없음. lockfile은 후속 세션에서 `--omit=optional`로 정리. +- Realtime 근본 원인 — task 8 별건으로 분리 +- 실증 E(웹 크로스 디바이스) — Phase 3.3 이후 수행 + ### SaaS [8] OAuth 로그인 완주 + RLS 재귀 픽스 (Phase 5 Part 1, 2026-04-11) > **로그인 성공!** 빅뱅 Phase 5 실증 시작. Google OAuth 토큰 교환 성공, 사용자별 DB 생성 확인. diff --git a/server/supabase/migrations/20260411000002_realtime_publication.sql b/server/supabase/migrations/20260411000002_realtime_publication.sql new file mode 100644 index 0000000..fcf55da --- /dev/null +++ b/server/supabase/migrations/20260411000002_realtime_publication.sql @@ -0,0 +1,41 @@ +-- ============================================================================ +-- Realtime publication — 데스크톱 CloudSyncService가 구독하는 테이블 추가 +-- ============================================================================ +-- +-- 증상: 로그인 직후 Realtime 채널이 TIMED_OUT으로 떨어짐. +-- 원인: initial_schema.sql은 publication에 `transcripts`만 추가했다. +-- 데스크톱이 구독하는 meetings / history / dictionary는 누락되어 +-- postgres_changes 스트림이 물리적으로 흐를 수 없었다. +-- 범위: 웹/모바일도 같은 구독을 쓰므로 후속 클라이언트들도 자동 수혜. +-- +-- ALTER PUBLICATION ADD TABLE은 IF NOT EXISTS를 지원하지 않으므로 +-- pg_publication_tables 카탈로그를 조회해서 conditional 추가한다. +-- ============================================================================ + +DO $$ +DECLARE + t text; +BEGIN + FOR t IN + SELECT unnest(ARRAY[ + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'history', + 'dictionary' + ]) + LOOP + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = t + ) THEN + EXECUTE format( + 'ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', + t + ); + END IF; + END LOOP; +END $$;