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:
parent
c8338ec458
commit
e55687d298
19 changed files with 132 additions and 38 deletions
|
|
@ -33,7 +33,7 @@ export default defineConfig(({ mode }) => {
|
|||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: ['nanoid', 'electron-store', ...workspaceExclude]
|
||||
exclude: ['electron-store', ...workspaceExclude]
|
||||
})
|
||||
],
|
||||
resolve: { alias: sharedAlias },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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 생성 확인.
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
Loading…
Add table
Add a link
Reference in a new issue