diff --git a/.codex/config.toml b/.codex/config.toml index e69de29b..5116fd6b 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -0,0 +1,6 @@ +[mcp_servers.code-review-graph] +command = "/Users/user/.pyenv/versions/3.12.13/bin/python3.12" +args = ["-m", "code_review_graph", "serve", "--repo", "/Users/user/Luie", "--auto-watch"] +cwd = "/Users/user/Luie" +enabled = true +startup_timeout_sec = 30 diff --git a/docs/architecture/startup-pipeline-dissection.md b/docs/architecture/startup-pipeline-dissection.md new file mode 100644 index 00000000..cbf234d9 --- /dev/null +++ b/docs/architecture/startup-pipeline-dissection.md @@ -0,0 +1,273 @@ +# Startup Pipeline Dissection — OS 중립 경량화 관점 전체 해부 + +생성: 2026-09-05. 목적: "Windows에서만 느린가?"가 아니라 "Luie가 OS에 상관없이 가볍게 도는 시스템인가?"에 답하기 위해 +시작 파이프라인 전체(메인 부트스트랩 → 데이터 계층 → 렌더러 부트 → 백그라운드 시스템)를 사실 기준으로 고정하고, +크로스플랫폼 위험 지도와 단계별 재구성 방향을 정의한다. + +규칙: 사실은 파일:라인 근거를, 추측은 `(추측)`으로 명시한다. 이 문서는 즉시 코드 변경을 지시하지 않는다. + +--- + +## 1. 현재 시작 파이프라인 전체 지도 (사실) + +### Phase 0 — 프로세스 진입 (`src/main/index.ts`) + +| 순서 | 단계 | 근거 | 비고 | +|---|---|---|---| +| 0-1 | dotenv 로드 → Electron import | `index.ts:2-8` | | +| 0-2 | 싱글 인스턴스 락 (실패 시 즉시 exit) | `index.ts:82-84`, `lifecycle/single-instance/singleInstance.ts:16-25` | `E2E_DISABLE_SINGLE_INSTANCE=1` 우회 | +| 0-3 | 파일 로거 구성 (userData/logs) | `index.ts:20-31` | | +| 0-4 | lazy import 7종 Promise.all (prismaEnv, lifecycle, domains/sync 36파일, utility bridge) | `index.ts:94-110` | 이 시점에 sync 도메인 전체가 모듈 그래프에 진입 | +| 0-5 | 크래시 리포트 등록 (로컬 전용, 네트워크 없음) | `index.ts:112`, `lifecycle/crash/crashReporting.ts` | | +| 0-6 | `luie://` 프로토콜 등록 — **settingsManager(electron-store+레거시 마이그레이션)를 lazy import로 당겨옴** | `index.ts:32-75` | darwin `open-url` 등록은 `index.ts:116-121` | +| 0-7 | `registerAppReady` + `onFirstRendererReady` 콜백에 `syncService.initialize()` 결합 | `index.ts:130-146` | | +| 0-8 | `whenReady` 후 유틸리티 프로세스 fork (fire-and-forget) | `index.ts:148-151`, `utilityProcessBridge/core.ts:57-114` | 헬스 ping 5초 타임아웃, 실패 시 lazy 재시작 | + +### Phase 1 — `app.whenReady()` 직렬 초기화 (`lifecycle/app-ready/appReady.ts:69-331`) + +**사실: 아래 3개 구간이 전부 직렬 await이고, 이것이 끝나야 첫 창이 생성된다.** + +1. `ensureBootstrapReady()` (`appReady.ts:73` → `lifecycle/bootstrap/bootstrap.ts:56-95`) + - `db.initialize()`: 임시 연결로 `ensurePackagedSqliteSchema`(drizzle migrate + 컬럼/인덱스 패치 + **MemoryChunk FTS trigram 재색인 + ChapterBody 백필** — `database/database/main/databaseSchemaBootstrap.ts:219-265`, `memoryChunkFtsMigration.ts:87-146`) → 실서비스 연결 생성(sqlite-vec 로드, PRAGMA 5종) → (packaged) seed + - 성공 시 `void triggerLlmfitInstall()` — **부팅 직후 GitHub 다운로드(60초 타임아웃) fire-and-forget** (`bootstrap.ts:23-33,78`) + - 캐시: Promise 병합 + isReady 캐시. 실패 시 재시도 무백오프 (`bootstrap.ts:57-92`) +2. `await registerAllIPCHandlers()` (`appReady.ts:284-285`) + - `handler/index.ts:2-39`가 analysis/world/project/sync/memory/llm/derivedJobs 등 **수백 모듈을 정적 import** (도메인 퍼사드가 re-export만 하므로 트리셰이킹 없음) +3. `await startupReadinessService.getReadiness()` (`appReady.ts:293`) + - **8개 검사 전부 순차 await** (`services/features/startup/startupReadinessService.ts:100-111`): safeStorage → userData 쓰기 프루브 → Documents 프루브 → sqlite connect(여기서 **cacheDb 최초 open + migrate**) → **`PRAGMA integrity_check`(readonly 별도 연결, 전체 DB 스캔, better-sqlite3 동기 = 메인 프로세스 블로킹)** (`:178-223`) → WAL/PRAGMA 검증 → Supabase 설정 → **Supabase 세션 네트워크 fetch(5초 타임아웃)** (`:311-425`, timeout 상수 `:27`) + - **결과 캐시/무효화 정책 없음 — 호출마다 전체 재검사** (`:50-66`) + +이후 분기(`appReady.ts:300-307`): `mustRunWizard`이면 위자드 창(즉시 show), 아니면 메인 창(deferShow). + +### Phase 2 — 위자드 창 (렌더러 콜드 부트 #1) + +- 위자드 창은 `out/renderer/index.html#startup-wizard`를 로드 — **메인 창과 동일한 풀 번들** (`manager/window/windowStartupWizard.ts:171-178`, `windowRouting.ts:51-59`) +- 렌더러 첫 paint는 `Promise.allSettled([initI18n(), setupRenderer()])` 이후 (`app/main.tsx:74-88`) + - `initI18n`은 **ko/en/ja 3개 로케일 전부 await** (`i18n/index.ts:42-51,79`) +- 위자드 진행 중 `setStartupWizardSize` → 메인에서 600~800ms easeOutCubic 보간, 렌더러는 **650ms 하드 대기** (`useStartupWizardState.ts:142,148` = 피드백 800ms + 확장 650ms, 합계 1.45초 하드코딩) +- 프로젝트 생성은 `project.create → chapter.create → chapter.update → markOpened → completeStartup` **순차 IPC 폭포** (`useStartupWizardState.ts:166-189`) + +### Phase 3 — 위자드 완료 → 메인 창 전환 + +- 렌더러가 `startup.getReadiness` → `startup.completeWizard` IPC (`useStartupWizardState.ts:91,99`) +- `completeWizard()`는 내부에서 **getReadiness 2회** (before/after, `startupReadinessService.ts:81,86`) +- **스타트업당 getReadiness 전체 재검사 최대 4~5회** (appReady 1 + 렌더러 1 + completeWizard 2 + macOS activate 시나리오) +- 전환 순서: `closeStartupWizardWindow()` **→ 그 후** `startMainWindowFlow` → 새 메인 창 생성(`windowManager.ts:133-257`, deferShow) → **`did-finish-load`까지 show 안 함** (렌더러-ready 유실 시 **8초 폴백** `appReady.ts:20,168-173`) → show 후에도 `BootstrapGate` (`app/App.tsx:478-496`) +- 즉 **동일 번들 콜드 부트 2회 + 창 0개 간극 + show 지연 3단** (`window-all-closed`에서 non-darwin은 quit이므로 간극이 짧아야 성립하는 구조 — `shutdown.ts:219-223`) + +### Phase 4 — 메인 화면 초기화 + +- `useProjectInit` 2단 병렬 폭포: `loadProjects()+loadSettings()` → currentProject 확정 후 5종 로드 (`app/../useProjectInit.ts:29,59-65`) +- `markOpened` 성공 시 `loadProjects()` 재호출 (`App.tsx:135-160`), `loadShortcuts()` (`App.tsx:210-213`) + +### Phase 5 — show 후 백그라운드 전개 (0~30초) + +| 시점 | 작업 | 근거 | +|---|---|---| +| did-finish-load | 메인 창 표시, derivedJobWorker.start(), syncService.initialize() | `appReady.ts:88-117,97-103` | +| sync initialize | connected && autoSync면 **즉시 startup sync run** (SQLite 번들 수집 + Supabase push/pull) | `syncService.ts:107-109` | +| +1.5s | 지연 유지보수(1회성): 미러 풀순회 flush, 스냅샷 프루닝, 고아 아티팩트 재귀 스캔, 경로 정산, 엔티티/파생/임베딩 purge(dry-run+실행 ×2) | `deferredStartupMaintenance.ts:8,13-83` | +| +수 초 | 임베딩 잡 있으면 llama-server 사이드카 spawn(bge-m3 ~418MB, CPU 4스레드) | `utility/llm/sidecarSupervisor.ts:125-246` | +| 영구 | derivedJobWorker **500ms 폴링** — 잡이 없어도 틱당 3~4개 쿼리, **메인 프로세스에서** | `derivedJobs/derivedJobWorker.ts:37-40,110-121` | + +### 정리: 시작 경로에 걸려 있는 비용의 성격 + +사실: 첫 창 도달 전에 발생하는 작업은 ① 전체 마이그레이션+재색인, ② 전체 DB integrity 스캔, ③ 수백 모듈 로드, ④ 네트워크 검사 2종(llmfit은 fire-and-forget이지만 대역 점유, Supabase 세션은 await)이다. 이 중 OS에 따라 증폭되는 것은 디스크 fsync(integrity/마이그레이션/seed), Defender 실시간 검사(임시 DB 연결·프루브 파일·`.luie` 생성), 네트워크 품질이다. — 즉 **병목은 Windows 고유가 아니라 "첫 창 전 필수 작업 과다"라는 구조 문제**이며, Windows가 증폭기 역할을 한다. + +--- + +## 2. OS 분기 지도 (사실) + +| 위치 | 분기 | 내용 | +|---|---|---| +| `index.ts:116` | darwin | `open-url` 딥링크 (그 외 OS는 second-instance argv) | +| `menu.ts:9,14,32` | darwin | 앱 메뉴 (non-darwin은 null) | +| `shutdown.ts:220` | non-darwin | window-all-closed → quit | +| `windowChrome.ts:63,81,125,133,145` | darwin / win32 | hiddenInset+trafficLight / titleBarStyle:"hidden" / trafficLight 좌표 재적용 버그 워커라운드 | +| `windowManager.ts:189` 등 | non-darwin | autoHideMenuBar | +| `ipcWindowHandlers.ts:271,288,306` | darwin | simpleFullScreen, setWindowButtonVisibility | +| `llmfitInstaller.ts:120,259`, `sidecarConstants.ts:3-8` | 플랫폼키 | win32-arm64, linux-arm64 사이드카/llmfit 자산 없음 | +| `modelDownloader.ts:370` | win32 | 바이너리 파일명 | +| 경로 정규화 6곳 (`pathValidation.ts:7,12`, `projectPathPolicy.ts:20` 등) | win32 | 대소문자 무시 정규화 | +| `appUpdateFeedUtils.ts:106-108`, `settingsDefaults.ts:133-135` | win32/darwin | 피드 판정, 단축키/메뉴바 기본값 | +| `cacheSchemaBootstrap.ts:66-83` | (환경) | better-sqlite3 로드 실패 시 `node:sqlite` 폴백 | + +사실: Linux 명시 분기는 0개 — Linux는 "non-darwin && non-win32" 공통 경로로 흐른다. + +--- + +## 3. 크로스플랫폼 위험 지도 — 재구성 시 건드리면 안 되는 것 + +1. **safeStorage blocking 검사** (`startupReadinessService.ts:113-126`): Linux keyring 미구성 환경에서 `isEncryptionAvailable()===false` → 위자드 강제. 이 검사를 경량화 명목으로 제거하면 Linux에서 첫 로그인이 조용히 실패한다. syncAuthService에는 fallback이 있지만 readiness와는 별개 (`syncAuthService.ts:98-170`). +2. **딥링크 이중 경로**: macOS `open-url` vs Windows/Linux `second-instance` argv. `registerSingleInstance`가 프로토콜 등록보다 먼저여야 두 경로가 모두 커버된다. Linux는 DE별 `setAsDefaultProtocolClient` 결과 편차 — 현재의 실패-무시 정책(`index.ts:49-62`) 유지 필요. +3. **창 0개 간극**: 위자드 close → 메인 create 사이 창이 0개가 되는 순간, non-darwin은 quit 위험이, darwin은 `activate` 핸들러 개입 위험이 있다 (`appReady.ts:321-331`, `shutdown.ts:219-223`). "먼저 만들고 닫기"로 순서를 바꾸면 macOS activate가 위자드를 재생성할 수 있다 — 간극 제거 설계가 선행돼야 한다. +4. **window-state 복원 + fitWorkArea**: electron-window-state는 메인 창 전용(`windowManager.ts:146,197`), 위자드 완료 직후 `fitWorkArea:true`가 저장된 state를 **의도적으로** 덮어씀(`:165-177`). 이 동작을 유지하지 않으면 전환 후 작은 창 잔존. +5. **macOS trafficLight 좌표 리셋 버그 워커라운드** (`windowChrome.ts:113-130`): 버튼 가시성 토글 경로는 토글 직후 좌표 재적용 필수 (electron#48463 계열). +6. **메뉴 쌍 계약**: non-darwin `Menu.setApplicationMenu(null)` + darwin만 메뉴바 기본 "visible" (`menu.ts:32-34`, `settingsDefaults.ts:135`). +7. **PRAGMA 계약**: bootstrap 임시 연결은 synchronous 미설정, 서비스 연결은 `FULL`(`databaseService.ts:90-94`), `checkSqliteWal`은 FULL(≥2)을 강제(`startupReadinessService.ts:251-256`). 초기화를 병렬화/재순서화하면 이 검증이 오탐한다. +8. **Prisma baseline + FTS tokenizer 재색인 안전망**: 매 부팅 실행이 "안전장치"다(`databaseSchemaBootstrap.ts:214-217`, `memoryChunkFtsMigration.ts:140-146`). 지연/제거 시 완료 플래그로 대체하지 않으면 기존 유저 DB가 영구 불일치. +9. **이중 프로세스 마이그레이션**: 메인 + 유틸리티 프로세스가 같은 WAL DB에 대해 각자 `ensurePackaged*Schema`를 실행(`utilityProcessMain.ts:21-26`) — busy_timeout으로 흡수되지만 유틸리티가 늦게 뜨면 SQLITE_BUSY 위험. 마이그레이션 소유권은 메인 단일화가 안전. +10. **`.luie` 패키지·경로 정책**: `.luie`는 단일 SQLite 컨테이너(`luieSqliteContainer.ts:89`), win32 경로 lowercase 키(`projectPathPolicy.ts:18-21`) — 정책 변경 시 대소문자 충돌 재발. +11. **런타임 바이너리 다운로드**: llama-server/llmfit은 런타임 다운로드(electron-builder extraResources에 LLM 자산 없음, `electron-builder.json:32-58`). macOS quarantine으로 미서명 바이너리 spawn 차단 가능 `(추측)`, 프록시/오프라인 미지원 `(추측)`, **win32-arm64/linux-arm64 자산 갭(확정)**. +12. **wizard 플래시 방지 계약**: 네이티브 backgroundColor `#212123` ↔ CSS `--color-wizard-bootstrap` 값 일치(`windowStartupWizard.ts:163-165`, `global.tokens.css:32-35`), 메인 창 테마 색 ↔ `--bg-app` 일치(`windowChrome.ts:12-32`). 창 생성 옵션을 바꿀 때 플래시 재발. + +--- + +## 4. 진단 — 왜 OS 무관하게 무겁고, Windows가 특히 증폭되는가 + +사실(구조): 현재 파이프라인은 4가지 아키텍처 성격을 갖는다. + +1. **UI-우선 원칙의 부재**: 첫 창 도달 경로에 DB 마이그레이션·전체 integrity 스캔·네트워크 검사·수백 모듈 로드가 직렬로 매여 있다. 첫 창은 "준비성 검사 결과"가 아니라 "그릴 수 있는 HTML"이다 — 그런데 결과가 나와야 그린다. +2. **감사(audit)가 상태가 아님**: getReadiness는 캐시 없이 호출마다 전체 재검사되고, 스타트업당 4~5회 돈다. 무결성·네트워크·fs 프루브는 "한 번의 판단"이지 매번 재수행할 계산이 아니다. +3. **메인 프로세스 만능 구조**: 500ms 폴링 워커, 유지보수 I/O, sync 수집, 패키지 export가 전부 메인 이벤트 루프에서 돈다. 워커 스스로 tick 지연을 warn하는 설계(`derivedJobWorker.ts:69-80`)는 이 경쟁을 이미 인정하고 있는 셈이다. +4. **창 = 프로세스 콜드 부트**: 위자드 닫기 → 메인 창 새로 생성 → did-finish-load 대기. 동일 번들 2회 콜드 파싱 + BootstrapGate 재대기. + +의견: 이 4가지가 해결되면 Windows의 증폭기(Defender·fsync·디스크)는 남더라도 체감 임계는 크게 내려온다. 반대로 이 4가지를 안 고치면 Windows 특정 튜닝은 표면만 긁는다. + +--- + +## 5. 목표 — "OS에 상관없이 가볍게 도는" 시스템 설계 + +### 원칙 (P0~P6) + +- **P0 첫-창-우선**: 첫 창 표시 경로에는 DB 쓰기·전체 스캔·네트워크·대형 모듈 로드를 두지 않는다. 첫 창은 설정 스토어(electron-store) 수준의 최소 상태만으로 그린다. +- **P1 준비성은 상태, 반복 감사가 아니다**: readiness는 1회 평가 → 캐시 스냅샷 → 설정/스키마 변경 시에만 무효화. integrity_check는 부팅 경로에서 제거하고 `quick_check` + 완료 플래그 기반 주기적 백그라운드 검사로 이동. +- **P2 창은 자산이다**: 위자드 진행 중 메인 창을 hidden으로 pre-warm하고, 전환은 "닫고 생성"이 아니라 "숨기고 보여주기/교체"다. 렌더러 프로세스·번들 파싱 비용을 1회로 수렴시킨다. +- **P3 작업 3분류 — 대화형 / 백그라운드 / 유지보수**: 메인 이벤트 루프는 대화형 전용. 백그라운드(derived job, 임베딩, sync 수집)와 유지보수(스냅샷 프루닝, purge, export)는 유틸리티 프로세스로. 폴링(500ms)은 이벤트 기반 wake(잡 enqueue 알림)로 대체. +- **P4 부팅 구간 네트워크 격리**: 첫 창 전/직후에는 외부 네트워크 호출(llmfit 설치, Supabase 세션, startup sync run)을 금지하고 idle 스케줄러(+백오프 재시도)로 이동. 오프라인·프록시 환경에서도 동일하게 동작해야 OS 무관성이 성립한다. +- **P5 렌더러 예산제**: 창별 엔트리(선례: `auth-result.html`), 기능 단위 청크 경계, 부트 그래프에서 export 계열 라이브러리(docx/jszip/dompurify/diff) 분리, i18n은 감지 언어 1개 초기 주입. 첫 paint 전 await 체인은 테마 시드 수준으로 최소화. +- **P6 데이터 계층 단일 소유**: 프로세스당 DB 연결 1회 수렴(현재 정상 부팅 기준 메인 DB 3회/캐시 DB 2회 + 유틸리티 2회), 마이그레이션 소유권은 메인 전담, 무거운 백필/재색인은 완료 플래그 기반 1회성 + 이후 지연 작업. 내구성 정책(`synchronous=FULL → NORMAL` + 스냅샷 미러 이중화)은 별도 결정안으로. + +### 단계별 실행 계획 (위험 오름차순) + +**Phase A — 측정·고정 (동작 변경 없음)** +- `Startup checkpoint` 로그 집계로 실측 병목 확정(Windows/macOS/Linux 각 1대). +- `pnpm build` 후 `out/renderer/assets` 청크 바이트 실측 → 본 문서 (추측) 항목을 사실로 치환. +- 시작 예산(SLO) 정의: `first-window ≤ Xms`, `first-interaction ≤ Yms` — check 스크립트 형태로 회귀 게이트화(기존 `check:writing-loop-regression` 패턴 준용). + +**Phase B — 저위험 단순화 (플랫폼 공통, 계약 불변)** +- readiness 캐시+무효화 도입, completeWizard의 2회 재검사 제거. +- 위자드 1.45초 하드코딩 대기 → 애니메이션 완료 이벤트/`data-animations` 연동. +- i18n 감지 언어 1개 초기 로드(`ensureLanguageResources` 경로는 이미 존재 — `i18n/index.ts:32-40`). +- manualChunks 재편: zod를 vendor-data에서 분리, reactflow CSS를 캔버스 청크로 이동. +- cacheDb 초기화를 첫 검사/파생 작업 시점으로 지연(구조적으로 self-hydrate 가능 — `chapterSearchCacheService.ts:350-390`). +- `integrity_check → quick_check` + 주기 백그라운드 검사(완료 플래그). + +**Phase C — 창·프로세스 전환 재구성 (macOS/Linux 위험 지도 3~5번 통과 필요)** +- 위자드 중 메인 창 pre-warm(hidden) → 전환 시 show/교체. 창 0개 간극 제거(quit/activate 개입 방지 설계 선행). +- show 트리거를 `did-finish-load` → `ready-to-show`+렌더러 첫 프레임 신호로 앞당김, 8초 폴백 단축. +- `handler/index.ts` 모듈 그래프 lazy 로딩(채널 등록기 경계 유지 — `check:ipc-*` 게이트 통과 필수). +- 유틸리티 프로세스를 요청 기반 lazy start로(현재 whenReady 즉시 fork — 유휴 사용자에게 낭비). + +**Phase D — 아키텍처 이행 (migration-guardrails 준용)** +- derivedJobWorker·유지보수 작업을 유틸리티 프로세스로 이동, 잡 큐 이벤트 기반 wake. +- 백필/재색인의 완료 플래그 기반 1회성 전환 + 마이그레이션 메인 전담화. +- 부팅 네트워크 격리(llmfit·sync·세션 체크 idle 이동) + 재시도/백오프. +- 내구성 정책 결정: `synchronous=FULL→NORMAL` + 미러/스냅샷 이중화 근거 문서화. + +### 각 단계 공통 게이트 + +- `pnpm run typecheck`, `pnpm run qa:core`, `check:ipc-*`, `check:utility-process-boundary`, `check:main-service-boundaries` 전부 통과. +- 플랫폼 스모크: Windows(`build:win:x64`), macOS(release-macos 워크플로), Linux(AppImage 빌드) 각 1회 부팅 checkpoint 로그 비교. +- 되돌림 수단: `LUIE_*` env 플래그로 신구 경로 병행(기존 관례: `LUIE_DISABLE_STARTUP_MAINTENANCE` 등). + +--- + +## 5a. 확정 원칙 (2026-09-05 합의 — 이후 작업의 판단 기준) + +1. **데이터 형식 불변**: 이 작업은 SQLite 스키마, drizzle 마이그레이션 파일, `.luie` 컨테이너 형식을 절대 변경하지 않는다. 변경 대상은 데이터 절차(초기화 시점, 검사 강도, 내구성 정책, 소유권)에 한정한다. 기존 유저 DB·`.luie`는 업그레이드 후 그대로 사용되어야 한다. +2. **단일 파이프라인 + 어댑터**: 시작 파이프라인 토폴로지는 OS 무관하게 하나다. OS 차이는 기존 어댑터 지점(windowChrome 옵션, sidecarConstants 자산 매핑, pathValidation 정규화 등)과 **기능(capability) 플래그**(safeStorage 가용, 애니메이션 지원, reduced-motion)로만 표현한다. OS 문자열(`win32`/`darwin`) 기반 파이프라인 분기를 새로 만들지 않는다. Windows 특유의 느림은 OS 분기가 아니라 checkpoint 실측 기반 스케줄링(지연, idle 트리거)으로 흡수한다. +3. **UI 디자인 불변**: 첫 paint 이후의 화면 디자인·토큰·컴포넌트는 변경하지 않는다. 변경 가능한 것은 창 전환 타이밍(위자드→메인)과 부트 순서이며, 창 배경색 계약(`--color-wizard-bootstrap` ↔ 네이티브 backgroundColor, `--bg-app` ↔ `resolveWindowBackgroundColor()`), trafficLight 좌표 재적용, window-state 복원, `fitWorkArea` 동작은 보존 계약이다. +4. **롤백 가능성**: 각 동작 변경은 `LUIE_*` env 플래그로 신구 경로 병행(기존 관례: `LUIE_DISABLE_STARTUP_MAINTENANCE` 등 준용)하며, 단계별로 `pnpm run typecheck` + `qa:core` + `check:ipc-*`/`check:utility-process-boundary` 게이트를 통과한다. + +## 5b. Phase A 실측 결과 (2026-09-05, Windows 11 — 추측을 사실로 치환) + +### 실측 1: 렌더러 부트 페이로드 (`pnpm build` 후 `out/renderer`) + +사실: `index.html`이 modulepreload하는 부트 JS는 **18개 파일 1,111.1 KB** + 부트 CSS(index) **162.5 KB**. 위자드 창/메인 창 모두 동일 페이로드를 로드한다. + +| 청크 | 크기 | 비고 | +|---|---|---| +| vendor-react | 408.1 KB | **@tiptap/react·@tiptap/core가 포함됨 — 버그** | +| vendor-prosemirror | 340.9 KB | **부트에 정적 preload — 버그** | +| vendor-data | 128.7 KB | zod가 전체 청크(supabase/jszip/docx/dompurify/diff)를 부트로 당김 — 확인됨 | +| index (엔트리) | 61.4 KB | | +| vendor-ui | 57.2 KB | | +| chapterStore/es2015 등 기타 | ~115 KB | | + +원인 규명(사실): `electron.vite.config.ts`의 manualChunks에서 `id.includes("/react/")` 매칭이 `node_modules/@tiptap/react/...` 경로와도 일치한다(@tiptap/react가 vendor-react로 편입). @tiptap/react는 @tiptap/core와 prosemirror를 정적 import하므로 vendor-react → vendor-prosemirror 정적 의존 엣지가 생기고, 둘 다 부트 preload에 포함된다. 즉 **에디터를 렌더하지 않는 위자드·export 창도 ~750KB의 에디터 라이브러리를 파싱한다.** (추가: `/i18next` substring 매칭은 i18next-browser-languagedetector까지 vendor-react에 흡수 — 무해하나 segment 단위 매칭이 필요) + +### 실측 2: 부팅 checkpoint 로그 (`%APPDATA%\luie\logs\luie.log`, 3회 부팅) + +**packaged(2026-09-04, isPackaged:true)** — 구조적 진단과 별개로 이 부팅은 빠르다: +- App ready +77ms → IPC handlers ready +431ms → readiness 평가 +573ms → **위자드 창 요청 +698ms, 렌더러 로드 완료 +868ms** +- 소규모 DB에서는 integrity/migrate 비용이 수십~수백 ms 수준. DB 성장 시 이 구간이 확대된다(정적 분석 결론 유지). + +**dev(2026-09-05, pnpm dev)** — 체감 슬로우의 주범은 dev 서버 콜드 변환: +- 위자드 렌더러 로드 완료 **+29.5s / +41.9s** (Vite가 대형 모듈 그래프를 on-demand 변환하는 콜드 비용) +- 로드 후 첫 프레임 painted +794ms (i18n 3-로케일 + setup 대기 — 섹션 1 Phase 2 진단과 일치) +- 위자드 완료 → 메인 창 표시: +2.3s (Vite 웜 상태, 콜드 부트 #2의 정황 증거) +- 그 외 실측: `llmfit install attempted during bootstrap`(부팅 중 네트워크 확인), 유틸리티 프로세스 헬스체크 실패 1회(자동 재시작 없음 — utilityStarted:false), deferred maintenance 20ms(소규모 데이터) + +**해석(의견)**: prod 첫 창은 1초 내외로 양호하지만, ① dev 환경 체감 지연은 렌더러 모듈 그래프 규모 문제(=부트 페이로드 문제와 동일 원인)이며, ② prod는 DB/데이터 성장 시 Phase 1 직렬 구간이 확대되는 구조적 노출이 그대로 있다. 부트 페이로드 축소는 dev 콜드 변환 시간도 직접 줄인다. + +### SLO 기준안 (Phase A-3, 실측 기반) + +| 지표 | 현재 | 1차 목표(Phase B 후) | 게이트 방식 | +|---|---|---|---| +| 부트 JS 페이로드 (modulepreload 합계) | 1,111 KB | ≤ 700 KB (prosemirror/vendor-data 분리 시 예상) | `check:render-boot-budget` 스크립트 | +| 부트 CSS | 162.5 KB | ≤ 120 KB (reactflow CSS 분리 후) | 동일 스크립트 | +| prod: 첫 창 표시 (bootstrap→wizard/main shown) | ~0.9s | ≤ 1.0s 유지 (DB 성장해도) | checkpoint 로그 | +| prod: bootstrap→IPC ready | 354ms | DB 성장 구간 분리 후 소폭 유지 | checkpoint 로그 | +| dev: 위자드 로드 완료 | 29~42s | 모듈 그래프 축소에 비례 감소 | 수동 관측 | +| readiness 전체 재평가 횟수/부팅 | 최대 4~5회 | 1회 + 무효화 | 로그 계수 | + +## 5c. Phase B 진행 결과 (2026-09-05) + +| 항목 | 상태 | 결과 | +|---|---|---| +| B-1 manualChunks segment 매칭 수정 + zod 분리 | **완료** | 부트 JS 1,111.1 → **573.1 KB(-48%)**. vendor-prosemirror(341KB)·vendor-data(129KB)가 부트 preload에서 제거됨. vendor-react 408→247.7KB(순수 react 스택). `check:render-boot-budget` 예산 600KB로 하향 고정 | +| B-2 i18n 감지 언어 1개 로케일 초기 로드 | **완료** | `initI18n`이 감지 언어+fallback(ko)만 주입(`i18n/index.ts` `loadInitialLocaleResources`). 나머지 로케일은 `ensureLanguageResources` 경로로 지연. `check:i18n-parity` 통과 | +| B-3 readiness 캐시 | **완료** | `startupReadinessService`에 5초 TTL 캐시 + 진행 중 promise 병합 + `setStartupCompletedAt` 시 무효화. 부팅당 전체 재평가 최대 4~5회 → 1~2회 | +| B-4 위자드 리사이즈 블라인드 대기 제거 | **완료** | `WINDOW_SET_STARTUP_WIZARD_SIZE` 핸들러가 애니메이션 완료까지 await해 반환(중단 시 이전 awaiter resolve 보장). 렌더러는 IPC 완료를 직접 대기 — 650ms 블라인드 타이머 제거, 애니메이션 꺼짐 시 즉시 진행. 완료 피드백 800ms는 의도된 UX라 유지 | +| B-5 cacheDb 지연 + quick_check 전환 | 보류 | 데이터 절차 변경 — 별도 배치로 진행(§3 위험 지도 7~9번 검토 후) | + +검증: `check:render-boot-budget` 통과(573.1/600KB), `check:i18n-parity` 통과. +`startupReadinessService`·`ipcWindowHandlers`·`startupWizard*` 테스트의 실패 4건은 stash 기준선에서도 동일한 **사전 존재 실패**로 본 작업과 무관. `typecheck`의 `Sidebar.tsx:157 handleRenameProject` 미사용 에러 1건도 사전 존재(본 작업 파일 아님). + +## 5d. Phase C-1/C-2 진행 결과 — 첫-창-우선 (2026-09-05) + +사용자 실측: "로딩중... 이 뜰 때까지 5초 이상" — 원인은 위자드 창 생성이 readiness 전체 평가(integrity 스캔 + Supabase 세션 네트워크 최대 5초)까지 직렬 대기하는 구조(§1 Phase 1). + +**C-1 첫-창-우선 재구성** (`lifecycle/app-ready/appReady.ts`): +- 위저드 필요 여부를 `isStartupWizardForced() || !completedAt`만으로 1차 판정(cheapNeedsWizard)하고, 참이면 **IPC 핸들러 등록 직후 위자드 창을 즉시 생성**한다. bootstrap·readiness는 백그라운드로 진행되고, 위저드 UI는 기존대로 `startup.getReadiness` IPC(B-3 캐시와 병합)로 결과를 받아 단계를 연다. +- blocking 검사 실패는 이 1차 판정에 위저드를 "추가"할 뿐 반대로 만들지 않으므로, 놓친 실패는 전체 평가와 위저드 완료 게이트가 보정한다. completedAt이 있는 정상 부팅은 기존대로 readiness 평가 후 메인 창 흐름을 유지해 라우팅 의미를 보존했다. +- `triggerFirstRendererReady`는 캡처된 bootstrap 상태 대신 캐시된 `ensureBootstrapReady` promise를 다시 읽어, 창 표시가 bootstrap 완료보다 빠른 위자드 경로에서도 derived job worker 시작이 유실되지 않게 했다. + +**C-2 readiness 검사 병렬화** (`startupReadinessService.runChecks`): +- 8개 검사를 직렬 await에서 병렬로 전환. `checkSqliteConnect`(db.initialize = DB 파일 생성) 완료 후 integrity/WAL이 시작되는 체인만 유지해 첫 실행에서 DB 파일이 없는 상태의 integrity 오탐을 방지한다. 반환 순서는 원본 유지(진단 UI 표시 순서 보존). 총 대기 = 합계 → 최댓값(네트워크 5초는 integrity 스캔과 겹침). + +**실측(dev, 강제 위자드 부팅)**: Main bootstrap → App ready +75ms → IPC ready +262ms → **위자드 창 생성 +431ms** (기존: readiness 평가가 +0.8s~5.8s를 선행한 뒤에야 창 생성). 백그라운드 readiness 완료 +784ms. 참고: dev에서 렌더러 첫 프레임까지의 잔여 시간은 Vite dev 서버의 모듈 콜드 변환 지배 — B-1의 모듈 그래프 축소가 이 비용도 함께 줄인다. + +검증: typecheck 통과(사전 존재 `Sidebar.tsx` 에러 제외), `appReadyDeferredWorker`·`startupReadinessService`·`deepLink` 테스트 6건 통과. + +## 5e. Phase C-3/C-4 진행 결과 (2026-09-05) + +**C-3 위자드→메인 "교체" 전환** (`appReady.ts`): +- 기존 "위자드 닫기 → 메인 창 생성 → 렌더러 로드 → 표시"는 창 0개 빈 화면 구간을 만들었다(non-darwin quit 위험 + 콜드 부트 동안 공백). 이제 `onWizardCompleted`에서 `wizardClosePending = true` 후 메인 창을 deferShow로 생성하고, **메인 렌더러 준비(did-finish-load 또는 폴백 타이머) 시점에 show와 함께 위자드를 닫는다.** 8초 폴백도 같은 경로를 타므로 렌더러 로드 실패 시에도 위자드가 닫힌다. +- `closeStartupWizardWindow`는 이미 destroyed 가드가 있어 중복 닫기 안전. 위자드↔메인 공존 구간이 생기므로 `ipcWindowHandlers`의 "공존 없음" 주석을 실제 동작에 맞게 갱신(포커스 판정 우선, 위저드 폴백은 차순 — 로직 자체는 변경 없음). + +**C-4 유틸리티 프로세스 lazy start** (`index.ts`): +- whenReady 즉시 fork 제거. bridge의 `embed/askRagQa/generateText`는 이미 `start()`를 자체 보장하고 `getSidecarStatus`는 미기동 시 `stopped`를 반환하므로, 첫 LLM/임베딩/RAG 요청 시점에 fork된다. 임베딩·LLM 미사용 부팅에서 fork+헬스체크(≤5s)와 프로세스 상주 제거. + +**실측(dev, 정상 부팅)**: App ready +94ms → IPC ready +259ms → readiness 평가 +478ms → 메인 창 요청 +638ms. 로그에 "utility process" 체크포인트가 사라진 것으로 lazy 전환 확인. 참고: dev에서 8초 폴백이 발화한 것은 Vite 모듈 콜드 변환(수십 초) 때문으로, prod의 868ms와는 무관한 dev 전용 현상 — B-1의 부트 그래프 축소가 이 변환량도 줄인다. + +검증: typecheck 통과(사전 존재 에러 제외), 관련 테스트 통과(유일 실패 1건은 사전 존재 `INVALID_INPUT malformed chapter id`). 교체 전환의 위자드 전 구간(완료→메인 진입)은 인터랙티브 스모크 권장. + +## 6. 열린 질문 (결정 필요) + +1. integrity/마이그레이션 안전망을 "완료 플래그"로 바꿀 때, 기존 유저 DB의 어떤 상태를 신뢰할 것인가(버전 마커 기준). +2. `synchronous` 내구성 정책 — 스냅샷 미러를 공식 내구성 계층으로 승격할 것인가. +3. 유틸리티 프로세스로 이동할 작업의 경계(상태 브로드캐스트는 메인 전용이라 sync 수집은 분리 난이도 높음). +4. 위자드를 메인 창 내부 라우트로 통합할 것인가(창 2회 부트 제거의 가장 확실한 방법이지만 window-state/activate 개입 설계 필요). +5. win32-arm64/linux-arm64 사이드카 자산 지원 여부(배포 정책 문제). diff --git a/docs/quality/cross-platform-performance-research-2026-09-09.md b/docs/quality/cross-platform-performance-research-2026-09-09.md new file mode 100644 index 00000000..62e99dc7 --- /dev/null +++ b/docs/quality/cross-platform-performance-research-2026-09-09.md @@ -0,0 +1,479 @@ +# 크로스플랫폼 성능 사전조사 — 2026-09-09 + +상태: 사전조사 완료 / **P0 계측 구현 진행 중**. 조사 소스 기준 `bbc2842d`. 현재 세션 기기는 사용자 제공 정보 기준 MacBook Air M4, 16GB다. 후속 보고: Windows 11 x64 실기(i5-9400F, RX 570, 16GB, SSD)는 Wizard·main window 약 3초이며 portable·개발 실행 모두 비슷하다. Apple CPU로 표시된 Windows 11 VM(8GB, SSD)은 5초 이상이며 guest OS/app arch와 가상 GPU는 아직 미확정이다. Mac 약 1초를 포함해 모두 같은 조건의 계측값은 아니다. 실행 방법·ISTQB 관점 테스트·실패·미검증 항목은 [P0 테스트 기록](startup-performance-p0.md)에 계속 기록한다. + +**후속 사용자 결정 — 아래의 기존 sidecar 운용·모델 예산 제안보다 우선한다:** 모든 지원 OS/아키텍처에서 생성용 LLM sidecar와 **BGE-M3 임베딩용 sidecar를 모두 비활성화할 예정**이다. RAG·Memory Engine 및 이를 담당하는 기존 utility process는 유지한다. 이번 변경은 문서 기록이며 제품 코드에서 비활성화를 실행한 상태가 아니다. 임베딩 없는 검색 범위·RAG 응답 생성 경로는 별도 검증하고, 외부 API로 자동 전환하거나 기존 모델·벡터·원고 데이터를 삭제하지 않는다. 상세 범위와 완료 조건은 §11을 따른다. + +**실행 범위:** P0~P3만 활성 계획이다. P4는 **Deferred**이며 완료를 위한 필수 단계가 아니다. 제품에는 CPU/RAM 기반 자동 튜닝·기기 등급·상시 자원 sampler를 추가하지 않는다. §11.3~11.5는 플랫폼 사실, OS 이벤트, 명시적 진단, 기존 job 규칙만 정의한다. 이전 빌드/테스트 결과는 조사 기준선이며 이 설계의 구현 검증 결과가 아니다. + +**2026-09-10 후속 구현:** Windows 실기 접근 전에도 공통 경로를 개선하라는 사용자 승인으로 preview 2개의 lazy 로딩을 적용했다. 첫 Wizard 정적 JS 범위 1,540,157→708,060 bytes; Windows 성능 개선은 미검증이다. 세부 전후 표본·테스트·기존 실패·트레이드오프는 [P1-1 기록](startup-performance-p1.md)을 따른다. P0 전체 완료로 표시하지 않는다. + +**P1-2 모션 후속:** Wizard의 800ms/16ms native resize 보간을 제거하고 목표 bounds를 0~1회 적용하도록 변경했다. Intro·전체 preview fade 제거, 일반 단계는 200ms opacity-only. 회귀 테스트·실행 결과와 수동 resize/Windows 미검증 경계는 [P1 §6](startup-performance-p1.md#6-p1-2--위저드-애니메이션프로그램-리사이즈-비용-축소)에 기록한다. + +**현재 resize 정책:** 후속 사용자 요청에 따라 macOS만 Electron 기본 창 애니메이션을 사용한다(앱 애니메이션 off 또는 OS 동작 줄이기 on이면 즉시 적용). Windows는 실측 전까지 즉시 적용을 유지하며 JS 보간은 복원하지 않는다. [P1 §7의 정책·추가 검증](startup-performance-p1.md#7-p1-2-조정--macos-창-전환만-애니메이션-허용)을 우선한다. + +**배경 전환 추가:** 위저드 resize 중에는 배경만 렌더하고, macOS `resized` 완료(누락 시 2초 안전 복구) 후 다음 preview를 마운트한다. Windows는 별도 이벤트 대기 없이 진행한다. 새 채널 없이 기존 IPC를 사용하며 오류·닫힘·중복 요청을 정리한다. [P1 §8의 테스트·Mac 실측](startup-performance-p1.md#8-p1-2-조정--resize-중-배경만-표시하고-완료-후-preview-마운트)이 최신 완료 대기 계약이다. + +**최신 Windows 정책:** 사용자 요청으로 Windows에도 200ms/최대 13단계의 제한된 창 보간을 활성화했다. 배경만 표시한 뒤 최종 bounds 적용 후 preview를 마운트한다. Mac은 native 유지, OS 동작 줄이기/rich-animation 비권장/앱 off는 즉시 적용한다. [P1 §9](startup-performance-p1.md#9-windows-애니메이션-활성화--작업-예산과-lifecycle-제한)가 앞선 Windows 즉시 적용 정책을 대체하며, Windows 실기 성능은 여전히 미검증이다. + +메인 조사와 서브에이전트 3개가 시작 경로, 최신 스택, OS 자원, IPC/utility를 분담했다. 기존 [성능 기준 v1](performance-standard-v1.md), [IPC 감사](performance-audit-2026-09-08/ipc.md), [main/플랫폼 감사](performance-audit-2026-09-08/main-platform.md), [시작 파이프라인 문서](../architecture/startup-pipeline-dissection.md)를 현재 소스와 대조했다. 최초 조사 이후 code-review-graph MCP를 CLI로 연결하고 HEAD까지 갱신하여, 같은 3개 조사 영역을 그래프 우선으로 재검증했다. 그래프의 누락·모호한 연결은 소스·새 production 빌드·기존 테스트로 보완했다(§10). §1~10 사전조사 당시에는 제품 코드·의존성·OS 설정 변경이나 앱 실행·실기 벤치마크를 하지 않았다. 후속 P0에서는 테스트 전용 실행기를 추가해 Mac 계측을 시작했으며 그 결과/실패는 별도 P0 문서에 기록한다. MCP 연결 설정은 별도 선행 작업이다. + +## 1. 결론과 버전 기준 + +우선순위는 **동일 조건의 시작 시간 측정 → 첫 화면 이전 작업 축소 → 기존 작업의 실행량·수명 관리 → 실기 회귀 검증**이다. GPU/메모리 진단은 원인 구분에 사용하며 기기별 자동 정책으로 연결하지 않는다. 현재 근거로 Windows의 5초를 GPU, GC, Defender 또는 네트워크 하나의 문제라고 확정할 수 없다. + +| 항목 | 저장소 선언 및 로컬 설치 확인 | 공식 공개 자료 확인 | +| --- | --- | --- | +| Electron | 44.2.0 | 최신 stable 44.3.0, 2026-09-08 공개 | +| Chromium / 내장 Node | 44.2.0의 공개 조합: 152.0.7977.76 / 24.20.0 | 44.3.0: 152.0.7977.78 / 24.20.0 | +| Electron 42 계열 | 사용자가 언급한 버전, 실제 Windows 바이너리는 미확인 | 유지보수 42.11.3: Chromium 148 / Node 24.19.0 | +| React / React DOM | 19.2.8 | 공식 19.2.8 릴리스 확인 | +| Tailwind CSS | 4.3.3 | 공식 latest 4.3.3 확인 | +| Vite / electron-vite | 8.2.2 / 5.0.0 | 이번 조사에서 최신 여부 별도 판정하지 않음 | + +근거: [Electron stable 목록](https://releases.electronjs.org/release?channel=stable), [React 19.2.8](https://github.com/react/react/releases/tag/v19.2.8), [Tailwind 릴리스](https://github.com/tailwindlabs/tailwindcss/releases). 로컬 버전은 `package.json`과 설치 패키지의 `package.json`을 읽어 확인했다. 내장 Chromium/Node는 릴리스 표의 조합이며 실제 프로세스의 `process.versions`를 실행해 검증한 값은 아니다. + +42와 44는 엔진이 다르므로 서로 다른 버전의 Mac/Windows 결과를 OS 차이로 해석하지 않는다. 먼저 현재 44.2.0 기준선을 만들고, 동일 소스의 44.3.0 비교를 별도 실험으로 둔다. 업그레이드만으로 5초 문제가 해결된다는 근거는 없다. + +추가 확인: 44.3.0은 일부 Windows 시스템에서 `app.getGPUInfo('complete')`가 GPU process를 수초간 멈추게 하는 문제를 수정했다. 로컬 `src/main`, `src/preload`, `src/renderer/src`, `scripts` 검색에서는 해당 API의 직접 호출을 찾지 못했다. 따라서 현재 증상의 원인으로 확정하지 않으며, 새 진단 코드가 이 호출을 부트에 추가하지 않도록 제한한다. 의존성 내부 호출까지 없다는 증명은 아니다. [44.3.0 릴리스](https://releases.electronjs.org/release/v44.3.0) + +## 2. 현재 StartupWizard 경로에서 확인한 사실 + +```text +외부 실행 요청 + → [portable 사용 시 launcher·압축 해제·OS 검사] + → Electron 시작·main 모듈 평가 + → app ready / DB bootstrap 시작 / 전체 IPC 등록 + → 첫 사용자: 위저드 창 생성(show: true), readiness는 별도 진행 + → renderer 정적 모듈 평가 + → i18n + settings.getEditor 완료 대기 + → root.render → Suspense fallback + → StartupWizard 동적 import와 그 정적 의존성 평가 + → IntroStep 표시 → 시작 버튼 입력 → ModelStep 표시 + +기존 완료 사용자: 전체 readiness 완료 → 위저드 또는 메인 창 생성 +위저드 완료: readiness/설정 저장 → 메인 창 생성·로드 → 표시 후 위저드 닫기 +``` + +이는 논리적 의존 순서다. DB 초기화, 네트워크, renderer 로드는 일부 겹치며 `async` 함수 안의 동기 작업은 main 이벤트 루프를 계속 점유할 수 있다. + +| 발견 | 현재 근거 | 의미 / 검증할 가설 | +| --- | --- | --- | +| 첫 사용자의 위저드는 readiness보다 먼저 생성됨 | `src/main/lifecycle/app-ready/appReady.ts:324`, `window/windowStartupWizard.ts:161` | 새 설치 첫 IntroStep의 5초를 readiness 네트워크 timeout으로 바로 설명할 수 없음 | +| 창 생성 전에 전체 IPC 등록을 기다림 | `appReady.ts:312` | handler import·등록과 동시 bootstrap의 CPU/I/O 비용 측정 필요 | +| React 렌더 전에 i18n과 설정 IPC 대기 | `src/renderer/src/app/main.tsx:74`, `app/setup.ts:118` | 네이티브 배경이나 빈 창이 보여도 실제 위저드는 아직 없을 수 있음 | +| 위저드 모듈이 모든 단계를 정적 import | `features/startup/components/StartupWizard.tsx:5` | 첫 intro에 불필요한 후속 단계 코드가 동적 import 완료를 늦출 후보 | +| 테마·레이아웃 단계가 실제 Editor를 정적 의존 | `steps/ThemeStep.tsx:8` → `preview/WizardEditor.tsx:2` → `features/editor/components/Editor.tsx`; `LayoutStep` → `LayoutLivePreview`도 합류 | 그래프·소스·새 빌드에서 확인. intro에서 Editor가 mount되는 것은 아니지만 editor 코드가 wizard 정적 의존성에 포함됨 | +| 공통 프로젝트 초기화가 windowMode와 무관 | `app/App.tsx:133`, `features/project/hooks/useProjectInit.ts:22` | bootstrap ready 후 위저드에서도 설정/프로젝트 조회와 구독 경쟁. graph/export의 실제 필요 데이터는 따로 확인하고 좁혀야 함 | +| 설정 조회 경로가 여러 개 | `app/setup.ts:118`, `useProjectInit` → editor store, `useStartupWizardState.ts:43` | 중복 조회 후보. 실제 횟수는 창 모드·bootstrap·개발 StrictMode에 따라 달라짐 | +| 기존 사용자는 전체 readiness를 기다림 | `appReady.ts:342`, `startupReadinessService.ts:139` | 기존 사용자 재시작 지연은 첫 intro와 별도 원인 분석 필요 | +| readiness는 동기 integrity 검사와 조건부 5초 HTTP 검사를 포함 | `startupReadinessService.ts:223`, `:356` | 로그인 연결/userId/token/config가 없으면 HTTP 검사는 실행되지 않음. 동기 DB 검사는 별도 main CPU/I/O 후보 | +| Windows 배포 타깃은 portable | `electron-builder.json`의 `win.target` | `nsis` 설정 블록이 있어도 현재 설치본 배포를 뜻하지 않음 | +| 임베딩 상태 바 초기화는 상태 조회 | `features/startup/stores/modelInstallStore.ts:39` | 이 코드 자체가 자동 다운로드·모델 로드를 시작한다고 단정하지 않음. 다운로드는 `startDownload`에서 요청 | +| DB bootstrap 뒤 llmfit 설치 확인을 비차단 호출 | bootstrap의 `triggerLlmfitInstall` → `src/main/infra/llm/llmfitInstaller.ts:125` | 지원 플랫폼에서 기존 설치 상태보다 release 네트워크 조회가 먼저다. 필요시 다운로드·압축 해제까지 시작되어 첫 화면과 자원 경쟁 가능. 모델 자동 로드나 readiness의 직접 await와는 다름 | +| DB 시작 작업이 integrity 검사에 한정되지 않음 | `src/main/database/main/databaseService.ts:73`, `databaseSchemaBootstrap.ts:195` | migration·legacy 보정·FTS/backfill·native SQLite 및 sqlite-vec 로드를 분리 계측. 새 DB/현재 DB/구버전 DB의 비용이 다름 | + +### 새 production 빌드로 확인한 로딩 범위 + +동일 HEAD에서 기존 electron-vite를 임시 출력 경로로 빌드했다. 생성 JS의 정적 import/re-export만 재귀 추적하고 파일을 중복 제거한 결과다. 동적 import는 별도 진입점으로 취급했다. + +| 범위 | 고유 파일 수 | 비압축 산출물 크기 | +| --- | ---: | ---: | +| HTML에서 시작하는 부트 JS의 정적 의존성 | 17 | 586,895 bytes / 573.1 KiB | +| HTML CSS | 1 | 170,001 bytes / 166.0 KiB | +| StartupWizard 청크의 정적 의존성 전체 | 30 | 1,533,397 bytes / 1,497.5 KiB | +| 부트 JS 대비 wizard 추가 JS | 13 | 946,502 bytes / 924.3 KiB | + +wizard 의존성에 부트 JS 17개가 모두 포함되므로 두 집합의 합집합도 1,497.5 KiB다. 추가분의 큰 항목은 ProseMirror 349,124 bytes, TipTap 263,591 bytes, `useShortcuts` 100,555 bytes, workspace vendor 82,958 bytes, `EditorToolbar` 43,209 bytes다. 기존 부트 게이트는 JS 600 KiB/CSS 170 KiB 이내로 **통과**하지만 wizard 추가분은 판정하지 않는다. + +이는 모듈 로딩 범위의 증거이지 Windows에서 실제 읽은 전송량·평가 시간·상주 메모리 또는 5초 원인의 확정이 아니다. locale·추가 동적 import·이미지·폰트는 JS 집계 밖이다. intro의 조건부 JSX는 Editor를 mount하지 않으므로 editor 인스턴스·통계 worker까지 첫 화면에서 생성된다고 확대하지 않는다. + +빌드에서 `domains/manuscript/index.ts`의 dynamic import가 다른 static import 때문에 분리되지 않는 경고도 확인했다. 해당 경계는 산출물을 확인해야 하며, 이 경고만으로 모든 lazy layout이 eager라고 판단하지 않는다. + +첫 번째 작은 최적화 후보는 **첫 intro에 필요한 모듈과 설정만 로드하는 것**이다. 설정 캐시의 초기 표시, 작은 번역 리소스, 후속 단계 lazy import를 검토한다. `root.render`만 앞당겨 번역 키 노출·테마 깜빡임·사용자 설정 덮어쓰기를 만들면 완료된 개선이 아니다. 시작 버튼부터 다음 단계까지의 시간도 함께 측정해 지연을 다음 클릭으로 옮겼는지 확인한다. + +## 3. 기존 문서의 정정·조건부 항목 + +| 기존 서술 | 새 판단 | +| --- | --- | +| renderer 중심·Windows 전용 | 이번 요청은 main/preload/utility/OS와 macOS 회귀까지 포함. 공통 작업량 감소와 OS API 차이를 구분해 계획 | +| 모든 시작이 full readiness로 차단됨 | 현재 첫 사용자 위저드는 먼저 생성. 기존 완료 사용자·완료 전환 경로와 분리 | +| readiness 캐시가 없음 / utility가 부팅 즉시 시작 | 현재 TTL+in-flight 병합, 요청 기반 utility lazy 시작이 있음. 새로 구현할 작업으로 중복 산정하지 않음 | +| RAG/utility에 취소가 전혀 없음 | RAG QA는 ask/stop IPC와 AbortController가 이미 있음. 일반 생성·임베딩·컨텍스트 준비 단계의 signal 전파 공백으로 범위를 좁힘 | +| 위저드를 먼저 닫아 창 0개 간극 발생 | 현재 main 표시 후 wizard를 닫는 `wizardClosePending` 경로가 있음 | +| 위저드에 800ms+650ms 고정 대기 | 현재 650ms 대기는 handler 완료 await로 바뀜. 800ms 피드백 대기는 남아 있으나 첫 intro 표시 지연은 아님 | +| 위저드에도 DevTools 자동 오픈 | 현재 확인한 자동 오픈은 main 및 일부 secondary 경로. wizard 생성 경로에는 없음 | +| 정적 부트 JS 예산 이내이므로 번들이 지배 원인이 아님 | `check-render-boot-budget.mjs`는 index HTML의 script/preload/CSS 합계. 그 뒤 첫 intro까지 필요한 wizard 동적 의존성은 별도이므로 이 결론을 보장하지 못함 | +| `file://` JS 코드 캐시는 기본 활성 | 최신 Electron session 문서는 기본 http(s)만 명시. 확정 철회; 실제 버전의 캐시 생성/hit 검증 필요 | +| Node compile cache는 main 한 줄로 해결 | 최초 캐시 생성 비용이 있고 이미 평가된 자기 번들에 소급 적용되지 않음. 후속 import와 warm 시작에 대한 조건부 실험 | +| Toast value memo가 전체 트리 재렌더 해결·효율 1위 | context 소비자 갱신과 후손 렌더 범위를 측정해야 함. 새 value 객체는 확인됐지만 효과 순위는 미검증 | +| Activity hidden으로 숨김 탭 메모리까지 해결 | DOM/state 보존, Effect 정리, 낮은 우선순위 렌더. 메모리 해제와 다름; 미방문 탭 lazy mount와 구분 | +| Research 탭 lazy·통계 worker 재사용·SmartLink Map을 새로 도입 | 미방문 탭 lazy mount, 통계 worker lazy singleton, 엔티티 Map 조회가 이미 있음. 방문 후 숨김 Effect·worker 응답 소유권·전체 문서 재스캔을 검토 | +| culling/Compiler/메모화를 일괄 적용 | 각각 작은/큰 데이터와 화면에서 비용·정확성 비교 후 채택. 첫 main 초기화·IPC 대기를 해결하는 기능은 아님 | +| main의 resize timer를 rAF로 바꾸면 해결 | main에는 Window rAF가 없음. renderer 매 프레임 IPC 도입은 새 비용. 기존 최종 bounds·완료 응답을 보존하며 빈도/애니메이션 생략 A/B부터 | +| GPU status·RAM·스레드 수로 성능 등급 확정 | 기기 등급과 CPU/RAM 자동 튜닝은 이번 범위에서 제외. 메타데이터와 성능 측정만 유지하고 부트 합성 벤치마크는 추가하지 않음 | +| 450MB·1.5초를 모든 환경의 즉시 하드 게이트로 사용 | 실기·시나리오·시작점이 확정되기 전에는 잠정 목표. 기존 정적 CI 게이트는 유지하고 런타임 게이트를 별도 확정 | + +API 근거: [Electron code cache](https://www.electronjs.org/docs/latest/api/session#sessetcodecachepathpath), [Node compile cache](https://nodejs.org/docs/latest-v24.x/api/module.html#module-compile-cache), [React context](https://react.dev/reference/react/useContext), [Activity](https://react.dev/reference/react/Activity). 오래된 문서는 과거 증거로 보존하며 위 표를 현재 조사 기준으로 사용한다. + +추가 주의: Windows/Linux spellchecker 언어는 `availableSpellCheckerLanguages`와 실제 사전 가용성을 확인해 선택한다. `['ko']`를 무조건 지정하거나 저사양이라는 이유만으로 사용자 기능을 끄는 정책을 확정하지 않는다. macOS native spellchecker와 같은 동작이라고 가정하지 않는다. [Electron spellchecker](https://www.electronjs.org/docs/latest/tutorial/spellchecker) + +## 4. 가장 먼저 보완할 측정 + +### 4.1 현재 로그의 사각지대 + +- main `startupStartedAtMs`는 `src/main/index.ts:18`에서 설정된다. portable launcher와 Electron 자체 초기화, 그 지점 이전의 정적 모듈 평가는 포함하지 않는다. +- `index.ts`의 lifecycle 정적 import와 barrel의 re-export도 위 시각보다 앞선 평가 후보다. 그래프의 barrel import 0건을 비용 0으로 해석하지 않는다. +- renderer 시작 시각도 `app/main.tsx:16`으로 정적 import 평가 이후다. main의 시간과 서로 다른 시작점을 가진다. +- `root.render()` 직후의 “root mounted” 로그는 React commit 완료 신호가 아니다. 다음 rAF의 “first frame painted”도 paint 이전 callback이며 wizard 대신 Suspense fallback일 수 있다. [React createRoot](https://react.dev/reference/react-dom/client/createRoot), [rAF](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) +- `did-finish-load`, `ready-to-show`, native `show`, FCP, 위저드 콘텐츠 표시, 버튼 반응을 동일 지표로 취급하지 않는다. +- 위저드 완료 전환은 main의 `did-finish-load` 또는 8초 fallback에 따라 진행된다. 창 겹침 보호가 있다고 main 콘텐츠의 상호작용 준비까지 보장되는 것은 아니다. +- preload의 requestId가 main에 전달되지 않아 main handler 로그와 사용자 대기를 동일 요청으로 연결하기 어렵다. `ipc.md` I04가 현재도 해당된다. + +### 4.2 수집 계약 + +| 구간/필드 | 방법과 판정 | +| --- | --- | +| 실행 요청 → OS 프로세스 생성 → main 진입 | 외부 launch harness 시각 + OS trace. portable launcher와 실제 Electron PID 구분 | +| main → 창 생성 | 기존 checkpoint 재사용, bootstrap/handler import/등록 구간 분리 | +| navigation → FCP → IntroStep commit | PerformanceObserver paint entry + 컴포넌트 commit 표식. FCP는 위저드 완료 표시의 대리값으로 쓰지 않음 | +| 위저드가 실제 보임 | Chromium frame/screenshot trace와 foreground 상태를 대조. commit→rAF는 보조 신호 | +| 입력 → ModelStep 표시 | 실제 클릭 이벤트부터 다음 화면 반영까지. 단순 버튼 존재·enabled 검사만으로 종료하지 않음 | +| IPC 왕복/작업 | 같은 요청 식별자, 호출자 전체 duration, main 실행 duration, payload 규모·대기량. 프로세스별 performance.now 절대값을 직접 빼지 않음 | +| 재현 메타데이터 | commit/artifact hash, process.versions, host/process/native addon/sidecar arch, OS build, GPU/driver, 디스크, RAM, 전력 상태, 화면/DPI/Hz, 실행 모드, 사용자 상태 | + +proposal: warm 재실행 20회 이상에서 개별값·median·p95·max를 보관한다. 재부팅 후 첫 실행은 우선 5회 개별값·median·max로 조사하고, 적은 표본의 p95를 확정 게이트로 쓰지 않는다. 최초 설치, 재부팅 후 실행, warm 재실행, dev Vite cold/warm을 다른 시나리오로 둔다. 앱 캐시 삭제는 OS 파일 캐시 cold를 보장하지 않는다. + +새 테스트 userData와 synthetic DB로 로그인·설정·모델·캐시 상태를 통제한다. 기존 E2E helper의 DB URL 분리만으로 userData 전체 격리가 된다고 가정하지 않는다. 프로파일러를 켠 진단 실행과 일반 production의 시간 인증 실행을 분리한다. + +### 4.3 도구 선택 + +| 계층 | 우선 도구 | 한계/용도 | +| --- | --- | --- | +| Chromium 전체 프로세스 | Electron contentTracing | app ready 이후 사용. JS/layout/paint/GC/IPC 관련 trace를 필요한 범위만 수집 | +| app ready 이전 | OS trace + 대상 Electron에서 검증한 Chromium startup tracing | `--trace-startup` 계열은 실제 배포 바이너리에서 동작 확인 후 사용 | +| React 19.2 | React Performance Tracks / 별도 profiling 빌드 | scheduler·컴포넌트 작업 분석; 계측 오버헤드가 있어 시간 인증은 일반 production | +| Node main/utility | perf_hooks eventLoopUtilization, monitorEventLoopDelay, PerformanceObserver gc | 실제 내장 Node 24 API 사용. Node 26.5의 samplePerIteration 옵션은 사용 전제에 넣지 않음 | +| Windows | WPR/WPA, 필요한 경우 ProcMon·GPUView·Defender analyzer | CPU 실행과 thread wait, 파일 접근, page fault, DWM/GPU 대기 분리 | +| macOS | Instruments Time Profiler/Hangs/Thread State Trace/Allocations | Electron helper를 포함하고 CPU busy와 대기 상태 구별 | + +근거: [contentTracing](https://www.electronjs.org/docs/latest/api/content-tracing/), [Chromium startup tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool/recording-tracing-runs/), [React Performance Tracks](https://react.dev/reference/dev-tools/react-performance-tracks), [Node 24 perf_hooks](https://nodejs.org/docs/latest-v24.x/api/perf_hooks.html), [WPR](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/windows-performance-recorder), [Apple Instruments](https://developer.apple.com/tutorials/Instruments/getting-started-with-hang-analysis). + +Electron 43+의 `contentTracing.enableHeapProfiling()`은 실험 API로 native allocation 추가 조사 후보이며 로컬 44.2.0 타입에도 존재한다. 42에는 같은 API가 있다고 가정하지 않는다. 기존 `build:debug-profile`은 sourcemap 생성과 minify 해제이지 React profiling alias 활성화가 아니다. release fuse가 Node options/CLI inspector를 제한하므로 계측을 위해 제품 보안을 해제하는 방식은 기본 계획에 넣지 않는다. + +## 5. OS·기기별 최적화 방향 + +| 환경 | 먼저 확인할 병목 | 측정 후 적용할 작은 변경 | +| --- | --- | --- | +| Windows x64 native | portable 전개, Defender, main 동기 I/O, 모듈 평가, DPI/DWM | 같은 artifact의 portable vs unpacked/설치 형태 A/B, 반복 파일 작업·초기 import 축소 | +| Windows ARM64 native | 앱/.node/sidecar의 실제 아키텍처, Adreno/가상 GPU, native asset 지원 | 아키텍처 일치·지원 기능 점검, GPU backend와 CSS/resize 비용 비교 | +| Windows ARM 위 x64 | Prism 에뮬레이션 + 위 항목 | native ARM64와 비교하되 native Intel/AMD x64 인증으로 사용하지 않음 | +| M4 Air 16GB | 현재 빠른 경로 회귀, 장시간 CPU/전력/메모리 압박 | 동일 공통 최적화 적용 전후 비교, AC/battery·열 상태·다중 창 검사 | +| Windows 저사양 8GB | 저장장치·pagefile·낮은 단일 스레드 처리량·iGPU | 공통 background 실행량 제한과 화면 효과 A/B. 생성/BGE-M3 sidecar는 기기와 무관하게 비활성화 | +| Linux 배포 지원 | Wayland/X11·GPU backend·keyring·native addon | 공통 시작 흐름 유지, OS 기능 가능 여부 처리·기본 부팅/저장 회귀 | + +### CPU와 OS API + +Windows의 visible/foreground/occluded 상태는 QoS에 영향을 줄 수 있다. 실제 사용처럼 foreground 창으로 기준선을 측정한다. 제품은 idle polling·동시 작업량을 줄이는 데 한정한다. EcoQoS, native `SetProcessInformation`, CPU affinity·process priority·timer resolution 직접 제어는 이번 구현 범위 밖이며 P4의 묵시적 후속 작업으로도 잡지 않는다. [Windows QoS](https://learn.microsoft.com/en-us/windows/win32/procthread/quality-of-service) + +`powerMonitor`의 `speed-limit-change`는 macOS/Windows, `thermal-state-change`는 macOS에서 사용한다. 배터리 여부 자체를 저사양 판정으로 바꾸기보다 작업 경쟁·열/속도 제한을 관측한다. 이벤트 미발생은 throttling 부재의 증거가 아니다. [powerMonitor](https://www.electronjs.org/docs/latest/api/power-monitor/) + +### GPU와 화면 + +`gpu-info-update` 이후 필요할 때 `getGPUFeatureStatus()`를 확인하고 식별 정보가 더 필요하면 `getGPUInfo('basic')`을 조회한다. 첫 유효 조회는 1회로 시작하고 같은 정보의 중복 요청을 병합한다. `getGPUInfo('complete')`는 명시적 상세 진단에서만 허용하고 기본 부트/제품 이벤트 처리에서는 호출하지 않는다(§1의 44.3.0 수정 참고). OS 이름으로 D3D/Metal/Vulkan 사용을 단정하지 않으며 수집 자료에 없으면 backend는 미확인으로 기록한다. `gpu_compositing` 하나로 처리량이나 메모리 압박을 판정하지 않는다. [Electron GPU API](https://www.electronjs.org/docs/latest/api/app/) + +기본 가속 → native resize 애니메이션 생략 → blur/투명 효과 축소를 한 항목씩 A/B한다. JS가 짧은데 화면이 늦으면 raster/compositor/DWM/GPU 큐를 확인한다. 효과의 개수보다 화면 면적·중첩·DPI·반복 repaint가 중요하다. 적합한 애니메이션은 transform/opacity를 쓰되 모든 노드에 will-change를 붙여 layer 메모리를 늘리지 않는다. [GPUView](https://learn.microsoft.com/en-us/windows-hardware/drivers/display/using-gpuview), [애니메이션 성능](https://web.dev/articles/animations-guide) + +`--disable-gpu`와 SwiftShader는 진단용이며 기본 배포 최적화가 아니다. SwiftShader의 OpenGL ES 드라이버 모드와 unsafe WebGL fallback은 구별한다. 자동 fallback의 deprecation을 모든 버전에서의 제거로 확대하지 않는다. WebGL 실패와 일반 DOM/CSS 합성 실패도 다르다. [Chromium SwiftShader](https://chromium.googlesource.com/chromium/src/+/main/docs/gpu/swiftshader.md) + +### 저장장치·배포·ARM + +ProcMon에서 launcher/Electron 프로세스와 TEMP, app.asar, native addon, DB, locale 파일 작업의 횟수·시간을 확인한다. Defender는 켠 상태에서 분석기로 검사 기여도를 측정한다. 배포 형태 변경의 판단 근거는 같은 소스·서명 조건·설정의 A/B이며 현재 portable이 원인이라는 확정은 아니다. [ProcMon](https://learn.microsoft.com/en-us/sysinternals/downloads/procmon), [Defender 분석기](https://learn.microsoft.com/en-us/defender-endpoint/tune-performance-defender-antivirus) + +Windows ARM에서 x64 실행에는 에뮬레이션이 개입한다. 호스트 CPU, OS arch, Electron process.arch, native addon, 외부 sidecar를 따로 기록한다. `src/main/infra/llm/sidecarConstants.ts`에는 win32-arm64 자산 키가 없고 Windows x64 자산은 CPU 빌드다. 모델 단계 지원 공백이며 첫 IntroStep 지연과는 별도로 다룬다. GPU layer 설정만 바꿔 GPU 지원 바이너리가 생기지는 않는다. [Windows ARM 에뮬레이션](https://learn.microsoft.com/en-us/windows/arm/apps-on-arm-x86-emulation) + +이 공백은 llama sidecar 매핑에 대한 것이다. 별도 `llmfitConstants.ts`는 win32-arm64 자산을 지원하므로 AI 관련 바이너리 전체를 미지원으로 묶지 않는다. `scripts/rebuild-electron-if-needed.mjs`의 host Electron probe 성공 후 조기 반환도 cross-arch 산출물 인증이 아니다. 패키지가 잘못됐다는 증거는 아니며, 대상 Windows에서 native addon 로드와 sidecar 실행을 따로 검증해야 한다. + +## 6. 메모리·GC·CPU 관찰 계약 + +| 범위 | 수집할 값 / 해석 | +| --- | --- | +| Windows Electron | app.getAppMetrics의 PID별 privateBytes 합계·peak, workingSet 별도. KB→MiB 환산. system commit/available RAM/hard faults/pagefile I/O 병행 | +| macOS | 프로세스별 private/footprint, system memory pressure·압축·swap 변화. Windows private bytes와 동일 수치 게이트로 비교하지 않음 | +| Linux | smaps_rollup의 PSS/private/swap, RSS 별도. 공유 메모리 단순 중복 합산 주의 | +| V8 main/renderer/utility | heap 증가·할당률·GC 횟수/시간, external·ArrayBuffer, DOM/detached node 등 | +| native/GPU/sidecar | SQLite·이미지·GPU·llama 프로세스를 구분. Electron 합계와 기기 전체 소비를 모두 보고 | + +근거: [Electron MemoryInfo](https://www.electronjs.org/docs/latest/api/structures/memory-info), [Electron process](https://www.electronjs.org/docs/latest/api/process), [Linux proc](https://www.kernel.org/doc/html/latest/filesystems/proc.html), [Node memoryUsage](https://nodejs.org/docs/latest-v24.x/api/process.html#processmemoryusage). + +`arrayBuffers`는 Node `external`에 포함되며 따로 더하면 중복이다. worker_threads의 RSS는 프로세스 전체라 worker별 합산하지 않는다. `getAppMetrics()`는 **하나의 sampler**에서 읽어 CPU·메모리 소비자에 나눈다. 서로 다른 주기의 독립 호출은 CPU의 이전 호출 기준 평균 구간을 바꾸므로 v1의 “CPU/메모리 폴링 분리”를 이 방식으로 구체화한다. PID 재사용을 고려해 creationTime도 기록하고 Utility type만으로 앱 자체 utility와 Chromium 네트워크 서비스를 혼동하지 않는다. [ProcessMetric](https://www.electronjs.org/docs/latest/api/structures/process-metric) + +GC는 OS별 별도 collector를 선택하는 문제가 아니다. Electron에 내장된 V8의 할당·객체 수명·CPU 경쟁과 OS 메모리 압박을 연결해 본다. 긴 원고의 전체 문자열 복사, IPC DTO, 취소되지 않은 요청, 로그 큐의 증가와 GC pause가 겹치는지 먼저 확인한다. renderer는 DevTools allocation/Performance, main/utility는 GC observer로 나눠 측정한다. 강제 GC·heap 한도 증가·working-set trim을 성능 개선의 출발점으로 삼지 않는다. [Node GC 분석](https://nodejs.org/en/learn/diagnostics/memory/using-gc-traces), [Chrome 메모리 분석](https://developer.chrome.com/docs/devtools/memory-problems) + +후속 결정에 따라 생성용 LLM과 BGE-M3 sidecar의 기기별 상주/허용 정책은 구현 대상에서 제외한다. 대신 새 정책 적용 후 해당 프로세스가 직접 요청·자동 재시도·fallback으로 시작되지 않는지 확인한다. RAG·Memory Engine용 utility와 검색/DB 메모리는 계속 측정한다. 다운로드 파일 크기와 실행 중 resident memory는 다르며, 기존 모델 파일 보존과 프로세스 미기동도 다른 조건이다. + +## 7. IPC·utility 최적화 범위 + +| 현재 확인 | 우선 변경 후보 | 완료 확인 | +| --- | --- | --- | +| preload timeout은 대기만 종료, read 최대 1회 재시도 | 동일 read 병합·취소·중복 억제 | 느린 요청과 재시도가 무제한으로 겹치지 않음 | +| 일반 utility 요청은 timeout 후 pending 삭제만 하고 작업 생존 가능 | 기존 RAG 취소를 재사용하되 일반 생성·임베딩·컨텍스트 준비까지 signal 연결, 실행/대기 상한 | timeout 뒤 실제 provider 종료, queue 상한, 사용자 요청 진행. 기존 RAG stop을 새 기능으로 재구현하지 않음 | +| utility status/stop도 DB ready 뒤 처리 | DB 불필요 control 경로를 ready await 앞에서 처리 | DB 초기화 Promise 대기에 종속되지 않음. 같은 event loop의 동기 SQL/CPU 작업은 이 순서 변경만으로 선점할 수 없음 | +| logger는 batch 크기만 제한, 실패 시 개별 invoke 확대 | 진행 중 flush Promise에 합류·항목/byte 상한·반복 로그 제한, main sink까지 backpressure 점검 | shutdown은 진행 중 flush와 잔여 큐를 기다려야 함. 단순 `if (inFlight) return` 금지. IPC 성공 응답을 디스크 영속화로 간주하지 않음 | +| main/preload 요청 ID 불일치 | 검증되는 공통 trace metadata | UI action부터 DB/응답까지 한 요청을 추적 | +| derived worker 500ms idle polling | idle backoff·enqueue wakeup·필요 없는 조회 생략 | idle CPU/wakeup 감소, 새 작업 시작과 종료 안정성 유지 | +| derived worker stop은 5초 뒤 tick이 남아도 반환 가능 | idle 최적화와 종료 수명 관리를 별도 처리 | DB close 전에 작업 완료·실제 취소 또는 복구 가능한 재큐잉 보장. timeout만 줄이는 변경 금지 | +| readiness TTL이 검사 시작 시각 기준 | 완료 시각 기준 TTL + 기존 무효화 보존 | 검사 시간이 TTL보다 길어도 즉시 재검사하지 않음 | +| sidecar running이 health-ready보다 빠름 | 기존 코드의 관찰로 보존. 후속 결정에 따라 sidecar 활성 운용 최적화는 보류 | 대신 생성/BGE-M3 sidecar의 모든 시작 경로 차단 검증(§11) | +| provider가 이전 인증 closure를 재사용할 수 있음 | 최신 인증 resolver 갱신 또는 가벼운 wrapper 재생성 | 토큰 갱신 후 이전 인증 실패 재시도 부하 방지 | + +소스: `src/preload/index.ts:190,227,276`, `src/main/handler/core/ipcHandler.ts:53`, `services/features/utility/utilityProcessBridge/internal/core.ts:338`, `utility/process/utilityProcessMain.ts:246`, `utility/llm/textGeneration.ts:49`, `services/features/derivedJobs/derivedJobWorker.ts:107`, `services/features/startup/startupReadinessService.ts:96`, `utility/llm/sidecarSupervisor.ts:131,225`, `utility/llm/runtimeMaterializer.ts:93`. main 이하 축약 경로는 `src/main/` 기준이다. 과거 감사의 mock 재현 수치는 이번 세션의 성능 실측으로 재사용하지 않았다. + +RAG의 기존 취소 경로는 `preload/api/projectApi.ts` → `ipcRagQaHandlers.ts` → bridge ask/stop → utility dispatch → `ragQaWorker.ts`의 AbortController → 생성 signal이다. 컨텍스트 준비의 embedding callback과 일반 `textGeneration.ts`는 별도 점검 대상이다. 로그 종료 경로는 preload `completeAppFlush` → flush 대기 → `APP_FLUSH_COMPLETE`까지 보존하고, main logger의 비동기 파일 쓰기 완료는 별도 확인한다. + +프로세스 선택은 다음 범위로 제한한다. + +| 작업 | 경계 | +| --- | --- | +| 창·OS API·IPC 라우팅 | main 유지 | +| 순수 renderer CPU 계산 | 표준 Web Worker 후보; DOM은 renderer | +| 반복 Node CPU 계산 | 계측 후 재사용 worker_threads 후보 | +| RAG·Memory Engine | 기존 lazy utility 유지. 생성용 LLM/BGE-M3 sidecar 비활성화와 구분 | +| 비동기 파일·네트워크 I/O | 기존 비동기 API 우선 | +| 긴 DB 스캔 | main blocking을 확인한 후 전용 worker/기존 utility 적합성 검토. DB 소유권·migration·stop 경로 경쟁 먼저 설계 | + +`async` IPC는 main의 동기 SQLite 실행을 다른 스레드로 옮기지 않는다. worker마다 메모리·초기화 비용이 있어 작업마다 새 프로세스를 만들지 않는다. [Node worker_threads](https://nodejs.org/docs/latest-v24.x/api/worker_threads.html), [Electron utilityProcess](https://www.electronjs.org/docs/latest/api/utility-process) + +큰 IPC는 먼저 전송 횟수·필드·본문 복사·중복 결과를 줄인다. 지속 스트림에 필요가 입증되면 MessagePort를 검토한다. Electron postMessage의 transfer 계약이 MessagePort라는 점을 확인하고 ArrayBuffer의 무조건 zero-copy를 가정하지 않는다. contextBridge 격리와 main의 schema validation, 저장 순서·durable ack·shutdown flush를 보존한다. [ipcRenderer](https://www.electronjs.org/docs/latest/api/ipc-renderer), [MessagePorts](https://www.electronjs.org/docs/latest/tutorial/message-ports) + +## 8. React 19·Tailwind 4 적용 순서 + +Tailwind 4의 빠른 CSS 빌드는 개발/빌드 생산성이다. packaged 실행 중에는 생성된 CSS의 layout/paint/합성 비용을 측정한다. Windows ARM용 Oxide/Rolldown 패키지 문제를 production 렌더 지연과 바로 연결하지 않는다. [Tailwind 설치/동작](https://tailwindcss.com/docs/installation/using-postcss), [v4 빌드 성능](https://tailwindcss.com/blog/tailwindcss-v4) + +React는 초기 모듈 그래프와 설정 gate를 먼저 줄이고, Performance Tracks로 업데이트 비용을 찾는다. Compiler는 현재 Vite 구성에서 공식 설치 경로를 사용할 수 있지만 추가 빌드 의존성과 정확성 검증이 필요한 별도 변경이다. React 밖의 SmartLink 전체 스캔이나 동기 diff는 알고리즘/worker 범위이며 Compiler로 해결되지 않는다. `useDeferredValue`는 입력 우선순위를 조정할 뿐 필터 총 연산량을 없애지 않는다. [Compiler 설치](https://react.dev/learn/react-compiler/installation), [Compiler 최적화 범위](https://react.dev/learn/react-compiler/introduction), [useDeferredValue](https://react.dev/reference/react/useDeferredValue) + +숨김 탭은 미방문 lazy mount, 방문 후 state 보존, 완전 해제를 구분한다. blur·culling·Toolbar memo·store selector는 실제 큰 데이터 및 일반 데이터에서 입력 지연·메모리를 함께 비교한다. 한국어 IME 조합·커서·선택·자동저장 정확성은 최적화 전후 동일해야 한다. + +그래프에서 연결한 뒤 소스로 좁힌 실제 변경 후보는 다음과 같다. 이들은 intro 로딩과 별도 사용자 시나리오다. + +| 영역 | 이미 있는 처리 | 남은 측정/개선 대상 | +| --- | --- | --- | +| SmartLink | 엔티티 lookup은 Map 사용 | 초기 scan, docChanged/강제 갱신, 4개 store 변경에서 전체 문서 scan. lookup 자료구조를 다시 만드는 대신 scan 호출·범위부터 측정 | +| DiffExtension | 비교값이 없으면 반환, 텍스트 합계 50,000 초과 시 diff 생략 | cap 전에 전체 텍스트·mapping·HTML 변환이 발생. 모든 타이핑이 diff를 실행한다고 가정하지 말고 비교 활성 시 비용과 revision 캐시 필요성 확인 | +| Editor 통계 | 900ms 직렬화 debounce, 추가 120ms 통계 debounce, lazy singleton worker | getHTML/getText와 worker 전송의 전체 문자열 비용, 다중 editor의 requestId/editorId 없는 응답 공유. worker 신규 도입보다 정확성과 복사량 우선 | +| ResearchPanel | 방문한 primary tab만 mount, 이후 CSS hidden으로 보존 | 미방문 탭 최적화는 유지하고 방문 후 숨김 Effect의 실제 부하에 한해 Activity/해제 검토 | +| Toast | callback 안정화는 있음 | Provider value 객체와 실제 context 소비자 렌더를 측정. 기존 sidebar 재렌더 테스트를 Toast 효과의 증거로 쓰지 않음 | + +## 9. 검증 매트릭스와 실행 계획 + +### 최소 기기 매트릭스 + +| 프로필 | 역할 | +| --- | --- | +| 사용자 보고 Windows 기기 | 5초 증상의 최우선 재현, 실행 조건 확정 | +| M4 Air 16GB native ARM64 | 현재 Mac 기준선·공통 변경 회귀·전력/열 장시간 검사 | +| Windows Intel/AMD x64 16GB NVMe | native x64 기준선 | +| Windows ARM64 기기 | native ARM64 기준선, 같은 기기의 x64는 별도 에뮬레이션 실험 | +| Windows 8GB 저사양 | L 예산 확정. N100 결과로 N4020 지원을 인증하지 않음 | +| Linux x64 | 배포 지원에 맞춰 Wayland/X11·keyring·저장 기본 회귀 | + +M4의 Windows VM, CDP CPU throttle 4배, GPU 비활성화는 보조 스트레스 실험이다. 실제 x64/ARM CPU, eMMC/Defender, 저가 iGPU/Adreno, pagefile, 열 특성을 재현한다고 가정하지 않는다. 프로필 이름은 실제로 제한한 자원만 표현한다. + +시나리오는 새 사용자 intro, 기존 사용자 재시작, 위저드 완료 전환, 인증 네트워크 지연, 첫 프로젝트 입력, 큰 원고 IME/저장, 다중 창, 10분 idle/AI background 부하로 나눈다. 처음부터 모든 조합을 돌리지 않고 보고 기기와 M4의 시작 경로부터 확정한다. + +### 단계별 계획 + +| 단계 | 작업 | 다음 단계로 넘어갈 증거 | +| --- | --- | --- | +| P0 측정 계약 | 실제 Windows 환경·artifact 식별, 외부 시작점/intro 표시/입력 표식, 기존 IPC/checkpoint 연결 | 5초 중 어느 구간이 지배적인지 보이는 같은 조건의 Mac/Windows trace | +| P1 첫 위저드 | 후속 preview 정적 의존 분리, 설정/i18n gate·불필요 프로젝트 초기화·handler 작업 축소. 모델 단계/llmfit은 §11.2의 비활성화 범위와 정합화 | intro 의존성 924.3 KiB 추가분의 감소와 intro/다음 클릭 runtime 개선을 각각 확인; 테마·언어·오류 UI 유지 | +| P1 별도 배포 실험 | portable vs unpacked/설치 형태 비교, 44.2 vs 44.3 별도 비교 | launcher와 앱 내부 개선량 구분. 배포 변경 여부 결정 가능 | +| P2 main/IPC | readiness TTL/검사 분리, 취소·큐 상한·로그 부하·idle polling, 동기 DB 병목 축소 | event-loop 지연·pending·idle CPU·메모리 감소, 저장/종료/인증 정상 | +| P3 renderer/GPU/GC | 큰 원고 알고리즘·복사량, blur/resize/culling/hidden 탭 실험, 필요시 Compiler | 입력/frame/GC와 메모리의 동시 개선, IME·접근성 회귀 없음 | +| P4 기기 적응 정책 — **Deferred** | 이번 구현·완료 기준에서 제외. baseline 학습·hysteresis·resource state engine·제품 CPU/RAM polling을 설계하지 않음 | P0~P3 이후 고정 작업 규칙으로 해결되지 않는 실기 문제가 남고 사용자가 별도 범위를 승인할 때만 재개 | + +P0는 OS별 별도 시작 파이프라인이나 새 성능 프레임워크를 만들 필요가 없다. 기존 로그, performance timer, IPC 계약, utility memory snapshot을 확장하는 정도부터 시작한다. Ponytail 원칙에 따라 기존 구현 재사용과 측정 가능한 작은 변경을 우선했다. + +v1의 1.5초 첫 표시·2.5초 상호작용·450MB는 새 매트릭스에서 재평가할 잠정 후보로 보관한다. source/정적 payload 게이트와 실기 runtime 게이트를 분리하고, OS별 기준선·제품 체감 목표·반복 측정의 변동폭을 근거로 회귀 허용 폭을 정한다. “100ms 이상 거의 0회” 같은 문구는 시나리오 길이와 허용 횟수를 정한 뒤 게이트로 바꾼다. + +### 기존 검증 재사용 + +- 정적 payload: `scripts/check-render-boot-budget.mjs`. 새 빌드 기준으로 실행하며 wizard 첫 intro까지의 후속 청크를 별도 관찰한다. +- readiness/utility: `tests/main/services/startupReadinessService.test.ts`, `utilityProcessBridgeProtocol.test.ts`, `utilitySidecarSupervisor.test.ts`에 변경별 최소 회귀 사례를 추가한다. +- wizard/저장: `tests/dom/startupWizardFlow.test.tsx`, `startupWizardModelStep.test.tsx`, `preloadAutoSaveQueue.test.tsx`와 실제 Electron 입력 검증을 조합한다. +- 기존 utility의 30초 memory snapshot은 시작 5초 분석에 불충분하므로 진단 모드에서만 짧은 구간 계측을 보완한다. +- 구현 시 해당 변경에 맞춰 `typecheck`, `lint`, `qa:core`, `check:ipc-*`, `check:utility-process-boundary`, `check:main-service-boundaries`, 정적 부트 예산 등 저장소 필수 게이트를 수행한다. source 문자열/mock 테스트의 통과를 Windows 실기 성능 인증으로 제시하지 않는다. + +이번 조사에서 확정하지 않은 것은 Windows 5초의 실제 원인·개선량·새 하드 게이트다. 다음 구현 단위는 **P0 시작 계측과 Windows 재현 조건 고정**이며, 그 결과에 따라 P1의 첫 수정 지점을 선택한다. + +## 10. 그래프 재검증 최종 리뷰 + +### 10.1 Overall Verdict + +**Risky — OS별 성능 개선을 인증할 단계는 아니다.** 사전조사는 완료했으며, 첫 화면의 불필요한 정적 의존성은 빌드까지 확인했다. Windows 5초의 지배 구간은 실기 trace가 없어 미확정이다. 이번 평가는 조사 대상 경로와 최적화 계획에 한정되며 제품 전체의 보안/품질 인증이 아니다. + +code-review-graph를 CLI MCP로 초기화하고 `build_or_update_graph_tool`로 HEAD에 맞췄다. 조사 시점 그래프는 **9,169 nodes / 111,826 edges / 1,690 files / 18 communities**였다. 메인은 구조·영향·새 빌드를, 서브에이전트 3개는 renderer, main/OS, IPC/utility 경로를 다시 대조했다. `semantic_search_nodes_tool`, `query_graph_tool`, `get_architecture_overview_tool`, `list_communities_tool`, `get_impact_radius_tool`, `get_review_context_tool`, `detect_changes_tool`, `get_affected_flows_tool`을 사용했다. + +### 10.2 Critical Issues (Must Fix) + +여기서 Must Fix는 **성능 개선을 완료했다고 판정하기 전 충족할 조건**이다. 제품 코드를 이번 조사에서 수정했다는 의미가 아니다. + +| 우선 | 확인한 문제 | 완료 판정 조건 | +| --- | --- | --- | +| P0 | 첫 표시·native show·React commit·입력 반응을 섞은 측정으로 OS 차이를 설명할 수 없음 | 동일 artifact/사용자 상태의 외부 실행→IntroStep 표시→ModelStep 입력 trace | +| P0 | 모델 단계 테스트 3개와 현재 UI 계약 불일치 | 하단 바를 숨기는 현재 의도와 테스트 기대를 정합화하고 전체 흐름 재검증. 실패 assertion을 단순 삭제해 통과시키지 않음 | +| P1 | Intro가 사용하지 않는 editor/preview가 wizard 정적 의존성에 포함 | 산출물에서 경계 분리 확인 후 첫 화면과 후속 단계 runtime을 함께 비교 | +| P2 | flush/stop 최적화를 잘못 적용하면 종료 ACK 이후 작업·로그가 남을 위험 | 공유 flush Promise 합류·잔여 큐 배출·DB close 전 작업 수명 보장. 성능을 위해 저장/종료 보장을 축소하지 않음 | + +### 10.3 Structural Problems + +| 경로 | 그래프/소스 판정 | 최적화 경계 | +| --- | --- | --- | +| StartupWizard → ThemeStep / LayoutStep → WizardEditor → Editor | 실제 정적 의존성이며 production 청크에서도 확인 | intro/model과 후속 preview 경계를 분리. 모든 단계용 새 framework는 불필요 | +| App → useProjectInit → 설정/프로젝트 store | windowMode 분기 이전 공통 hook, 조회는 bootstrap ready 이후 | wizard에 필요한 구독/설정은 보존하고 불필요 조회만 좁힘 | +| main bootstrap → DB / 전체 handler / llmfit | await·비차단 호출·동기 작업이 섞임 | dependency gate와 단순 동시 자원 경쟁을 구분; OS별 bootstrap 복제 금지 | +| utility dispatch → ensureReady → status/stop | control 요청이 초기화 대기에 결합 | ready await 분리와 동기 작업 취소 가능성을 다른 문제로 처리 | +| preload flush → APP_FLUSH_COMPLETE → main logger sink | 요청 완료와 파일 쓰기 완료는 다른 경계 | existing shutdown 계약을 따라 backpressure와 완료 의미를 검증 | + +그래프 결과의 사용 한계도 직접 확인했다. + +- `tests_for`는 StartupWizard 등에서 0건이지만 `imports_of(tests/dom/startupWizardFlow.test.tsx)`는 실제 StartupWizard import를 반환한다. 0건을 테스트 부재로 해석하지 않았다. +- `rootShell/index.ts`, lifecycle barrel은 `imports_of`가 0건이어도 소스에 re-export가 있다. type-only import도 런타임 비용에서 제외해야 한다. +- `get_affected_flows`는 조사한 startup 파일의 상대/절대 경로 모두 0건이었다. 이벤트·JSX·IPC를 포함한 전체 경로를 보장하지 않으므로 수동 경계 대조가 필요했다. +- `getReadiness`, `embed`, `stop`처럼 이름이 같은 메서드는 서로 다른 receiver의 호출이 섞일 수 있었다. 함수명 검색 결과만으로 프로세스 간 직접 호출을 만들지 않았다. +- StartupWizard depth 1 영향 결과는 23 nodes/추가 12 files였지만 dependency를 포함한 연결 범위다. 실제 변경 소비자 수나 로딩 시간으로 환산하지 않는다. +- 제품 코드 diff가 없으므로 `detect_changes`의 변경 함수/risk 0은 자연스럽다. 런타임 안전성 판정도, 조사한 가설의 부정도 아니다. 릴리스·OS API 사실은 이 그래프가 아니라 §1/§4~6의 공식 자료 근거를 유지한다. + +### 10.4 Practical/Production Concerns + +새 production 빌드는 성공했고, 같은 임시 출력물에 기존 부트 예산 검사를 실행해 `RENDER_BOOT_BUDGET_OK`를 확인했다. 정적 청크 분석은 설치된 TypeScript parser로 최상위 import/re-export를 추적했으며 parse 오류 없음·상대 경로 해석·중복 제거·합집합 bytes 불변식을 검사했다. 제품 출력 경로와 의존성은 변경하지 않았다. + +| 로컬 검증 | 결과 | 보장하지 않는 것 | +| --- | --- | --- | +| electron-vite production build | 성공; manuscript 경계 ineffective dynamic import 경고 | Electron 앱 실행·Windows packaging·실제 시작 시간 | +| 기존 render boot budget | JS 573.1 KiB / CSS 166.0 KiB 통과 | 후속 wizard 924.3 KiB 추가분 및 runtime | +| utility/materializer/sidecar/bridge + preload 자동저장/계약 5개 파일 | 32개 테스트 통과 | 실제 sidecar health 경쟁·토큰 갱신·로그 sink/취소 신규 사례 | +| wizard/project/SmartLink/readiness/deferred worker/llmfit 8개 파일 | 42개 통과, 3개 실패 | Windows 실기 성능·native DB 초기화 비용 | +| 실패한 모델 단계 파일 단독 재실행 | 동일 3개 실패, 1개 통과 | 실패가 다른 파일 병렬 실행에서만 생긴다는 설명을 지지하지 않음 | + +중복 재실행을 제외하면 **13개 파일, 77개 테스트 중 74개 통과·3개 실패**다. 실패는 `tests/dom/startupWizardModelStep.test.tsx:174,209,245`의 하단 다운로드/완료 바·원본 오류 문자열 기대다. 현재 `StartupWizard.tsx`는 `step !== "model"`일 때만 `EmbeddingModelStatusBar`를 렌더하고, `ModelStep.tsx`는 중앙 진행률·완료 안내·일반 오류 문구를 표시한다. 직접적인 실패 원인은 이 UI/테스트 기대 불일치이며, 추가 `act(...)` 경고도 관찰했다. 제품 코드를 변경하지 않은 기준선 결과로 기록하고, 제품 의도를 임의로 바꾸거나 테스트를 완화하지 않았다. + +검증 명령은 설치된 로컬 실행기를 사용했다. 최초 pnpm 테스트 실행은 supply-chain 검증 중 registry DNS 제한으로 진행되지 않아 중단했고, 의존성 설치나 검증 정책 설정 변경 없이 같은 로컬 Vitest를 직접 실행했다. 이는 프로젝트 전체 필수 게이트를 통과했다는 뜻이 아니다. + +```sh +# 임시 산출물: 이번 실행 경로이며 재현 시 새 임시 디렉터리를 사용한다. +node node_modules/electron-vite/bin/electron-vite.js build --outDir /private/tmp/luie-graph-review.PsK21U/out +node --input-type=module -e 'process.chdir("/private/tmp/luie-graph-review.PsK21U"); await import("/Users/user/Luie/scripts/check-render-boot-budget.mjs")' + +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run \ + tests/main/services/utilityRuntimeMaterializer.test.ts \ + tests/main/services/utilitySidecarSupervisor.test.ts \ + tests/main/services/utilityProcessBridgeProtocol.test.ts \ + tests/dom/preloadAutoSaveQueue.test.tsx \ + tests/scripts/preloadContractRegression.test.ts + +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run \ + tests/dom/startupWizardFlow.test.tsx \ + tests/dom/startupWizardModelStep.test.tsx \ + tests/dom/projectInitOperationalScenarios.test.tsx \ + tests/dom/smartLinkInitialScan.test.tsx \ + tests/renderer/smartLinkEntityLookup.test.ts \ + tests/main/services/startupReadinessService.test.ts \ + tests/main/lifecycle/appReadyDeferredWorker.test.ts \ + tests/main/services/llmfitInstaller.test.ts +``` + +### 10.5 Improvements (Actionable) + +1. **기준선 고정:** 모델 단계 테스트 계약을 정리하고 Windows 환경/실행 형태를 확정한다. P0 계측은 기존 로그·IPC를 확장하며 새 프레임워크를 만들지 않는다. +2. **첫 화면 작업량 축소:** preview 단계의 import 경계를 작게 분리한다. 설정 gate와 useProjectInit은 호출 횟수/대기 측정 후 좁힌다. 모델 단계·llmfit 동작은 §11.2의 비활성화 결정과 함께 정리한다. +3. **작업 수명부터 보장:** 유지할 RAG/Memory 작업의 취소·저장·종료를 검증한 다음 기존 batch/queue/polling 규칙을 보강한다. readiness TTL·인증 resolver는 별도 회귀 사례로, 생성/BGE-M3 sidecar는 cold 최적화 대신 미기동 검증으로 분리한다. +4. **실기 trace에 따라 개선 선택:** I/O가 지배하면 배포/native DB, CPU가 지배하면 모듈/연산, 화면 대기가 지배하면 resize/합성, 메모리 증가가 지배하면 할당/보존 객체를 조사한다. 이를 OS별 자동 튜닝 엔진으로 연결하지 않는다. + +### 10.6 Optional Improvements + +React Compiler·Activity는 해당 renderer 병목이 확인될 때만 별도 평가한다. native QoS·WASM 자원 모니터·새 daemon·적응형 스케줄러는 이번 범위 밖이다. 별도 DB worker나 MessagePort 전환도 자동 후속 작업으로 잡지 않는다. 기존 lazy utility·통계 worker·Map lookup·미방문 탭 mount 제어를 재구현하지 않는다. senior-code-reviewer 기준으로 실패/종료 경계를 검토하고, Ponytail 원칙으로 기존 구조의 작은 변경을 우선했다. + +## 11. 후속 결정: 작가 에디터 범위와 sidecar 비활성화 + +이 절은 이전 절의 조사 시점 코드 설명은 보존하되, 충돌하는 향후 구현 제안을 대체한다. + +### 11.1 확정 범위 + +- `bootstrapMac`/`bootstrapWindows`로 분리하지 않는다. 공통 시작 체인을 유지하고 실제 OS 차이가 있는 경계에서만 분기한다. +- 플랫폼 정보와 동적 성능 관찰은 분리한다. 단일 책임 원칙(SRP)으로 설계하며, SPA 화면 구조와 프로세스/모듈 책임 분리는 서로 다른 축이다. +- 이 최적화를 위해 새 native addon, Go/Rust 성능 daemon, 별도 감시 utility process를 추가하지 않는다. 기존 Electron/Node API와 기존 프로세스를 사용한다. 외부 OS 진단 도구는 개발/실기 조사에만 사용한다. +- **생성용 LLM sidecar와 BGE-M3 임베딩용 sidecar를 모든 지원 OS/아키텍처에서 비활성화할 예정이다.** 고사양·AC 전원에서도 자동 활성화하지 않는다. +- **RAG·Memory Engine·관련 utility process는 유지한다.** sidecar 비활성화를 utility 전체 종료나 메모리 기능 제거로 구현하지 않는다. +- 기존 모델 파일·벡터·원고 데이터는 삭제하지 않는다. 외부 provider 사용·데이터 전송·새 모델 도입은 이 결정에 포함하지 않는다. + +### 11.2 sidecar 비활성화 TODO와 완료 조건 + +현재 `src/main/utility/llm/runtimeMaterializer.ts`에는 생성용 `utilitySidecarSupervisor.ensureStarted`와 임베딩용 `utilityEmbeddingSidecarSupervisor.ensureStarted`가 별도로 있다. BGE-M3도 같은 llama-server 계열 실행 경로를 사용하므로 생성 경로만 차단해서는 충분하지 않다. + +- [ ] 직접 시작 IPC뿐 아니라 route 선택 → materializer → supervisor, 자동 재시도/fallback, background 작업의 간접 시작까지 조사해 두 sidecar 모두 차단한다. +- [ ] 생성 모델/BGE-M3의 자동 다운로드·설치/재시작 유도 UI를 비활성화 정책과 맞춘다. 기존 모델 파일을 지우는 방식으로 막지 않는다. llmfit 추천/설치도 남길 소비자가 없다면 실행하지 않도록 범위를 확인한다. +- [ ] RAG·Memory Engine의 수집·저장·조회·취소·종료 기능이 유지되는지 검사한다. 임베딩이 필요한 작업은 무한 재시도·대기열 누적 없이 명시적 상태로 처리한다. +- [ ] 현재 embedding resolver의 FTS-only/deterministic fallback이 실제 검색·색인·응답 생성 경로에서 무엇을 보장하는지 검증한다. 이름이나 fallback 반환만으로 의미 검색 품질·정상 RAG 답변을 보장한다고 주장하지 않는다. 가짜 임베딩을 정상 벡터로 저장하지 않는다. +- [ ] 기존 벡터와 새 provider의 차원·모델 의미를 섞지 않는다. 대체 provider/벡터 재생성은 별도 결정하며, 현재 작업에서 클라우드로 조용히 전환하지 않는다. +- [ ] Mac/Windows x64/ARM64에서 위저드·프로젝트 열기·RAG·background 작업·재시도 후 두 sidecar의 spawn 및 관련 자동 다운로드가 0회인지 검증한다. utility는 필요한 기능 요청 시 정상 동작해야 한다. + +### 11.3 최소 경계: 플랫폼 사실 / OS 이벤트 / 명시적 진단 + +`resourceMonitor` 런타임 서비스 제안을 철회한다. 아래는 책임 경계와 파일명 후보이지 새 framework/서비스 계층 3개를 먼저 만들라는 지시가 아니다. 기존 모듈에 같은 책임이 있으면 재사용한다. + +| 책임 | 입력/출력 | 하지 않는 일 | +| --- | --- | --- | +| `platform.ts` | OS 식별자·OS 버전·현재 process arch를 1회 확인 | 기기 등급·GPU 탐색·job 정책 | +| `resourceSignals.ts` | Electron 전원/lifecycle 이벤트 → 지원 여부와 확인된 상태 | 타이머 polling·CPU/RAM 판단·job 실행 | +| `performanceDiagnostics.ts` | 명시적으로 시작/중지하는 CPU·메모리·GPU 진단 | 제품 정책에 snapshot 공급·자동 실행·새 프로세스 생성 | +| 기존 job 서비스 | 자신이 소유한 요청/queue 상태 → 다음 batch 시작 여부 | 전역 resource manager·OS별 scheduler | +| `index.ts` | 위 모듈과 기존 lifecycle의 연결·정리 등록 | 표본 처리·임계값 판정·GPU 조회 완료 대기 | + +의존 방향은 `index → platform / signals / diagnostics / 기존 lifecycle`이다. signals는 platform 정보가 필요하면 주입받지만 platform은 다른 모듈을 import하지 않는다. diagnostics 결과는 보고서/로그로만 가고 job 정책 입력으로 흐르지 않는다. renderer에는 필요한 기능 결과만 기존 preload 계약으로 전달한다. import만으로 이벤트/타이머가 등록되지 않게 한다. + +**플랫폼 값:** `process.arch`는 호스트 CPU 판별값이 아니다. 필드명을 `processArch`로 구분하고 알 수 없는 OS/arch를 임의로 x64/Linux로 치환하지 않는다. 표시 이름 변환이 필요해도 raw 값은 보존한다. 지원 타깃 검증과 OS 버전 문자열 기록을 분리하고, `version`/`osVersion`처럼 계약 이름이 어긋나는 예제를 실제 타입으로 채택하지 않는다. + +**OS 이벤트:** suspend/resume을 공통 수명 신호로, battery/AC·speed-limit을 지원 OS에서, thermal state를 macOS에서 받는다. 이벤트 수신 전/미지원은 `unknown`이다. 가능한 초기 상태만 Electron getter로 확인하고, 반복 동일 이벤트는 중복 작업을 만들지 않는다. Windows에 thermal 이벤트가 없다고 정상 온도로 간주하지 않고, Linux도 모든 신호를 일괄 미지원 처리하지 않는다. OS별 수집 분기는 이 adapter에 제한하되 창·업데이트 등의 기존 기능별 OS 분기까지 여기로 이동시키지는 않는다. [powerMonitor](https://www.electronjs.org/docs/latest/api/power-monitor) + +**진단 수명과 비용:** 제품 CPU/메모리 polling은 추가하지 않는다. 기존 utility의 30초 메모리 로그는 현행 비용으로 별도 기록하고 중복 sampler를 붙이지 않는다. 진단 세션이 필요할 때만 단일 소유자가 `getAppMetrics()`를 읽고, 종료·suspend·진단 시간 만료 시 타이머/리스너를 정리한다. 재시작/복귀 시 CPU 기준점을 다시 잡고 놓친 tick을 몰아 실행하지 않는다. `500ms / 최대 15초`는 짧은 시작 진단의 실험 입력 예일 뿐 제품 기본값이 아니다. 제품용 `5초/30초` 감시 설계는 삭제한다. + +CPU 평균 구간·PID+creationTime·측정 시각·미지원 필드를 보존한다. 첫 CPU 표본은 기준점이며 Windows의 idleWakeups 0은 API 제약이다. `privateBytes`와 다른 OS의 메모리를 동일 값으로 강제 정규화하지 않는다. 매 표본 renderer broadcast·원고 포함 로그·동기 파일 기록은 하지 않고 진단 기록은 시간/개수 상한을 둔다. 계측 OFF/ON의 CPU·wakeup·메모리·로그/IPC·시작/입력 지연을 함께 비교한다. 비용을 재기 전에는 오버헤드 0이나 일정 ms 이하를 보장하지 않는다. [CPUUsage](https://www.electronjs.org/docs/latest/api/structures/cpu-usage), [MemoryInfo](https://www.electronjs.org/docs/latest/api/structures/memory-info) + +**GPU:** §5처럼 feature status → 필요한 경우 basic 조회를 사용하고 complete는 명시적 상세 진단에서만 허용한다. native addon은 필요하지 않지만 정확한 GPU utilization/VRAM pressure를 제공하는 계약은 아니다. 앱 수준의 새 resource IPC는 만들지 않으며 Electron 내부 IPC/OS 조회까지 비용 0이라고 표현하지 않는다. + +### 11.4 제품 정책: 기존 작업의 시작·완료 경계만 강화 + +기기 등급, 전역 `idle/normal/overloaded`, CPU/RAM 자동 튜닝, baseline 학습, hysteresis 엔진은 구현하지 않는다. 상수는 **우리 작업의 batch 크기·동시성·queue 상한·timeout·재개 지연**에만 둔다. 제안 예시의 `100/1500ms/30초/50`을 검증 없이 새 기본값으로 복사하지 않는다. 기존 상수를 재사용하고 바꾸는 값마다 대상 작업·근거·회귀 테스트를 연결한다. + +현재 `derivedJobWorker.ts:148`에는 `running/inTick` 중복 방지와, 비-stress 모드에서 `autoSaveManager.getPendingSaveCount()`가 양수일 때 tick을 미루는 처리가 이미 있다. 따라서 기존 규칙의 공백을 보강하는 작업이지, 우선순위 시스템을 처음 도입하는 작업이 아니다. 저장 대기 수는 모든 입력·실행 중 저장·사용자 RAG 요청의 상태를 대신하지 못한다. + +| 상황 | 기존 서비스에 추가/검증할 규칙 | 안전 조건 | +| --- | --- | --- | +| 사용자 저장·검색/RAG 요청 수락 | 같은 자원을 쓰는 다음 비필수 background batch 시작을 미룸 | UI 입력을 새 전역 queue에 넣지 않음. 사용자 요청의 완료/취소/실패 경계를 사용 | +| 이미 background batch 실행 중 | 안전한 batch 경계에서 양보; 지원되는 작업만 실제 취소 | 동기 SQL/JS를 즉시 선점한다고 보장하지 않음. 단일 작업이 길면 별도 hotspot 개선 | +| foreground 요청 여러 개 | 해당 소유자에서 모두 정리될 때까지 보류 사유 유지 | 단일 boolean로 첫 완료 시 조기 재개하지 않음. 오류/창 종료/취소 시 누락·이중 해제 방지 | +| foreground 완료 | 다른 보류 사유가 없으면 기존 bounded batch 재개 | 필요성이 입증된 재개 지연만 사용. 재개 전 상태를 다시 확인하고 영구 기아 여부 검사 | +| queue 상한 도달 | 실행 중/메모리 적재량 제한, 합칠 수 있는 파생 요청만 병합 | durable job·원고 저장을 drop하지 않음. 저장소 backlog와 메모리 queue를 구분 | +| timeout/취소 | 실제 종료 또는 안전한 중단을 확인한 뒤 실행 슬롯 해제 | Promise timeout을 작업 종료로 간주하지 않음. 미종료 작업 위에 재시도 중첩 금지 | +| suspend/resume·종료 | suspend는 새 비필수 작업 시작 억제, resume은 기존 상태 확인 후 재개 | suspend에 무제한 flush를 기대하지 않음. 기존 저장/DB close 순서 보존, missed tick 폭주 금지 | +| thermal critical 등 명확한 OS 제한 | 실제 소비자가 있는 비필수 작업의 새 batch만 보류 | AC/battery만으로 과부하 판정하지 않음. 회복 신호 하나가 foreground/종료 보류를 해제하지 않음 | + +**프로세스 경계:** derived worker는 `appReady.ts`가 main에서 시작하고 RAG 실행은 utility dispatch로 간다. 따라서 전체 작업을 utility 내부에서만 조절할 수 있다고 가정하지 않는다. 먼저 main이 이미 관찰하는 요청 수락/완료와 저장 상태로 main의 다음 batch를 제어하고, utility 내부 경쟁은 그 소유자 안에서 처리한다. 추가 연결이 정말 필요하면 기존 요청 식별/수명 계약을 확장하고 경계 검사를 수행한다. CPU·메모리 표본을 보내는 주기 IPC나 전역 resource-pressure protocol은 만들지 않는다. 클라이언트 timeout으로 실제 utility 작업 완료를 추정하지 않는다. + +이 정책은 실행 순서 보장이지 모든 노트북의 반응 시간을 보장하는 장치가 아니다. 느린 단일 batch·SQLite lock 대기·긴 renderer 계산이 남으면 P0~P3의 해당 병목을 줄인다. 입력 없이 읽는 시간을 기기 idle로 간주해 background 작업량을 늘리지 않는다. + +### 11.5 구현 전 설계 체크와 종료 기준 + +§11.1~11.2는 확정 결정이고, 아래는 아직 실행하지 않은 검증 TODO다. 구조를 단순화해도 작업의 종료/데이터 계약은 생략하지 않는다. + +- [ ] **작업 목록:** 남길 RAG/Memory 작업별로 소유 프로세스·실행 경로·유효 provider·durable queue·실제 취소 가능 지점·최대 한 번의 batch 범위를 기록한다. 특히 sidecar가 필요한 작업을 계속 enqueue한 뒤 매 tick 실패시키지 않는다. +- [ ] **비활성 기능의 사용자 계약:** 생성/BGE-M3가 필요한 작업은 비활성/대체 가능 여부를 명시한다. 로컬 텍스트 검색·저장 보존을 의미 검색/생성 품질 유지로 표현하지 않는다. 기존 외부 provider 설정은 자동 변경하지 않고, 신규 외부 전송/모델 선택은 별도 승인 대상으로 둔다. +- [ ] **기존 규칙 테스트:** foreground 겹침·실패·취소·창 종료, background 실행 중 foreground 도착, timeout 뒤 작업 생존, queue 가득 참, 재개/기아, suspend 중 종료를 검증한다. 즉시 선점이나 데이터 drop을 통과 조건으로 삼지 않는다. +- [ ] **신호 adapter 테스트:** 지원 OS·unknown·초기 상태·중복 이벤트·listener 정리·복수 보류 사유를 검사한다. 실제 native 이벤트/가속은 mock 통과와 별도로 Mac/Windows에서 확인한다. +- [ ] **진단 경계 테스트:** 기본 실행에 새 sampler timer/상세 GPU 조회가 없고, 명시적 진단도 시간 만료/종료 후 호출·로그·리스너가 남지 않는지 검사한다. collector를 새 utility로 옮기지 않는다. +- [ ] **실기 완료 판정:** P0 측정 계약으로 위저드·입력/IME·저장·사용자 검색·idle·종료를 전후 비교하고, 두 sidecar 미기동/자동 다운로드 차단과 기존 데이터 보존을 확인한다. 이전의 74 통과/3 실패는 이 신규 계약을 검증한 결과가 아니다. + +P0~P3으로 목표와 회귀 기준을 충족하면 여기서 종료한다. P4는 고정 작업 규칙으로 해결되지 않는 구체적 실기 문제가 남고, 추가 계측/정책의 이득·비용·롤백 조건과 사용자 승인이 갖춰질 때만 다시 연다. 새 native/WASM/Go/Rust 자원 관리 구성요소는 자동으로 따라오는 선택지가 아니다. + +VS Code에서 참고할 것은 지연 초기화·프로세스 경계·성능 mark·실기 검증이다. extension host·대규모 DI·추가 daemon·적응형 기기 관리 구조는 복제하지 않는다. 작가 에디터의 목표는 빠른 위저드, 한국어 입력/읽기 흐름, 저장 안정성, 조용한 idle이다. diff --git a/docs/quality/ipc-channel-allowlist.json b/docs/quality/ipc-channel-allowlist.json index 7c95aa7b..a96a70d6 100644 --- a/docs/quality/ipc-channel-allowlist.json +++ b/docs/quality/ipc-channel-allowlist.json @@ -1,7 +1,8 @@ { "channels": [ - { "channelKey": "WINDOW_MINIMIZE", "reason": "네이티브 창 최소화 버튼용으로 정의만 유지" }, - { "channelKey": "WINDOW_UNMAXIMIZE", "reason": "네이티브 창 최대화 해제용으로 정의만 유지" }, - { "channelKey": "WINDOW_TOGGLE_DEV_TOOLS", "reason": "개발용 채널 — 로컬 디버깅에서만 수동 사용" } + { + "channelKey": "WINDOW_TOGGLE_DEV_TOOLS", + "reason": "개발용 채널 — 로컬 디버깅에서만 수동 사용" + } ] } diff --git a/docs/quality/ipc-contract-map.json b/docs/quality/ipc-contract-map.json index fc69354a..c34a7e02 100644 --- a/docs/quality/ipc-contract-map.json +++ b/docs/quality/ipc-contract-map.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-03T00:45:21.226Z", + "generatedAt": "2026-09-09T23:47:33.577Z", "summary": { - "definedChannels": 224, + "definedChannels": 225, "errors": 0, "warnings": 0 }, @@ -180,7 +180,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 167 + "line": 105 } ], "main_emit": [], @@ -213,7 +213,7 @@ "renderer_listen": [ { "file": "src/preload/api/windowApi.ts", - "line": 27 + "line": 37 } ], "renderer_send": [], @@ -273,7 +273,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 149 + "line": 87 } ], "main_emit": [], @@ -304,7 +304,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 161 + "line": 99 } ], "main_emit": [], @@ -366,7 +366,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 179 + "line": 117 } ], "main_emit": [], @@ -397,7 +397,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 155 + "line": 93 } ], "main_emit": [], @@ -428,7 +428,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 141 + "line": 79 } ], "main_emit": [], @@ -459,7 +459,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 120 + "line": 58 } ], "main_emit": [], @@ -492,7 +492,7 @@ "renderer_listen": [ { "file": "src/preload/api/windowApi.ts", - "line": 40 + "line": 50 } ], "renderer_send": [], @@ -521,7 +521,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 130 + "line": 68 } ], "main_emit": [], @@ -552,7 +552,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 173 + "line": 111 } ], "main_emit": [], @@ -6262,13 +6262,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 48 + "line": 60 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 108 + "line": 46 } ], "main_emit": [], @@ -6293,13 +6293,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 47 + "line": 58 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 188 + "line": 137 } ], "main_emit": [], @@ -6317,14 +6317,19 @@ } }, { - "key": "WINDOW_MINIMIZE", - "channel": "window:minimize", - "allowlisted": true, + "key": "WINDOW_MAXIMIZED_CHANGED", + "channel": "window:maximized-changed", + "allowlisted": false, "usage": { "renderer_invoke": [], "main_handle": [], "main_emit": [], - "renderer_listen": [], + "renderer_listen": [ + { + "file": "src/preload/api/windowApi.ts", + "line": 65 + } + ], "renderer_send": [], "main_listen": [] }, @@ -6332,6 +6337,37 @@ "renderer_invoke": 0, "main_handle": 0, "main_emit": 0, + "renderer_listen": 1, + "renderer_send": 0, + "main_listen": 0 + } + }, + { + "key": "WINDOW_MINIMIZE", + "channel": "window:minimize", + "allowlisted": false, + "usage": { + "renderer_invoke": [ + { + "file": "src/preload/api/windowApi.ts", + "line": 57 + } + ], + "main_handle": [ + { + "file": "src/main/handler/system/window/ipcWindowHandlers.ts", + "line": 126 + } + ], + "main_emit": [], + "renderer_listen": [], + "renderer_send": [], + "main_listen": [] + }, + "usageCounts": { + "renderer_invoke": 1, + "main_handle": 1, + "main_emit": 0, "renderer_listen": 0, "renderer_send": 0, "main_listen": 0 @@ -6345,13 +6381,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 55 + "line": 69 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 254 + "line": 217 } ], "main_emit": [], @@ -6376,13 +6412,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 60 + "line": 74 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 276 + "line": 239 } ], "main_emit": [], @@ -6407,13 +6443,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 51 + "line": 63 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 218 + "line": 181 } ], "main_emit": [], @@ -6438,13 +6474,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 62 + "line": 76 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 285 + "line": 248 } ], "main_emit": [], @@ -6469,13 +6505,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 53 + "line": 67 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 236 + "line": 199 } ], "main_emit": [], @@ -6521,13 +6557,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 49 + "line": 61 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 202 + "line": 165 } ], "main_emit": [], @@ -6547,18 +6583,28 @@ { "key": "WINDOW_UNMAXIMIZE", "channel": "window:unmaximize", - "allowlisted": true, + "allowlisted": false, "usage": { - "renderer_invoke": [], - "main_handle": [], + "renderer_invoke": [ + { + "file": "src/preload/api/windowApi.ts", + "line": 59 + } + ], + "main_handle": [ + { + "file": "src/main/handler/system/window/ipcWindowHandlers.ts", + "line": 151 + } + ], "main_emit": [], "renderer_listen": [], "renderer_send": [], "main_listen": [] }, "usageCounts": { - "renderer_invoke": 0, - "main_handle": 0, + "renderer_invoke": 1, + "main_handle": 1, "main_emit": 0, "renderer_listen": 0, "renderer_send": 0, diff --git a/docs/quality/performance-audit-2026-09-08.md b/docs/quality/performance-audit-2026-09-08.md new file mode 100644 index 00000000..52d849bd --- /dev/null +++ b/docs/quality/performance-audit-2026-09-08.md @@ -0,0 +1,239 @@ +# Luie 크로스플랫폼 성능·저장·프로세스 심층 감사 + +2026-09-08 · 기준 commit `128d8be6` 및 현재 작업 트리 · macOS arm64 + +후속 [DB·저장 아키텍처 재검토](storage-architecture-review-2026-09-08.md)에서 파일 재열기의 최신 DB 덮어쓰기, package 단건 원자성, read/write 크기 제한 불일치를 추가 재현했다. 저장 영역의 착수 순서와 `.luie` 증분 설계는 후속 문서를 우선한다. + +## 1. 종합 판정 — Risky + +가장 큰 문제는 **작은 편집 하나가 프로젝트 전체 작업으로 확대되는 것**, **동기 작업이 main/renderer 실행 스레드를 점유하는 것**, **여러 비동기 계층의 최신 버전·완료·취소 조건이 일치하지 않는 것**이다. OS별 플래그나 worker 개수를 먼저 바꿔서는 이 문제를 해결할 수 없다. 저장 정확성을 먼저 고정하고, 불필요한 전체 작업을 줄인 다음, 남은 무거운 작업의 실행 위치를 바꾸는 순서가 필요하다. + +기존 기반에는 유효한 개선도 많다. 본문과 목록 DTO 분리, 본문 캐시의 retain/generation/inflight 처리, preload 저장 병합, WAL, 패키지 원자 교체와 revision 확인, utility의 모델 분리, 화면 lazy load가 이미 있다. 이를 유지하며 실제로 깨진 경계를 수정하는 것이 가장 작은 해결책이다. + +세 서브 에이전트가 DB/파일·renderer·main/OS를 독립 조사했고, 주 에이전트가 preload/IPC와 종단 흐름을 검토하고 재현·빌드 결과를 대조했다. 적용 지침: `senior-code-reviewer`, `ponytail`, renderer의 `vercel-react-best-practices`. 요청된 code-review-graph MCP가 이 세션에 제공되지 않아 범위를 좁힌 소스 탐색과 실제 호출부·테스트 대조를 사용했다. + +상세 근거는 다음 네 문서에 있다. 항목 ID는 각 문서에서 사용한다. + +| 문서 | 범위 | +|---|---| +| [DB·저장·검색·동기화](performance-audit-2026-09-08/database.md) | DB-01~14, transaction·FTS·chunk·revision·package·sync | +| [Renderer·에디터·메모리](performance-audit-2026-09-08/renderer.md) | R1~13, 입력·저장 전환·graph·diff·store·IME | +| [Main·utility·OS](performance-audit-2026-09-08/main-platform.md) | M01~12, startup·scheduler·모델·전원·배포 | +| [IPC·preload·측정 경계](performance-audit-2026-09-08/ipc.md) | I01~05, timeout·retry·로그·trace·검증 한계 | + +`확정`은 코드 경로나 분리 재현에서 확인했다는 뜻이다. 실제 사용자 데이터 손실 발생 건수나 모든 OS의 성능을 측정했다는 뜻은 아니다. 합성 데이터의 시간은 문제 규모를 보여주는 근거이며 개선율 보장은 아니다. + +### 기준과 조사 범위 + +현재 설치/선언 버전은 Electron 44.2.0, React 19.2.8, TypeScript 6.0.2, better-sqlite3 13.0.3, Vite 8.2.2, Vitest 5.0.0이다. `AGENTS.md`의 Electron 40/TS5 스냅샷과 다르므로 현재 소스를 기준으로 판단했다. 처음부터 존재한 `package.json`, `pnpm-lock.yaml` 변경을 보존했다. + +tracked TS/TSX/JS/MJS/CJS/SQL 파일 기준 src/main 463개, preload 7개, renderer 526개, shared 127개, src/types 2개로 **1,125개/146,658줄**을 목록화했다. 관련 tests는 399개, scripts는 75개이며 배포 워크플로와 Supabase 경계도 조사했다. 목록화와 모든 줄의 수동 검토는 다르다. 주요 실행 경로를 종단 추적한 감사이며, 모든 SQL statement·OS 드라이버·클라우드 운영 설정까지 전수 검증한 것은 아니다. + +### 현재 쓰기 경로 + +```mermaid +flowchart TD + A[TipTap 실제 문서 변경] --> B[Editor 직렬화 debounce 900ms] + B --> C[React draft와 autosave debounce 300ms] + C --> D[handleSave: 캐시 변경과 저장 enqueue] + D --> E[preload 최신 payload 병합 300ms] + E --> F[IPC AUTO_SAVE] + F --> G[main pending과 mirror / 기본 debounce 200ms] + G --> H[Chapter + Body + Revision + derived jobs] + H --> I[단건 검색 cache 갱신] + H --> J[main derived worker] + J --> K[프로젝트 전체 FTS rebuild] + J --> L[chunk 전체 교체 / embedding과 summary] + H --> M[package export debounce 500ms] + M --> N[전체 프로젝트 수집 / 새 SQLite package / 교체] + S[수동 저장·종료] --> T[save buffer registry] + T --> E + T -. raw 900ms 문서 버퍼 누락 .-> B +``` + +관련 소스: [Editor](../../src/renderer/src/features/editor/components/Editor.tsx), [handleSave](../../src/renderer/src/features/manuscript/hooks/useChapterManagement.ts), [preload](../../src/preload/index.ts), [autoSaveManager](../../src/main/manager/autoSave/autoSaveManager.ts). + +일반 입력이 멈춘 뒤 기본 타이머만 합쳐도 `900 + 300 + 300 + 200 = 1,700ms`다. 이는 측정값이 아니라 소스상 대기 시간의 합이며, SQL/스케줄링/IPC/패키지 비용은 별도다. 설정 주석의 약 0.5초 목표는 앞단 타이머들을 반영하지 못한다. 900ms보다 짧은 간격으로 계속 입력하면 직렬화 타이머가 계속 밀리므로 미전달 raw draft가 존재하는 기간은 900ms보다 길 수 있다. 수동 저장이 이 지연을 안전하게 우회해야 한다. + +## 2. 먼저 고쳐야 하는 문제 + +### 저장·최신성: 성능 변경 전에 보장해야 할 조건 + +| 우선순위 | 확인한 문제 | 실제 영향과 최소 조치 | 상세 | +|---|---|---|---| +| P1 | raw 문서의 900ms 버퍼가 저장 registry에 없음 | 즉시 저장/언마운트가 최신 입력을 놓침. raw document revision과 flush를 기존 registry에 연결 | R1 · 실제 Editor/hook 분리 재현 | +| P1 | split editor가 이전 chapterId를 버림 | A의 미저장 본문을 B로 전환할 때 B에 저장. wrapper에서 3번째 인자 보존 | R2 · 실제 wrapper/hook 재현 | +| P1 | main 저장 완료가 새 pending을 무조건 삭제 | A 저장 중 도착한 B가 사라진 상태로 saved 발송. 완료한 generation만 정리 | DB-01 · 실제 함수 재현 | +| P1 | 실패가 저장 성공 경로까지 전달되지 않음 | DB update 실패 후 옛 DB의 package export만 성공해도 수동 저장 성공 가능. flush가 실패/요구 revision을 검사 | DB-02 · 호출 경로 확인 | +| P1 | 같은 SQLite 연결에서 BEGIN 뒤 await | 다른 도메인의 성공한 SQL이 chapter rollback에 함께 취소. 동기 transaction 내부에 DB 변경만 묶기 | DB-03 · native SQLite + 실제 소스 재현 | +| P1 | derived 완료에 source generation 비교 없음 | 새 본문 B가 있는데 A의 chunks를 만들고 pending=0. claim/완료를 generation CAS로 연결 | DB-04 · 실제 소스 재현 | +| P1 | remote fetch에 pagination 없음 | 서버 행 상한을 넘으면 일부 결과를 전체로 취급. 안정적 cursor/Range로 완전 수집 | DB-07 · 서버 상한 조건부 | + +실제 마지막 저장 호출도 확인했다. [useChapterManagement.ts:355](/Users/user/Luie/src/renderer/src/features/manuscript/hooks/useChapterManagement.ts:355)는 lastSavedRef를 먼저 갱신하고 `void api.autoSave(...)`의 실패를 로그만 남긴다. 따라서 상위 `useEditorAutosave`의 저장 성공·dirty 해제·재시도가 실제 enqueue 실패를 받지 못한다. 여기서 promise를 반환해도 main의 응답은 우선 enqueue 승인이다. **편집 버퍼 반영, 큐 접수, DB commit, package checkpoint를 각각 구분하고 수동 저장은 요구한 revision의 완료를 확인해야 한다.** preload의 실패 payload 보존이 있어 모든 경우를 영구 손실로 단정할 수는 없다. + +DB-07의 1,000행 기준은 저장소 `supabase/config.toml`에 명시된 값이다. 운영 클라우드 설정은 확인하지 않았다. 서버가 설정된 상한까지만 응답한다면 현재 fetch는 뒤 페이지를 요청하지 않는다. [Supabase select 문서](https://supabase.com/docs/reference/javascript/select) + +SQLite 트랜잭션은 연결에 귀속된다. async callback을 transaction helper에 그대로 감싸는 수정으로는 해결되지 않는다. 외부 비동기 준비를 끝낸 뒤 동기 SQL 블록을 실행해야 한다. [better-sqlite3 transaction 문서](https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#transactionfunction---function) + +### 사용 중 멈춤을 만드는 큰 연산 + +| 우선순위 | 병목 | 비용이 커지는 이유 | 우선 수정 | +|---|---|---|---| +| P1 | chapter 하나 변경 후 전체 검색 rebuild | 직접 단건 cache upsert 뒤에도 모든 chapter를 다시 처리 | dirty sourceId만 갱신, 전체 rebuild는 명시 유지보수에 한정 · DB-05 | +| P1 | FTS의 반복 DELETE+INSERT | UNINDEXED chapterId scan과 개별 commit. rebuild의 id 탐색이 제곱으로 증가 | transaction과 prepared INSERT, 일반 테이블 rowid 매핑 · DB-06 | +| P1 | renderer force layout | 75/85회 × 모든 노드 쌍 비교 | worker로 이동·이전 요청 취소, 이후 알고리즘 비용 감소 · R4 | +| P1 | snapshot diff | 50k 문자 이하여도 토큰 차이가 많으면 수초 계산. selection에서도 재계산 | revision별 결과 재사용, 시간 제한과 worker · R5 | +| P1/P2 | SmartLink | 매 docChanged와 단순 자료 선택에도 전체 문서·decoration 재생성 | 의미 있는 store 변경만 재검색, 변경 textblock만 처리 · R3 | + +`async`, `Promise.all`, `setTimeout`만으로 동기 CPU/SQLite 작업의 실행 스레드가 바뀌지 않는다. 현재 DerivedJobWorker도 이름과 달리 main에서 동작하는 interval 작업이다. 긴 작업은 main의 창·IPC 처리와 분리해야 한다. [Electron 성능 지침](https://www.electronjs.org/docs/latest/tutorial/performance), [Node 동기·비동기 파일 I/O](https://nodejs.org/api/fs.html) + +## 3. 구조적 성능·메모리 문제 + +### SQL·파일·동기화 + +1. **불변 chunk의 embedding도 폐기한다.** source chunk 전체 DELETE와 새 UUID 생성으로 FK cascade가 기존 embedding을 지운다. 아래 계층의 hash 재사용 코드가 작동할 자료가 남지 않는다. content/index hash가 같은 chunk를 보존해야 한다. 동일 본문 재생성에서도 embedding=0을 재현했다. DB-08. +2. **자동 저장마다 전체 본문 revision을 누적한다.** ChapterRevision에는 runtime 보관 상한을 찾지 못했다. Snapshot retention은 별도라 이를 정리하지 않는다. 보관 정책을 정하고 변경량/시간 단위 revision으로 묶어야 한다. 감사 중 사용자 이력을 삭제하지 않았다. DB-09. +3. **작은 변경도 전체 `.luie` 파일을 다시 만든다.** 모든 본문·자료·snapshot·memory를 JS 객체와 JSON으로 만든 뒤 새 SQLite 파일에 동기 기록한다. snapshot 개수 제한도 모두 읽은 뒤 적용한다. SQL projection/limit과 변경 entry 범위를 먼저 줄이고 전체 checkpoint는 별도 실행 경로에서 처리한다. DB-10. +4. **변경 없는 sync도 비용을 낸다.** local/remote/merged bundle을 동시에 보관하고 동일 row까지 다시 UPDATE/POST한다. 동일 본문 UPDATE도 revision trigger와 package 저장을 유발할 수 있다. no-op sync의 DB write·package write·원격 upsert가 0이 되도록 우선 고친다. DB-11. +5. **job 조회 인덱스와 runnable 조건이 실제 쿼리에 맞지 않는다.** 전역 pending 조회에 projectId 선두 인덱스만 있고, LIMIT 뒤에 exhausted failed를 JS로 제거해 새 작업이 계속 밀릴 수 있다. runnable 조건을 SQL로 내리고 그 조건의 EXPLAIN에 맞춰 인덱스를 둔다. DB-12. +6. **키워드 출현마다 SQL을 반복한다.** 같은 인물 1,000회 등장에 appearance INSERT와 firstAppearance 조회가 반복된다. 고유 인물별 처리와 bulk transaction, committed content 기반 derived 작업이 필요하다. DB-13. +7. **저사양 검색 정책 일부가 실행부에 연결되지 않았다.** low-end의 lexical-hit 시 vector skip 값이 정의되어 있지만 utility 검색이 소비하지 않는다. 해당 모드의 query embedding 호출 수부터 검증한다. DB-14. + +### Renderer·IPC + +8. **CanvasMarkdownEditor는 입력마다 전체 텍스트·Markdown을 직렬화한다.** fallback getText도 선계산하고 selection마다 직접 forceUpdate한다. fallback lazy 평가와 필요한 toolbar 상태 구독부터 적용한다. R6. +9. **그래프 여러 항목 변경이 중간 전체 문서를 반복 저장한다.** 개별 삭제마다 목록 reload와 graph persist가 이어지고 queue closure가 각 snapshot을 보관한다. 사용자 한 동작을 한 batch로 묶고 최종 상태를 한 번 저장한다. R7. +10. **통계 worker 메시지에 대상과 request ID가 없다.** 여러 editor가 같은 worker의 모든 응답을 global stats에 쓴다. 활성 chapter/editor의 최신 응답만 반영해야 한다. worker를 여러 개 만드는 해결은 필요 없다. R8. +11. **프로젝트 이름 변경이 전체 초기화를 다시 실행한다.** effect가 project ID 대신 전체 객체에 의존해 5개 자료 reload와 본문 cache reset을 유발한다. 전환과 명시적인 복원 refresh를 분리한다. R9. +12. **renderer 첫 paint도 설정 IPC를 기다린다.** main의 첫 창 지연에 더해 renderer setup이 settings 완료를 기다리고, 이후 다시 읽는다. 별도 export/wizard 창에도 공통 초기화가 실행된다. 최소 shell과 필수 데이터의 경계를 줄인다. R10. +13. **RAG delta마다 전체 대화와 스크롤을 갱신한다.** 과거 메시지·근거 DOM까지 다시 렌더하고 session history 상한이 없다. 짧은 batch로 delta를 모으고 마지막 row만 갱신하며, history의 프로젝트별 수명과 페이지 처리를 정한다. R11. +14. **큰 자료 목록과 숨긴 탭도 DOM/구독을 유지한다.** 이는 의도된 상태 보존이며 곧바로 누수라고 부를 수 없다. 실제 규모가 큰 목록에 이미 설치된 가상화 도구를 적용하고 숨긴 탭의 비싼 계산을 중지한다. R13. +15. **IPC timeout은 원래 작업을 취소하지 않는다.** 15초 뒤 read를 재요청하면 두 작업이 겹친다. 다이얼로그도 같은 제한을 받아 늦은 정상 선택이 유실된다. 요청 수명, read single flight, 장기 job handle, cancel 전파를 구분해야 한다. I01~02. +16. **로그 배치에는 전체 in-flight 상한이 없다.** 1,000개 로그가 50개 동시 batch, batch 실패 후 1,000개 동시 fallback invoke로 확대되는 것을 재현했다. main에서도 로그마다 appendFile을 생성한다. 단일 bounded flush/sink와 rotation이 필요하다. I03/M08. +17. **IPC 구간을 연결할 trace가 없다.** preload UUID와 main UUID가 서로 다르고 main duration은 callback 진입 전 대기와 응답 복사를 제외한다. 같은 trace ID로 구간별 duration·payload bytes·queue depth를 측정해야 한다. I04. + +contextBridge와 IPC에는 값 복사/직렬화 경계가 존재한다. 우선 본문 전송 횟수와 DTO 크기를 줄여야 한다. 모든 IPC를 공유 메모리로 바꾸는 제안은 현재 근거보다 복잡성이 크다. [Electron contextBridge](https://www.electronjs.org/docs/latest/api/context-bridge), [IPC 직렬화](https://www.electronjs.org/docs/latest/tutorial/ipc) + +### Main·utility·백그라운드 + +18. **utility queue의 admission·취소 경계가 없다.** bridge timeout은 pending만 지우며 utility의 요청별 async 작업은 계속될 수 있다. 긴 generation과 짧은 status/stop을 같은 대기 조건에 묶지 말고 기존 signal을 실제 provider까지 연결한다. M01. +19. **provider cache가 갱신된 proxy 인증을 무시한다.** 동일 모델의 wrapper가 이전 Supabase token closure를 재사용한다. 실제 materializer를 제어한 재현으로 확인했다. proxy resolver를 최신화하거나 가벼운 HTTP wrapper를 재생성한다. M02. +20. **일반 재시작이 full readiness 검사에 묶인다.** 창 생성 전에 동기 integrity_check와 non-blocking으로 표시된 네트워크 세션 점검을 모두 기다린다. slow check에서는 TTL도 완료 전에 소진된다. 필수 연결 점검과 나중에 가능한 검사를 분리하고 TTL은 완료 시 계산한다. M03~04. +21. **sidecar가 health-ready 전에 running으로 보인다.** concurrent 요청이 시작 promise에 합류하기 전에 baseUrl을 받아 준비 전 서버에 접근할 수 있다. promise 검사 순서와 상태 변경 시점을 수정하면 된다. M05. +22. **idle에도 500ms DB polling이 계속된다.** macOS는 창을 모두 닫아도 프로세스와 worker가 남는다. enqueue wake와 empty backoff, 전원·visibility에 맞춘 background budget이 필요하다. M06. +23. **stop timeout을 drain 완료로 취급한다.** 5초 뒤에도 진행 중인 tick이 남아 DB close와 경합할 수 있다. tick promise·취소·재예약의 완료를 확인한 뒤 저장소를 닫아야 한다. 반대로 sidecar idle timer는 active request 수를 몰라 긴 요청도 종료 대상으로 삼을 수 있다. M07/M10. + +## 4. 실제 운영·OS별 고려사항 + +### 이번에 얻은 수치 + +아래는 macOS arm64, Node v22.23.0의 분리 측정이다. Electron 앱의 FPS/저장 p99/Windows·Linux 속도로 일반화하지 않는다. graph/SmartLink는 워밍업 후 3회 median, diff와 FTS는 스트레스 입력의 개별 실행값이다. + +| 관측 | 입력/조건 | 결과 | +|---|---|---| +| 실제 graphLayout 함수 | 500 / 1,000 / 2,000 nodes, 75회 | 123 / 359 / 1,444ms | +| 실제 단어 diff | 합산 45,778자, 서로 다른 토큰 각 4,000개 | 2,320ms; 현재 50k 제한 이하 | +| 실제 SmartLink/DecorationSet | 265,000자, 20,000 matches | 144ms | +| 합성 FTS rebuild | 1,200장 × 5,625 bytes, WAL/FULL | 현행 패턴 493.30ms; transaction+불필요 DELETE 제거 54.32ms | +| 합성 pending 조회 | 이력 100,000행/pending 10, 30회 평균 | 2.469ms; runnable partial index 후 0.013ms | +| full revision 누적 | 64KiB 본문 × 600회 | DB 39,665,664 bytes, 약 37.8MiB | +| 현재 소스 임시 production build | 기존 boot budget script | JS 573.1KiB/17개, CSS 166.1KiB, 통과 | + +FTS·queue 수치는 **제품 수정 결과가 아니라 원인 확인을 위한 합성 SQL 비교**다. 제품에는 아직 적용하지 않았다. graph/SmartLink의 반올림된 0ms 소형 결과도 비용이 0이라는 의미는 아니다. + +### 플랫폼 지원과 검증의 실제 차이 + +| 항목 | macOS ARM64 | macOS Intel | Windows x64 | Windows ARM64 | Linux x64 | Linux ARM64 | +|---|---|---|---|---|---|---| +| 배포 경로 | DMG 서명 CI | universal script, 독립 runtime CI 없음 | portable CI | portable CI | AppImage/deb 설정, CI 없음 | 명시 runtime CI 없음 | +| local llama 다운로드 map | 있음 | 있음 | CPU binary | 없음·unsupported | Ubuntu x64 binary | 없음·unsupported | +| 마지막 창 닫힘 | 프로세스 유지 | 프로세스 유지 | quit | quit | quit | quit | +| 이번 OS 실기 성능 검사 | 미실시; Node 분리 벤치만 | 미실시 | 미실시 | 미실시 | 미실시 | 미실시 | + +근거: [sidecarConstants](../../src/main/infra/llm/sidecarConstants.ts), [electron-builder](../../electron-builder.json), [Windows workflow](../../.github/workflows/release-windows.yml), [macOS workflow](../../.github/workflows/release-macos.yml). Windows ARM64 앱을 배포하는 것과 해당 아키텍처에서 local LLM을 제공하는 것은 현재 일치하지 않는다. Windows x64의 gpuLayers 설정만으로 CPU용 binary가 GPU용으로 바뀌지도 않는다. + +OS별 우선 검증은 다음과 같다. + +- **macOS:** 창 없는 상태의 idle CPU/wakeups, App Nap과 background 작업, 절전/복귀, Apple Silicon의 CPU/GPU 통합 메모리, Intel 별도 실행. Metal/GPU 효율은 실제 제공 binary와 기기로 확인한다. +- **Windows:** x64/ARM64 packaged native load, Defender/동기화 폴더의 파일 교체 지연과 잠금, 긴 경로·UNC, Microsoft IME. GPU runtime은 지원 backend와 driver를 감지하고 CPU fallback을 제공하는 범위에서 선택한다. +- **Linux:** AppImage/deb의 native library와 glibc 호환, Wayland/X11, IBus/Fcitx, 배포판별 file dialog, suspend/resume. macOS와 같은 배터리·thermal 이벤트를 제공한다고 가정하지 않는다. +- **공통:** 조합 중 Enter를 무시하지 않는 RAG composer 경로(R12), 저장 도중 창 닫기/종료 취소/절전, 느린 디스크·외장 볼륨·DB lock·모델 cold start를 같은 fixture로 검사한다. + +전원 이벤트의 지원 OS는 기능마다 다르므로 capability 확인 후 연결해야 한다. [Electron powerMonitor](https://www.electronjs.org/docs/latest/api/power-monitor) + +### 저수준 최적화에서 유지할 원칙 + +- SQLite `synchronous=FULL`과 fsync를 일괄 제거하지 않는다. canonical 데이터와 재생성 가능한 cache의 내구성 요구를 분리한 다음 transaction·write amplification을 먼저 줄인다. cache의 설정 변경은 현재 startup PRAGMA 검사와 복구 계약도 함께 조정해야 한다. [SQLite PRAGMA](https://www.sqlite.org/pragma.html#pragma_synchronous) +- 현재 main의 `busy_timeout=5000`, integrity_check, 큰 동기 transaction은 이벤트 루프 점유 후보다. lock 시간과 checkpoint duration을 측정한다. 단순 timeout 상향으로 처리량을 해결하지 않는다. +- worker/thread/process 수를 코어 수만큼 늘리지 않는다. DB write owner와 순서를 고정하고 LLM·embedding·foreground 입력의 CPU/RAM budget을 나눈다. `threads=4`, cacheRam 2,048MiB는 현재 옵션값이며 실제 RSS 할당량을 뜻하지 않는다. +- 메모리는 renderer/main/utility/llama별 JS heap, native/external/ArrayBuffer, private working set, model/KV/GPU를 구분한다. shared page가 있는 RSS 합계는 실제 물리 메모리를 중복 계산할 수 있다. 긴 세션에서 증가 기울기와 GC 뒤 잔존량을 함께 본다. +- 큰 벡터 전송의 boxing/복사 비용이 측정되면 typed array와 명시적인 transfer 경로를 검토한다. 현재 persisted vector 전량을 JS로 가져와 계산하는 fallback은 확인되지 않았다. 실제 기본 RAG는 utility의 native scalar cosine 검색이다. +- 전역 GPU blacklist 무시, contextIsolation/sandbox 해제, backgroundThrottling 일괄 해제, 무조건 `UV_THREADPOOL_SIZE` 확대, 새 native/SIMD/ANN 모듈 추가를 현재 해결안으로 삼을 근거는 없다. + +## 5. 실행 가능한 수정 순서와 검증 + +### 1단계 — 저장 정확성과 요청 수명 + +R1/R2, DB-01~04를 먼저 고친다. 원고 version과 chapterId를 raw editor → enqueue → DB commit → package checkpoint까지 보존한다. 수동 저장은 실패를 전달하고, worker 완료는 처리한 source generation이 최신인지 검사한다. utility에서는 기존 requestId/signal로 timeout→cancel→settle을 연결한다. + +완료 기준: 즉시 Ctrl/Cmd+S·연속 입력 후 전환·저장 중 재입력·DB 실패·종료 취소에서 마지막 입력과 대상 chapterId가 보존된다. unrelated domain write가 다른 transaction 실패에 취소되지 않는다. 새 source를 처리하지 않은 job이 completed로 끝나지 않는다. + +### 2단계 — 같은 일을 반복하는 비용 제거 + +DB-05/06/08/09/11/12/13, R3/R7/R9와 I03을 고친다. 단건 FTS, bulk transaction, 불변 chunk 보존, 올바른 runnable SQL, 의미 있는 store 변경만 처리, graph 최종 상태 1회 저장을 적용한다. revision 보관은 복구 요구와 함께 정한다. + +완료 기준: 한 chapter 수정 시 관련 없는 chapter의 FTS/embedding을 다시 쓰지 않는다. no-op update/sync가 불필요한 write를 만들지 않는다. SQL/commit 수와 log queue depth가 입력 규모·실제 변경량에 맞는다. + +### 3단계 — 남은 무거운 연산의 위치 변경 + +renderer의 graph/diff는 기존 순수 함수를 worker에서 실행하고 최신 request 결과만 반영한다. package 전체 checkpoint·큰 index/maintenance는 main의 창/IPC 처리와 분리한다. 기존 utility/worker 기반을 재사용하되 LLM 장기 연산이 DB 제어 요청을 막지 않도록 작업 소유권을 정한다. 모든 단건 SQL을 무조건 IPC로 바꾸는 전면 개편은 피한다. + +권장 경계: renderer는 입력과 화면, preload는 검증된 API와 요청 수명, main은 창·OS·권한·수명 조정, 데이터 작업 소유자는 완결된 DB transaction/큰 batch, 기존 모델 utility는 inference와 sidecar 수명을 맡는다. transaction 내부 statement를 여러 IPC 호출로 쪼개지 않는다. + +완료 기준: graph/diff/checkpoint가 진행 중이어도 입력과 창·IPC 처리가 응답한다. 일반 변경 저장은 관련 없는 package entry를 다시 쓰지 않으며, 전체 checkpoint의 원자 교체와 revision 확인은 유지한다. + +### 4단계 — OS/하드웨어별 예산과 회귀 검증 + +새 benchmark 프레임워크보다 기존 fullprod/stress/save-latency를 확장한다. artifact hash, Electron/Node/SQLite 버전, OS/arch/CPU/RAM/전원 상태, fixture 크기를 함께 기록한다. userData까지 격리한 clean packaged 앱으로 실행한다. + +| 시나리오 | 반드시 볼 지표 | +|---|---| +| cold/warm 시작, 오프라인 시작 | 첫 창·첫 React paint·editor ready, 단계별 critical path | +| 5k/50k/250k 원고와 한글·일본어 입력 | key→paint p50/p95/p99, long task, serialization·SmartLink 시간 | +| 100/500/1,000/2,000 노드 | layout wall time, 계산 중 입력 반응, pan/zoom frame, stale 작업 수 | +| 300/1,200장 저장과 sync | enqueue/commit/checkpoint 각각의 지연, SQL/commit 수, WAL/DB/package bytes | +| idle 10분·창 없음·절전 복귀 | CPU/wakeups, runnable poll 횟수, private memory 증가, 재개 정확성 | +| model cold/warm·느린 provider | queue depth, cancellation latency, active count, chat/embedding 자원 경쟁 | +| 닫기/전환 반복과 대화 장기 사용 | listener·worker·timer 수, retained objects, history/캐시 bytes | + +입력 p95 16ms 이내, main/renderer의 지속적인 50ms 초과 task 제거 같은 목표를 출발점으로 삼을 수 있으나, 이는 **제안 목표**다. 저장 p95와 RAM 상한은 장치 등급·프로젝트 규모·내구성 단계별 baseline을 먼저 얻어 설정해야 한다. 이번 조사로 목표 달성이나 특정 비율의 개선을 인증하지 않았다. + +### 실제 실행 결과 + +| 검사 | 결과와 의미 | +|---|---| +| 기존 IPC/preload/handler suite | 5개 파일 / 29개 테스트 통과 | +| 기존 renderer 저장·캐시·통계 suite | 5개 파일 / 34개 테스트 통과 | +| 기존 main/lifecycle/sidecar suite | 7개 파일 / 20개 테스트 통과 | +| 분리 재현 | raw editor flush, split chapter target, pending 삭제, transaction 혼입, job 세대 손실, embedding 폐기, sidecar ready/token/TTL, IPC retry/dialog/log 현상 확인 | +| 현재 작업 트리 production build | 임시 out에 main/preload/renderer 빌드 성공 | +| 현재 빌드 render boot budget | JS 573.1KiB ≤600, CSS 166.1KiB ≤170, 통과 | +| TypeScript 전체 검사 | 기존 Sidebar.tsx:157의 unused `handleRenameProject` TS6133 한 건으로 실패 | +| preload 계약·handler schema gate | 통과 | +| IPC contract map gate | 문서 drift 발견. 검사 도구가 만든 변경만 복원, patch를 임시 보관 | +| pnpm 실행 | registry 접근/서명 검증 오류로 실패; 설치된 로컬 runner로 해당 검사를 직접 실행 | +| full qa:core·실앱 OS profiler | 전체 실행하지 않음. 위 항목을 전체 통과로 표현하지 않음 | + +기존 테스트는 총 **83개 통과**했다. 분리 재현은 제품이 안전하다는 테스트가 아니라 현재 결함이 나타나는 조건을 확인한 관측 검사다. 정식 수정 때 기대값을 정상 동작으로 바꾸고 관련 integration regression으로 편입해야 한다. + +빌드는 `node node_modules/electron-vite/bin/electron-vite.js build --outDir /private/tmp/luie-audit-build/out`으로 실행했다. 저장소의 기존 `out`은 현재 소스와 달랐으며, 기존 1,108.9KB 부트 초과를 현재 결함으로 보고하지 않는다. 새 빌드에는 manuscript barrel의 `INEFFECTIVE_DYNAMIC_IMPORT` 경고가 남지만 부트 예산은 통과한다. + +명령·fixture·JSON·mock 경계는 각 상세 보고서에 있다. 재현 스크립트는 `/private/tmp/luie-*-repro.cjs`, `/private/tmp/luie-main-probes.cjs`, `/private/tmp/luie-ipc-audit/`, `/private/tmp/luie-renderer-audit/`에 보존했다. 이는 세션 임시 산출물이므로 장기 보관하려면 함께 보관해야 한다. 사용자 앱·DB·외부 모델 서버를 기동하거나 native rebuild·배포를 실행하지 않았다. + +## 6. 후순위 개선과 남은 확인 + +- logger의 순환 배열/깊이·크기 처리, synopsis cache 보관 상한, performance.measure 누적은 보강 대상이다. 핵심 저장/전체 재처리 문제보다 먼저 대형 구조를 만들 필요는 없다. +- cache 엔트리 수와 bytes 상한은 다르다. 현재 본문 LRU·retain을 무시하고 활성 문서를 내보내면 IPC 재조회가 증가한다. 정확한 retained bytes를 보고 조정한다. +- vector exact scan의 O(N×D) 비용, layout spatial approximation, chunk별 증분 통계, 이미지 원문과 decode bitmap 중복은 큰 데이터에서 profile 후 선택한다. +- 클라우드 PostgreSQL의 실 운영 EXPLAIN·RLS·index, 모든 memory/eval SQL, OS별 heap snapshot·GPU·disk latency는 이번 감사에서 전수 확인하지 않았다. 원격 데이터가 늘 때의 sync merge/CAS와 native 파일 교체의 crash 내구성도 실환경 검증이 남았다. +- 보안 경계, 입력 검증, 원자 저장, 실패 시 복구를 유지한다. 지금 가장 큰 개선 여지는 새 의존성이나 저수준 재작성보다 **변경 범위 축소와 정확한 작업 수명 관리**에 있다. diff --git a/docs/quality/performance-audit-2026-09-08/database.md b/docs/quality/performance-audit-2026-09-08/database.md new file mode 100644 index 00000000..0dcccf84 --- /dev/null +++ b/docs/quality/performance-audit-2026-09-08/database.md @@ -0,0 +1,187 @@ +# Luie DB·저장·검색·동기화 심층 감사 + +판정: **Risky**. 저장 완료 신호, 트랜잭션 격리, 파생 작업의 최신 세대 보장에 실제 소스로 재현한 결함이 있다. 성능상 가장 큰 공통 비용은 SQLite 엔진 자체보다 **한 번의 본문 변경이 전체 검색 재구축·전체 패키지 생성·전체 chunk 교체를 유발하는 작업 증폭**이다. + +읽기 전용 감사. 애플리케이션/사용자 데이터/의존성/네이티브 모듈을 수정하지 않았다. 실험은 `/private/tmp` 및 SQLite `:memory:`만 사용했고 임시 DB와 임시 번들은 정리했다. 기준 런타임: macOS arm64, Node v22.23.0, 설치된 better-sqlite3가 보고한 SQLite 3.53.4. 이 수치는 Electron 배포판이나 Windows/Linux 실측값이 아니다. + +## 실행 경로 + +1. `AUTO_SAVE` IPC → `AutoSaveManager.triggerSave` → pending map + mirror queue → debounce timer/interval/manual flush → `performAutoSave`. +2. `ChapterService.updateChapter` → 기존 canonical body 읽기/검증/키워드 추적 시작 → singleton better-sqlite3 connection에서 Chapter + ChapterBody + ChapterRevision + SearchDirtyQueue + MemoryBuildJob 쓰기 → direct search cache upsert → package export debounce 500 ms. +3. DerivedJobWorker는 OS thread가 아니라 **main의 500 ms setInterval**이다. 검색 dirty는 프로젝트 전체 cache rebuild. Memory source는 batch 선조회 후 job 사이 setImmediate; 각 job의 실제 chunk 변환/SQL 트랜잭션은 main에서 동기 실행한다. +4. Package export는 모든 챕터·엔터티·스냅샷·canonical memory를 읽고 JSON entry들을 만들고, 신규 SQLite `.tmp` 파일에 전체 내용을 동기 삽입한 후 rename한다. +5. Sync는 전체 로컬 bundle과 전체 원격 bundle을 모아서 merge → 전체 로컬 upsert 트랜잭션 → .luie persistence → 전체 원격 table POST를 수행한다. +6. 벡터 검색의 기본 경로는 utility process의 RAG이다. main은 기본값 `LUIE_VECTOR_SEARCH_UTILITY_ONLY=true`로 vector 검색을 생략한다. utility는 자체 better-sqlite3 연결을 열고 scalar `vec_distance_cosine`으로 검색한다. + +## 우선 수정해야 할 확정 이슈 + +### DB-01 · P1 · 저장 완료가 새 pending 본문을 지운다 — 실제 함수 재현 + +- 위치: `src/main/manager/autoSave/autoSavePerformSave.ts:45`, `:60`, `:65`, `:69`; 호출부 `autoSaveManager.ts:192`, `:228`, `:239`. +- Trigger: A 본문 저장이 진행되는 동안 동일 chapter의 새 본문 B가 `triggerSave`로 도착한다. +- 원인: 저장 시작에 캡처한 pending=A를 await 후 무조건 `pendingSaves.delete(chapterId)`한다. B가 같은 키를 대체했는지 검사하지 않는다. 새 timer의 map entry도 지운다. debounce 경로는 project task queue를 통하지 않으므로 동시에 저장 호출되는 경우도 배제되지 않는다. +- 결과: A만 DB에 저장됐는데 B pending이 없어지고 saved event가 발송된다. B의 기존 timer가 나중에 실행돼도 pending을 못 찾는다. trigger 시 B mirror가 있었다 하더라도 A 완료 직후 old pending의 mirror를 다시 써서 복구 신호를 약화할 수 있다. mirror의 최종 상태는 interleave에 따라 달라지므로 이 부분은 별도 OS crash 재현이 필요하다. +- 최소 수정: 완료한 pending 객체 또는 generation이 현재 map과 같은 경우에만 삭제/타이머 정리/saved 처리. 새 세대가 있으면 유지해 반드시 후속 저장. chapter별 in-flight 처리는 기존 queue와 통합한다. +- 검증: `/private/tmp/luie-autosave-pending-repro.cjs`는 실제 `performAutoSave` 소스를 TS 변환해 실행했다. old save Promise 대기 중 map을 new payload로 바꾼 결과 `savedContents=['old payload']`, `newContentStillQueued=false`, `savedEvents=['c']`. + +### DB-02 · P1 · DB 저장 실패가 수동 저장 성공으로 바뀔 수 있다 — 호출 경로 확정 + +- 위치: `autoSavePerformSave.ts:80`의 catch; `autoSaveFlushOps.ts:82`의 flush; `src/main/handler/writing/ipcAutoSaveHandlers.ts:55`. +- Trigger: 본문 DB update는 실패하지만 기존 DB 내용으로 패키지 export는 성공한다. 제약 오류/일시 SQLite 오류/DB와 package가 서로 다른 저장 장치인 상황 등이 가능하다. +- 원인: performAutoSave는 error event만 emit하고 `Promise`를 정상 resolve한다. flush는 pending이 남았는지 또는 각 저장 결과가 실패인지 확인하지 않는다. MANUAL_SAVE는 flush가 끝나고 export가 true면 `{success:true,exported:true}`를 반환한다. +- 결과: Ctrl/Cmd+S의 성공 응답이 최신 본문의 저장 성공을 증명하지 않는다. pending은 남아도 사용자에게 성공 응답이 전달된다. +- 최소 수정: 내부 save는 실패를 호출자에게 전달하고 timer 경계가 이를 처리하거나, 명시적 결과를 반환해 flush가 실패/미저장 세대를 검사한다. 수동 저장은 해당 프로젝트의 요구 revision까지 DB에 반영됐음을 확인한 뒤 checkpoint한다. +- 기존 테스트: `tests/main/handler/manualSaveHandler.test.ts`는 flush를 항상 정상 resolve mock하고 checkpoint 실패만 검사한다. 실제 performAutoSave→flush→handler 실패 전파 검증이 필요하다. 이것은 호출 경로 확인이며 full handler 실패 주입 실험은 수행하지 않았다. + +### DB-03 · P1 · async 수동 트랜잭션이 다른 도메인의 성공한 쓰기까지 rollback한다 — 실제 함수 재현 + +- 위치: `src/main/services/core/chapter/chapterWriteOperations.ts:235`–`:278`(BEGIN/await/COMMIT); 생성 경로도 `:88` 전후 동일 구조. `src/main/services/features/manuscript/chapterService.ts:34`의 queue는 chapter service만 보호한다. +- Trigger: chapter 트랜잭션의 await 사이에 다른 handler/파생 작업의 동일 singleton connection 쓰기가 실행되고 chapter 저장이 실패한다. +- 원인: better-sqlite3는 동기 연결인데 `BEGIN IMMEDIATE` 이후 여러 await로 JS 실행권을 양보한다. SQLite 트랜잭션은 async task가 아닌 connection 소속이다. 다른 도메인의 SQL이 열린 chapter 트랜잭션에 합류한다. chapter 전용 queue는 다른 도메인 접근을 직렬화하지 못한다. +- 결과: 다른 도메인이 성공으로 인식한 변경이 chapter ROLLBACK에 같이 취소된다. 다른 수동 BEGIN은 nested transaction 오류가 날 수 있다. 잠금 보유 시간도 비동기 작업 지연에 노출된다. +- 최소 수정: 준비/검증/해시/외부 IO를 트랜잭션 앞에서 마치고 `store.transaction(tx => { ...run(); ... })`의 동기 callback 안에 DB 변경만 모은다. helper도 명시적 tx를 전달받고 동기 SQL로 실행한다. async callback을 그대로 drizzle transaction에 옮기면 해결되지 않는다. +- 검증: `/private/tmp/luie-chapter-tx-repro.cjs`. 실제 updateChapterRecord/contentStore/derivedJobs와 native SQLite 사용. BEGIN 이후 microtask에서 unrelated domain INSERT 성공 후 revision INSERT에 실패 trigger 주입. 최종 `otherDomainWriteCompleted=true`, `otherDomainRowsAfterChapterRollback=0`, chapter는 old로 rollback. 환경 초기화/로그/검증 부수효과/package만 격리했다. + +### DB-04 · P1 · 작업 도중 새 본문이 와도 이전 내용을 completed로 확정한다 — 실제 함수 재현 + +- 위치: `memoryProjectionService.ts:99` source batch 조회 → `:108` setImmediate → `:110` claim → `:114` old sourceMap → `:212` completed update. `chapterDerivedJobs.ts:69` 전후 upsert; `memoryJobConstants.ts:36`의 dedupe status에 pending/running/failed/paused 포함. `jobControl.ts:112` claim은 id/status만 검사. +- Trigger: worker가 source A를 읽은 뒤 양보한 시점 또는 다른 job을 처리하는 동안 같은 source가 B로 저장된다. +- 원인: 새 enqueue는 기존 job의 priority/updatedAt만 바꾼다. worker가 처리한 source generation/hash와 현재 canonical generation을 완료 단계에서 비교하지 않는다. +- 결과: DB body는 B인데 chunks는 A이고 해당 source의 pending chunk job은 0이다. summary/embedding도 running job을 같은 방식으로 덮으므로 일반적인 세대 관리 결함이다. 검색 dirty도 `dbMaintenanceService.ts:334` rebuild 후 `:345` id만 조건으로 completed를 덮는 구조다. +- 최소 수정: enqueue generation/source hash와 worker가 claim한 generation을 구분한다. 완료는 같은 generation에서만 확정하고 새 generation이면 pending 유지/재enqueue. source를 claim 뒤 읽는 것은 창을 줄이지만 전체 해결은 아니며, 완료 시 CAS가 필요하다. +- 검증: `/private/tmp/luie-db-source-repro.cjs`는 실제 memoryProjectionService/chapterDerivedJobs/jobControl/sourceRows를 실행한다. source 선조회 이후 setImmediate 지점에 새 body+enqueue를 삽입했더니 body=`new unsaved-to-index text`, chunk=`old original text`, remainingPendingChunkJobs=0. + +### DB-05 · P1 · 한 chapter dirty가 전체 프로젝트 FTS 재구축을 유발한다 — 경로 확정 + 합성 성능 재현 + +- 위치: `dbMaintenanceService.ts:303` queue 조회 → `:316` project grouping → `:334` `rebuildProjectIndex(projectId)`; `chapterSearchCacheService.ts:373` rebuild; `chapterWriteOperations.ts:301` 직접 단건 cache upsert도 존재. +- Trigger: 자동 저장으로 chapter 하나의 내용이 바뀌고 worker가 검색 dirty를 처리한다. +- 원인: sourceId는 큐에 보관하지만 실제 처리에서는 사용하지 않고 모든 chapter 본문을 조회해 cache를 지운 뒤 다시 넣는다. 직전 저장에서 수행한 단건 upsert도 중복된다. +- 복잡도: 저장 1회가 프로젝트 전체 본문 B에 비례하는 text copy/tokenization과 C개 chapter 쓰기를 유발한다. package export도 같은 시점 근처에 동작하여 IO와 GC가 겹친다. +- 최소 수정: chapter dirty는 해당 sourceId들만 upsert/clear하고 명시적 `search:rebuild-all`만 전체 rebuild한다. 직접 fast-path와 dirty worker 중 검색 cache의 실제 write owner를 하나로 정리한다. +- 검증: 기존 `dbMaintenanceService.test.ts`는 한 chapter가 완료되는지만 검사한다. 300/1,200 chapter 프로젝트에서 1개 수정 후 FTS INSERT 수가 1인지, 관련 없는 row가 보존되는지 확인해야 한다. + +### DB-06 · P1/P2 · FTS rebuild는 O(C²) id scan + 개별 commit을 main에서 실행한다 — 합성 측정 + +- 위치: `chapterSearchCacheService.ts:82`–`:96`, `:397`–`:426`; `src/main/database/cache/fts5Schema.sql:3`–`:4`; `cacheDb.ts:83`–`:87`. +- 원인: FTS chapterId/projectId는 UNINDEXED다. 각 syncFtsDocument가 `DELETE ... WHERE chapterId=?` 후 INSERT하며 둘을 묶는 transaction도 없다. 프로젝트 clear 직후 신규 rebuild인데 각 chapter의 DELETE까지 반복한다. `Promise.all`은 이 동기 SQL 루프를 병렬화하거나 event loop에 양보하지 않는다. +- 복잡도/증상: 누적 C개 FTS rows를 chapter마다 scan하므로 rebuild의 id lookup 부분은 O(C²). FULL/WAL autocommit과 FTS update가 main을 점유한다. 다른 프로젝트 row가 많을수록 scan 범위도 늘 수 있다. FTS row count도 모든 프로젝트 cache를 scan한다. +- 측정: 실제 설치 native SQLite, 동일 FTS schema, WAL/FULL, chapter당 5,625 UTF-8 bytes의 합성 데이터. 300장 현행 패턴 52.05 ms → transaction+clear 뒤 불필요 DELETE 제거 12.00 ms; 1,200장 493.30 ms → 54.32 ms. 이것은 FTS 단계만의 단일 합성 실행이며 Electron UI latency나 안정적 speedup 비율로 일반화할 수 없다. +- EXPLAIN: chapterId 조회는 `SCAN ChapterSearchDocumentFts VIRTUAL TABLE INDEX 0:`. +- 최소 수정: 전체 rebuild는 transaction 안에서 project clear + prepared INSERT 반복. 단건은 일반 projection의 rowid와 FTS rowid를 연결해 O(1) delete/update 경로를 사용. 먼저 DB-05의 전체 작업 범위를 줄이는 것이 더 큰 수정이다. +- 검증: `/private/tmp/luie-db-audit-check.cjs`. 실제 앱에서는 SQL statement/commit 수와 main event-loop p95/p99를 별도 측정. + +### DB-07 · P1 · 1,000행 초과 원격 table이 조용히 누락될 수 있다 — 설정 조건부 확정 + +- 위치: `src/main/services/features/sync/repository/http.ts:64`–`:80`; `repository/index.ts:31`–`:41`; `supabase/config.toml:18`의 `max_rows=1000`. +- Trigger: 한 사용자의 chapters/memory_canonical_rows/tombstones 등 table 하나가 서버 응답 상한을 넘는다. +- 원인: select=* GET 한 번만 요청하고 Range/limit-offset/cursor/안정적 order/Content-Range 종료 확인이 없다. +- 결과: 서버가 정상 200/206으로 일부 행만 보내도 complete bundle로 취급한다. 새 기기 복원이나 충돌/삭제 판정이 누락될 수 있다. 1,000은 로컬 repo Supabase 설정이며 현재 클라우드 설정은 확인하지 않았다. +- 최소 수정: 안정적 keyset 또는 Range pagination으로 전체 수집을 보장한다. 가능하면 후속 delta sync의 updatedAt+id cursor와 연결한다. 원격에서 실제로 삭제됐다고 임의 추론해서는 안 된다. +- 검증: fetch mock에서 1,001/2,001행 및 tombstone을 페이지별 반환하고 전부 복원되는지. `syncRepository.test.ts`의 확인한 테스트는 빈 table/snapshot 제외/payload 필드 중심이고 pagination 경계를 검증하지 않는다. + +## 구조적 성능·보관 문제 + +### DB-08 · P2 · 동일 chunk까지 재임베딩: hash 재사용이 상위 DELETE로 무효화된다 — 실제 함수 재현 + +- 위치: `memoryProjectionService.ts:139`–`:158` source chunk 전체 DELETE + 새 UUID; `src/main/database/schema/memory.ts:128`–`:132` embedding FK cascade; `embeddingProjector.ts:209`–`:237` chunkId keyed hash/model 비교. +- Trigger: chapter 하나가 변경되거나 동일 내용에 대한 명시적 rebuild가 실행된다. +- 결과: 불변 chunk까지 삭제되면서 해당 embedding이 cascade 삭제되고, UUID가 바뀌어 contentHash가 같아도 재사용되지 않는다. 장이 길수록 CPU/모델/API 비용과 vector 공백 시간이 커진다. +- 최소 수정: source/순서와 content/index hash로 불변 chunk를 보존하고 바뀐 chunk만 교체한다. ID만 deterministic하게 바꾸는 것으로는 DELETE cascade를 막지 못한다. 동일 source hash는 전체 rebuild를 skip하되 title/indexText hash 변화도 포함해야 한다. +- 검증: 실제 함수 재현에서 동일 본문 유지인데 chunkIdChanged=true, unchangedContent=true, embeddingRowsAfter=0. 기존 memoryProjectionService 테스트는 검색/offset/window/episode enqueue를 검사하지만 동일 본문 ID/embedding 보존과 동시 enqueue를 검사하지 않는다. + +### DB-09 · P2 · ChapterRevision이 모든 자동 저장의 전체 본문을 무제한 누적한다 — 경로 확정 + 저장량 측정 + +- 위치: `chapterWriteOperations.ts:262`–`:269`; `autoSavePerformSave.ts:60`; `src/main/database/schema/manuscript.ts:76`. +- 결과: 자동 저장도 reason=`manual_save`로 full revision INSERT한다. Chapter와 ChapterBody에도 같은 본문을 쓰고 hash를 2번 계산한다. 감사 범위의 runtime 소스에서 ChapterRevision reader/retention/prune를 찾지 못했다; chapter hard delete FK cascade만 있다. Snapshot retention은 별도 table이므로 이 이력을 정리하지 않는다. +- 측정: 64 KiB 본문 600회 revision 저장만으로 DB 약 39,665,664 bytes(37.8 MiB). 본문 크기×저장 횟수에 선형 증가한다. 실제 저장 간격에 대한 시간당 수치는 계산하지 않았다. +- 최소 수정: recovery 요구에 맞춰 revision을 시간/내용 변화 단위로 묶고 chapter별 보관 상한을 둔다. 먼저 autosave/manual reason을 구분하고 사용 중인 snapshot/복구 정책과 중복 여부를 결정한다. 사용자 이력을 감사 단계에서 삭제하는 것은 금지. +- 단기: contentHash를 한 번만 계산하고 전달; Chapter legacy content 이중 보관 제거는 기존 읽기 caller 전환을 모두 확인한 뒤 수행해야 한다(`chapterKeywords.rebuildProjectKeywordAppearances` 등 legacy reader 존재). + +### DB-10 · P2 · 작은 변경마다 전체 `.luie`를 동기 재작성하며 불필요한 snapshot 본문도 먼저 읽는다 + +- 위치: `projectService.ts:48`, `:461`; `src/shared/constants/runtime/interactionTiming.ts:4`(500 ms); `projectExportEngine.ts:71`, `:85`, `:90`, `:91`; `exportEngine/projectRecord.ts:43`, `:82`; `projectExportPayload.ts:137`; `luieSqliteContainer.ts:228`–`:273`. +- Trigger: attached .luie 프로젝트에서 연속 저장/엔터티 변경. debounce는 호출 수만 줄이고 각 export 비용은 줄이지 않는다. +- 원인: 모든 chapter/body와 snapshots를 읽고 JS DTO/JSON entries로 확장한 다음 신규 SQLite 파일 전체를 쓴다. snapshot export limit은 SELECT가 아니라 모든 snapshot을 map한 뒤 slice한다. 읽기엔 legacy Chapter.content와 bodyContent도 함께 선택한다. +- 결과: 단일 변경 Δ가 전체 package bytes B의 읽기/직렬화/쓰기/원자 교체로 증폭된다. 동기 transaction은 main을 점유하며 peak JS heap/native SQLite page cache가 커진다. 변경이 export보다 빠르면 queue runLoop는 dirty revision이 없어질 때까지 연속 전체 export한다. +- 최소 수정 순서: snapshot limit을 SQL에 적용, 중복 legacy body projection 제거 → 기존 package entry writer 기반으로 변경 entry+meta를 한 transaction에 갱신하는 안전성 검토 → 전체 export가 필요한 checkpoint/Save As는 IO worker로 이동. 파일 포맷/복구 보장을 버리고 synchronous=OFF로 숨기지 않는다. +- OS: Windows에서 파일 교체/백신/동기화 드라이브, macOS 외장 볼륨, Linux 느린 filesystem에 각각 확대 가능하지만 실측하지 않았다. atomic replace 실패 경로는 해당 OS에서 별도 crash test 필요. + +### DB-11 · P2 · 모든 sync가 전체 로컬·원격 자료를 다시 upsert한다 + +- 위치: `syncRunExecutor.ts:66`, `:127`–`:128`; `syncBundleHelpers.ts:79`, `:100`, `:120`; `syncBundleApplier.ts:96`–`:123`; `syncLocalApply.ts:95`–`:143`; `repository/http.ts:118`. +- 원인: local/remote/merged bundle이 동시에 보관되고, 변하지 않은 chapter까지 select→update + ChapterBody upsert + SHA256. direct revision trigger는 값이 같은 Chapter/Body UPDATE에도 project revision을 올린다(`projectRevisionTriggerSql.ts:61` 전후, `:116` 전후). 이후 패키지 persistence와 remote JSON.stringify/POST도 전체 범위다. +- 결과: 쓰지 않은 프로젝트까지 IO/CPU/네트워크/GC 비용을 낸다. 대용량 content 배열과 serialized body가 함께 존재한다. table별 전체 POST가 커지면 서버 payload/timeout 한계도 받는다. +- 최소 수정: 현행 baseline/hash/updatedAt으로 변하지 않은 row를 로컬 write와 원격 payload에서 제외한다. 그 다음 table별 제한 크기 batch와 cursor 기반 delta fetch. 우선 whole bundle 방식에서 no-op sync가 실제로 no-op인지 검증한다. +- 별도 미검증 경쟁: remote fetch 대기 중 local snapshot 이후의 편집을 merged bundle이 덮는지 CAS/revision 검증을 추가 점검해야 한다. 현재 package persistence의 capturedRevision은 apply 후에 캡처되어 apply 이전 stale local overwrite를 막는 증거가 아니다. 이 경쟁은 이번 실제 함수 재현에는 포함하지 않았으므로 확정 결함 수에 넣지 않는다. + +### DB-12 · P2 · 500 ms idle polling이 완료 이력까지 scan하고 재시도 불가 작업이 뒤 작업을 막는다 + +- 위치: `derivedJobWorker.ts:36`, `:127`; `dbMaintenanceService.ts:303`–`:311`, `:423`–`:429`, `:442`; `memoryProjectionService.ts:85`–`:93`; `embeddingProjector.ts:88`–`:100`; `schema/search.ts:20`, `schema/memory.ts:71`. +- Index 문제: global status 조회에 맞는 leading status/ready index가 없고 SearchDirtyQueue의 기존 index는 projectId/status와 sourceType/sourceId다. terminal history는 orphan purge 외 retention이 없다. 완료 건이 커져도 매 500 ms 조회/long-pending count가 반복된다. +- EXPLAIN/측정: SearchDirtyQueue 100,000행 중 pending10을 요청하면 `SCAN SearchDirtyQueue`, `USE TEMP B-TREE FOR ORDER BY`. 30회 평균 2.469 ms; runnable partial index(updatedAt WHERE status IN pending/failed) 추가 후 0.013 ms. 합성 in-memory 측정이며 disk cold cache 미측정. +- Starvation 문제: SQL LIMIT를 먼저 적용한 뒤 JS에서 attempts>=5 실패를 제거한다. 오래된 terminal failed가 `max(limit*3,30)`개 앞에 있으면 뒤 pending이 계속 선택되지 않는다. memory/embedding의 동일 후보 패턴에도 적용된다. 기본 search limit50이면 150개 exhausted failed가 앞을 점유할 수 있다. enqueue가 failed dedupe 시 attempts/status를 reset하지 않는 점도 새 내용의 재시도를 막는다. +- 최소 수정: 실행 가능 조건(terminal attempts/backoff 포함)을 SQL WHERE에서 적용하고 그 쿼리에 맞는 partial/composite index를 EXPLAIN으로 정한다. completed/terminal history는 별도 보관 정책. 작업 enqueue 신호로 깨우고 완전 idle 시 backoff하면 OS 공통 배터리 비용 감소. 새 내용을 위한 retry generation은 실패 세대와 분리한다. +- 검증: exhausted failed를 후보 수 이상 만든 뒤 pending1을 넣어 처리되는지. 조건 구조는 확정, 해당 starvation은 이번 actual-source script에서 실행하지 않았다. + +### DB-13 · P2 · 키워드 추적이 출현 횟수만큼 SQL/commit과 중복 최초 등장 조회를 실행한다 + +- 위치: `chapterContentValidation.ts:106` 전후 fireAndForget; `src/main/services/features/manuscript/chapterKeywords.ts`의 trackKeywordAppearancesInternal/updateCharacterFirstAppearance/updateTermFirstAppearance; `appearanceCacheService.ts:49`, `:71`, `:145`. +- Trigger: 같은 인물/용어가 수백~수천 번 나오는 장을 저장한다. 같은 본문 no-op 판단(`chapterWriteOperations.ts:223`)보다 검증/keyword dispatch가 먼저 실행된다. +- 원인: chapter appearance를 모두 삭제한 뒤 occurrence별 INSERT 반환을 await하고 매번 main entity firstAppearance를 SELECT한다. `characters.find`/`terms.find`도 각 occurrence마다 선형 탐색한다. 최초 등장 때는 entity마다 immediate full package export까지 await한다. +- 결과: occurrence M개에 cache INSERT M개+main SELECT M개, FULL autocommit과 O(M·E) entity 탐색. fireAndForget은 실행 프로세스/스레드를 바꾸지 않으며 저장 실패보다 먼저 cache/firstAppearance를 갱신할 수 있다. +- 최소 수정: no-op 여부를 먼저 확인하고 committed content만 derived worker에 전달; 이름→entity Map, appearance bulk transaction, firstAppearance는 unique entity마다 1회 conditional UPDATE, package export 1회 coalesce. +- 검증: 같은 인물이 1,000번 등장한 본문 저장 시 statement/commit 수와 저장 실패 후 cache 일관성. 이 항목은 구조 확인이며 성능 실측은 미수행. + +### DB-14 · P2 · 저사양 검색 policy의 vector skip이 runtime에 연결되지 않았다 + +- 위치: `searchOptimizationPolicy.ts:42`, `:132`; `chunkSearch.ts:156`; caller `chunkOperations.ts:40`, `contextAssembler.search.ts:268`. +- 사실: low-end는 vectorSearchMode=`skip-when-lexical-hits`를 선언하지만 값의 runtime consumer가 없다. searchHybrid input에는 mode 자체가 없고 extension/process/embedQuery만 검사한다. utility의 lexical hit가 있어도 query embedding+vector SQL을 실행한다. rerankCacheTtlMs는 benchmark에서 읽지만 runtime에 실제 cache 적용은 확인되지 않는다. +- 최소 수정: 이미 정의한 policy를 shared search executor까지 전달하여 lexical hit가 기준을 충족하면 vector/embedding을 건너뛴다. ranking 품질 변화는 low-end 요구 기준으로 검증. +- 조건: 기본 main 경로는 process guard로 이미 vector skip한다. 이 문제는 vector enabled utility/RAG 및 override 경로에 해당한다. +- 검증: low-end+lexical hit+vector-enabled utility 환경에서 embed call=0인지. profile 숫자만 검사하는 `tests/main/services/search/searchOptimizationPolicy.test.ts`와 실제 검색 경로 검증을 연결한다. + +## 추가 관찰: 측정 후 결정 + +- **동기 adapter**: main과 cache는 better-sqlite3이고 `.all/.get/.run`이 호출 thread에서 동기 실행된다. async 함수/Promise.all 표기는 비차단 IO의 근거가 아니다. `busy_timeout=5000`은 lock 경쟁 시 main이 그만큼 응답하지 못할 여지를 주며 `runWalCheckpoint(FULL)`/integrity_check도 동기다. 그렇다고 모든 단건 SQL을 즉시 IPC worker로 옮겨 메시지 비용을 늘릴 이유는 없다. 위 작업 증폭/transaction 오류를 먼저 고치고 큰 batch/export/index를 작업 소유 process로 옮기는 게 우선이다. +- **벡터 실제 구조**: `chunkSearch.ts:283`은 project/dimension/current hash로 필터 후 native scalar cosine 거리 전체 계산+top-K다. persisted vector 전체를 JS로 읽어 cosine 계산하는 runtime fallback은 찾지 못했다. extension 미가용이면 FTS로 fallback한다. 따라서 “JS 전체 벡터 materialization”을 현행 결함으로 보고하면 안 된다. O(N·D) exact scan 확대 시 utility latency와 memory bandwidth를 측정하고, 후보 한정 또는 기존 sqlite-vec의 다른 검색 방식을 검토하되 새 ANN dependency를 우선 도입하지 않는다. +- **단건 package entry transaction**: `luieSqliteContainer.ts:347`–`:359`는 entry/meta/container timestamp를 세 개의 autocommit으로 갱신한다. `handler/system/fs/fsPackageOperations.ts:208` 실제 caller가 있다. 한 transaction으로 묶으면 commit 감소와 atomic consistency를 같이 얻는다. canonical 전체 export와 달리 이 경로의 crash 일관성은 미검증. +- **과대 bulk IN/VALUES**: chapterSearch rebuild는 모든 chapter를 하나의 INSERT VALUES에 넣고 sync/memory는 모든 id를 IN에 넣는 부분이 있다. 매우 큰 프로젝트에서 SQLite bound variable limit을 넘을 수 있다. 정확한 threshold는 런타임 compile option과 실 SQL bind 수에 따라 다르므로 이번 보고서에서 특정 chapter 수를 보장하지 않는다. bounded batch 검증이 필요하다. +- **snapshot 보관**: Snapshot에는 별도 retention/prune가 구현되어 있고 AUTO는 id/createdAt만 읽는 경로도 있다. 문제는 그 정책이 ChapterRevision에 적용되지 않고 export 전에는 모든 snapshot body를 읽는다는 점이다. + +## 이미 적용된 유효한 구조 + +- Chapter list/trash list는 metadata DTO로 본문을 보내지 않는다(`chapterService.ts` getAllChapters/getDeletedChapters). 이 경계를 유지해야 한다. +- main/cache WAL, foreign_keys=ON, explicit busy timeout; authoritative DB FULL은 저장 안정성 목적이므로 단순 OFF/NORMAL 전환으로 속도만 올리면 안 된다. cache는 regeneration 가능한 전용 table이어서 transaction 정리 후 durability tuning을 따로 검토할 수 있다. +- package export queue의 debounce/dirty revision/captured revision 재확인, path별 write lock, 임시 파일+교체, startup stale export recovery가 있다. 이 장치를 지우는 최적화는 부적절하다. +- memory chunk rebuild는 job별 동기 transaction이고 job 사이 setImmediate가 있다. 문제는 chunk 단위 재사용과 세대 비교이지 “worker에 yield가 하나도 없다”가 아니다. +- utility에 실제 모델/embedding/RAG가 분리되어 있으며 벡터 guard도 존재한다. SQL 전체를 main에서 돌린다는 포괄 주장은 정확하지 않다. +- 정상 bulk 읽기 JOIN/Map grouping과 bounded search result도 여러 곳에 이미 있다. 현재 helper들을 재사용하면 됨. + +## 검증 산출물과 재실행 + +모든 스크립트는 사용자 데이터 대신 synthetic fixture를 사용한다. 실제 소스 관련 스크립트는 현재 checkout의 파일을 매번 읽고 설치된 TypeScript/esbuild를 사용한다. 의존성 설치나 rebuild는 하지 않는다. + +```sh +node /private/tmp/luie-autosave-pending-repro.cjs +NODE_PATH=/Users/user/Luie/node_modules node /private/tmp/luie-chapter-tx-repro.cjs +NODE_PATH=/Users/user/Luie/node_modules node /private/tmp/luie-db-source-repro.cjs +node /private/tmp/luie-db-audit-check.cjs +``` + +결과 파일: + +- `/private/tmp/luie-autosave-pending-repro-results.json` +- `/private/tmp/luie-chapter-tx-repro-results.json` +- `/private/tmp/luie-db-source-repro-results.json` +- `/private/tmp/luie-db-audit-results.json` + +기존 테스트는 caller/보장 범위 확인 목적으로 읽었으며 이 하위 감사에서 전체 vitest/qa:core를 실행하지 않았다. root가 수행한 테스트 결과와 합쳐야 한다. 확인한 테스트: dbMaintenanceService, memoryProjectionService, chapterService, manualSaveHandler, autoSaveManager.runtimeStats, syncRepository, syncBundleApplier.commitOrder, syncPackagePersistence.retry 관련 내용. + +## 권장 순서와 범위 제한 + +1. 저장 세대/실패 전달/동기 transaction/derived generation을 먼저 수정한다. 병목을 다른 process로 옮기기 전에 최신성 및 성공 신호를 보장한다. +2. chapter dirty 단건 처리, FTS transaction/rowid, 불변 chunk 보존, revision retention을 적용하고 같은 부하에서 SQL count·commit count·main event-loop p95/p99·heap/external/RSS·DB/WAL/package bytes를 비교한다. +3. package/export/sync의 변경 범위를 줄이고 적절한 worker에 무거운 batch를 이전한다. 단일 canonical DB writer, read-only utility connection 등 ownership은 migration/새 writes/cancellation/shutdown과 함께 설계한다. +4. Windows/macOS/Linux에서 동일 corpus/행 수/저장 주기/동기화 backlog로 실기기 cold+warm, battery+AC, slow disk/crash, lock contention을 검증한다. + +이번 감사는 주요 저장·DB/FTS/chunk/embedding·package·sync 경로를 종단 추적했다. 모든 memory entity/eval/summary SQL의 각 statement, 클라우드 PostgreSQL EXPLAIN/RLS/index/실서버 설정, OS별 실사용 IO latency, 배포판 Electron profiler/heap snapshot은 전수 측정하지 않았다. 검토한 코드와 재현 결과를 “전체 코드에 버그가 더 없다”는 보장으로 해석하면 안 된다. diff --git a/docs/quality/performance-audit-2026-09-08/ipc.md b/docs/quality/performance-audit-2026-09-08/ipc.md new file mode 100644 index 00000000..626d2edb --- /dev/null +++ b/docs/quality/performance-audit-2026-09-08/ipc.md @@ -0,0 +1,64 @@ +# IPC / preload / 측정 경계 감사 + +기준: 2026-09-08, commit `128d8be6`의 현재 작업 트리. 아래 재현은 현재 preload 소스를 TypeScript로 메모리에서 변환하여 Electron 전송부와 시계만 제어한 관측이다. Electron의 실제 복사 시간이나 heap 사용량을 측정한 것은 아니다. + +## I01 — P2: read timeout 재시도가 끝나지 않은 원래 작업과 겹친다 + +- 근거: `src/preload/index.ts:185`의 `invokeWithTimeout`, `:210`의 invoke, `:227`의 Promise.race, `:237`–248의 read retry. main은 `src/main/handler/core/ipcHandler.ts:53`에서 별도 요청을 무조건 시작한다. +- 15초 timeout은 호출자 대기만 종료하며 main 요청을 취소하지 않는다. 검색/프로젝트 읽기 같은 retry 대상은 한 번 더 시작된다. 지속적으로 요청하면 오래 걸리는 기존 작업과 새 작업이 누적될 수 있다. retry는 최대 1회이므로 무한 retry 루프라고 표현해서는 안 된다. +- 재현: 완료하지 않은 SEARCH 요청에 가상 시간 15초를 진행시키자 동일 검색 2개가 동시에 미완료 상태가 되었다. 두 번째가 성공해도 원래 요청은 남아 있었다. +- 최소안: 동일 read의 single flight, 호출자 관심 종료 시 취소 전파, 상태/작업별 제한. idempotent read만 재시도하는 현재 방침은 유지한다. 즉각 retry가 과부하를 증폭하는 경우 backoff를 둔다. +- 검증: 응답 없는 search 100개에서 active/queued 상한과 AbortSignal 전달, 빠른 새 검색이 이전 결과로 덮이지 않는지 확인. + +## I02 — P2: 사용자 다이얼로그와 긴 작업이 모두 기본 15초 제한을 받는다 + +- 근거: `src/preload/index.ts:49`의 LONG_TIMEOUT_CHANNELS에는 FS_SELECT_FILE / DIRECTORY / SAVE_LOCATION / SNAPSHOT_BACKUP과 memory eval/calibration, RAG_QA_ASK가 없다. 기본값은 `src/shared/constants/runtime/preload.ts:6`의 15초다. +- 다이얼로그는 `src/main/handler/system/fs/ipcFsHandlers.registry.ts:126`, `:148`, `:185`에서 사용자 선택을 기다린다. 사용자가 15초 후 선택하면 main은 정상 경로 승인과 결과를 반환하지만 renderer promise는 이미 실패다. 이 결과 손실을 source probe로 재현했다. +- 긴 계산은 `src/main/handler/memory/ipcMemoryHandlers.ts:92`–122 → `memoryEvalRunner.ts:99` / intent·episode calibration loop로 이어진다. `narrativeMemoryQueryService.ts:98`은 옵션에 따라 LLM 분류를 기다리고 utility generate timeout은 180초다. outer timeout은 job을 중단하지 않는다. 동일 버튼 재클릭 시 별도 eval run이 시작될 수 있다. +- 최소안: dialog 채널은 사용자가 취소하거나 창이 닫힐 때 종료하는 수명으로 구분한다. 장기 작업은 job id를 즉시 반환하고 진행/취소/완료를 별도 전달한다. 짧은 과도기에는 채널 deadline을 실제 하위 budget과 맞추되 모든 timeout을 일괄 증가시키지 않는다. +- 검증: 30초 기다린 파일 선택 성공, 명시 사용자 취소, 원격 작업 지연/취소, 버튼 중복 실행 차단. + +## I03 — P2: 로그 배치가 동시 실행량을 제한하지 않는다 + +- 근거: `src/preload/index.ts:276`–291의 flushLogs는 in-flight guard가 없다. `:299`–301은 queue 20개 또는 error마다 즉시 새 flush를 시작한다. batch 실패 시 `Promise.all(batch.map(LOGGER_LOG))`로 개별 전송한다. +- 재현: 빠른 INFO 로그 1,000개 → 응답을 기다리는 batch invoke 50개. batch를 실패시키면 개별 invoke 1,000개가 동시에 생겼다. batch 크기 20은 전체 대기/실행 상한이 아니다. +- main `src/shared/logger/index.ts:282`–291은 다시 매 로그 appendFile을 생성한다. 디스크가 느리거나 오류가 반복되면 진단 경로가 저장 I/O와 경쟁한다. 실제 상주 메모리 증가량은 미측정이다. +- 최소안: preload 단일 in-flight flush + queue의 항목/byte 상한 + 반복 로그 제한. batch 실패가 공통 transport 장애이면 같은 메시지를 개별 IPC로 폭발시키지 않는다. main bounded sink/rotation과 함께 수정한다. +- 검증: 느린 logger sink에 burst를 넣고 inflight=1, 큐 상한, ERROR 보존/드롭 계수와 종료 flush를 검사한다. + +## I04 — P2: 현재 request metadata로는 실제 IPC 지연을 분해하기 어렵다 + +- 근거: `src/preload/index.ts:190`에서 만든 requestId는 `:211`의 IPC 인자로 전달하지 않는다. main은 `src/main/handler/core/ipcHandler.ts:55`에서 새 UUID를 만든다. main duration은 callback 시작 후 `Date.now()`로 계산한다. +- 영향: preload timeout log와 main success/error log를 동일 요청으로 정확히 연결할 수 없다. main의 duration에는 전송·입력 직렬화·main callback 전 대기·응답 clone·renderer 처리 시간이 포함되지 않는다. main duration이 짧다는 이유로 사용자 저장 응답이 빠르다고 판단하면 잘못된 최적화가 된다. +- 최소안: 기존 IPC 계약에 검증되는 공통 trace id와 deadline 메타데이터를 설계한다. 각 프로세스에서 monotonic clock으로 자기 구간 duration을 측정하고, payload byte/queue depth/작업 종류를 기록한다. 프로세스별 performance.now 절대값을 직접 빼지 않는다. 본문·토큰은 로그에 기록하지 않는다. +- 검증: 한 요청에서 UI action → renderer buffer → preload wait → main handler → DB commit → package ack의 동일 trace를 확인한다. + +## I05 — P3: logger sanitizer의 순환 배열은 stack overflow를 일으킨다 + +- 근거: `src/preload/index.ts:30`–35는 Array 분기에서 seen 검사를 건너뛴다. shared logger의 `redactLogData`도 같은 순서다. +- 재현: `const value=[]; value.push(value)`를 실제 sanitizeForIpc에 넣으면 Maximum call stack size exceeded. 해당 API는 logger 데이터를 받고 있으므로 정상 원고 문자열의 모든 IPC가 이 비용을 지는 것은 아니다. +- 최소안: 배열·Error를 포함해 객체마다 순환/깊이 검사를 먼저 적용하고 출력 크기를 제한한다. 민감정보 redaction은 유지한다. + +## 이미 적용된 보호 + +- `ipcRenderer.sendSync`를 사용하는 앱 경로를 찾지 못했다. raw ipcRenderer는 renderer API로 노출하지 않는다. +- preload autosave는 최신 payload coalescing, sequence 확인, drain tail, 실패 pending 보존이 있다. 이것을 main의 별도 pending 삭제 결함과 혼동하지 않는다. +- manual save는 preload flush를 기다리고, main handler는 schema 검증을 수행한다. 중요한 문제는 각 계층의 실제 durable 완료 조건과 더 앞단의 editor buffer다. +- 일반 IPC payload 전체에 sanitizeForIpc를 반복 적용하는 구조가 아니다. 해당 재귀 순회는 주로 logger에 적용된다. +- 대용량 본문은 여전히 renderer→contextBridge→preload→IPC 복사 경계를 지난다. 우선 전송 횟수/필요 필드를 줄이고 DTO를 좁힌다. 모든 메시지를 SharedArrayBuffer/MessagePort로 바꾸는 근거는 없다. Electron [contextBridge](https://www.electronjs.org/docs/latest/api/context-bridge), [IPC serialization](https://www.electronjs.org/docs/latest/tutorial/ipc) 문서가 복사·structured clone 경계를 설명한다. + +## 검사 결과와 한계 + +- `SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/dom/preloadAutoSaveQueue.test.tsx tests/scripts/preloadContractRegression.test.ts tests/scripts/ipcContractMap.test.ts tests/scripts/ipcHandlerSchemas.test.ts tests/main/handler/manualSaveHandler.test.ts`: 5개 파일 29개 테스트 통과. +- `node /private/tmp/luie-ipc-audit/probes.cjs /Users/user/Luie`: 위 retry/dialog/log burst/circular array의 관측 assert 4개 통과. 결함 관측용이므로 제품 수정 후에는 기대값을 정상 동작으로 바꿔 정식 회귀 테스트로 이전해야 한다. +- 현재 소스 전체 빌드: `node node_modules/electron-vite/bin/electron-vite.js build --outDir /private/tmp/luie-audit-build/out` 성공. 임시 빌드에서 저장소의 render-boot-budget gate: JS 573.1 KiB / 17 files, CSS 166.1 KiB, 통과. 이전 out의 1108.9KB는 stale이어서 현재 회귀로 보고하지 않는다. +- `node node_modules/typescript/bin/tsc6 --noEmit`: 기존 `src/renderer/src/features/manuscript/components/Sidebar.tsx:157`의 unused handleRenameProject(TS6133)로 실패. 이번 조사에서 수정하지 않았다. +- pnpm 실행은 npm registry 접근/서명 검증 불가로 ERR_PNPM_PNPM_ENGINE_IDENTITY_UNVERIFIABLE. 설치된 동일 로컬 runner를 직접 사용했으며 패키지를 설치/변경하지 않았다. +- `check-preload-contract-regression`과 `check-ipc-handler-schemas` 통과. `check-ipc-contract-map`은 기존 문서 drift를 발견하고 map을 재생성했다. 조사 전 해당 파일은 깨끗했으므로 조사로 생긴 변경만 복원했으며, 차이는 `/private/tmp/luie-audit-ipc-map-drift.patch`에 남겼다. + +## 성능 테스트가 증명하는 경계 + +- `scripts/benchmark-writing-loop.mjs:133`–158의 worker simulation은 실제 indexing/embedding 대신 status만 completed로 바꾼다. 이 SQL microbenchmark는 실앱 작업 완료시간을 인증하지 않는다. +- `tests/e2e/writingLoop.fullprod.spec.ts:111`–139는 window.api.chapter.update를 직접 호출한다. 실제 renderer↔main 경로의 benchmark로 유효하지만 TipTap 키 입력·IME·900ms editor buffer·SmartLink decoration은 통과하지 않는다. +- `tests/main/performance/saveLatencyCertification.test.ts:40`–79는 real DB/FS와 mock Electron/API 경계다. 서비스의 내구성/지연 증거는 되지만 IPC clone, OS compositor, 입력 frame time의 증거는 아니다. +- 소스 목록: src/main 463개/66,016줄, preload 7개/1,304줄, renderer 526개/67,742줄, shared 127개/11,582줄, src/types 2개/14줄. tracked TS/TSX/JS/MJS/CJS/SQL 기준이다. 관련 tests 399개/72,955줄, scripts 75개/13,898줄을 목록화했다. 목록화와 전체 줄 수동 검토는 다르며, 감사는 주요 실행 경로와 실제 caller/테스트를 추적했다. diff --git a/docs/quality/performance-audit-2026-09-08/main-platform.md b/docs/quality/performance-audit-2026-09-08/main-platform.md new file mode 100644 index 00000000..21045fde --- /dev/null +++ b/docs/quality/performance-audit-2026-09-08/main-platform.md @@ -0,0 +1,155 @@ +# Luie main / lifecycle / utility / OS 성능 감사 + +작성일: 2026-09-08. 소스 수정 없음. 조사 범위: main bootstrap, lifecycle, window manager, utility bridge, utility entry, sidecar supervisor/materializer/providers, derived job scheduling, logger, 파일 기록 공통 유틸, native rebuild와 배포/성능 테스트 경계. SQL 인덱스/쿼리 세부는 DB 담당, renderer/preload는 다른 담당 결과와 병합해야 한다. + +## 판정 + +**Risky.** utility process 분리, single flight, 저장 안전장치, lazy startup 등 이미 유효한 기반이 있다. 그러나 비동기 API라는 이유로 main의 동기 SQLite 작업이 분리되지는 않으며, timeout이 실행 취소로 이어지지 않는다. 실제 source mock으로 sidecar readiness race, provider token cache 오류, readiness TTL 즉시 만료를 재현했다. OS/아키텍처별 기능과 배포/검증 범위도 일치하지 않는다. + +현재 package.json Electron 44.2.0, better-sqlite3 13.0.3, pnpm 11.25.0. AGENTS snapshot Electron 40과 다르다. 기존 out/main/index.js는 현재 src/main/index.ts와 일치하지 않는다: out에는 whenReady 시 utility 시작이 있고 현재 src는 요청 시 lazy 시작이다. 기존 out/profile 산출물로 현재 소스 성능을 단정할 수 없다. + +## 검증한 것 + +- 저장소 및 src/main AGENTS 읽음. 부모가 graph MCP 미제공을 확인한 후 scoped rg/read로 조사했다. +- `SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/services/utilitySidecarSupervisor.test.ts tests/main/services/utilityProcessBridgeProtocol.test.ts tests/main/services/startupReadinessService.test.ts tests/main/lifecycle/appReadyDeferredWorker.test.ts tests/main/lifecycle/shutdownLifecycleSafety.test.ts tests/main/lifecycle/deferredStartupMaintenance.test.ts tests/main/manager/windowChrome.test.ts` +- 결과: **7 파일, 20 테스트 통과, 595ms**. mock/source-text 테스트이므로 실 OS 성능 인증이 아니다. +- `/private/tmp/luie-main-probes.cjs`는 현재 TS source를 메모리에서 transpile하고 Electron/DB/프로세스/네트워크를 mock한다. `node /private/tmp/luie-main-probes.cjs` 결과 3가지 결함 재현 assert 통과. +- 실제 Electron 앱, 사용자 DB/설정, llama-server, native rebuild, 설치/배포는 실행하지 않았다. 새 파일은 /private/tmp의 보고서와 probe뿐이다. + +## 상세 발견 + +### M01 — P1: utility timeout 이후 작업이 계속 살아 있고 admission/backpressure가 없다 + +- 위치: `src/main/services/features/utility/utilityProcessBridge/internal/core.ts:333`–347, 특히 341–346. `src/main/utility/process/utilityProcessMain.ts:246`–305. `src/main/utility/llm/textGeneration.ts:49`. `src/main/services/features/llm/providers/externalApiProvider.ts:131`–147. +- 실제 흐름: derived summary / episode / temporal / narrative 작업 → utilityProcessBridge.generateText → request timeout 180초 → main pending map 삭제와 reject만 수행. utility는 요청별 async IIFE를 제한 없이 실행한다. generateUtilityText는 provider.generate에 signal을 전달하지 않는다. external provider fetch 역시 기본 deadline 없이 options.signal만 사용한다. +- embedding은 provider fetch 자체 30초 제한이 있지만 materialization(model cold load) 후에 그 제한이 시작되고 bridge도 별도로 30초 제한이므로 bridge timeout 뒤 작업이 남을 수 있다. cold load health timeout 30초가 request 30초 budget을 거의 소모한다. caller는 embeddingProjector.ts:249이며 검색도 bridge.embed를 사용한다. +- 영향: 느린/멈춘 remote stream, 동시에 실행하는 background projection과 사용자 RAG, 재시도 시 종료되지 않은 작업/문자열/연결이 겹친다. `--parallel 1` sidecar 위에 무제한 요청을 쌓을 수 있고, timeout 반환을 성공적인 cancellation으로 오해하게 된다. RSS/CPU/지연 증가 가능; 증가량은 실측하지 않았다. +- 최소 수정: 기존 requestId로 cancel 전파, utility request AbortController registry, finally 제거; 배치/생성의 동시 실행 수와 대기 수에 한도; 사용자 foreground 우선/중복 derived job 병합. status/stop/shutdown은 compute 대기열과 분리. ModelRuntimeClient의 이미 존재하는 signal을 연결한다. +- 추가 관련: utilityProcessMain.ts:249는 sidecar.status/stop 및 rag.stop조차 db+cache ensureReady 이후 처리한다. DB 초기화/lock에 control request가 지연될 수 있다. DB가 필요 없는 control 분기를 먼저 처리하면 된다. +- 검증: fake never-ending provider를 요청 timeout/취소 후 abort 관측; 100개 동시 요청에서 inflight/queue 상한 유지 및 foreground 요청 우선; cold embed startup을 포함한 deadline 검증. + +### M02 — P1: provider cache가 갱신된 Supabase 인증 정보를 무시한다 + +- 위치: `src/main/utility/llm/runtimeMaterializer.ts:67`–77, 93–106, 124–137. `src/main/services/features/llm/modelRuntimeFactory.ts:18`–20, 47, 148–153, 168–172. +- 실제 흐름: main이 매번 새 runtime route plan과 Supabase proxy accessToken을 전송 → utility singleton key는 baseURL/model/embeddingModel/apiKey만 비교 → packaged apiKey는 고정 placeholder → 기존 provider 반환 → supabaseProxy resolver는 최초 candidate closure의 이전 token/URL/error 유지. +- 영향: token refresh/relogin/계정 또는 proxy config 변경 후에도 utility 재시작 전까지 이전 credential로 생성 요청. 만료 인증 실패 반복과 background retry 부하. 오래 유지되는 process 캐시가 correctness를 깬다. +- 증거: source-based mock에서 old-token plan 후 refreshed-token plan을 전달했으나 동일 provider, resolver 결과 old-token임을 assert 확인. Gemini key도 동일 구조다. +- 최소 수정: 무거운 모델 자체가 아닌 가벼운 HTTP wrapper이므로 proxy 후보는 재생성하거나 cache hit에도 최신 resolver를 갱신한다. 비밀을 긴 cache key/log에 추가할 필요 없다. 현재 invalidateModelRuntimeCache는 no-op이라 이것만 호출해선 해결 안 된다. +- 검증: 같은 모델·placeholder key에서 token/URL/초기 proxy 오류→정상 설정을 변경하는 테스트. + +### M03 — P2: 기존 사용자의 첫 창이 여전히 전체 readiness 검사에 묶인다 + +- 위치: `src/main/lifecycle/app-ready/appReady.ts:312`–318, 342–355. `src/main/services/features/startup/startupReadinessService.ts:139`–155, 223–240, 400–409. +- trigger: 위저드를 이미 완료한 사용자의 재시작. `getReadiness()` 전체 완료 뒤 startMainWindowFlow를 호출한다. checkSupabaseSession은 결과상 non-blocking인데 Promise.all completion에는 blocking이다. 네트워크 5초 제한에 main thread `better-sqlite3.pragma('integrity_check')` 전체 스캔이 겹친다. +- 영향: 첫 창이 늦고 큰 DB integrity scan 동안 main의 IPC/창 이벤트가 멈출 수 있다. 처음 위저드가 필요한 경로만 cheap gate→창 먼저로 개선되어 있다. Promise.all은 synchronous SQLite를 병렬 CPU 실행하지 않는다. +- 최소 수정: DB 연결/쓰기 가능 여부 같은 필수 최소 게이트와 full integrity/remote session 검사를 분리; 창 shell 먼저 생성하고 완료 상태 반영; full integrity는 비정상 종료/마이그레이션/명시 진단에 맞춰 worker/utility로 이동. SQLite durability 자체를 낮추지 않는다. +- 검증: completedAt가 있고 네트워크 5초 지연/차단 상태에서 첫 창 시점 측정, 큰 synthetic DB에서 event-loop delay 측정. 기존 appReadyDeferredWorker 테스트는 소스 문자열 순서만 검사해 이 경로를 보증하지 않는다. + +### M04 — P2: readiness TTL을 완료가 아닌 시작 시각에 계산한다 + +- 위치: `src/main/services/features/startup/startupReadinessService.ts:64`–75, 78, 94–97. +- trigger: full checks가 5초 이상 걸릴 때; fetch timeout+DB integrity 검사라는 실제 경로가 있다. +- 영향: computeReadiness(now)의 now는 검사 시작 시각이며 expiry=now+5000이다. 검사 결과가 반환되자마자 cache가 이미 만료될 수 있어 renderer IPC/completeWizard의 다음 호출이 full DB/네트워크 검사를 재실행한다. +- 증거: source-based fake clock에서 6초 걸리는 runChecks 뒤 즉시 getReadiness 재호출 → runChecks 2회 assert. +- 최소 수정: cache 저장 시 `Date.now() + TTL`. 설정 mutation 무효화 정책은 유지. +- 검증: 검사 시간>TTL인 fake clock 회귀 테스트 한 개. + +### M05 — P2: sidecar가 health-ready 전에 running으로 노출된다 + +- 위치: `src/main/utility/llm/sidecarSupervisor.ts:130`–135, 225–227. +- trigger: cold local runtime으로 동시에 두 요청. 첫 요청 doStart는 spawn 직후 state=running으로 두고 health fetch를 기다린다. 두 번째 ensureStarted는 startingPromise 검사보다 앞선 running/configKey 일치 분기로 baseUrl을 반환한다. +- 영향: 두 번째 요청이 준비되지 않은 llama-server에 generation/embed를 보내 ECONNREFUSED/503 가능. 불필요한 실패/재시도가 cold start 지연을 악화시킨다. +- 증거: source-based spawn/fetch mock에서 first health promise pending 상태로 second ensureStarted가 baseUrl을 반환함을 assert 재현. +- 최소 수정: pending startingPromise를 running fast-path보다 먼저 합류; running은 health 완료 뒤 선언. 다른 config로 동시 요청한 경우 현재 startingPromise를 잘못 재사용하지 않는 정책도 명시. +- 검증: delayed health + concurrent same-config 호출; health failure; different config; stop during startup. + +### M06 — P2: idle 상태에도 500ms마다 main에서 DB 검색/집계를 계속한다 + +- 위치: `src/main/services/features/derivedJobs/derivedJobWorker.ts:37`–40, 107–114, 148–188, 304–313. `src/main/lifecycle/shutdown/shutdown.ts:219`–223. +- 실제 흐름: renderer shown 후 worker.start. 기본 500ms tick. 작업이 비어 있어도 search queue, memory project group, episode extraction group, temporal evidence anti-join, long pending counts 두 개를 실행한다. +- SQL 근거: dbMaintenanceService.ts:303–308 / 423–429 / 442–460; memoryEpisodeExtractionProcessor.ts:51–57; memoryTemporalFactExtractionRunner.ts:173–183. +- 영향: idle empty queue 기준 적어도 6개 query/tick, 약 12개 query/sec 구조다(건당 실측 비용은 미측정). 에피소드 LLM 추출이 꺼져 있어도 후보 조회는 매 tick 수행한다. mac은 모든 창을 닫아도 app를 유지하며 이 worker를 pause하지 않는다. 전체 앱에서 powerMonitor suspend/resume/battery/thermal 이벤트 연결을 찾지 못했다. +- 최소 수정: 기존 queue enqueue 때 wake; empty일 때 점진 backoff, 새 작업 때 즉시 재개. 비활성 기능 후보 쿼리 skip. background 작업 budget을 battery/thermal/app visibility에 맞추되 저장 flush는 유지. +- 검증: 1분 idle query count/event-loop CPU/wakeups, mac windowless 상태, AC↔battery/suspend↔resume. 기능 플래그 꺼진 상태에서 episode/temporal poll 0회. + +### M07 — P2: derived worker stop이 drain을 보장하지 않는다 + +- 위치: `src/main/services/features/derivedJobs/derivedJobWorker.ts:123`–145, 200–269; `src/main/lifecycle/shutdown/runtimeLifecycle.ts:30`–45; `src/main/lifecycle/shutdown/shutdown.ts:427`–465. +- trigger: 5초를 넘는 LLM/embedding/summary tick 중 quit. stop은 running=false와 interval clear만 수행하고 inTick가 남아도 5초 뒤 resolve한다. tick 본문은 계속 다음 단계로 진행한다. +- 영향: 호출자는 stop 완료로 보고 sidecar/utility 종료, main/cache checkpoint/disconnect로 이동할 수 있는데 background job은 아직 완료되지 않았다. DB 종료와 쓰기/재시도 경합 가능. 데이터 손실을 실재현한 것은 아니며 정확한 위험 조건은 장기 작업이 걸린 종료다. +- 최소 수정: 현재 tick promise 보관, shared cancellation signal 전달, boundary마다 중단 확인, 취소 settle 또는 durable 재예약 확인 후 DB close. 단순 timeout을 drained 성공으로 취급하지 말 것. +- 추가: pauseShutdownBackgroundWork는 deferred startup maintenance의 running 전체를 timeout 없이 기다린다(deferredStartupMaintenance.ts:137–143). 대형 snapshot/path cleanup 중 quit UI 진행이 지연될 수 있다. 이런 비필수 maintenance는 chunk boundary에서 취소/재개 가능해야 한다. +- 검증: tick 단계 중간에 deferred provider를 매달고 stop 이후 새 단계/DB write가 없는지 확인; quit-cancel resume도 검증. + +### M08 — P2: 로그가 무제한 appendFile 팬아웃과 무제한 파일 성장 구조다 + +- 위치: `src/shared/logger/index.ts:168`–198, 282–291; `src/main/index.ts:20`–28. utilityProcessMain.ts:18–26,315. +- 실제 흐름: production main INFO logging 활성. 로그마다 데이터 deep redaction, console 출력, JSON.stringify, async import 후 별도 appendFile. filesystem write 완료를 기다리는 큐 상한/배치/rotation/shutdown flush 없음. +- 영향: burst IPC/derived error에서 promise와 log entry가 backlog에 유지되고 파일 open/write/close 작업이 mirror/gzip/download 등 libuv I/O와 경쟁한다. 장기 사용시 파일 무한 성장. utility는 configureLogger 없이 기본 DEBUG 콘솔 출력 상태라 stderr debug redaction도 실행한다. 동적 import는 module cache가 있으므로 매번 module 재로딩이라고 해석하면 안 된다; 핵심은 per-entry write와 무제한 backlog다. +- 최소 수정: main process 단일 bounded write stream/배치와 size rotation, 완료 대기 가능한 bounded flush. INFO hot event sampling 및 반복 WARN rate-limit. 민감정보 redaction은 유지. utility production log level 명시. +- 검증: 느린 sink에 10k 로그 burst, peak retained bytes/queued writes, 파일 최대 크기/rotation, fatal/quit flush deadline. 실제 처리시간/MB는 측정하지 않았다. + +### M09 — P2: 배포 아키텍처와 local LLM runtime 지원이 불일치한다 + +- 위치: `src/main/infra/llm/sidecarConstants.ts:3`–14; `src/main/handler/system/settings/modelDownloadHandlers.ts:56`–60; `src/main/handler/system/settings/llmHandlers.ts:51`–55; `.github/workflows/release-windows.yml:18`–23. +- trigger: Windows ARM64 또는 Linux ARM64에서 local model/binary provisioning. +- 영향: Windows 배포 matrix는 ARM64를 포함하지만 runtime URL/hash map에 win32-arm64가 없어 명시 unsupported error. linux-arm64도 없다. Windows x64 기본 URL은 명시 `bin-win-cpu-x64.zip`이므로 gpuLayers=-1을 설정해도 GPU runtime binary 선택 자체가 없다. +- 최소 수정: OS/arch/backend 지원표를 단일 기준으로 UI 선택/배포/다운로드/검증에 반영. 우선 ARM 플랫폼에 지원 binary 또는 명시 remote fallback; GPU별 CUDA/Vulkan 등을 일괄 넣기 전에 실제 지원 기기/driver/backend probe에 맞춘 선택과 실패 fallback을 제공. +- 검증: 실제 설치 artifact에서 binary version, startup health, embed+generate smoke. URL 존재 확인만으로 성능 인증하지 말 것. + +### M10 — P3: sidecar 자원/idle 정책이 실제 활성 작업과 하드웨어 budget을 반영하지 않는다 + +- 위치: `src/main/utility/llm/sidecarSupervisor.ts:12`,131–133,330–393,431–436. `src/main/utility/llm/embeddingModelConstants.ts` 및 `src/main/infra/llm/sidecarConstants.ts:34`–44. +- 근거: 3분 idle timer를 ensureStarted에서만 갱신하고 완료/진행중 reference count가 없다. generic text generation은 180초 bridge timeout이나 내부 fetch에는 signal 없고, 모델 준비 이후에 긴 active request가 있어도 idle timeout은 stop을 호출한다. chat 기본 threads=4, cacheRam=2048 MiB, parallel=1; embedding 별도 프로세스도 threads=4이다. +- 영향: 긴 active generation이 idle 종료 대상이 되거나, CPU core 적고 RAM 적은 기기에서 chat+embedding의 CPU/RAM budget 충돌. 높은 사양에서는 무조건 4 threads가 비효율일 수도 있으나 벤치 없이 thread 증가를 권장할 수 없다. 2GB는 cache budget 옵션이지 언제나 2GB RSS 할당됨을 측정한 것은 아니다. +- 최소 수정: request lease/reference count를 사용해 active=0일 때 idle timer 시작. hardware fit와 사용자 override를 유지하며 availableParallelism, 실제 memory 여유, foreground typing budget으로 한도를 결정. 모든 OS 동일 하드코딩 대신 실측 profile 적용. +- 검증: fake clock long-running request, chat+embedding 동시 workload, 8GB/16GB RAM, battery/thermal throttle. utility 강제 종료 시 descendant llama process가 남지 않는지도 OS process tree로 검증(현재 orphan을 실제 관측한 것은 아님). + +### M11 — P3: lazy import를 표방하지만 static barrel 경계가 넓다 + +- 위치: `src/main/index.ts:16`,94–107; `src/main/lifecycle/index.ts:1`–7; `src/main/handler/index.ts:1`–39; `src/main/lifecycle/app-ready/appReady.ts:312`–318. +- source static import/re-export traversal(TS AST, relative local source only) 결과: main index 246 files / 966KiB, single-instance 직접 barrel 2 files / 2KiB, handler index 415 files /1758KiB, utility entry72 files/257KiB. 이는 **source 연결 규모**이며 tree shaking 이후 bytes/실제 로드 시간/RSS 수치가 아니다. +- 영향: single instance lock 획득 전 lifecycle 전체 barrel이 static entry graph에 연결되고, 첫 창 전에 모든 domain handler/service registry를 import한다. loadAutoSaveManager도 해당 manuscript domain을 이미 static import하므로 독립 lazy boundary로 보기 어렵다. startupStartedAt는 import evaluation 이후라 이 초기 load 시간을 현재 checkpoint가 덜 보여줄 수 있다. +- 최소 수정: single-instance를 직접 entry에서 import; hot startup handler는 먼저 얇게 등록하고 heavy service는 handler 실행 시 기존 async lazy pattern으로 resolve. 실제 bundle graph/profile에서 유효한 경계만 변경. +- 검증: clean build static chunk closure와 cold process import profile; wizard/빈 project에서 analysis/sync/LLM 모듈 로딩 여부 비교. + +### M12 — P2: OS 성능 검증 범위가 배포 범위에 못 미친다 + +- 배포 CI: Windows x64/arm64 build matrix가 있으나 packaged launch/performance 테스트가 없다. macOS는 arm64 DMG 서명/staple 검증만 있고 Intel runtime job 없음. Linux는 package target(AppImage/deb)만 있고 `.github/workflows`에 Linux workflow 없다. +- native rebuild: scripts/rebuild-electron-if-needed.mjs:23 targetArch를 읽지만 59–69의 host Electron load probe 성공이면 다른 targetArch라도 skip. 이는 target architecture 검증이 아니다. 단 **better-sqlite3 13.0.3 설치본은 win/mac/linux/arm/x64 N-API prebuilds를 모두 동봉**하므로 이것만으로 ARM DB failure를 확정해서는 안 된다. sqlite-vec 같은 추가 native component도 packaged target에서 별도 smoke가 필요하다. +- 성능 harness: scripts/run-writing-loop-profiles.mjs:31–38은 bunx를 사용하고 writingLoop.stress.spec.ts:45–48은 sync/maintenance/package export를 끈다. fullprod 별도 테스트는 존재(writingLoop.fullprod.spec.ts:37)하므로 전체 기능 성능 테스트가 전혀 없다고 해석하지 말 것. 해당 E2E helper는 DB URL만 분리하고 userData/settings는 격리하지 않는다(_helpers/electronApp.ts:35–43). +- 최소 수정: packaged artifact/hash/runtime 버전과 OS/CPU/RAM/전원 상태를 기록하는 재현 가능한 scenario matrix; benchmark 전 source와 build 일치 보장; tmp userData로 settings/log/model 상태까지 격리. 기존 fullprod와 stress를 목적별 유지하고 OS별 budget/baseline을 둔다. +- 측정 항목: cold/warm first paint+editor ready, main event-loop delay, typing p95/p99, IPC p95/p99+payload bytes+queue depth, empty idle CPU/wakeups, main/renderer/utility/llama RSS+GPU memory, save→mirror→DB→package ack, quit/suspend/resume, DB busy/wal checkpoint, model cold/warm latency. 본 감사에서 실수치는 측정하지 않았다. + +## 플랫폼 행렬 + +| 항목 | macOS ARM64 | macOS x64 | Windows x64 | Windows ARM64 | Linux x64 | Linux ARM64 | +|---|---|---|---|---|---|---| +| 배포 자동화 | 서명 DMG CI | universal script만, 독립 CI 없음 | portable CI | portable CI | AppImage/deb 설정, CI 없음 | 명시 CI 없음 | +| local llama 다운로드 | macOS arm64 ZIP | macOS x64 ZIP | CPU x64 ZIP | map 누락, unsupported | Ubuntu x64 ZIP | map 누락, unsupported | +| 창 설정 | hiddenInset, native traffic lights | 동일 | custom title bar | 동일 | 기본 native frame | 동일 | +| 마지막 창 닫힘 | 프로세스 유지, worker polling 유지 | 동일 | app.quit | app.quit | app.quit | app.quit | +| 전원 대응 | 구현 없음; suspend/resume/battery/thermal 적용 가능 | 동일 | 구현 없음; battery/speed-limit 적용 가능 | 동일 | 구현 없음; suspend/resume 적용 가능 | 동일 | +| native 검증 | host better-sqlite3 load probe | 해당 target probe 없음 | host probe | host x64 runner의 probe는 arm64 런타임 검사 아님 | 해당 CI 없음 | 해당 CI 없음 | + +OS 전원 기능은 Electron 공식 문서에 명시된 범위다: [powerMonitor](https://www.electronjs.org/docs/latest/api/power-monitor). Linux에 battery change/thermal event가 macOS와 동등하다고 가정하면 안 된다. GPU blacklist 무시나 global backgroundThrottling=false를 성능 해결책으로 넣을 근거도 현재 없다. createSecureWebPreferences는 contextIsolation/nodeIntegration=false/sandbox=true를 유지하고 backgroundThrottling override는 하지 않는다. + +## 이미 잘 적용한 기반 + +1. main index의 utility 요청 시 lazy start. 기존 out artifact는 구버전이므로 혼동 주의. +2. 첫 위저드 cheap gate→window-first, bootstrap promise single flight, readiness inflight 병합, renderer ready fallback 8초, derived worker는 main window show 뒤 시작. +3. main/preload/renderer trust boundary 유지; secure webPreferences와 Electron fuses 적용. +4. utility process가 local llama spawn을 소유. loopback bind, random free port, health check, failure exponential cooldown, bounded stderr tail, idle stop. 결함은 기존 구조의 작은 guard/order/ownership 수정으로 해결 가능한 부분이 많다. +5. derived worker inTick overlap 방지, pending editing일 때 skip, batch 크기, 저장과 background 작업 우선 분리의 기본값. +6. shared writeFileAtomic은 gzip async, file fsync와 rename, 가능한 OS에서 directory fsync. package writer는 경로별 queue와 backup restore. 따라서 fsync를 삭제하거나 durability를 일괄 낮추는 것은 성능 최적화로 제안하지 않는다. Windows directory fsync 불가/antivirus lock/UNC/긴 경로는 별도 OS 테스트 필요. +7. renderer flush는 sender+requestId를 검증하고 timeout/error/quit 취소 회복 경로를 둔다. 문제는 background work drain과 OS system shutdown/suspend까지 연결되지 않은 부분이다. +8. vendor chunk 분리와 native asarUnpack, release 서명/staple 확인, save latency/core/fullprod/stress 성능 검사 기반이 이미 있다. + +## 우선 실행 제안 + +1. source probe 3개를 정식 regression으로 옮기며 sidecar ready race, provider auth cache, readiness TTL 수정. 첫 창 critical gate 분리. +2. timeout→cancel→settle 연결과 bounded utility scheduling. derived worker stop/drain 및 idle backoff부터 적용. worker/process 수를 무작정 늘리지 않는다. +3. logger bounded sink와 metric correlation. fullprod userData 격리와 clean packaged benchmark. +4. OS/arch/backend 지원표를 정리하고 Windows ARM64/Linux, mac Intel, GPU/저메모리 프로파일을 채운다. SQLite/renderer 병목에 대한 다른 담당 finding과 함께 실제 profile에 따라 worker offload 결정. + +Electron 공식 [performance](https://www.electronjs.org/docs/latest/tutorial/performance) 지침은 main blocking 회피, 필요한 시점까지 작업/모듈 load 연기, 실측에 기반한 병목 제거를 권한다. 이 보고서의 코드상 결함과 재현 결과를 먼저 고치고, 성능 향상 %나 RAM 감소량은 clean artifact의 실제 OS 측정 이후에만 제시해야 한다. diff --git a/docs/quality/performance-audit-2026-09-08/renderer.md b/docs/quality/performance-audit-2026-09-08/renderer.md new file mode 100644 index 00000000..bc243a40 --- /dev/null +++ b/docs/quality/performance-audit-2026-09-08/renderer.md @@ -0,0 +1,170 @@ +# Renderer 심층 성능·메모리·비동기 감사 + +감사 범위: renderer/app 시작, Editor/TipTap/SmartLink/diff/statistics, manuscript 본문 캐시·저장, canvas/ReactFlow/graph mutation, research 자료/채팅/메모리, export, shared UI 저장 registry. 읽기 전용 감사이며 소스 수정·실앱 기동·사용자 DB 접근·native rebuild 없음. 기존 package.json/pnpm-lock.yaml 변경은 건드리지 않음. 그래프 MCP 미제공 확인 후 scoped rg/read 사용. Renderer/features AGENTS 및 DESIGN.md 읽음. docs/quality/frontend-css-agents.md는 현 작업트리에 없음. React best-practices/ponytail 적용. + +상태 표기: **확정**은 실제 소스 경로의 비용/결함이 확인됨을 뜻하며 전체 앱의 지연값은 뜻하지 않는다. **재현**은 분리 검사에서 관찰했다는 뜻이다. **측정 필요**는 packaged Electron/실제 OS에서 실제 크기·빈도 검증이 남았다. + +## 우선순위 및 근거 + +### R1 · P1 · 저장 최적화가 raw editor draft를 유실시키는 900ms 공백 — 재현 + +- `src/renderer/src/features/editor/components/Editor.tsx:160`: onUpdate는 타이머만 갱신하며 900ms 뒤에야 getHTML/getText → setContent를 수행한다. useEditorAutosave에 넘어간 content는 그때까지 이전 본문이다(`:114`). 첫900ms 동안 lifecycle dirty도 갱신되지 않는다. +- `src/renderer/src/features/editor/hooks/useEditorAutosave.ts:283`: flushLatestDraft는 React로 전달된 latestDraftRef만 읽고 raw TipTap document/900ms timer를 모른다. `:319`에서 이 flusher만 registry에 등록한다. +- `src/renderer/src/features/editor/components/Editor.tsx:136`: unmount에서 raw serialization timer를 clear하고 본문을 직렬화하거나 저장하지 않는다. 따라서 저장 전환/종료를 최적화할 때 debounce를 단순 연장하면 데이터 유실창도 늘어난다. +- 호출 흐름: TipTap onUpdate → 900ms → React content → autosave 300ms(`src/shared/constants/runtime/interactionTiming.ts:5`) → handleSave/IPC. 수동 저장은 `saveCoordinator.ts:25`의 flushSaveBuffers → main manualSave, 종료는 `useProjectQuitFlush.ts:17`의 동일 registry를 사용하므로 앞단900ms가 빠져 있다. +- 분리 재현 `/private/tmp/luie-renderer-audit/editor-buffer.test.tsx`: 실제 Editor + 실제 useEditorAutosave를 사용하고 기존 테스트의 TipTap mock만 재사용. (a) onUpdate 직후 flushSaveBuffers → onSave 0회,900ms 후 flush→최신본문 저장. (b)900ms 전에 unmount→2초 후에도 onSave0회. 두 관찰 assertion 통과. 실제 native IME/OS close 자체를 재현한 검사는 아님. +- 최소 수정안: raw document의 dirty/version 및 serialization flush를 기존 저장 registry에 연결. 수동저장·chapter 전환·unmount/종료 때 최신 raw 문서를 정확한 chapterId로 직렬화하고 저장을 await하도록 같은 경로로 모은다. 평상시 debounce는 유지 가능하나 저장 보장과 분리해야 한다. +- 기존 `editorAutosaveManualFlush`/`editorAutosaveChapterTargeting` 테스트는 hook에 이미 최신 content를 직접 전달하므로 이900ms 경계를 검증하지 않는다. + +### R2 · P1 · SplitViewEditor wrapper가 이전 챕터 저장 타깃을 버림 — 실제 wrapper + 실제 hook 통합 재현 + +- `src/renderer/src/features/workspace/components/panels/SplitViewEditor.tsx:39`: key에 chapterId가 없어서 챕터 전환 시 인스턴스를 재사용한다. +- 같은 파일`:49`: onSave wrapper가 `(nextTitle, nextContent) => onSave(nextTitle, nextContent, chapterId)`이므로 useEditorAutosave가 세 번째 인자로 전달한 previousChapterId를 무시한다. +- `useEditorAutosave.ts:260`에서 챕터 전환 미저장 draft를 previousChapterId로 flush하려고 하지만 onSaveRef/performSaveRef는 새 render callback으로 갱신된 뒤 이 effect를 수행한다. 따라서 새 chapterId를 강제하는 wrapper가 이전 draft를 새 원고로 보낼 수 있다. +- 최소 수정안: wrapper를 제거하여 원본 onSave를 전달하거나 `(title,content,targetChapterId) => onSave(title,content,targetChapterId ?? chapterId)`로 계약을 보존. pending save가 있는 전환까지 integration regression으로 확인 필요. +- 최종 bounded integration probe `/private/tmp/luie-renderer-audit/split-chapter-target.test.tsx`는 실제 SplitViewEditor와 실제 useEditorAutosave를 연결했다. TipTap display만 이전 local title/content를 유지하는 harness로 대체했다. A 저장본문 → A 최신 미저장본문 → 300ms debounce 전 B 전환(content 미도착/suppressed) 시 `onSave("A title", "

A unsaved latest

", "chapter-b")`가 1회 실행되고 chapter-a 저장은 0회였다. 이후 wrapper에 명시 target chapter-a를 직접 전달해도 chapter-b로 바뀌는 계약 위반도 함께 확인했다. 단독 1 file / 1 test PASS, 761ms. 따라서 원인과 chapter 전환 호출 흐름을 모두 분리 환경에서 재현했으며 native Electron UI 전환을 직접 조작한 검사는 아니다. +- hook 단독 타깃 테스트의 성공을 wrapper 타깃 보장으로 확대 해석하면 안 된다. 이번 검증에서 다른 벤치와 suite는 재실행하지 않았다. + +### R3 · P1 · SmartLink가 매 입력과 무관한 store 변경에 전체 원고를 동기 스캔 — 재현·실측 + +- `src/renderer/src/features/editor/components/extensions/SmartLink.ts:30`: 모든 tr.docChanged에서 findSmartLinks(tr.doc). `:49` requestRescan 및`:55` 네 연구 store의 selector 없는 subscribe는 currentItem/isLoading/error 변경에도 동일 스캔을 발화한다. +- `src/renderer/src/features/editor/services/smartLinkService.ts:52`: service 자체도 네 store의 모든 변경에 정규식/엔티티 map 캐시를 무효화한다. `:119` findSmartLinks는 전체 doc.descendants → text마다 regex.exec → 모든 decoration 새 할당 → DecorationSet.create(`:156`). 매치→entity Map 최적화는 이미 적용되어 있으며 그것을 다시 권고할 이유는 없다. +- 비용: 매 keystroke마다 본문 전체 탐색 + M개의 decoration 재구축. 여러 editor가 살아 있으면 store rescan 비용이 editor 수에 곱해진다. IME 조합 중에도 docChanged가 생성되면 경로를 통과한다. +- 실제 ProseMirror document/DecorationSet 사용, DOM mount와 IPC를 제외한 분리 측정(`/private/tmp/luie-renderer-audit/smartlink-results.json`): 100문단/5,300자/400매치 median0ms, 1,000문단/53,000자/4,000매치 7ms, 5,000문단/265,000자/20,000매치 144ms(워밍업 후3회; millisecond 반올림). 실제 store의 currentItem만 변경해도 scan1회 발생하는 assertion 통과. +- 최소 수정안: store listener는 이름/id/type 집합이 바뀔 때만 invalidation/rescan. 일반 typing은 tr.mapping으로 기존 decoration 위치를 갱신하고 changed textblock만 재스캔. Worker 도입 전에 불필요한 전체 스캔 자체를 제거한다. 변경 범위 경계의 긴 이름·한글조합·삭제·undo/redo 검증 필요. + +### R4 · P1 · 그래프 force layout이 renderer에서 75/85×N² 동기 실행 — 실측 + +- `src/renderer/src/features/canvas/components/graph/GraphSurface.tsx:81` effect →`:104` calculateForceLayout. node/edge 구성 또는 character/event mode 변경 때 실행한다. focusId 클릭 재실행 방지는 이미 되어 있다. +- `src/renderer/src/features/canvas/utils/graphLayout.ts:63`: iterations loop 내부`:68`/`:72`의 모든 i,j 쌍 비교. `graphSurfaceParts/constants.ts:7`/`:8`에서75/85회 지정. edge 인덱스 Map도 이미 있다. +- 최신 보존 JSON, macOS arm64 Node22.23.0 실제 순수 함수 워밍업+3회 median: N100/E99 4ms, N500/E499123ms, N1000/E999359ms, N2000/E19991444ms(75회). 브라우저/OS 비교 벤치가 아니라 단일-thread 알고리즘 비용 측정이다. +- 최소 수정안: 기존 순수 함수를 Worker로 옮기고 request generation으로 stale 결과를 폐기. 취소/새 topology coalescing 필요. N 증가에도 전량 쌍 비교는 총CPU 비용이 유지되므로 이후 측정에 따라 spatial approximation/기존 dagre 등 레이아웃 요구에 맞는 알고리즘 선택. 단순 await/Promise.resolve는 thread를 바꾸지 않는다. +- DOM도 GraphSurface와 BaseCanvasViewport가 모든 노드/엣지를 전달하며 visibility culling 옵션을 사용하지 않는다. 불투명도0 focus는 DOM 제거가 아니므로 layout 개선 후에도 pan/zoom paint/DOM 비용 별도 측정 필요. + +### R5 · P1 · 50k문자 제한 이하에서도 snapshot diff가 수초 동기 실행 — 실측 + +- `src/renderer/src/features/snapshot/components/SnapshotViewer.tsx:75` useMemo에서 HTML 문자열에 diffWordsWithSpace를 동기 수행한다. 상한은 합산50k자뿐이며 토큰 차이량/시간 상한이 없다. +- `src/renderer/src/features/editor/components/extensions/DiffExtension.ts:67` decorations(state)에서 매호출 getDocTextMap/HTML→text/단어diff를 다시 수행(`:73`,`:81`). doc이 안 바뀐 selection transaction에도 결과 캐시가 없으며 added-part마다 전체 mapping을 다시 순회(`:101`). snapshot viewer와 highlight editor가 각각 diff를 계산한다. +- 최신 JSON `/private/tmp/luie-renderer-audit/bench-results.json`: 실제 설치 diff9.0.0, 서로 다른 `a0...`/`b0...` 단어 입력 각1000개/합9778자145ms, 각2000개/21778자575ms, 각4000개/45778자2320ms. 모두 코드의50k 제한 이하. 토큰 전부 변경된 스트레스 입력이며 일반 원고의 평균값은 아님. +- 최소 수정안: 문서/비교본 revision에 대해 diff/decorations 캐시, selection만 변하면 mapping 재활용. 시간/작업량 제한을 가진 worker 계산(설치 diff의 옵션을 먼저 확인)과 취소·오래된 결과 폐기. 결과 목록도 수천 additions이면 가상화 필요. 기존50k 일괄 skip으로만 해결하면 장편 기능을 포기하는 셈이므로 요구를 따로 정해야 한다. + +### R6 · P2 · CanvasMarkdownEditor는 저장만 debounce하고 매 입력 전체 직렬화 — 확정 + +- `src/renderer/src/features/canvas/components/shell/document/CanvasMarkdownEditor.tsx:139`: getMarkdown(editor.storage, editor.getText())에서 fallback인 getText()를 먼저 전체 실행한 뒤 getMarkdown도 실행한다. 결과 최신 ref 갱신 후500ms 저장 timer. `:147`/`:150`는 입력과 selection마다 forceUpdate({})로 toolbar/bubble/editor shell 전체 React render를 강제한다. extensions 배열도 render마다 재생성된다. +- N자 원고×입력건수의 직렬화/문자열 할당·GC + selection render. 글자수가 많은 canvas 문서에서 main editor의900ms 정책과 다른 hot path다. +- 최소 수정안: fallback lazy 평가로 getText 불필요 실행부터 제거. 문서직렬화는 revision/dirty ref로 지연하되 R1처럼 flush 손실창을 만들지 않는다. toolbar의 실제 선택 상태만 TipTap useEditorState로 구독하여 forceUpdate 제거. +- 설치 @tiptap/react/src/useEditor.ts:363-364는 shouldRerenderOnTransaction을 생략해도 transaction rerender를 비활성화한다. main Editor의 옵션 미지정을 rerender 버그로 지적하는 것은 오탐이다. 여기서는 직접 forceUpdate이므로 확정이다. + +### R7 · P2 · 여러 그래프 항목 삭제·이동이 전체 그래프를 반복 저장 — 확정 + +- `src/renderer/src/features/canvas/components/viewport/BaseCanvasViewport.tsx:182`: 여러 node 삭제를 reduce Promise chain으로 하나씩 순차 처리. edge도`:196` 같은 경로. 데이터 일관성을 위해 순차화한 의도는 타당하지만 각 항목마다 전체 저장한다. +- `src/renderer/src/features/research/stores/worldBuilding/worldBuildingStore.actions.ts:227`: 개별 삭제→그래프 filter→syncGraphBackedStore 전체 목록 재조회(`:241`)→persistGraphDocument(`:242`). 개별 관계 삭제도`:331-341` 전체 document 저장. +- `worldBuildingActions/runtime.ts:40-93`: 각 작업의 전체 graphData snapshot을 Promise closure로 보관하고 buildWorldGraphDocument→IPC setDocument를 매작업 수행한다. queue는 직렬화하며 중간 state를 합치지 않는다. 느린 filesystem/IPC에서 pending snapshot 배열/graph 객체가 증가할 수 있다. +- batch positions라는 API는 이미 있다(`worldBuildingStore.actions.ts:175`)지만 내부에서도 K개의 updateNodePositionInGraph 전체 nodes map(`:196`) 및 K개 updatePosition IPC를 Promise.all로 발사(`:208`), 마지막 전체 graph persist1회. K×N scan과 burst가 남는다. +- 최소 수정안: 사용자 한 번의 multi-select 변경을 하나의 graph mutation으로 만들고 nodes Map으로 한번 갱신, 엔티티 mutation 완료 뒤 전체 graph persist1회. 기존 updateGraphNodePositionsBatch 재사용 가능. 중간 snapshot coalescing은 호출자 acknowledgment/실패 보존 계약을 먼저 정의하고 구현한다. + +### R8 · P2 · 통계 worker 공유에 editor/request ID가 없어 교차갱신·오래된 응답 가능 — 확정 + +- `src/renderer/src/features/editor/hooks/useEditorStats.ts:18`: 모든 Editor가 singleton worker를 acquire,`:20` 모든 message를 무조건 global setStats. postMessage는 `{text}`만 전송(`:42`), worker 응답도 count만 반환(`workers/stats.worker.ts:33`). +- main+split+snapshot이 모두 hook을 mount한다. readOnly가 초기통계를 보내지 않도록 한 guard는 있으나 listener는 붙는다. A가 요청하고B로 전환한 뒤A응답이 도착하면B통계에 적용할 수 있다. 동시에 Editor H개가 있으면 응답1건에 동일 store set H회. StatusFooter는 전체 stats store를 구독(`src/shared/ui/StatusFooter.tsx:11`). +- 최소 수정안: editor/chapter key + monotonic requestId를 응답에 돌려주고 활성 대상의 최신 응답만 commit. readOnly/숨김 footer 등 소비자가 필요없는 경우 hook 일을 생략. worker 하나 자체는 적절하며 매 전환마다 spawn/terminate로 되돌릴 필요 없음. +- getText 및 string postMessage 복사 비용은 renderer에 남는다. worker가 전체 문자열 복사·ProseMirror traversal까지 없애는 것은 아니다. 필요 시 변경된 textblock별 통계 누적을 측정 후 고려. + +### R9 · P2 · 같은 프로젝트 metadata 갱신이 프로젝트 전체 초기화를 반복 — 확정 + +- `src/renderer/src/features/project/hooks/useProjectInit.ts:13`가 currentItem 객체 전체 구독,`:81` effect dependency도 객체 전체. 제목/설명·path 갱신으로 동일ID 새객체가 되면`:59` chapters/characters/events/factions/terms5개 전체load 반복. +- `src/renderer/src/shared/store/createCRUDStore.ts:295`는 update response를 currentItem으로 교체한다. `chapterStore.ts:103` loadAll은 content cache reset부터 하므로 단순 project rename도 본문 캐시 무효화+재조회+SmartLink rescan 폭증으로 이어질 수 있다. +- 최소 수정안: 프로젝트 전환 effect는 primitive currentProjectId에 의존. 복원/임포트 같은 외부 데이터 변경은 기존 명시 loadAll 경로로 유지하여 신선도를 보장한다. 프로젝트 rename과 restore/import를 나눈 call-count 검사 필요. + +### R10 · P2 · 초기 paint가 설정 IPC 완료를 기다리고 별도 창도 공통 초기화 — 확정, 사용자 지연 측정 필요 + +- `src/renderer/src/app/main.tsx:74`: i18nPromise와 setupRendererPromise가 모두 settled되어야 root render. setupRenderer는 먼저 cached theme을 반영하지만 `src/renderer/src/app/setup.ts:116` 이후 settings.getEditor를 await하므로 theme seed가 있어도 첫React shell은 IPC 완료 전 그리지 못한다. +- 이후 `useProjectInit.ts:29`가 editorStore.loadSettings→동일 getEditor를 또 호출한다. `App.tsx:133` useProjectInit 호출은 export(`:411`),oauth,wizard(`:465`) 조기return보다 앞에 있으므로 bootstrapReady인 별도 창도 projects/settings를 load한다. +- 최소 수정안: cache/default theme shell을 먼저 그린 뒤 settings hydrate를 background 처리하고 부트에서 이미 가져온 editor settings를 재사용. windowMode별 필요한 초기화만 활성화. 설정 실패·잘못된 캐시·초기 마이그레이션 gate를 유지해야 한다. +- root fresh isolated build 결과: JS573.1KB/17files(예산600), CSS166.1KB(170), PASS. 기존 out1108.9KB는 stale이라 성능 finding에서 제외. fresh build의 INEFFECTIVE_DYNAMIC_IMPORT(manuscript barrel)은 청크 분리 의도가 무효인 근거이며 현재 부트 budget 초과는 아니다. + +### R11 · P2 · RAG delta마다 전체 메시지 render·smooth scroll, 세션 history 무상한 — 확정, 실효 비용 측정 필요 + +- `src/renderer/src/features/research/stores/analysis/actions/ragChatActions.ts:79`: delta마다 messages.map + 해당 content에 문자열 append. `:42`는 신규 질문·응답을 영구 append하고 상한/페이지 분리 없음. +- `src/renderer/src/features/research/components/analysisSection/chat/useRagChat.ts:52`: 매messages변경 bottom.scrollIntoView({behavior:'smooth'}). 사용자가 위쪽 과거 답을 보고 있어도 stream마다 스크롤을 재요청. +- `.../chat/MessageList.tsx:40`: 과거 메시지 및 evidence DOM까지 매delta 전체 map, row memo/가상화 없음. input도 같은 useRagChat hook/store selection이므로 새질문 입력중에도 상위 AnalysisSection re-render와 전체 목록 render가 발생한다. +- `analysisStore.ts:115`에 reset은 있지만 renderer production caller가 검색되지 않음. 프로젝트 전환 뒤에도 답변/evidence/narrativeMemory객체가 global session store에 남는다. bytes 기준 상한 없음. +- 최소 수정안: 스트림 chunk를 rAF/짧은 시간 단위로 묶고 마지막 message만 갱신; 기존 row memo+이미설치 react-virtuoso 사용; 사용자 bottom-follow일 때만 스크롤. 대화 history는 프로젝트/세션별 명확한 수명과 저장/페이지 정책을 정하며 임의 삭제하지 않는다. +- 별도 mock에서 ask Promise reject시 isStreaming=true가 남는 경로는 재현했지만 preload safeInvoke가 정상 transport/timeout 실패를 envelope로 반환하므로 실제 timeout defect로는 **제외**했다. 뜻밖의 bridge throw 방어만 P3 후보이다. + +### R12 · P2 · 한글/일본어 IME 확정 Enter가 곧바로 RAG 전송 — 코드 경로 확정, OS 실기 확인 필요 + +- `.../analysisSection/chat/useRagChat.ts:66`: Enter && !shiftKey만 검사하고 preventDefault/onSend. nativeEvent.isComposing, keyCode229 또는 composition 상태 확인 없음. PromptComposer.tsx의 textarea가 이 handler를 그대로 사용한다. `features/ai/components/AIPanel.tsx:78` 시뮬레이션UI에도 같은 형태가 있다. +- 조합중 문장을 확인하는 Enter가 요청 전송/비싼 inference를 시작하면 UX와CPU/메모리 낭비 모두 생긴다. shared BufferedInput은 compositionStart/end와 flush 정책이 이미 있으나 이 composer에는 적용 안 됨. +- 최소 수정안: 조합중 Enter 제외, 실제 macOS KOR/JPN·Windows Microsoft IME·Linux IBus/Fcitx에서 compositionend/keydown 순서를 확인. jsdom synthetic event만으로 OS일관성을 주장하지 않는다. + +### R13 · P2 · 연구 갤러리·사이드바는 전량 DOM, 숨긴 탭도 메모리에 유지 — 확정, 임계규모 실측 필요 + +- `src/renderer/src/features/research/components/wiki/EntityGallery.tsx:167` 검색키마다 이름+description 전체 lowerCase/filter, `:362`/`:467`는 모든 entity card/row를 렌더. 이미지 lazy/async decoding과 attributes500-entry cache는 이미 적용되어 있다. +- `ResearchPanel.tsx:217`/`:245`/`:273`: 한번 방문한 character/event/faction을 CSS hidden으로 유지. state/DOM/subscription은 살아 있으므로 프로젝트 전환/자료변경 시 안 보이는 탭도 작업한다. 의도는 remount/selection보존이며 완전누수라고 부르면 부정확하다. 다만 많은 자료·중복 panel에서 resident memory 및 render 비용에 더해진다. +- SidebarChapterList.tsx:130, SidebarCharacterList.tsx:184 등은 전체 map. Sidebar 상위 content-visibility:auto 한개는 리스트 내부 row 수를 제한하지 않는다. +- 최소 수정안: 이미설치 react-virtuoso로 큰목록만 가상화하고 검색용 정규화text는 entity데이터변경 때만 생성. 숨김tab의 expensive 구독/계산을 정지하거나 필요한 UI상태만 보존. 작은목록은 그대로 두어도 된다. + +## 메모리·수명 추가 관찰(P3/측정 후 결정) + +- `worldPackageStorage.ts:86,134,144,167`: synopsisCacheByProject는 프로젝트별 strong Map이며 eviction/clear가 없다. 장기세션에서 방문한 모든 프로젝트 시놉시스를 보유. 소수 프로젝트에는 작을 수 있으므로 실제 retained bytes 측정 후 bounded eviction/close invalidate. +- `saveCoordinator.ts:15-20,41-44`: 매수동저장 performance.measure 두개 누적, production clearMeasures는 검색되지 않음. 낮은빈도라 큰병목으로 과장하지 않고 장기계측 ring buffer/observer drain 후보로 기록. +- `chapterContentStore.ts:19`4-entry 상한·inflight coalescing·generation invalidation·active retain은 이미 적용. 단 엔트리수는 bytes상한이 아니며 열린editor는 상한을 넘어 유지한다. 활성문서를 버려IPC ping-pong을 만드는 공격적메모리최적화는 금지. unmount 후 release는 다음 setContent 때까지 eviction을 즉시 수행하지 않는다. +- `parseStructuredAttributes.ts:6`500-entry FIFO는 이미 존재. image data URI가 attributes에 들어가면 원문string+파싱string+DOM/decodebitmap이 각 layer에 존재할 수 있으므로 엔트리수와 실제byte를 함께 관찰해야 한다. +- listener/timer 정리는 editor SmartLink destroy, typewriter RAF, ResizeObserver, save timers 등 주요경로에서 구현됨. 타이머가 있다는 이유만으로 메모리누수를 주장하지 않았다. +- ExportManager는 단일chapter 조회이고 sanitize/prepare는 content변경에 memo화되어 있다. 전체project export로 오인하지 않았다. `ExportPreview.tsx:124`는 chapter전체 DOM을 한sheet에 넣고 typography/margin 변경시 전체reflow 가능, `:90` transition-all은 layout속성 애니메이션 비용 후보. 긴본문일 때만 실제measure 후 preview pagination/범위렌더 고려. exportCreate로 다시 전체content를 보내는 IPC복사는 별도창에서 수정하지 않는본문이라 생략가능성 있지만 export snapshot 계약을 먼저 확인해야 한다. + +## 확인된 기존 개선 및 오탐 배제 + +1. 본문은 chapter list DTO에서 분리,4-entryLRU 및 retain/inflight/generation 적용. +2. 대부분 hot store는 selector/useShallow이며 main Editor의 TipTap transaction rerender는 설치버전 기본으로 꺼짐. +3. graph focus시force layout재실행 차단,edge-nodelookup Map,adjacencySet 적용. +4. graph drag시position은 drag-end persist,main graph canonical writes는 queue직렬화. +5. locale는 감지언어+ko fallback만 초기로드,나머지lazy. editor/layout/research/export 화면lazy+hoverprefetch 적용. +6. fresh renderer boot budget PASS. stale out는근거로사용안함. +7. export HTML sanitize memo,이미지lazyload,attributesparse cache,기존엔티티ensureLoaded dedup 적용. +8. 통계 worker1개 공유는spawn churn을줄이는선택이며문제는응답식별/소비자수명이다. + +## 실행 검증·재현 산출물 + +기존 suite 명령: + +```sh +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/dom/editorAutosaveManualFlush.test.tsx tests/dom/editorAutosaveChapterTargeting.test.tsx tests/dom/editorStatsOnCreate.test.tsx tests/renderer/stores/chapterContentStore.test.ts tests/renderer/services/saveCoordinator.test.ts +``` + +결과 5 files / 34 tests PASS, 1.29s. native DB 초기화 및 실앱 실행 없음. + +분리감사 명령: + +```sh +node /private/tmp/luie-renderer-audit/bench.mjs +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run --config /private/tmp/luie-renderer-audit/vitest.config.mjs +``` + +최종 분리 suite 3 files / 4 tests PASS, 1.49s. editor-buffer 2개 + smartlink-cost 1개는 확정 관찰이다. rag-state 1개는 정상 preload 경로와 달라 실제 결함 목록에서 제외했다. 임시 directory의 node_modules symlink는 기존 설치 패키지를 읽는 용도다. + +파일: +- `/private/tmp/luie-renderer-audit/bench.mjs`: actual graphLayout/diff9.0.0벤치. +- `/private/tmp/luie-renderer-audit/bench-results.json`: 최신공통벤치결과. final보고는이JSON수치사용. +- `/private/tmp/luie-renderer-audit/smartlink-results.json`: actualProseMirror/DecorationSet벤치. +- `/private/tmp/luie-renderer-audit/editor-buffer.test.tsx`: 900ms flush/unmount 공백 2건 관찰. +- `/private/tmp/luie-renderer-audit/split-chapter-target.test.tsx`: 실제 SplitViewEditor + 실제 autosave hook의 전환 타깃 오라우팅 관찰 1건. +- `/private/tmp/luie-renderer-audit/smartlink-cost.test.tsx`:정상선택store변경rescan관찰및벤치. +- `/private/tmp/luie-renderer-audit/rag-state.test.tsx`:정상preload와다른exception모델대조용,실사용timeout재현아님. +- `/private/tmp/luie-renderer-audit/vitest.config.mjs`:repoaliases+기존setup사용,외부tmp테스트범위. + +root 검증 공유: fresh isolated build `/private/tmp/luie-audit-build/out` 성공 및 boot budget PASS. tsc6 --noEmit는 기존 `src/renderer/src/features/manuscript/components/Sidebar.tsx:157` unused handleRenameProject 1건으로 실패했다. 이번 renderer 감사는 소스 변경이 없으므로 자체 타입 회귀가 아니다. + +## OS 검증 순서 + +핵심 알고리즘·직렬화·상태 경계는 macOS/Windows/Linux에 공통적이다. 실제 측정은 macOS arm64 Node에서만 했으며 Windows/Linux 속도를 확정하지 않았다. Windows x64/arm64, macOS arm64/x64, Linux Wayland/X11의 packaged production에서 동일 fixture로 입력 p95, long task, 메모리 peak, idle CPU, graph pan/layout, IME Enter 및 native quit flush를 비교해야 한다. JS thread 병목은 OS 플래그나 일괄 worker 증설보다 불필요한 전체 연산과 원고 유실 경계를 먼저 제거하여 해결해야 한다. + + +### R2 추가 단독 검증 명령 + +```sh +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run --config /private/tmp/luie-renderer-audit/vitest.config.mjs /private/tmp/luie-renderer-audit/split-chapter-target.test.tsx +``` + +1 file / 1 test PASS (761ms). 기존 벤치 JSON은 변경하지 않았다. diff --git a/docs/quality/performance-standard-v1.md b/docs/quality/performance-standard-v1.md new file mode 100644 index 00000000..a0e1ea36 --- /dev/null +++ b/docs/quality/performance-standard-v1.md @@ -0,0 +1,203 @@ +# Luie 성능 기준 v1 — 저사양 Windows 타깃 + +작성일: 2026-09-09. 상태: **v1 확정**. 하드 게이트 수치는 실제 저사양 하드웨어(eMMC/N4020급) 실측 전 잠정값이며, L synthetic profile 측정 인프라(§8) 구축 후 재검증한다. + +범위 원칙: **renderer 중심, Windows 환경 우선.** 다른 OS의 구조 변경은 본 문서의 플랜에 포함하지 않는다(별도 상의). 근거 조사: [renderer 감사(2026-09-08)](performance-audit-2026-09-08/renderer.md), [main/플랫폼 감사(2026-09-08)](performance-audit-2026-09-08/main-platform.md), [시작 파이프라인 해부](../architecture/startup-pipeline-dissection.md), Electron/React 계층 추가 조사(2026-09-09, 본문 §7~§8). + +--- + +## 1. 타깃 기기 등급 + +일반 사용자의 "저사양 노트북"을 시판 기기 기준으로 정의한다. VM이 아니라 실제 사용자 기기가 기준이다. + +| 등급 | 대표 하드웨어 | RAM | 저장장치 | GPU | 디스플레이 | +|---|---|---|---|---|---| +| **L-min (최소 지원)** | Intel N4020급, 구형 i3-8130U | 8GB | eMMC/HDD 또는 저가 SATA SSD | 내장(UHD 600급). 드라이버 상태에 따라 GPU 기능 비활성/소프트웨어 경로 가능 | 1080p, 배율 125~150% | +| **L-typical** | Intel N100급, Ryzen 3 | 8GB | 저가 NVMe/SATA SSD | 내장(UHD급) | 1080p | +| **M (표준)** | 12세대 i5, Ryzen 5 | 8~16GB | NVMe SSD | 내장 최신세대 | 1080p~1440p | +| H | 그 이상 | 16GB+ | NVMe | dGPU 포함 | 고해상도 | + +- **최소 지원 선 = L-min(N4020급)** 로 명시한다. N100 통과를 최소사양 통과로 오인하지 않도록 테스트 기준 머신을 L-min/L-typical로 분리한다(§8). +- L-min의 병목 4요소: ① 저장장치(eMMC 읽기 100~200MB/s + packaged portable 자가 추출 + Defender), ② CPU 단일 스레드(현세대 대비 ~1/4 — SmartLink 실측 144ms/키가 400~700ms로 확대), ③ GPU 차단/드라이버(Chromium이 GPU 기능을 비활성하거나 소프트웨어 경로로 진입 가능. 자동 SwiftShader WebGL 폴백은 deprecated — 이제 WebGL 컨텍스트 생성 실패가 기본), ④ RAM 압박(8GB에서 Windows 4~5GB 선점 → 스와핑 시 전체 UI 정지). +- 지원 범위 밖: 2GB RAM, 32bit — 미지원을 명시한다. + +## 2. 성능 예산 — 목표와 하드 게이트의 분리 + +**원칙**: 목표값은 최적화 방향을 정하고, 하드 게이트만 CI/릴리스 실패 조건이다. 실측 전 단계에서 목표값을 게이트로 걸면 방향이 왜곡된다. + +| 지표 | L 하드 게이트 | L 목표 | M 목표 | 측정 정의 | +|---|---|---|---|---| +| 첫 visible paint | **≤1.5s** | ≤1.0s | ≤0.6~0.8s | 콜드 스타트 → 스켈레톤/위저드 첫 프레임 | +| 첫 상호작용 가능 | **≤2.5s** | ≤1.8s | ≤1.2s | 첫 클릭 응답 | +| 에디터 입력 가능 | **≤3.0s** | ≤2.5s | ≤1.8s | 첫 키 입력 반영 | +| 키→paint p95 | **≤50ms** | ≤40ms | ≤33ms | 성능 트레이스 | +| 앱 JS per-key p95 | **≤6ms** | ≤4ms | ≤4ms | main-thread 작업량 | +| 애니메이션 프레임 | **p95 ≤33.3ms** | 30fps 이상 | p95 ≈20ms 이하 | rAF 샘플링 | +| 심각한 frame stall | **>100ms 거의 0회** | 동일 | 동일 | long task 관측 | +| 앱 메모리 | **aggregate private bytes ≤450MB** | ≤350~400MB | ≤350MB | §5 측정 방법 | + +L-min의 per-key ≤6ms가 의미하는 것: SmartLink 제거 후에도 남은 모든 keystroke 작업(통계 직렬화 복사 등)의 합계가 6ms 안에 들어와야 한다. 큰 문서에서 getText 전체 복사 하나로도 예산 초과라 R8 정리는 필수다. + +## 3. 런타임 등급 판정 설계 + +기기의 **기본 성능 등급(`data-perf`)**과 현재 **전력/열 상태(`data-power`)**를 분리한다. 사용자의 `data-animations` 설정(on/off)은 최상위 존중 대상. + +```html + +``` + +### 3.1 등급 판정 신호 + +| 신호 | 방법 | 비고 | +|---|---|---| +| GPU 상태 | main: `app.once('gpu-info-update')` 후 `app.getGPUFeatureStatus()`. `gpu_compositing`이 `disabled_software`/`unavailable_software`/`disabled_off`/`unavailable_off` 계열이면 degraded | `enabled*` 4종 이외는 degraded 처리. **gpu-info-update 이전 호출은 무효**. WebGL 폴백 deprecated로 "WebGL 컨텍스트 생성 실패" 경로도 커버 | +| RAM | main: `os.totalmem()/1024**3 < 10` → lowMemory | `navigator.deviceMemory`는 fingerprinting 방지 뭉개진 값이므로 **사용하지 않는다**. main에서 판정해 IPC로 전달 | +| CPU | renderer: `navigator.hardwareConcurrency` ≤ 4 + 첫 프레임 미세 벤치마크(≤50ms 프루브) | 보조 신호 | +| CPU 스로틀 | main: `powerMonitor.on('speed-limit-change', ({limit}) => limit < 80 → data-power="reduced")` | **이벤트명 주의: `speed-limit-change`** (d.ts:11019, payload `{limit}` 퍼센트). `thermal-state-change`는 macOS 전용이라 Windows 타깃에서 사용 불가. 배터리 자체(on-battery)는 등급에 영향 없음 — 충전기 분리로 UI가 갑자기 저하되면 오히려 이상하다 | +| 런타임 FPS | 상호작용 시작 후 10초 window, p95 frame > 33ms 또는 100ms+ stall 반복 → 강등 후보. **연속 2~3 window 나쁨 → 강등(hysteresis)**. 초기 5초는 부팅 몰림으로 오판 위험 → 참고 데이터만 | 한 번 나빴다고 바로 강등하지 않는다 | + +### 3.2 등급별 동작 매트릭스 + +| 항목 | H/M | L (`data-perf="l"`) | +|---|---|---| +| backdrop-filter(유리 효과 66곳) | 유지 | plain 배경으로 교체 — 개별 클래스를 때리지 말고 **CSS 변수 단일 관문**으로: `:root { --glass-blur: blur(20px) }` → `html[data-perf="l"] { --glass-blur: none }` | +| transition-all / flex-grow 패널 전환 | 유지 | 필요 속성 명시 + 즉시 전환 | +| 위저드 리사이즈 애니메이션 | 재생 | 스킵(기존 skip 경로: `ipcWindowHandlers.ts:371`) | +| reactflow 가상화(culling) | on | on (공통) | +| spellcheck | on | 기본 off 제안 (§6 상의 항목) | +| 로컬 LLM sidecar | 허용 | §6 정책 | + +`data-power="reduced"`는 `data-perf`와 독립적으로 transition/animation을 일시 축소한다. 등급 자체를 바꾸지 않는다. + +## 4. 부팅 진단 요약 — 왜 첫 UI가 늦은가 + +근거: startup-pipeline-dissection.md, renderer 감사 R10, 부트 조사(2026-09-09). + +1. **첫 paint가 i18n 번들(ko 80.7KB 청크) + settings IPC(`getEditor`) 완료까지 전면 대기** — `main.tsx:74-88`, `setup.ts:118`. 테마 시드는 이미 동기 적용되므로 즉시 그릴 수 있음에도 "loading" 텍스트조차 못 그리는 구간이 존재. +2. **부팅 중 `settings.getEditor` 3중 호출** — `setup.ts:118` / `useProjectInit.ts:29→editorStore.ts:69-70` / `useStartupWizardState.ts:43`. +3. **useProjectInit이 모든 windowMode에서 실행** — `App.tsx:133`이 조기 return들보다 앞. 위저드/export 창도 프로젝트 목록+설정 로드. +4. **메인 창 이중 게이트** — main deferShow(did-finish-load) + BootstrapGate(isReady). isReady는 main readiness(Supabase 세션 확인 최대 5초 타임아웃 + DB integrity)에 묶여 있어 **renderer만으로 메인 창 "진짜 UI 1초"는 불가능** — main readiness 분리는 상의 항목(P2-B). +5. **부트 번들 580KB/18파일 + CSS 163KB** — 예산(600/170KB) 이내. radix-ui 파생(33KB)/chapterStore(19.6KB)/lucide 전체(63.8KB)가 부트에 포함된 낭비는 있으나 지배 원인 아님. +6. **dev 모드**: DevTools 자동 오픈(`windowManager.ts:220`, 보조 창 포함) + Vite 콜드 변환(과거 실측 29.5s). **packaged 모드**: portable %TEMP% 자가 추출 + Defender 실시간 검사. +7. **보조 창(main/export/world graph/wizard)이 같은 index.html 풀 번들**을 각 renderer 프로세스로 로드. Electron에서 창=별도 프로세스는 구조적 한계(`affinity`는 Electron 14 제거, WebContentsView도 프로세스 공유 아님). + +## 5. 메모리 측정 정의 + +- **지표명: Electron app aggregate private bytes.** `app.getAppMetrics()` → `ProcessMetric[].memory.privateBytes`(win32 전용, KB) 합산 / 1024 = MB. `workingSetSize`·`peakWorkingSetSize`는 별도 관찰값. +- `process.getProcessMemoryInfo()`는 호출 프로세스 단위라 전체 게이트로 부적합. +- 주의: `getAppMetrics()`의 CPU 필드는 호출 간격을 main의 다른 코드와 공유하므로 **CPU 폴링과 메모리 폴링은 별도 주기**로. `type` 필드(Browser/Utility/GPU)로 프로세스별 분해 집계. +- LLM sidecar 프로세스는 게이트에서 제외하되 별도 관찰(§6). + +## 6. 로컬 LLM sidecar 정책 (8GB 이하) + +**"자동 다운로드 중단"이 아니라 "자동 상주 금지 + 완전 lazy"가 원칙.** 디스크에 모델 1.9GB가 있는 것은 RAM 문제가 아니다. 위험한 것은 Electron+renderer+sidecar+Windows가 합쳐 8GB를 넘어 pagefile 스와핑이 시작되는 순간이다. + +``` +8GB 이하 (data-perf="l" 또는 lowMemory): +앱 시작 → sidecar 시작 X, 모델 로드 X + → 사용자가 AI 기능 사용 시도 + → (필요 시) 모델 다운로드 안내 + → 메모리 여유 확인 + → sidecar lazy start +사용 종료/idle → 기존 idle stop 정책 유지 + 적극 적용 +``` + +상의 필요: 8GB에서 로컬 LLM 자체를 허용할지(경고 후 허용 등), 다운로드 자체를 사용자 행동 이후로 미룰지의 UI 문안. + +## 7. 조사 발견 요약 (수정 대상) + +### 7.1 타이핑 (목표: 글이 따라오는 느낌) + +| 우선순위 | 발견 | 근거 | 수정 방향 | +|---|---|---|---| +| 1 | **SmartLink 매 keystroke 원고 전체 재스캔** (265k자 실측 144ms/키, store 무관 변경에도 재스캔, 에디터 수 곱셈) | `SmartLink.ts:32-35,55-60`, `smartLinkService.ts:52-57,118-150` | typing은 `tr.mapping`으로 기존 decoration 위치 갱신 + 변경 textblock만 재스캔. store 구독은 엔티티 이름 집합 변경 시에만 invalidate | +| 2 | DiffExtension이 selection 트랜잭션에도 전체 diff 재계산(캐시 0) — 45k자 실측 2.3s | `DiffExtension.ts:67-125` | 문서/비교본 revision 캐시, worker화는 P2 | +| 3 | 통계 worker: 전체 문자열 복사 + 응답 식별 없음(에디터 H개면 H회 전역 set) | `useEditorStats.ts:20-43` | requestId + 활성 에디터만 commit | +| 4 | 900ms 경계 getHTML+getText 2회 전체 직렬화 + 마지막 900ms 유실창 | `Editor.tsx:160-172` | raw dirty/version을 저장 registry에 연결 | +| 5 | CanvasMarkdownEditor 매 키 getText + forceUpdate | `CanvasMarkdownEditor.tsx:139-151` | lazy fallback + useEditorState | +| 6 | spellcheck: true + 언어 미지정(Windows hunspell 사전 필요 시 CDN 다운로드) | `windowChrome.ts:176` | `setSpellCheckerLanguages(['ko'])` 명시, 다운로드 타이밍 첫 paint 이후로 | + +키 입력 자체는 React 리렌더를 만들지 않는다(TipTap 3 기본 off). React 쪽 리렌더 낭비는 아래 7.3. + +### 7.2 부팅/창 (§4 진단에 대응하는 수정) + +| 우선순위 | 수정 | 위치 | +|---|---|---| +| 1 | root.render 즉시 호출 — i18n/설정 배경 hydrate | `main.tsx`, `setup.ts` | +| 2 | settings.getEditor 1회 캐시 | 위 3곳 | +| 3 | useProjectInit을 app 모드 전용으로 | `App.tsx:133` | +| 4 | 보조 창 경량 엔트리(manualChunks 분리돼 있어 rollup input 추가만 가능) | `electron.vite.config.ts` | +| 5 | dev DevTools 자동 오픈 플래그화 | `windowManager.ts:220` | +| 6 | GPU 상태 관측 로그 추가(동작 변경 없음 — 게이트 미달 머신 원인 분리용) | main 부트 | +| 7 | AUTO_SAVE 동일 내용 early-return 앞당김(현재 미러 쓰기 이후) | `autoSaveManager.ts:186-188` | +| 8 | handler barrel startup-critical 채널만 즉시 등록(+259~431ms 실측) | `appReady.ts:312-314` | +| 9 | portable → NSIS 일반 배포 전환(portable은 %TEMP% 자가 추출 구조) | `electron-builder.json` — 배포 정책이라 상의 | + +### 7.3 애니메이션/렌더링 + React + +| 우선순위 | 발견 | 근거 | 수정 | +|---|---|---|---| +| 1 | **ToastContext value 매 렌더 새 객체** — 토스트 1건마다 App 아래 전체 재렌더 | `Toast.tsx:34` | useMemo 1줄 (효율 1위) | +| 2 | canvas-glass-node 등 backdrop-filter 66곳 | `canvas.css:209` 등 | §3.2 CSS 변수 관문 + data-perf | +| 3 | EditorToolbar(520줄)/EditorBubbleMenu 미memo — 900ms 경계·리사이즈 프레임마다 reconcile | `EditorToolbar.tsx`, `EditorBubbleMenu.tsx` | memo화(props 이미 안정) 또는 Compiler 위임 | +| 4 | useElementWidth ResizeObserver가 리사이즈 프레임마다 setState → EditorLayout→Ribbon→Toolbar 연쇄 | `EditorLayout.tsx:192`, `MainLayout.tsx:121` | rAF 스로틀/측정값 소비처 분리 | +| 5 | flex-grow transition(8개 레이아웃 그룹) + transition-all 103건 중 레이아웃 속성 애니메이션 | `global.behaviors.css:120`, `Editor.tsx:369` 등 | 필요 속성 명시 + L등급 즉시 전환 | +| 6 | reactflow 가상화 미사용 + 노드 전면 유리 효과 | `GraphSurface.tsx`, `BaseCanvasViewport.tsx` | `onlyRenderVisibleElements` on | +| 7 | ResearchPanel 숨김 탭이 CSS hidden으로 구독/DOM 유지(R13) | `ResearchPanel.tsx:217/245/273` | **React 19.2 내장 ``** — 추가 설치 불필요, 상태 보존+Effect 언마운트 | +| 8 | StatusFooter가 유일한 zustand 전체-store 구독 | `StatusFooter.tsx:11` | selector 분리 | +| 9 | EntityGallery 검색 필터 동기 전체 재계산 | `EntityGallery.tsx:166` | `useDeferredValue` + 검색 텍스트 사전 계산 | +| 10 | MessageList delta마다 전체 재렌더+smooth scroll(R11) | `MessageList.tsx:34-149` | 행 memo + react-virtuoso(이미 설치) + delta rAF 배치 | +| 11 | Suspense fallback이 빈 "loading" 화면 | `App.tsx:405-409` | 레이아웃 형태 스켈리턴 | +| 12 | 위저드 리사이즈 16ms setTimeout으로 setBounds 매 프레임(800ms) | `ipcWindowHandlers.ts:117-157` | rAF 정렬 + L등급 스킵 | + +### 7.4 Electron 계층 (문서 확인 완료 사항) + +- Chromium 코드 캐시는 file:// 로드에서 **이미 기본 동작**(2회차 부트 수혜). 커스텀 프로토콜 전환 시 오히려 꺼짐 — 전환 시 `codeCache: true` 필요. +- `module.enableCompileCache()`(Node 24 내장) main 부트 1줄 시도 가능 — 단일 번들이라 이득 소폭(추정). +- `affinity` 제거됨(E14), BrowserView deprecated(E30+) — 창=별도 프로세스는 구조 확정. +- `backgroundThrottling`은 **기본값(true) 유지** — false 전환 비권장. +- `speed-limit-change` 구독은 로그/`data-power` 용도로만. `thermal-state-change`는 macOS 전용. +- 메뉴 불필요 시 `Menu.setApplicationMenu(null)`은 이미 적용(`windowChrome` 경로). + +### 7.5 React Compiler 도입 판정 + +- **React Compiler v1.0 안정(2025-10)**. React 19.2.8 조건 충족. Vite 8/rolldown 경로: `@rolldown/plugin-babel` + `reactCompilerPreset()`(`@vitejs/plugin-react` v6에서 내장 Babel 제거됨). +- `eslint-plugin-react-hooks` v7.1.1 이미 설치 → compiler 규칙 프리셋(`recommended-latest`) 전환 가능. 위반 컴포넌트는 컴파일러가 스킵만 하므로 점진 도입 안전(`"use no memo"` 탈출구 존재). +- 기대 효과: 7.3의 수동 memo 작업(EditorToolbar/리스트 행 등)을 자동화. 리스크: 빌드 시간 증가(추정 수 초~십수 초), `check:version-pins`/`check:deps` 규칙에 맞춘 신규 devDeps 3개 핀, `forceUpdate` 패턴(R6) 재검토. +- 판정: **점진 도입** — 7.3 수동 수정과 병행하되, 장기적으로 memo 유지보수를 컴파일러로 이관. + +## 8. 검증 방법 — L synthetic CPU/GPU profile + +**한계 명시**: CDP CPU throttle은 CPU slowdown factor만 적용한다. eMMC 랜덤 I/O, Defender 검사, 8GB 메모리 압박/pagefile, 구형 iGPU 드라이버, 발열 스로틀링은 CI에서 재현 불가 → VM 프로필과 실기 canary가 보완한다. + +### CI 프로필 (매 빌드) + +| 프로필 | 구성 | 판정 | +|---|---|---| +| L-cpu | CDP `Emulation.setCPUThrottlingRate(4)` | typing p95 < 50ms, per-key JS < 6ms | +| L-gpu-disabled | `--disable-gpu` 실행 | frame p95 ≤ 33.3ms + **data-perf="l" 자동 판정 확인** | +| L-swiftshader | `--use-gl=angle --use-angle=swiftshader` | **테스트 전용.** 소프트웨어 래스터 스트레스 | + +### VM 프로필 (릴리스 후보) — 기존 8GB VM을 "Luie-L"로 + +| 프로필 | 구성 | 판정 | +|---|---|---| +| L1 CPU | 8GB 고정, 2 vCPU + `Set-VMProcessor -Maximum`(호스트별 캘리브레이션 — 앱 microbenchmark로 N4020급 스케일 맞춤), 1080p/150%, GPU 정상 | typing p95, per-key | +| L2 GPU worst | L1 + `--disable-gpu` | frame p95, data-perf 동작 | +| L3 memory pressure | 8GB, sidecar 실제 실행, 큰 문서 오픈, 타이핑+화면 전환 | aggregate private ≤450MB, UI stall 없음, pagefile 관찰 | +| L4 cold storage | Defender ON(제외 없음), 캐시 삭제, 콜드 부트, portable/NSIS 각각 | 첫 paint ≤1.5s 하드게이트(목표 1.0s) | + +- Hyper-V 고정 RAM: `Set-VMMemory -VMName "Luie-L" -DynamicMemoryEnabled $false -StartupBytes 8GB`. +- 개발 VM의 Defender 제외(node_modules/out)는 **개발 속도용**이며, 실사용자 재현(L4)에는 제외 없이 돌린다. +- 실기: N4020/8GB 중고 노트북 1대를 최종 현실 검증용으로 — synthetic이 못 잡는 eMMC 지연·실제 드라이버·발열 확인. 개발 단계 필수 아님. +- 하드 게이트 판정은 기존 `check:render-boot-budget`(정적 페이로드)에 더해, 상기 런타임 지표를 스크립트 게이트화하는 방향. + +## 9. 실행 플랜 요약 + +| 단계 | 항목 | 상의 필요 | +|---|---|---| +| **P0** | §7.2-1~5(첫 paint 비게이트, IPC 중복 제거, 모드 전용 초기화, DevTools 플래그), §7.1-1(SmartLink), §7.3-1(Toast useMemo) | 불요 | +| **P1** | data-perf/data-power 판정+CSS 변수 관문, reactflow culling, 툴바 memo/리사이즈 스로틀, Activity 전환, 위저드 리사이즈 rAF, R1/R6/R8 정리, spellcheck 언어 명시, GPU 관측 로그, 보조 창 경량 엔트리 | 작은 설계 결정 1~2개 | +| **P2** | main readiness 분리(메인 창 진짜 UI 1초의 필수 조건 — M03), 위저드 전용 경량 엔트리, NSIS 전환, manuscript barrel 정리, diff worker화, React Compiler 본격 도입 | **필요** | + +Windows 전용 원칙: 판정·등급·정책 로직은 플랫폼 분기로 Windows에만 적용 가능하도록 하고, macOS/Linux 공통 경로의 구조 변경은 별도 상의한다. diff --git a/docs/quality/startup-performance-p0.md b/docs/quality/startup-performance-p0.md new file mode 100644 index 00000000..3b4a3d4e --- /dev/null +++ b/docs/quality/startup-performance-p0.md @@ -0,0 +1,140 @@ +# P0 시작 성능 측정·테스트 기록 + +상태: **진행 중 — Windows 실기 계측 전.** P0만 수행하며 P1 최적화와 sidecar 비활성화는 아직 구현하지 않는다. ISTQB 관점의 위험 기반 설계·동등 분할·경계값·상태 전이·요구사항 추적을 적용한다. 인증이나 모든 기기의 성능 보장을 뜻하지 않는다. + +후속 승인(2026-09-10): Windows 실측을 보류한 채 코드로 입증되는 공통 preview 의존성을 먼저 분리했다. 이 문서는 P0 당시 기록이며, 이후 제품 변경/검증은 [P1-1 기록](startup-performance-p1.md)에 구분한다. P0의 Windows 완료 조건은 여전히 미충족이다. + +`qa-agent`의 재현·위험 우선 기준과 `ponytail`의 기존 도구 재사용 원칙을 적용해 제품 코드 대신 테스트 진입점만 추가했다. QA 스킬의 일부 보조 지침 파일은 없어 읽을 수 있는 실행 지침과 저장소 테스트 규칙을 사용했다. graph MCP를 먼저 조회했고, 새 실행기 노드는 아직 반환되지 않아 해당 변경은 소스·직접 실행 테스트로 확인했다. + +## 1. 사용자 보고와 가설 + +| ID | 환경 | 관찰 | 미확정 조건 | +| --- | --- | --- | --- | +| MAC-01 | MacBook Air M4, 16GB, macOS | 약 1초, 원활함 | OS 버전, 당시 artifact/commit, 측정 시작·종료점 | +| WIN-01 | i5-9400F, RX 570, 16GB, SSD, Windows 11 x64 실기 | Wizard·main window 약 3초. portable·개발 실행 모두 비슷함 | GPU 드라이버, Windows 빌드, 앱/Electron 버전, 신규/기존 프로필, 두 창 시간의 개별 정의 | +| VM-01 | Apple CPU로 표시되는 Windows 11 VM, RAM 8GB, SSD | 5초 이상 | VM 제품/버전, host/vCPU, guest OS arch, **실행 앱 arch**, 가상 GPU·가속·공유 메모리 | + +모두 사용자의 체감 보고이며 자동 계측값이 아니다. VM ARM64는 추정이므로 확정하지 않는다. `process.arch`는 실행 중 앱의 아키텍처로 기록하며 host/guest 아키텍처를 대신하지 않는다. VM을 Windows 실기 저사양 대표로 사용하지 않는다. + +현재 판단: portable와 개발 실행이 비슷하므로 압축 해제 **단독** 원인 가설의 우선순위를 낮춘다. renderer 가설에는 Wizard가 후속 editor 청크까지 정적 의존한다는 코드 근거가 있다. 하지만 main IPC 등록·DB 작업·설정/i18n gate·GPU 합성 비용을 아직 분리하지 못했다. RAM·GC·GPU 중 하나를 확정 원인으로 쓰지 않는다. + +## 2. 자동 측정 범위와 왜곡 + +`pnpm bench:startup`은 설치된 Playwright/Electron과 **production 빌드 JS**를 실행한다. 새 dependency, 제품 sampler, native addon, IPC 채널을 추가하지 않는다. 테스트 전용 `startup-probe.cjs`가 제품 main import 전에 새 임시 userData/sessionData/DB를 설정한다. 사용자의 원고·설정·모델을 복사하거나 지우지 않는다. 증거 디렉터리는 자동 삭제하지 않는다. + +통제 조건은 결과 JSON에도 남긴다. + +- 실제 `luie://` 연결 보호를 위해 테스트 프로세스의 등록 API만 false로 대체한다. 제품의 등록 실패 경로를 실행한다. +- 기존 `LUIE_DISABLE_SYNC=1` 사용. llmfit 최신 release fetch는 테스트에서 503 응답으로 대체해 자동 바이너리 설치를 막는다. **전체 offline 시뮬레이션이나 제품 sidecar 차단 완료가 아니다.** 모델 다운로드 버튼은 누르지 않는다. +- Playwright inspector 연결과 테스트 wrapper가 있다. portable launcher·release fuse·OS 검사까지 포함한 배포 성능 인증이 아니다. 보안 fuse, sandbox, GPU 가속 설정을 변경하지 않는다. +- fresh는 **새 앱 프로필**이지 OS 파일 캐시 cold가 아니다. repeat는 이번 실행기가 만든 동일 프로필의 **미완료 Wizard 재시작**이지 기존 완료 사용자의 main window 재시작이 아니다. +- 자동화가 heading 표시를 관찰한 시간은 실제 표시 시각의 상한에 가까운 관찰값이다. 최초 OS paint·React commit·입력 가능 시각과 같다고 부르지 않는다. 자동화 연결 이후에야 관찰하므로 실제 OS 창 표시 지연은 외부 녹화/trace가 필요하다. +- schema v2는 `introObservedMs`(DOM 관찰)와 `introOpaqueObservedMs`(heading/조상 opacity 곱 ≥ 0.99)를 분리한다. 다음 화면도 DOM/불투명 관찰값을 따로 남긴다. 투명도 조건은 전환 도중 screenshot을 찍는 오류를 줄이는 보조 조건이지 실제 scanout·대비·가려짐의 증명이 아니다. 화면 애니메이션을 비활성화하지 않는다. +- main mark는 probe 진입 기준, renderer navigation은 renderer의 timeOrigin 기준, 외부 관찰값은 runner 기준이다. 서로 다른 clock의 elapsed 값을 빼거나 병렬 구간을 더하지 않는다. 기존 main 로그의 bootstrap 시작점은 정적 import 이전이 아니다. +- CPU는 1회 호출만으로 비교하지 않는다. model 화면에서 `getAppMetrics()` 메모리 snapshot 1회, 수신된 GPU 상태가 있을 때 feature status만 기록한다. **GPU utilization/VRAM/GC pause/peak RSS 수치가 아니다.** `getGPUInfo('complete')`는 호출하지 않는다. + +## 3. 실행 절차 + +먼저 같은 source/lockfile/빌드 모드인지 확인하고 빌드한다. 기존 Electron용 native dependency 준비 절차를 따른다. 빌드나 ABI 오류는 성능 표본이 아니라 환경 실패로 기록한다. + +```sh +pnpm run build +pnpm bench:startup --runs 3 --profile fresh +pnpm bench:startup --runs 3 --profile repeat +``` + +진단 표본을 늘릴 때 `--runs 20`을 사용한다. 기본 phase timeout 30초는 지원 SLO가 아니라 hang 감지 한도다. `--timeout-ms`는 1000~120000, runs는 1~100만 허용한다. script는 저장소 위치를 기준으로 실행한다. + +출력 `P0 evidence: <임시 디렉터리>` 아래 report.json, run별 **Model 화면** screenshot, profile별 DB·설정·앱 로그가 남는다. JSON의 실패 phase/error/fatalError/cleanupError를 먼저 본다. 하나라도 실패하면 exit 1이다. 정상 측정 후에는 원고가 없는 테스트 앱만 `app.exit(0)`으로 끝낸다. **저장·flush·정상 종료는 검증하지 않으며**, repeat도 clean shutdown 인증이 아니다. 종료 이벤트를 10초 안에 받지 못하면 소유한 Electron만 SIGKILL하고 실패로 기록해 후속 반복을 중지한다. 잔여 자식 프로세스 여부는 별도 확인한다. + +실행 전 기록을 저장하고 `uncaughtExceptionMonitor`로 launch 내부의 미처리 오류도 남긴다. 런타임의 기본 실패 종료를 억제하지 않는다. 단, SIGKILL/디스크 기록 실패 등에서는 마지막 checkpoint만 남을 수 있다. artifact 검증/CLI 오류처럼 세션 생성 이전 오류는 stderr에 남는다. report의 `passed`는 이 제한된 **관찰 시나리오** 성공이지 성능 SLO 통과가 아니다. raw report와 로그는 로컬 경로/기기 정보를 포함하므로 외부 공유 전 검토한다. + +통계: 성공 표본 median과 실패 수를 함께 공개한다. 성공 20개 미만이면 p95는 null이다. 20개 p95도 탐색값이지 인증 기준이 아니다. 실패·timeout을 빠른 표본으로 대체하거나 삭제하지 않는다. 최초 실행을 사후 warm-up이라는 이유로 버리지 않는다. + +## 4. 테스트 추적표 + +| ID | 기법·위험 | 기대 결과 | +| --- | --- | --- | +| P0-CLI-01/02 | 경계값·동등 분할; 잘못된 runs/timeout/profile | 허용 경계 통과, 범위 밖·누락·임의 userData 옵션은 실행 전 거부 | +| P0-STAT-01 | 정상/실패/빈 집합·소표본 | 실패 수 보존, 유효하지 않은 시간 거부, 소표본 p95 미발행 | +| P0-SAFE-01/02 | 프로필 소유권·packaged/relative 입력 | import 전에 userData·DB 격리, OS protocol 미변경; 잘못된 프로필이면 변경 전 실패 | +| P0-RUN-01 | 새 프로필 Intro → 시작 버튼 → Model 상태 전이 | DOM/불투명 heading 관찰, 버튼 처리 후 Model heading, snapshot·증거 보존, 테스트 앱 종료 이벤트 | +| P0-RUN-02 | 미완료 프로필 재시작 | repeat 시 같은 임시 프로필만 재사용, Intro 재표시 | +| P0-RUN-03 | 환경 오류·timeout·종료 실패 | 실패 phase와 원문 오류 보존, 성공으로 집계하지 않음 | +| P0-WIN-01 | 실기 비교 | WIN-01에서 동일 production JS 진단 결과와 실제 portable 외부 시작 trace 확보 | +| P0-MAIN-01 | 완료 사용자 재시작 / Wizard 완료 전환 | 별도 fixture·계정 없는 검증용 프로필로 main 표시·첫 입력·저장 확인; 현재 실행기로 인증하지 않음 | + +```sh +SKIP_DB_TEST_SETUP=1 pnpm exec vitest run tests/scripts/startupBenchmark.test.ts +``` + +Windows PowerShell은 `$env:SKIP_DB_TEST_SETUP="1"` 설정 후 `pnpm exec vitest run ...`을 실행한다. 로컬 pnpm registry 접근 장애가 있으면 **설치된 동일 binary**를 직접 실행한 사실을 결과에 남긴다. 패키지 설치·의존성 업데이트나 native rebuild를 조용히 섞지 않는다. + +## 5. 실기·CPU/GPU/메모리 후속 측정 + +Tier 1은 macOS ARM64·Windows x64의 적극적인 재현/회귀, Tier 2는 Windows ARM64·Linux x64의 기능 호환성, x64-on-ARM과 특수 GPU/저사양은 best effort로 구분한다. 이번 최우선은 MAC-01/WIN-01이다. + +실기에서는 실행 요청→native 창→Intro→첫 버튼 처리, 완료 사용자 main 재시작을 각각 기록한다. 전원 모드·AC 여부·해상도/DPI·모니터·OS/GPU 드라이버·SSD 여유·백그라운드 앱·보안 검사 여부를 기록한다. Defender를 끄거나 메모리 trim/강제 GC로 기준선을 만들지 않는다. portable/dev는 같은 commit이라도 빌드·HMR 조건이 다르므로 서로 다른 행으로 둔다. + +원인이 좁혀질 때만 Windows WPR/WPA 또는 Chromium trace, macOS Instruments, renderer DevTools Performance/Memory로 한 번씩 추가 진단한다. profiler를 켠 결과와 기본 반복 시간은 섞지 않는다. CPU main/renderer/utility, JS evaluate·IPC 대기·style/layout/paint, GC, hard fault/디스크 대기를 구분한다. CPU throttle은 CPU 부하 실험일 뿐 RX 570·가상 GPU·메모리 압박을 재현하지 않는다. OS별 privateBytes/RSS 등 필드의 의미와 단위를 보존하며 기기 전체 RAM 사용률로 자동 등급을 만들지 않는다. + +## 6. 실행 결과·문제·TODO + +실행일: 2026-09-09~10 KST. HEAD `4116bcf5` + 이번 테스트/문서 변경. 제품 `src/` diff 없음. 기존 조사에서 74 통과/3 실패했던 ModelStep footer 테스트는 이번 신규 계약의 통과 근거로 재사용하지 않는다. + +### 자동 검사 + +| 검사·실제 명령 | 결과 | +| --- | --- | +| `node node_modules/electron-vite/bin/electron-vite.js build` | 성공. 기존 manuscript 정적/동적 import 혼용 경고 유지 | +| `node --check scripts/benchmark-startup.mjs`, `node --check scripts/startup-probe.cjs` | 통과 | +| `SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/scripts/startupBenchmark.test.ts` | 신규 13개 통과. 최종 수정 후 재실행 통과 | +| 위 명령에 `tests/main/services/startupReadinessService.test.ts tests/main/lifecycle/appReadyDeferredWorker.test.ts tests/dom/startupWizardFlow.test.tsx` 포함 | 4파일 22개 통과 | +| `node scripts/check-render-boot-budget.mjs` | 통과: JS 579.7 KiB / CSS 166.0 KiB. 기존 조사와 다른 시점의 산출물 값이며 성능 개선량이 아님 | +| `node scripts/check-utility-process-boundary.mjs` | 통과 | +| `node scripts/check-target-file-drift.mjs` | exit 0이지만 current-task-packet.json 부재로 **검사 생략**, 검증 통과로 세지 않음 | +| `git diff --check` | 통과 | +| `node node_modules/typescript/bin/tsc6 --noEmit` | 실패: 기존 `src/renderer/src/features/manuscript/components/Sidebar.tsx:157`의 미사용 `handleRenameProject` (TS6133) | +| `node scripts/check-security-profile.mjs` | 실패: 기존 toolbar/constants.ts의 CSS `token` 16개와 locale settingsAdvanced.ts의 `apiKey` 표시 문구 3개를 secret 후보로 탐지. 읽은 값은 CSS 변수·번역 문구이며 실제 비밀 값이라는 근거는 없음. gate 자체는 수정하지 않음 | +| `node scripts/check-no-escape-hatches.mjs` | 실패: 기존 llmfitInstaller/modelDownloader 및 다른 src의 eslint-disable/cast 패턴. 이번 src 수정은 없음. 전역 품질 통과로 보고하지 않음 | + +기존 pnpm registry 접근 문제 때문에 설치된 binary를 직접 호출했으며 의존성을 바꾸지 않았다. 최초 typecheck에서 잘못된 `node_modules/typescript6/bin/tsc` 경로를 사용해 MODULE_NOT_FOUND가 났고, 설치된 shim을 확인해 위 실제 `typescript/bin/tsc6` 경로로 재실행했다. `qa:core` 전체/전체 E2E/전체 lint는 이번 범위에서 실행하지 않았다. + +### 실행기 검증 및 원시 표본 + +증거 루트는 이 Mac의 `/var/folders/jh/rqqbpbts0sqfj9qjxr2qr1qm0000gn/T/`다. 아래 디렉터리에 원시 report/설정/DB/로그/screenshot을 보존했다. 임시 OS 경로라 영구 보관을 보장하지 않으며 Windows 결과와 함께 프로젝트 외부의 지정 증거 저장소에 보관할 수 있다. + +| 세션 디렉터리 | 조건 | 결과·관찰값 | +| --- | --- | --- | +| `luie-p0-oQoiQF` | 초기 sandbox 실행 | launch 실패. Playwright 내부 미처리 rejection으로 최초 버전은 JSON을 남기지 못함. stderr `Process failed to launch!` 보존 사실을 여기에 기록 | +| `luie-p0-5YDUKd` | GUI 실행 승인, 초기 정상 종료 시도 | Intro DOM 2203ms, 다음 DOM 26ms 관찰 후 종료 확인창/10초 제한으로 **실패**. 성공 통계에 넣지 않음 | +| `luie-p0-KM7OHi` | v1, fresh 3회, 빈 프로필 app.exit | 관찰 시나리오 3/3. Intro DOM **575 / 1359 / 1282ms**; median 1282ms, p95 없음 | +| `luie-p0-LwbST9` | v1, repeat 3회 | 3/3. Intro DOM **1294 / 1275 / 5424ms**. 3회차 probe→app-ready 4349ms. 별도 sandbox 실패 실험이 이 세션 중 겹쳤으므로 **통제 기준선으로 사용하지 않음** | +| `luie-p0-ZEKTgK` | 실패 기록 보완 후 sandbox 재현 | launch 실패/exit 1. report에 failed=1, fatalError 보존 확인 | +| `luie-p0-c3lt0F` | 최종 v2, fresh 3회 순차 실행 | 3/3. Intro DOM **8018 / 6931 / 705ms**, 불투명 관찰 **8048 / 6962 / 930ms**. 다음 DOM **26 / 18 / 91ms**, 다음 불투명 관찰 **287 / 278 / 349ms** | + +실제 runtime: Apple M4, 10 logical CPU, RAM 16GiB, Darwin 25.6.0, 앱 arm64, Electron 44.2.0 / Chromium 152.0.7977.76 / Node 24.20.0. GPU compositing enabled는 확인했으나 GPU 사용률/병목은 측정하지 않았다. 각 세션에서 llmfit-release-blocked mark와 installed=false 로그를 확인했고 userData 및 main/cache DB 경로가 해당 임시 profile 안에 있음을 확인했다. 이것으로 모든 sidecar 경로의 미기동을 인증하지 않는다. + +**최종 v2의 느린 두 표본은 probe→app-ready가 6974 / 5844ms**, 세 번째는 196ms다. 창 생성 이후 renderer did-finish-load까지는 각 약 105 / 105 / 154ms였다. 같은 main clock 안의 차이이며 Intro 표시 완료를 뜻하지 않는다. 즉 이 Mac 진단에서 관찰한 수초 지연은 renderer 하나만으로 설명할 수 없다. 자동화·OS·선행 module 평가 중 무엇이 원인인지는 미확정이며 Windows 원인으로 전용하지 않는다. + +이전 세션은 창 479×697/DPR 2, 최종 세션은 다른 디스플레이 위치의 창 538×760이었다. 전원/열/사용자 동시 작업 및 디스플레이를 고정하지 않은 **실행기 검증 표본**이므로, 위 0.6~8초를 Mac 제품 성능 분포나 최적화 전후 개선량으로 발표하지 않는다. 최종 Model screenshot은 사람이 읽을 수 있는 불투명 상태로 확인했다. Intro 첫 실제 paint는 아직 외부 trace로 확인하지 않았다. + +### 발견한 문제와 조치 + +| ID / 심각도 | 근거·재현 | 조치·남은 검증 | +| --- | --- | --- | +| P0-ISS-01 / Medium | sandbox launch 실패 시 Playwright 내부 rejection이 runner catch 전에 종료 | 실행 전 checkpoint + uncaughtExceptionMonitor로 실패 보존; ZEKTgK에서 재현 확인. Electron 보안 설정을 낮추지 않고 GUI 실행 권한 범위에서 별도 실행 | +| P0-ISS-02 / Medium | Wizard-only 종료에서 mainWindow가 없어 rendererFlushed=false 유지 → `!rendererFlushed`로 확인창. `src/main/lifecycle/shutdown/shutdown.ts:310`, 5YDUKd 로그에 save/flush retry/취소 기록 | P0에서는 빈 프로필 app.exit 정리만 사용. 제품 저장/종료 코드는 바꾸지 않음. P2에서 별도 회귀와 실제 저장 경로 검증 필요 | +| P0-ISS-03 / Medium | KM7OHi screenshot은 Model 전환 중 거의 투명. DOM visibility만으로 최종 표시를 판정할 수 없음 | v2에 조상 opacity 대기와 별도 timestamp 추가. c3lt0F screenshot 재확인. 외부 paint 검증은 남음 | +| P0-ISS-04 / Medium | c3lt0F의 app-ready 이전 수초 편차, 세션 간 디스플레이/동시 실행 조건 차이 | 빠른 표본만 선택하지 않음. 안정된 환경의 반복과 OS/Electron main trace가 필요. Windows renderer 병목으로 단정하지 않음 | +| P0-ISS-05 / Low | unpackaged 실행 로그의 Electron CSP 경고 및 기존 품질 gate 실패 | 원래 보안 설정 유지. release 보안 검증과 기존 gate 정리는 별도 추적; 이번 측정기 통과와 구분 | + +- [x] 신규 단위 테스트, 구문 검사, 관련 정책 gate 실행 결과 기록(실패/생략 포함). +- [x] MAC-01 실행기 동작·프로필 격리·llmfit 차단·빈 프로필 종료 확인. 정상 저장/종료 인증은 제외. +- [ ] MAC-01 고정 디스플레이·전원·동시 작업 조건에서 안정된 기준선과 app-ready 이전 trace 확보. +- [ ] WIN-01 동일 계측과 artifact/드라이버/Windows 빌드 기록. +- [ ] VM-01 guest OS/app arch와 가상 GPU 상태 확정. 실기와 별도 비교. +- [ ] 완료 사용자 main 경로 및 외부 시작→실제 화면 표시 trace 보완. +- [ ] 지배 구간의 근거를 얻은 후에만 P1의 가장 작은 변경 선택. + +P0 완료 조건은 스크립트 존재나 Mac 통과가 아니라 **같은 조건의 Mac/Windows 증거로 지배 구간을 설명할 수 있음**이다. Windows 결과가 없는 동안 P0 전체 완료로 표시하지 않는다. P4는 Deferred를 유지한다. diff --git a/docs/quality/startup-performance-p1.md b/docs/quality/startup-performance-p1.md new file mode 100644 index 00000000..6c013ecd --- /dev/null +++ b/docs/quality/startup-performance-p1.md @@ -0,0 +1,328 @@ +# P1 — Wizard preview 지연 로딩·모션 비용 축소 + +날짜: 2026-09-10 KST. 상태: **공통 경로의 첫 변경 구현·로컬 검증, Windows 효과 미검증.** 사용자 승인으로 Windows P0 실측을 기다리지 않고 코드·산출물로 입증되는 의존성부터 줄였다. P0 전체 완료나 P1 전체 완료를 의미하지 않는다. + +## 변경과 트레이드오프 + +`ThemeStep`의 `WizardEditor`, `LayoutStep`의 `LayoutLivePreview`만 모듈 최상위 `React.lazy`로 바꿨다. 두 step 자체와 선택/이전/다음 버튼은 그대로 두고, 이미 있던 `Suspense`·`PreviewBoundary`·fallback을 재사용했다. OS별 bootstrap, 새 의존성, IPC, 자원 sampler는 추가하지 않았다. + +`vercel-react-best-practices`의 조건부 로딩과 `ponytail`의 기존 경계 재사용을 적용했다. React 문서에 따라 lazy 선언은 렌더 함수 밖에 두며, Promise 실패는 기존 오류 경계에 맡긴다. [React lazy](https://react.dev/reference/react/lazy), [Suspense](https://react.dev/reference/react/Suspense). + +트레이드오프는 editor 준비 비용이 없어지는 것이 아니라 **테마/레이아웃 preview를 처음 여는 시점으로 이동**한다는 점이다. 그동안 기존 배경 fallback이 보이고 dock은 유지된다. 동기 JS 평가 중에도 입력 지연이 전혀 없다는 보장은 아니다. 실패 시 기존 preview fallback으로 진행할 수 있으나, 실패한 lazy import의 자동 재시도는 추가하지 않았다. prefetch/상시 warming도 이번 범위 밖이다. + +## 1. 빌드 전후 결과 + +동일 HEAD `4116bcf5`, 동일 설치 의존성과 production 빌드에서 비교했다. 기준선은 P0 문서의 오래된 숫자가 아니라 **이번 변경 직전 재빌드**다. + +| emitted 정적 JS 집합 | 변경 전 | 변경 후 | +| --- | ---: | ---: | +| HTML boot 정적 closure | 17파일 / 593,655 bytes | 18파일 / 594,234 bytes | +| Wizard 정적 closure | 30파일 / 1,540,157 bytes | 21파일 / 708,060 bytes | +| boot 밖 Wizard 추가분 | 13파일 / 946,502 bytes | 3파일 / 113,826 bytes | +| boot ∪ Wizard | 30파일 / 1,540,157 bytes | 21파일 / 708,060 bytes | + +첫 Wizard 정적 범위는 **832,097 bytes, 약 54.0% 감소**했다. boot 자체는 청크 분할에 따라 **579 bytes 증가**했으며 기존 600KiB 예산 안이다. JS 580.3KiB/CSS 166.0KiB로 boot gate 통과. ProseMirror·TipTap·EditorToolbar는 첫 정적 closure에서 빠지고 후속 preview 경로에 남는다. + +이는 중복 제거한 비압축 JS 파일 크기다. 패키지 전체 용량·RSS·네트워크 전송량·OS 실행 시간이 54% 감소했다는 뜻이 아니다. 다른 동적 경로에서 같은 editor를 불러올 가능성은 별도 runtime trace로 확인해야 한다. + +새 `pnpm check:startup-wizard-bundle`은 build 후 HTML+Wizard의 정적 import/re-export를 AST로 재귀 추적하고 editor 청크가 재유입되면 실패한다. `--report`는 기준선 기록용이며 gate 통과 명령이 아니다. import cycle·공유 청크는 한 번만 센다. 빌드 청크 이름을 바꾸면 검사도 재검토해야 하며, ProseMirror 기준 청크 자체가 없어도 실패하도록 했다. arbitrary byte 예산은 새로 만들지 않았다. + +## 2. Mac 실행 관찰 + +기존 P0 실행기로 새 임시 프로필 3회씩 순차 실행했다. M4 16GB, arm64, Electron 44.2.0, 창 479×697, DPR 2. 사용자 설정/DB는 격리하고 llmfit release 503·sync off·protocol 등록 false·측정 후 빈 앱 app.exit 통제 조건을 유지했다. 저장/정상 종료 인증은 아니다. + +| 관찰값(ms), 표본 순서 유지 | 변경 전 | 변경 후 | +| --- | --- | --- | +| 외부 launch 요청 → Intro DOM 관찰 | 1745 / 1284 / 1280 | 970 / 498 / 514 | +| → Intro 불투명 관찰 | 1776 / 1312 / 1310 | 1225 / 754 / 772 | +| 클릭 요청 → Model DOM 관찰 | 34 / 19 / 18 | 86 / 81 / 79 | +| → Model 불투명 관찰 | 300 / 296 / 296 | 347 / 330 / 349 | + +Intro DOM median **1284 → 514ms**, 불투명 관찰 median **1312 → 772ms**다. 반면 다음 화면 불투명 관찰 median은 **296 → 347ms**로 늘었다. 빠른 첫 화면 숫자만 골라 전체 반응성이 개선됐다고 주장하지 않는다. 애니메이션·자동화 actionability/관찰 시점·OS 캐시가 포함된 각 3표본 탐색 결과이며, p95·Windows 개선율·고정 SLA는 발표하지 않는다. 테마/레이아웃의 최초 실제 preview paint는 이번 실행기 범위 밖이다. + +증거 루트: `/var/folders/jh/rqqbpbts0sqfj9qjxr2qr1qm0000gn/T/`. + +- 변경 전: `luie-p0-NXj5Bp/report.json`, 3/3 관찰 성공. +- 변경 후: `luie-p0-xdDufu/report.json`, 3/3 관찰 성공. +- report와 함께 profile별 로그·DB·Model screenshot 보존. 임시 디렉터리의 영구 보관은 보장하지 않는다. + +시도한 renderer Resource Timing JS 목록은 세 실행 모두 **목록 전체가 0개**였다. 따라서 빈 editor 목록을 “editor가 runtime에서 안 로드됨”의 증거로 사용하지 않았다. 유효하지 않은 collector는 최종 실행기에 남기지 않았다. 현재 입증한 것은 emitted 정적 의존성 분리와 제한된 UI 관찰값이다. + +## 3. ISTQB 관점의 테스트 계약 + +`qa-agent`의 위험 기반·재현 가능한 판정을 적용한다. 기능/오류 상태 전이를 먼저 검증하고 performance 수치는 별도 판정한다. + +| ID | 위험·기법 | 통과 조건 | +| --- | --- | --- | +| P1-BUNDLE-01 | import/re-export/dynamic import/문자열 동등 분할 | 정적 의존성만 파싱, 잘못된 JS 거부 | +| P1-BUNDLE-02 | cycle·공유 import·정적 회귀·출력 밖 경로 | 중복 합산 없음, eager editor 탐지, 출력 밖 참조 거부 | +| P1-DOM-01 | 모듈 미사용→pending→resolve 상태 전이 | step 모듈만 import하면 preview factory 미실행; theme 렌더 시에만 실행; pending에도 테마 선택·이전·다음 callback 유지; 완료 후 preview 표시 | +| P1-DOM-02 | pending→reject 실패 주입 | 실제 PreviewBoundary가 실패를 받으며 layout dock·이전·완료 callback 유지 | +| 기존 startupWizardFlow | 정상 전체 흐름 | Intro→Model→Theme→Layout→Prepare→completeWizard, 테마 반영·설정 저장 계약 유지 | + +신규 DOM 테스트에서는 preview 모듈을 제어 가능한 Promise로 대체해 로딩·실패를 재현한다. 기존 flow 테스트는 실제 컴포넌트와 mock IPC를 사용한다. 기존 테스트의 preview 사전 import 및 일부 조건부 assertion 때문에 그것만으로 cold import/모든 preview 기능을 증명하지 않으며 신규 테스트와 빌드 gate를 함께 사용한다. + +```sh +pnpm run build +pnpm check:startup-wizard-bundle +pnpm check:render-boot-budget +SKIP_DB_TEST_SETUP=1 pnpm exec vitest run tests/scripts/startupWizardBundle.test.ts tests/dom/startupPreviewLazy.test.tsx tests/dom/startupWizardFlow.test.tsx +pnpm bench:startup --runs 3 --profile fresh +``` + +실제 로컬 실행은 기존 registry 문제를 피하기 위해 설치된 binary를 직접 호출했다. 예: `node node_modules/electron-vite/bin/electron-vite.js build`, `SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run ...`. 의존성 설치/업데이트는 하지 않았다. + +## 4. 실행 결과·문제 기록 + +- production build 전/후 성공. 변경 전 manuscript ineffective dynamic import 경고가 있었고 변경 후 해당 경고는 관찰되지 않았다. 경고 제거를 일반 성능 개선량으로 계산하지 않는다. +- 신규 script/DOM 4개 통과. 관련 7파일 31개 실행에서 **28 통과 / 3 기존 실패**. 포함 파일: startupPreviewLazy, startupWizardFlow, startupWizardModelStep, startupPreviewResearchReset, wizardFreshBoot, startupWizardBundle, startupBenchmark. +- 변경 전 startupWizardFlow+startupWizardModelStep: 10개 중 7 통과/3 실패. 변경 후 동일 ModelStep `:174/:209/:245`의 footer 다운로드/완료/raw503 기대 실패가 유지됐다. 이 변경 때문에 생긴 실패로 분류하거나 통과하도록 assertion을 약화하지 않았다. 관련 act 경고도 남는다. +- 신규 DOM 테스트 최초 실행은 `document is not defined`로 2개 실패했다. 설치된 Vitest 환경에서 glob 설정만으로 jsdom이 적용되지 않아, 기존 DOM 테스트처럼 `@vitest-environment jsdom`을 명시한 뒤 4개 재실행 통과했다. +- 변경한 ThemeStep/LayoutStep 대상 ESLint 통과. renderer-store-usage, utility-process-boundary, boot budget, 신규 bundle gate, diff 공백 검사 통과. +- typecheck는 기존 `src/renderer/src/features/manuscript/components/Sidebar.tsx:157`의 미사용 handleRenameProject(TS6133)로 실패. 범위 밖 코드는 변경하지 않았다. +- renderer 금지 패턴 확인에서 ThemeStep의 기존 색온도 hex 3개가 검색됐다. 새 hex/스타일/Node·Electron 접근은 추가하지 않았다. CSS 보조 지침 경로가 없어 현재 DESIGN/renderer 규칙을 적용했다. +- P0에 기록한 전역 security/escape-hatch gate 실패는 해결한 것으로 간주하지 않는다. 전체 qa:core/전체 E2E/IME·원고 저장 실기는 미실행이다. +- 작업 중 측정 파일이 초기 버전으로 변경되어 잠시 편집을 멈췄고, 사용자 계속 진행 승인 후 schema v2의 실패 보존·불투명 관찰·빈 앱 종료를 복구했다. 결과 JSON의 script hash로 실제 실행 버전을 구분한다. +- 복구 후 startupBenchmark/startupWizardBundle/startupPreviewLazy/startupWizardFlow **4파일 23개 재실행 통과**. 최종 smoke `luie-p0-jsFrFJ/report.json`도 1/1 성공, schema 2·Intro DOM 968ms/불투명 1212ms·empty-profile-app-exit 확인. 이 smoke는 다른 디스플레이의 538×760 창이라 위 A/B 표본에 합치지 않았다. 포맷·구문·diff 검사도 통과했다. + +## 5. 다음 TODO와 종료 경계 + +- [x] 첫 Wizard 정적 그래프에서 editor preview 분리. +- [x] import 회귀 gate, pending/rejected 제어부 회귀 테스트, 제한된 Mac 전후 관찰. +- [ ] 테마/레이아웃 최초 preview 표시·오류·뒤로 이동을 실제 Electron 창에서 추가 확인. 느리면 실측 후 prefetch 필요성만 별도 판단. +- [ ] Windows 실기 사용 가능 시 같은 변경을 측정. 그 전까지 3초/5초 해결 선언 금지. +- [ ] P0에서 남은 app-ready 이전 지연 및 main window 별도 경로 조사. +- [ ] 생성 LLM/BGE-M3 비활성화, 공통 초기화/설정 IPC 축소는 별도 후속 변경. 이번 코드로 처리됐다고 간주하지 않음. + +롤백은 ThemeStep/LayoutStep의 두 preview import만 정적으로 복원하고 gate 실패를 재확인하면 된다. 데이터 migration이나 OS 설정 복원은 필요 없다. P4와 native/resource manager는 계속 Deferred다. + +## 6. P1-2 — 위저드 애니메이션·프로그램 리사이즈 비용 축소 + +> 아래는 모든 OS에서 즉시 적용했던 최초 변경 기록이다. 이후 사용자 요청으로 **macOS만 기본 창 애니메이션을 허용**했으며, 현재 정책과 추가 검증은 §7을 우선한다. 최초 smoke 수치를 변경된 정책의 실측으로 재사용하지 않는다. + +2026-09-10 KST. **공통 코드 구현 및 제한된 Mac smoke 완료, Windows·수동 창 리사이즈 성능은 미검증.** `ponytail`의 반복 작업 삭제, React 성능 지침의 불필요한 전환 축소, `qa-agent`의 위험 기반 테스트를 적용했다. graph 조회에서 resize 심벌이 검색되지 않아 실제 IPC handler와 세 호출 지점을 직접 추적했다. + +### 원인과 변경 범위 + +`ipcWindowHandlers.ts`는 animate=true일 때 **800ms 동안 16ms 간격**으로 창 위치·크기를 보간해 `setBounds()`를 호출했다. 초기 호출도 포함하므로 고정 50회라고 단정할 수 없고, OS 스케줄링에 따라 횟수가 달랐다. 단계 전환 중 불필요한 native 창 변경 요청을 만드는 경로인 것은 코드로 확인했지만 **Windows 최초 3~5초의 원인이라고 입증한 것은 아니다.** 첫 Intro 표시에는 이 resize가 호출되지 않는다. + +- 보간 함수·타이머·진행 중 Promise 완료 상태를 삭제했다. 최종 bounds와 현재 bounds가 같으면 0회, 다르면 `setBounds(target, false)` 1회만 호출한다. 사라진 창은 false 응답, API 예외는 기존 IPC 오류 경계를 이용한다. +- 기존 `(width, height, animate)` 인자 및 schema·preload API는 유지한다. animate는 호환용 인자로 남지만 위저드 native resize에는 적용하지 않는다. compact/preview/workArea 계산과 모니터 선택은 그대로다. +- Intro의 300ms fade/slide를 없앴다. 나머지 일반 단계는 200ms opacity-only로 축소했다. Theme/Layout의 전체 preview fade도 없앴으며 `key={uiMode}`와 오류 경계는 유지한다. +- 앱의 기존 애니메이션 off / reduced-motion CSS는 변경하지 않는다. 완료 알림 인지를 위한 800ms 대기는 창 보간과 다른 UX 계약이므로 유지한다. + +Electron의 `setBounds` animate 옵션은 macOS 전용이다. 이번에는 OS별 대체 애니메이션 없이 false로 통일한다. **IPC 성공은 적용 요청 완료이지 OS paint나 레이아웃 안정화 완료가 아니다.** 단일 API 호출이 단일 렌더 프레임이나 GPU 비용 0을 뜻하지도 않는다. [Electron BrowserWindow.setBounds](https://www.electronjs.org/docs/latest/api/browser-window#winsetboundsbounds-animate). + +트레이드오프: 창이 부드럽게 커지는 효과 대신 크기가 즉시 바뀐다. 최초 preview 준비 비용, 일반 OS 창 드래그, main editor의 ResizeObserver·레이아웃, dock blur/개별 버튼 효과는 이번 변경으로 제거하지 않았다. GPU 비활성화·will-change 상시 적용·별도 resize scheduler는 추가하지 않았다. + +### 테스트 설계와 실제 결과 + +| ID | 위험·기법 | 확인 결과 | +| --- | --- | --- | +| P1-RESIZE-01 | animate true/false 동등 분할, 같은 크기 재요청 | 각 1회 적용, 재요청은 추가 호출 없음, fake timer 0개 | +| P1-RESIZE-02 | 빠른 연속 요청의 상태 전이 | 두 응답 완료, 마지막 크기 유지, 지연된 덮어쓰기 타이머 없음 | +| P1-RESIZE-03 | 창 없음/파괴됨, native 예외 실패 주입 | false 또는 기존 IPC 오류 응답; 파괴된 창에는 setBounds 미호출 | +| P1-RESIZE-04 | compact(-1)/preview(1300)/workArea(4096), 음수 모니터 원점 | 기존 좌표·크기 계산 유지 | +| P1-RESIZE-05 | NaN/Infinity/잘못된 boolean 입력 | schema에서 INVALID_INPUT, native API 미호출 | +| P1-MOTION-01 | Intro→Model DOM 상태 전이 | Intro animate-in 없음, Model fade-in·duration-200 유지, slide 제거 | +| P1-SMOKE-02 | 격리 프로필 실제 Electron 실행 | Intro→Model 1/1 성공; native 단계 확장 실기는 범위 밖 | + +리사이즈 12개 사례는 기존 main IPC 테스트의 Electron 창·screen mock을 확장하여 검증한다. OS 창 관리자를 재현한 테스트는 아니므로 DPI·최소 크기 제약·실제 frame 비용까지 검증했다고 해석하지 않는다. DOM 테스트 역시 실제 CSS 합성 성능이 아닌 클래스 계약을 확인한다. + +재현 명령(설치된 binary 사용, dependency 변경 없음): + +```sh +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/handler/ipcWindowHandlers.test.ts tests/dom/startupWizardFlow.test.tsx tests/dom/startupPreviewLazy.test.tsx tests/scripts/startupBenchmark.test.ts tests/scripts/startupWizardBundle.test.ts +node node_modules/electron-vite/bin/electron-vite.js build +node scripts/check-startup-wizard-bundle.mjs +node scripts/check-render-boot-budget.mjs +node scripts/check-ipc-contract-map.mjs +node scripts/check-ipc-handler-schemas.mjs +node scripts/check-preload-contract-regression.mjs +node_modules/.bin/tsc6 --noEmit +node scripts/benchmark-startup.mjs --runs 1 --profile fresh +``` + +- 관련 **5파일 48개 중 47 통과 / 1 실패**. 신규 resize 12개와 DOM/preview/benchmark/bundle 테스트는 통과했다. 실패는 기존 export 테스트 `returns INVALID_INPUT response for malformed chapter id`가 non-UUID ID를 거부할 것으로 기대하나 성공 응답을 받는 항목이다. 이번 수정은 export handler/schema를 바꾸지 않았고, assertion을 통과시키려고 입력 검증을 변경하지 않았다. 기존 P1-1 ModelStep 실패 3개는 이번 선택 실행에 포함하지 않았다. +- 신규 연속 요청 테스트 최초 실행은 IPC 응답의 기존 `meta`까지 완전 동등 비교하여 실패했다. 의도한 success/data 계약과 bounds·timer 검증은 유지하면서 meta를 허용하도록 수정 후 통과했다. +- production build, boot budget(JS 580.3KiB / CSS 165.9KiB), Wizard 정적 의존 gate(707,993 bytes, eager editor 없음), IPC schema/preload gate 통과. +- IPC 계약맵 gate 최초 실행은 줄 번호 drift로 실패하며 `ipc-contract-map.json`을 재생성했다. 변경은 생성 시각·이 handler의 줄 번호뿐이며 재실행은 225채널 OK다. +- 변경 production 파일 및 main 테스트 ESLint 통과. DOM flow 파일 lint는 기존 미사용 `useTermStore`로 실패한다. 범위 밖 테스트 정리는 하지 않았다. +- typecheck는 앞서 기록한 Sidebar.tsx:157 TS6133이 유지된다. 처음 시도한 native-preview 경로는 설치되어 있지 않아 실행 자체가 실패했으며, 실제 package script의 `tsc6`로 재실행해 확인했다. 여러 명령을 한 shell에서 실행하면 마지막 명령 성공이 앞선 실패를 가릴 수 있어 각 출력으로 판정했다. +- Mac smoke 증거: `/var/folders/jh/rqqbpbts0sqfj9qjxr2qr1qm0000gn/T/luie-p0-DXNrQN/report.json`. Electron 44.2.0 arm64, 창 538×760, DPR 1. Intro DOM **967ms**, 불투명 관찰 **996ms**, 클릭→Model DOM **54ms**, 불투명 관찰 **216ms**. 각 1표본이며 이전 DPR 2 표본과 조건이 달라 개선율로 계산하지 않는다. GPU feature status는 수집했지만 utilization·VRAM·GC 감소는 측정하지 않았다. + +### 남은 resize 실기 TODO와 판정 경계 + +- [x] 위저드 프로그램 resize의 타이머 반복 제거, 동일 bounds 중복 방지, 회귀 테스트. +- [x] Intro/전체 preview 진입 애니메이션 제거, 일반 단계 opacity-only 축소. +- [ ] Mac 실제 창에서 Model→Theme→Layout→Prepare 및 뒤로 이동: 표시·버튼 clipping·focus·창 경계 확인. 애니메이션 on/off와 OS reduced-motion 각각 확인. +- [ ] main editor 수동 resize: 동일 문서·레이아웃·창 시작 크기로 10초 드래그를 3회 반복하는 **진단 실험**. renderer Performance trace에서 layout/style/paint·long task·ResizeObserver 호출·React commit을 보고, 한 시점 memory snapshot 대신 유휴→드래그→유휴 구간을 비교한다. main/native resize 경로와 renderer 작업을 분리하고, hotspot이 입증된 callback만 coalescing/중복 갱신 제거 대상으로 삼는다. 임의 전역 debounce·CPU 등급은 만들지 않는다. +- [ ] Windows x64 i5-9400F/16GB/RX570/SSD에서 같은 빌드·문서·DPI·전원 조건으로 확인. ARM VM 결과는 호스트·가상 GPU 불명 조건을 별도 표기하고 실기와 합산하지 않는다. portable/dev 결과도 분리 보관한다. + +실기 기록은 OS/arch/앱 버전·CPU/GPU·RAM·DPI·전원·창 크기·문서/레이아웃·실행 방식·실패 여부를 함께 남긴다. 입력/저장/IME 정상 여부도 확인한다. 수동 resize와 Windows 실측이 남아 있으므로 “전체 애니메이션·resize 최적화 완료” 또는 “P1 완료”로 표시하지 않는다. P4는 계속 Deferred다. + +## 7. P1-2 조정 — macOS 창 전환만 애니메이션 허용 + +> 이후 배경 전환 요청으로 IPC 완료 대기 계약을 보강했다. 아래의 “native 호출 반환 기준”과 “GUI 미실행”은 당시 기록이며, 현재 동작·실행 결과는 §8을 우선한다. + +2026-09-10 KST 사용자 요청: macOS의 부드러운 창 크기 전환은 유지하고, Windows는 실측 후 재검토한다. **JS 보간 루프를 복원하지 않고 기존 resize handler의 최종 setBounds 옵션만 분기**했다. `ponytail`의 OS 기본 기능 재사용을 적용했으며 새 platform 서비스·native addon·타이머·의존성을 추가하지 않았다. + +| 조건 | native resize 옵션 | +| --- | --- | +| macOS + 앱 애니메이션 on + OS 동작 줄이기 off | `setBounds(target, true)` | +| macOS + 앱 애니메이션 off 또는 OS 동작 줄이기 on | `setBounds(target, false)` | +| Windows / Linux | 앱 설정과 무관하게 `setBounds(target, false)` | + +Electron의 macOS 전용 animate 옵션과 `systemPreferences.getAnimationSettings().prefersReducedMotion`을 사용한다. OS 설정 조회는 **실제로 크기가 달라지는 macOS의 animate=true 요청에서만 1회** 수행하며 polling/cache/추가 IPC는 없다. 동일 bounds 요청 생략, 파괴된 창 보호, 기존 오류 경계와 인자 검증은 유지한다. [BrowserWindow.setBounds](https://www.electronjs.org/docs/latest/api/browser-window#winsetboundsbounds-animate), [systemPreferences.getAnimationSettings](https://github.com/electron/electron/blob/main/docs/api/system-preferences.md#systempreferencesgetanimationsettings). + +기존 800ms cubic 전환과 동일한 속도·곡선을 보장하지 않으며 macOS 기본 애니메이션에 맡긴다. OS 보간도 renderer layout/paint를 유발할 수 있으므로 비용 0이나 프레임 향상은 주장하지 않는다. 응답은 native 호출 반환 기준이며, 애니메이션 완료를 기다리는 추가 상태 머신은 없다. 빠른 연속 요청의 실제 OS 중단·전환 모양은 실기 확인 대상으로 남긴다. + +이번 요청은 **창 크기 전환**에 적용했다. Intro/전체 preview fade 제거와 일반 단계의 200ms opacity-only는 유지한다. 수동 창 드래그 및 Windows 애니메이션 자동 활성화는 변경하지 않았다. + +### 테스트와 문제 기록 + +`qa-agent`의 결정 테이블 방식으로 기존 2개 animate 사례를 OS·앱 설정·reduced-motion 조합 6개로 확장했다. 실제 OS를 실행하는 대신 handler 로드 후 `process.platform`을 임시 대체하고 finally에서 원래 descriptor를 복원한다. Electron 설정과 bounds는 mock이므로 **Windows 호환성이나 macOS 애니메이션 체감 검증을 대신하지 않는다.** + +- OS/설정 조합별 true/false 전달, setBounds 1회, 동일 bounds 재요청 생략, JS timer 0개를 검증했다. Windows/Linux/앱 off에서는 OS 설정 조회도 0회다. +- `SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/handler/ipcWindowHandlers.test.ts -t 'wizard resize work budget'`: **16 통과 / 범위 밖 13 제외**. +- 같은 파일 전체 + startupWizardFlow/startupPreviewLazy: **3파일 37개 중 36 통과 / 1 실패**. 실패는 §6의 기존 export ID 기대 불일치이며 수정하거나 건너뛰지 않았다. +- production build, 변경 main/hook/test ESLint, IPC schema·preload·계약맵(225채널), Wizard bundle·boot 예산 검사 통과. +- `node_modules/.bin/tsc6 --noEmit`: 기존 Sidebar.tsx:157 미사용 handleRenameProject(TS6133)로 실패. 이번 변경의 전체 typecheck 통과를 선언하지 않는다. +- 이번 조정 후 실제 Electron GUI의 native resize는 **미실행**. §6의 Intro→Model smoke는 resize 경로를 호출하지 않아 이번 native animation 검증 자료가 아니다. + +남은 수동 확인: macOS에서 Model→Theme, Theme→Model, Layout→Prepare를 앱 애니메이션 on/off·OS 동작 줄이기 on/off로 확인하고, 빠른 앞/뒤 전환에서 최종 bounds·버튼 clipping·focus를 기록한다. Windows는 계속 즉시 적용하며 사용 가능한 실기에서 동일 흐름을 확인한 뒤에만 활성화 여부를 별도 결정한다. + +## 8. P1-2 조정 — resize 중 배경만 표시하고 완료 후 preview 마운트 + +> Windows 즉시 적용은 이 절 작성 당시 정책이다. 이후 Windows 애니메이션도 켜 달라는 사용자 요청으로 §9의 제한된 보간을 추가했다. 배경/preview 마운트 및 오류 재시도 계약은 그대로 유지한다. + +2026-09-10 KST 사용자 승인으로 구현했다. **macOS native 애니메이션은 유지하되, 위저드 전환 중 무거운 화면을 마운트하지 않는다.** `ponytail`의 기존 IPC 재사용과 `vercel-react-best-practices`의 조건부 마운트 원칙을 적용했다. 새 IPC 채널·OS 서비스·반복 프레임 타이머·의존성은 추가하지 않았다. 아래는 §6~7의 완료 대기 계약을 대체한다. + +### 동작과 비용 경계 + +- Model→Theme, Theme→Model, 완료 알림→Prepare의 세 resize 지점을 기존 hook의 단일 요청 상태로 모았다. React commit으로 내용이 제거된 뒤 effect에서 IPC를 보낸다. 전환 중 `aria-busy=true`의 단색 배경만 렌더하므로 이전/다음 preview·dock·footer가 뒤에서 레이아웃을 계산하는 overlay 방식이 아니다. +- 배경은 **전환 전 단계의 테마**를 유지한다. Model은 기존 dark bootstrap, Theme/완료 알림은 현재 선택한 테마의 `bg-app`이다. 다음 단계 진입 시 원래 테마 적용 규칙을 따른다. 배경 paint의 정확한 시점까지 React commit이 보장하는 것은 아니다. +- macOS animate=true는 setBounds **호출 전에** `resized`/`closed` listener를 연결한다. 완료 이벤트에서 응답하고 listener·안전 타이머를 제거한다. 빠른 새 요청은 이전 대기를 false로 정리하고 새 요청을 적용한다. [Electron resized 이벤트](https://www.electronjs.org/docs/latest/api/browser-window#event-resized-macos-windows). +- 이벤트가 오지 않으면 **2,000ms 안전 타이머**에서 목표 크기를 애니메이션 없이 적용하고 대기를 종료한다. 정상 애니메이션 길이나 성능 SLA가 아니며, 프레임 polling도 아니다. 정상 요청은 setBounds 1회, 누락 fallback은 최대 1회 추가다. native API가 실패하면 기존 IPC 오류로 반환한다. JS event loop 자체가 멈춘 경우 벽시계 기준 2초를 보장하지 않는다. +- Windows/Linux·앱 애니메이션 off·OS 동작 줄이기 on은 완료 이벤트나 2초 타이머를 기다리지 않는다. renderer에서는 짧은 IPC 왕복 동안 배경이 보일 수 있으나 인위적인 대기 시간은 없다. 같은 bounds도 즉시 응답한다. +- renderer는 성공 응답과 true 결과를 확인한 뒤에만 다음 단계를 연다. 오류·false·Promise 거부는 기존 오류 화면으로 복귀하며, 재시도는 **실패한 resize**를 반복한다(completeWizard로 건너뛰지 않는다). unmount 후 늦은 응답은 무시한다. 기존 preload의 15초 IPC timeout도 유지되어 main 응답 자체가 없는 상황을 처리한다. +- 요청 처리 중 중복 클릭은 새 요청을 만들지 않는다. 완료 알림의 기존 800ms UX 대기는 유지한다. **실제 Writer/main window의 수동 resize에는 적용하지 않는다.** 위저드 preview의 편집 DOM은 재마운트되지만 테마·레이아웃 선택은 상위 hook에 유지한다. + +트레이드오프: 애니메이션과 editor 초기화의 경쟁을 줄이지만 editor 준비 시간이 없어지지는 않는다. 완료 후 lazy chunk/초기화가 느리면 기존 preview fallback이 더 오래 보인다. screenshot 복제·prefetch·새 scheduler는 추가하지 않았다. native animation도 OS/GPU 작업을 하므로 GPU/GC/RSS 감소율을 주장하지 않는다. + +### ISTQB 관점 테스트 계약 + +| ID | 기법/위험 | 검증 | +| --- | --- | --- | +| P1-BG-01 | 상태 전이 / 이중 클릭 | 배경 DOM commit 후 IPC 호출, 응답 전 버튼·editor·다음 단계 없음, 중복 호출 없음 | +| P1-BG-02 | 역방향 / 완료 경로 | Theme→Model 축소 시 선택 테마 유지, 완료→Prepare는 resize 응답 전 input 미마운트 | +| P1-BG-03 | 실패 주입 | IPC 오류 응답·false·Promise reject에서 blank 종료; retry는 같은 resize, completeWizard 미호출 | +| P1-BG-04 | lifecycle | 대기 중 unmount 후 늦은 완료 무시 | +| P1-BG-05 | native 이벤트 / 경계값 | resized·closed·새 요청·fallback·fallback 예외, 1,999ms 미완료 / 2,000ms 안전 복구, listener/timer 정리 | +| P1-BG-06 | OS 결정 테이블 | 기존 macOS/Windows/Linux, 앱 설정/reduced-motion 조합 유지; 동기 resized에도 정상 완료 | + +main 테스트는 Node EventEmitter로 실제 once/removeListener 동작을 사용하되 창/API 자체는 mock이다. DOM flow는 resize Promise를 제어하고 기존 전체 흐름도 유지한다. 기존 flow/ModelStep mock의 undefined 응답은 실제 IPC `{success, data}` envelope로 바로잡았다. 오류 assertion을 약화하지 않았다. + +실행 명령: + +```sh +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/handler/ipcWindowHandlers.test.ts -t 'wizard resize work budget' +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/handler/ipcWindowHandlers.test.ts tests/dom/startupWizardFlow.test.tsx tests/dom/startupPreviewLazy.test.tsx tests/dom/startupWizardModelStep.test.tsx tests/scripts/startupBenchmark.test.ts tests/scripts/startupWizardBundle.test.ts +node node_modules/electron-vite/bin/electron-vite.js build +node scripts/check-startup-wizard-bundle.mjs +node scripts/check-render-boot-budget.mjs +node scripts/check-ipc-contract-map.mjs +node scripts/check-ipc-handler-schemas.mjs +node scripts/check-preload-contract-regression.mjs +node_modules/.bin/tsc6 --noEmit +node scripts/benchmark-startup.mjs --runs 1 --profile fresh --scenario resize +``` + +- main resize 집중 실행 **21 통과 / 범위 밖 13 제외**. 관련 전체 선택 실행은 **6파일 67개 중 63 통과 / 기존 4 실패**(export ID 1, ModelStep footer/raw503 기대 3). flow 11개·preview 2개는 통과했다. 기존 ModelStep act 경고도 유지되며 전체 QA 통과로 표시하지 않는다. +- production build 및 변경 production/main test ESLint 통과. boot JS 580.3KiB / CSS 165.9KiB, Wizard 정적 closure 708,618 bytes, eager editor 없음. IPC schema·preload·계약맵(225채널) 통과; map 첫 실행의 handler 줄 번호 drift는 재생성 후 재검사했다. +- typecheck는 기존 Sidebar.tsx:157 TS6133이 그대로다. 이전 §6의 전역/DOM lint 문제를 해결한 것으로 간주하지 않는다. renderer 변경에 새 hardcoded color·!important·console 출력은 없다. +- graph는 삭제된 보간 함수가 남은 오래된 스냅샷을 반환해 실제 소스와 호출부를 대조했다. 보조 CSS 지침과 QA examples/self-check가 없어 기존 DESIGN·실행 프로토콜·체크리스트와 직접 회귀 검증을 사용했다. + +### 실제 Mac 관찰과 재현성 + +기존 격리 실행기의 **선택 옵션 `--scenario resize`**로 Intro→Model→Theme까지 진행한다. 기본 `--scenario intro`는 종전 범위를 유지한다. CLI 유효/무효 시나리오 테스트를 추가했다. 테스트 중에만 MutationObserver로 busy/editor DOM의 상태 변화(최대 16개), main의 resized 시각, 전후 bounds를 수집한다. 원고 저장/정상 종료 인증은 아니며 기존 빈 임시 프로필·자동 다운로드 차단·sync off 통제를 유지한다. 최종 screenshot은 resize 시나리오에서 Theme 화면이다. 시나리오별 snapshot 지점이 다르므로 메모리 숫자를 직접 합산/비교하지 않는다. + +실행 증거: `/var/folders/jh/rqqbpbts0sqfj9qjxr2qr1qm0000gn/T/luie-p0-58eaJP/report.json`, `run-1.png` 및 profile 로그. **1/1 성공**, Electron 44.2.0 arm64, M4, DPR 2, OS 동작 줄이기 off. 창 479×697→1402×879. + +| busy 배경 DOM 관찰 기준 | 상대 시각 | +| --- | ---: | +| 배경만 표시, editor DOM 없음 | 0ms | +| main resized 이벤트 | +353ms | +| busy 해제, editor DOM 아직 없음 | +361ms | +| editor DOM 나타남 | +688ms | + +프리뷰 DOM이 resize 완료 **뒤에** 생겼고 busy 중 editor가 함께 관찰되지 않았다. 완료 화면 screenshot에서 본문·테마 dock·이전/다음 버튼 표시를 확인했다. native 완료 후에도 editor 준비 약 335ms가 남았다는 점을 숨기지 않는다. MutationObserver/Date.now 기반 관찰이며 정확한 paint/frame-time 계측이나 안정적인 p95가 아니다. Intro DOM 1,562ms/불투명 1,592ms의 이번 한 표본도 이전 표본과 합쳐 개선율로 발표하지 않는다. + +남은 TODO: Mac 실제 역방향 축소·Prepare 확장·키보드 focus·reduced-motion/off 조합, Windows 실기, 실제 수동 resize의 renderer/CPU/GPU trace. fallback과 오류/연속 요청은 자동 mock 테스트로 검증했으며 실제 OS 실패 주입으로 인증한 것은 아니다. P0~P3 경계를 유지하고 P4는 열지 않는다. + +## 9. Windows 애니메이션 활성화 — 작업 예산과 lifecycle 제한 + +2026-09-10 KST 사용자 요청으로 **Windows에서도 위저드 창 전환을 애니메이션으로 처리**한다. macOS용 `setBounds(..., true)`는 Windows에서 지원되지 않으므로 같은 옵션만 켜는 것으로 구현하지 않았다. [Electron setBounds](https://www.electronjs.org/docs/latest/api/browser-window#winsetboundsbounds-animate). + +`ponytail` 원칙에 따라 별도 성능 엔진 없이 `src/main/manager/window/windowStartupWizardResize.ts`에 기존 bounds 계산·완료 대기와 Windows 보간을 모았다. IPC handler는 기존 인자 검증 후 이 함수를 호출한다. renderer·preload·채널·bootstrap은 이번 조정에서 바꾸지 않았다. **배경만 표시한 뒤 완료 응답 후 preview를 마운트하는 구조는 유지**한다. + +### 현재 OS 정책 + +| 조건 | 처리 | +| --- | --- | +| macOS, 애니메이션 허용 | 기존 native animate, resized 완료 / 누락 시 2초 안전 복구 | +| Windows, 애니메이션 허용 | nominal 200ms ease-out cubic, 16ms 간격 요청, 최대 13단계 | +| 앱 애니메이션 off / OS 동작 줄이기 on / rich animation 비권장 | 즉시 적용 | +| Linux | 기존 즉시 적용 유지 | + +OS 권고는 요청 시작 시 `getAnimationSettings()` **1회**만 읽는다. `prefersReducedMotion` 및 `shouldRenderRichAnimation`을 존중한다. 후자는 원격 세션·접근성 등의 OS 판단이며 CPU/RAM 기기 등급이 아니다. off·미지원 OS·동일 bounds 경로에는 불필요한 조회/타이머가 없다. [Electron animation settings](https://www.electronjs.org/docs/latest/api/system-preferences#systempreferencesgetanimationsettings). + +### Windows 비용·완료 계약 + +- 과거 800ms 보간을 그대로 복원하지 않았다. 200ms·최대 13단계는 **이 전환의 작업 예산**이며 전체 컴퓨터 성능을 분류하는 상수가 아니다. 실기 trace 후 조정할 수 있도록 창 관리 파일에 모았다. +- 첫 0% bounds 쓰기는 생략한다. 반올림 결과가 직전 요청과 같으면 native 호출도 생략한다. 정상 완료까지 `setBounds`는 **최대 13회**, 매 단계 `animate=false`로 전달한다. native API가 스스로 수행하는 OS 작업 횟수를 뜻하지는 않는다. +- `performance.now()`의 단조 시간으로 진행률을 계산하고 단일 재예약 timeout만 사용한다. 늦어진 프레임은 건너뛰며 밀린 단계의 catch-up 루프는 없다. 마지막 단계는 계산 오차 없이 목표 bounds를 사용하고, 적용 호출이 반환된 뒤 IPC를 완료한다. Windows의 `resized` 이벤트를 기다리지는 않는다. +- 창 닫힘/파괴, 새 요청, native 호출 예외에서는 pending Promise와 timer/listener를 정리한다. setBounds 호출 도중 closed가 발생해도 다음 timer를 다시 만들지 않는다. 새 요청은 이전 목표를 뒤늦게 덮어쓰지 않으며 현재 bounds에서 다시 시작한다. +- macOS와 Windows는 완료/취소/오류 정리를 공유한다. Windows에는 native 이벤트 누락용 2초 타이머를 추가하지 않는다. frame timer 하나로 진행·완료하고, IPC 통신 자체의 timeout은 기존 preload가 담당한다. + +**제약:** 타이머와 setBounds는 OS 스케줄링에 영향을 받으므로 실제 60fps나 정확히 200ms를 보장하지 않는다. macOS native와 Windows의 easing/속도가 완전히 같지도 않다. GPU/compositor 비용이 없어지는 것은 아니며, Windows 최초 3~5초 지연이 이 애니메이션 때문이었다는 증거도 아직 없다. 특히 Intro 진입 자체는 이 resize를 호출하지 않는다. Windows 프레임 비용·CPU/GPU/GC 개선율은 실기 미측정이다. + +### ISTQB 검증과 파일 분리 + +기존 QA 결정 테이블에 Windows 애니메이션 on, reduced-motion, rich-animation 비권장 사례를 추가했다. fake timer와 가짜 platform은 **실제 Windows 실행이 아니라 정책·lifecycle 검증**이다. + +- 정상: 199ms에는 미완료, 200ms에 최종 bounds/완료, 호출 수 ≤13, 완료 후 timer/listener 0개. +- 지연: 첫 callback이 600ms 시점에 실행된 상황에서 중간 단계를 따라잡지 않고 최종 bounds 1회 적용. +- 반올림: 1px 차이에서 같은 정수 bounds를 반복 적용하지 않음. +- 중단: closed, 이벤트 없는 destroyed, 진행 중 새 animated 요청, frame API 예외, setBounds 중 동기 closed. 이후 가상 시간 3초를 진행해도 추가 쓰기 없음. +- Mac 완료/닫힘/fallback/예외, 기존 bounds 계산·입력 검증, renderer 배경/재시도 테스트는 유지했다. + +추가 검사에서 누적된 테스트가 500줄 제한을 넘는 것이 확인되어 **resize 테스트를 `tests/main/handler/ipcWindowResize.test.ts`, `tests/dom/startupWizardResize.test.tsx`로 이동**했다. 기존 IPC/flow 테스트의 assertion을 삭제하지 않았다. 분리한 파일을 `qa:core` 명령에도 포함했다. resize main 462줄 / DOM 328줄, 남은 기존 파일 406줄 / 479줄로 이 작업의 초과를 해소했다. 테스트 설정 분리 중 제거된 mock의 reset이 남아 기존 IPC 13개가 일시 실패했으며, 해당 잔여 reset을 제거하고 다시 실행했다. + +재현 명령(§8의 과거 파일명 선택 실행보다 아래를 우선): + +```sh +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/handler/ipcWindowResize.test.ts tests/dom/startupWizardResize.test.tsx tests/dom/startupWizardFlow.test.tsx +SKIP_DB_TEST_SETUP=1 node node_modules/vitest/vitest.mjs run tests/main/handler/ipcWindowHandlers.test.ts tests/main/handler/ipcWindowResize.test.ts tests/dom/startupWizardFlow.test.tsx tests/dom/startupWizardResize.test.tsx tests/dom/startupPreviewLazy.test.tsx tests/dom/startupWizardModelStep.test.tsx tests/scripts/startupBenchmark.test.ts tests/scripts/startupWizardBundle.test.ts +node node_modules/electron-vite/bin/electron-vite.js build +node scripts/check-startup-wizard-bundle.mjs +node scripts/check-render-boot-budget.mjs +node scripts/check-ipc-contract-map.mjs +node scripts/check-ipc-handler-schemas.mjs +node scripts/check-preload-contract-regression.mjs +node scripts/check-source-loc.mjs +node scripts/check-core-complexity.mjs +node_modules/.bin/tsc6 --noEmit +node scripts/benchmark-startup.mjs --runs 1 --profile fresh --scenario resize +``` + +실행 결과: + +- resize main **32개 통과**, 분리 후 main resize+DOM resize+flow **3파일 43개 통과**. +- 최종 관련 **8파일 78개 중 74 통과 / 기존 4 실패**. export ID 기대 불일치 1개와 ModelStep footer/raw503 기대 3개만 남으며, 분리 중 발생한 13개 mock 오류는 재실행에서 해소됐다. assertion은 유지했다. +- production build, 변경 main/새 manager/resize 테스트 ESLint, IPC schema·preload·계약맵(225채널), Wizard 정적 의존 및 boot 예산 검사 통과. Wizard 708,618 bytes, boot JS 580.3KiB / CSS 165.9KiB로 renderer 산출물 크기는 이번 조정 전과 같다. +- `check-source-loc`은 이번 두 테스트 초과를 해소한 뒤에도 범위 밖 **16개** 위반으로 실패한다. `check-core-complexity`는 통과하되 기존 GoogleDocsLayout의 길이/복잡도 권고 2개가 있다. +- typecheck는 기존 Sidebar.tsx:157 TS6133이 유지된다. 전체 qa:core/전체 E2E 통과로 표시하지 않는다. graph가 오래된 상태여서 실제 소스·실행 결과를 우선했으며 없는 QA 보조 문서에 대한 이전 fallback도 유지했다. +- Mac 회귀 smoke: `/var/folders/jh/rqqbpbts0sqfj9qjxr2qr1qm0000gn/T/luie-p0-Xf9cxY/report.json`, 1/1 성공. M4 arm64 / Electron 44.2.0 / DPR 2, 479×697→1402×879. busy 시작 대비 native resized +352ms, busy 해제 +357ms, editor DOM +690ms. 단일 표본이며 Windows 효과나 성능 향상률로 해석하지 않는다. + +다음 실기 확인은 Windows x64 i5-9400F/16GB/RX570/SSD에서 동일 resize 시나리오와 앞/뒤 전환·IME/저장 확인이다. CPU/GPU trace와 native 호출 지연을 보고 예산을 조정한다. ARM VM은 별도 조건으로 기록한다. 수동 Writer resize·새 resource manager·native addon은 이번 범위에 추가하지 않는다. diff --git a/docs/quality/storage-architecture-review-2026-09-08.md b/docs/quality/storage-architecture-review-2026-09-08.md new file mode 100644 index 00000000..3d75ea78 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08.md @@ -0,0 +1,234 @@ +# DB·저장 아키텍처 재검토 + +2026-09-08 · commit `128d8be6` 및 현재 작업 트리 · 구현 변경 전 설계 감사 + +## 1. 종합 판정 — Risky + +**D부터 진행한다. 첫 작업은 저장 원본 선택·commit·복구 보장이며, 이어서 `.luie`의 변경 범위를 줄인다.** FTS 튜닝이나 프로세스 이전을 앞세우지 않는다. 재조사에서 최신 DB 본문이 오래된 `.luie`를 명시적으로 다시 여는 동작에 덮이는 경로를 실제 DB·파일·서비스로 추가 재현했다. + +저장 로직은 바꿀 필요가 있다. 그러나 JSON으로 되돌리거나 `.luie` 포맷과 모든 DB를 다시 설계할 필요는 아직 없다. 현재 SQLite 컨테이너와 export queue를 유지하면서, 저장 완료 조건을 고정하고 검증된 변경 집합을 한 transaction으로 반영하는 방향이 가장 작다. + +서브 에이전트 3명이 package writer/비용, 원본 선택/복구, snapshot/이력 구조를 나눠 재검토했다. 주 에이전트는 변경 범위 추적, 실제 DB 손실 복원, stale package 재열기, 테스트 계약을 확인했다. `senior-code-reviewer`, `ponytail` 지침을 적용했고, code-review-graph MCP는 세션에 제공되지 않아 scoped 소스 탐색을 사용했다. 제품·기존 테스트 코드와 사용자 데이터는 수정하지 않았다. + +상세: [package 구조·비용](storage-architecture-review-2026-09-08/package.md), [원본 선택·복구·증분 조건](storage-architecture-review-2026-09-08/authority.md), [snapshot·이력·cache](storage-architecture-review-2026-09-08/history.md). 기존 DB-01~14는 [앞선 DB 감사](performance-audit-2026-09-08/database.md)를 참조한다. + +### 왜 SQLite로 바꿨는데 전체 파일을 다시 쓰는가 + +현재 `.luie`의 SQLite schema는 프로젝트의 `Chapter`/`Character` 테이블이 아니다. `LuieContainerEntry(path TEXT PRIMARY KEY, content TEXT, createdAt, updatedAt)`에 기존 논리 파일을 저장한다. + +| entry | 현재 내용 | 변경 단위 | +|---|---|---| +| `manuscript/{chapterId}.md` | 한 챕터의 원문 | 챕터 1개 | +| `meta.json` | 프로젝트 정보와 전체 챕터 목록 | 프로젝트 metadata | +| `world/*.json` | 인물/용어/graph/문서 등의 JSON | 해당 collection/document | +| `memory/canonical.json` | 보존 대상 memory의 JSON | canonical memory 묶음 | +| `snapshots/index.json` | 본문까지 포함한 snapshot 배열 | 선택된 snapshot 전체 | +| `snapshots/{id}.snap` | index에도 있는 동일 snapshot | 개별 snapshot | + +즉 챕터는 이미 분리된 행이다. SQLite가 전체 파일 재작성을 요구하는 것이 아니다. [export engine](../../src/main/services/core/project/projectExportEngine.ts:71)이 전체 project payload를 만들고, [full writer](../../src/main/services/io/luieSqliteContainer.ts:214)가 **새 임시 SQLite 파일 → 모든 entry INSERT → close → 기존 파일 교체**를 실행하기 때문이다. 변경된 chapter ID를 exporter에 넘기는 계약도 없다. + +전체 JSON/ZIP 시절의 내보내기 흐름을 SQLite 행에 옮겼지만, 빈번한 저장을 증분 UPDATE로 전환하는 단계가 남은 상태다. JSON을 SQLite의 TEXT에 넣는 것 자체가 잘못은 아니다. 자주 바뀌는 원고가 이미 개별 행인데도 모두 다시 만드는 것이 우선 문제다. + +### 현재 저장 흐름과 “나중에 합친다”의 의미 + +```mermaid +flowchart TD + E[에디터 입력 버퍼] --> P[preload / main 최신 요청 병합] + P --> M[latest 복구 mirror] + P --> D[로컬 DB: 현재 본문 + revision + 파생 작업] + D --> Q[프로젝트 export 요청 병합 500ms] + Q --> A[전체 프로젝트 수집] + A --> L[새 .luie 전체 생성 후 교체] + D --> G[저장 후 챕터별 60초 snapshot gate] + G --> S[Snapshot 본문 + 별도 프로젝트 artifact] + S --> Q + D --> C[검색 cache / chunk / embedding 갱신] +``` + +합쳐지는 것은 **아직 실행하지 않은 요청**이다. 여러 변경분을 작은 delta로 기록했다가 나중에 병합하는 저장 구조는 아니다. main autosave는 기본 200ms, project export는 500ms debounce다. export 중 새 revision이 생기면 전체 export를 다시 실행할 수 있다. 자동 snapshot 생성도 export를 예약하므로 실행 시점에 따라 기존 요청과 합쳐지거나 후속 전체 export가 된다. + +사용자 snapshot, 내부 ChapterRevision, `.luie` checkpoint, SQLite WAL checkpoint는 서로 다르다. Ctrl/Cmd+S는 최신 저장을 flush하고 package를 반영하는 동작이며, 수동 이력 snapshot을 만든다는 뜻은 아니다. SQLite WAL checkpoint 역시 사용자 원고 이력 기능이 아니다. + +## 2. 필수 수정: 저장 정확성과 복원 가능성 + +### S-01 · P1 · 미반영 DB 변경이 명시적 파일 재열기에 덮인다 — 새 실제 재현 + +[projectImportOpen.ts:243](../../src/main/services/core/project/projectImportOpen.ts:243)는 `Project.updatedAt > package.meta.updatedAt`일 때만 DB가 더 최신이라고 보호한다. [chapter write](../../src/main/services/core/chapter/chapterWriteOperations.ts:235)는 chapter/body를 변경하지만 Project.updatedAt를 갱신하지 않는다. [DB trigger](../../src/main/database/main/projectRevisionTriggerSql.ts:108)는 project revision만 올린다. 파일에 아직 반영하지 않은 revision이 있는지는 이 선택 조건에서 확인하지 않는다. + +실제 서비스·native SQLite·임시 `.luie` 결과: + +| 단계 | 본문 | DB revision / exportedRevision | +|---|---|---| +| 최초 checkpoint | A | 5 / 5 | +| B의 DB commit, package는 아직 A | B | 7 / 5 | +| 같은 `.luie`를 명시적으로 열기 | **A로 되돌아감**, `conflict=luie-newer` | **9 / 9로 clean 표시** | + +실험에서는 export 예약 함수만 보류해 정상 500ms 대기 구간을 결정적으로 만들었다. import, DB transaction, 파일 읽기/쓰기는 실제 구현이다. 일반 최근 프로젝트 선택은 DB 객체를 여는 다른 경로이므로 이 문제를 모든 프로젝트 전환에 일반화하지 않는다. 재현: [stale-open 관측](storage-architecture-review-2026-09-08/evidence/stale-open-observation.json). + +최소 조치: local uncheckpointed revision/pending을 확인하고, 사용자가 이전 파일 복원을 명시한 경우와 일반 파일 열기를 분리한다. 불확실한 두 원본을 timestamp만으로 덮어쓰지 않는다. `Project.updatedAt` 한 군데를 보정하는 것만으로 clock skew·외부 파일 교체·부분 저장까지 해결되지는 않는다. + +### S-02 · P1 · 기존 단건 writer를 그대로 연결하면 부분 commit이 남는다 — 새 실제 재현 + +[writeLuieSqliteEntry](../../src/main/services/io/luieSqliteContainer.ts:291)는 chapter entry, meta entry, container info를 각각 별도 statement로 쓴다. 전체를 묶는 transaction이 없다. 실제 writer에 SQLite 실패 trigger를 주입했을 때: + +- metadata 쓰기 실패: 함수는 reject하지만 **새 chapter 본문만 이미 commit**됐다. +- container info 쓰기 실패: **새 본문과 metadata만 commit**되고 info는 이전 값이다. + +따라서 full exporter를 단건 API 반복 호출로 교체하는 수정은 부적절하다. 변경 entry들·삭제 entry들·meta·container info를 **하나의 동기 SQLite transaction**에 넣는 batch 경계가 먼저다. [실패 주입 결과](storage-architecture-review-2026-09-08/evidence/package-atomicity-results.json) + +이 API는 미래 증분 저장 후보일 뿐 아니라 현재 세계관 문서의 `FS_WRITE_PROJECT_FILE` 경로에서도 쓰인다. [fsPackageOperations.ts:208](../../src/main/handler/system/fs/fsPackageOperations.ts:208) → 실제 단건 writer로 연결된다. 따라서 원자성 문제는 해당 기존 경로까지 함께 수정해야 한다. + +### S-03 · P1 · 저장에 성공한 entry를 같은 앱이 읽지 못한다 — 새 실제 재현 + +[package reader](../../src/main/services/io/luieSqliteContainer.ts:198)는 entry당 5MiB, package 전체 256MiB 상한을 검사한다. full writer는 같은 상한을 검사하지 않고, 단건 writer도 기존 package 크기만 확인한 뒤 새 내용을 쓴다. + +ASCII 5MiB+1, 즉 **5,242,881 bytes**의 chapter를 실제 full/단건 writer 각각에 넣으면 저장은 성공하지만, 같은 앱의 reader는 `FS_2001: entry is too large`로 거부했다. 파일 전체는 약 5MiB여서 256MiB 상한과 무관하다. [크기 경계 결과](storage-architecture-review-2026-09-08/evidence/package-size-limit-results.json) + +개별 원고뿐 아니라 snapshot 전체 본문 배열과 canonical memory도 한 entry이므로 합계가 커지면 같은 제한에 걸릴 수 있다. import collections는 읽기 실패를 전파하므로 이 경우를 “빈 snapshot으로 정상 import된다”고 단정하지 않는다. 실제 재현은 chapter entry의 write/read 경계까지다. + +최소 조치: 쓰기 전 허용 범위를 검사해 이전 정상 파일을 보존하고, 최종 생성 크기도 교체 전에 확인한다. 장기적으로 큰 collection을 분리한다면 포맷·reader를 함께 변경한다. 제한값만 무조건 높이는 것으로 메모리 문제까지 해결했다고 판단하지 않는다. + +### 기존 DB-01~04도 같은 저장 계약 안에서 처리 + +- 처리 중인 A 저장 완료가 이후 도착한 B pending을 삭제하지 않아야 한다. +- DB 실패가 flush/manual save 성공으로 변하지 않아야 한다. +- 공유 SQLite connection의 `BEGIN`과 `COMMIT` 사이에 `await`로 다른 도메인 SQL이 섞이지 않아야 한다. +- 파생 작업의 완료는 실제 처리한 source generation을 조건으로 해야 한다. + +SQLite는 서로 다른 연결 사이를 격리하지만 같은 연결의 서로 다른 비동기 작업을 격리하지 않는다. 준비 작업을 먼저 끝내고 동기 SQL transaction을 실행해야 한다. [SQLite isolation](https://www.sqlite.org/isolation.html) + +DB부터 시작해도 에디터의 raw buffer flush와 chapterId 보존은 최종 수동 저장 검증에 포함한다. main에 전달되지 않은 입력은 DB의 내구성으로 구할 수 없다. UI 전반 재설계와는 별개인 저장 경계 수정이다. + +## 3. 구조적 문제: 무엇이 원본이고 무엇이 cache인가 + +| 저장소 | 현재 역할 | 지워도 재생성 가능한가 | +|---|---|---| +| main SQLite의 Chapter/Body·세계관·보존 memory | 현재 작업 상태, 아직 package에 없는 commit 포함 | **전체를 cache 취급해 삭제하면 안 됨** | +| `.luie` | 이동·DB 재구성을 위한 마지막 package 상태 | 최신 DB commit과 동일하다는 보장이 필요 | +| 별도 cache SQLite의 search/appearance/FTS | 검색·출현 위치의 파생 표현 | 원본이 있고 invalidation/rebuild가 정확하면 가능 | +| main DB의 chunks/embeddings/jobs/summary 등 | memory 파생 데이터·실행 상태 | 정책상 재생성 대상이지만 계산 비용과 최신성 확인 필요 | +| confirmed/rejected memory 및 근거 | 보존 대상 판단·사실 | 단순 cache 아님; package export 대상 subset 존재 | +| latest mirror | main에 접수됐으나 DB commit 전일 수 있는 본문 | 유일한 최신 복구본일 수 있어 임의 삭제 불가 | +| Snapshot / 외부 artifact / ChapterRevision | 서로 다른 이력·복구 계층 | 소비자·보존 계약 확인 전 감축 금지 | + +[current-main.md:177](../architecture/current-main.md:177)의 “.luie canonical, DB는 재구성 가능한 cache”는 마지막 checkpoint 이후 구간을 설명하기에 부족하다. detached 프로젝트는 package 자체가 없을 수도 있다. 권장 명칭은 **로컬 작업 DB, 휴대 가능한 package checkpoint, 재생성 가능한 검색 cache**다. + +### 스냅샷은 현재 비용과 보장 범위가 맞지 않는 부분이 있다 + +1. **ChapterRevision**: content 저장마다 전문 append, runtime restore/prune/export 소비자를 찾지 못했다. 현재 본문도 Chapter와 ChapterBody에 중복된다. 이력을 바로 지우지 말고, 보존해야 할 과거 자료와 새 append의 필요를 먼저 결정한다. +2. **Snapshot DB**: 저장 후 챕터별 60초 gate가 있으며 첫 저장도 생성 대상일 수 있다. 별도 프로젝트 주기 scheduler는 구현돼 있지만 production 연결을 확인하지 못했다. +3. **외부 artifact**: 개별 chapter snapshot이어도 활성 프로젝트 본문과 인물/용어 등을 다시 모아 JSON→gzip하고 동일 buffer를 최대 3곳에 쓴다. `FullSnapshot`이라는 이름과 달리 현재 세계관·canonical memory 전체를 보존하지 않는다. +4. **package snapshot**: 기본 최근 50개를 넣지만, SQL은 더 많은 snapshot을 모두 읽은 뒤 제한한다. 본문은 index와 개별 `.snap`에 중복된다. 현재 importer는 index의 본문을 읽으므로 index를 metadata-only로 먼저 바꾸면 빈 본문 복원이 가능하다. +5. **보관 차이**: DB의 project 단위 2,000개 cap은 MANUAL을 제외하지 않는다. 시간 prune은 AUTO를 대상으로 하므로 정책이 다르다. package export/import는 MANUAL/AUTO type도 보존하지 않는다. +6. **복구 발견**: 외부 artifact의 focus 본문은 미리보기에 쓰이지만 복원은 DB에서 캡처한 chapters를 사용한다. DB에 없는 snapshot ID를 orphan으로 삭제하는 cleanup은 DB 손실 후 독립 backup과 충돌할 수 있다. 두 항목은 소스 경로에서 확인한 위험이며 이번 실제 crash 시험 결과는 아니다. + +이 계층들을 한 번에 없애지 않는다. 먼저 **최신 입력 복구, 사용자가 만든 이력, 독립 프로젝트 backup**의 목적과 보존 범위를 구분하고 각각 읽기·복원 검사를 만든다. 상세 조건·caller·retention은 [이력 보고서](storage-architecture-review-2026-09-08/history.md)에 있다. + +## 4. 실제 비용과 운영 조건 + +### 실제 writer 비용 측정 + +현재 `writeLuieContainer`/단건 entry API를 실행했다. macOS arm64, Node v22.23.0, native SQLite, 임시 합성 데이터이며 장당 64KiB, snapshot 20개다. 한 chapter의 1바이트를 바꿔 각 경로를 3회 실행한 median이다. + +| 챕터 수 | package 크기 | 현재 전체 writer | 현재 단건 writer | 전체 재삽입 / 단건 갱신 entry | +|---|---:|---:|---:|---:| +| 50 | 5.69MiB | 12.32ms | 1.22ms | 81 / 2 | +| 250 | 18.33MiB | 25.37ms | 1.31ms | 281 / 2 | +| 1,000 | 65.70MiB | 80.91ms | 2.97ms | 1,031 / 2 | + +1,000장 사례에서 full 경로는 **68,281,159 bytes의 논리 entry content**를 다시 삽입하고 새 inode로 교체했다. 단건은 본문+meta **183,335 bytes**를 갱신하고 기존 inode를 유지했다. 이것은 커널의 물리 쓰기 바이트나 SSD 수명 측정이 아니다. 단건도 전체 chapter 목록 metadata를 다시 쓰므로 완전한 상수 비용이 아니다. + +계측에는 DB에서 전체 project를 수집하는 비용, main의 다른 저장·검색·snapshot 작업, 실제 Electron UI 지연이 포함되지 않는다. 계측 wrapper의 추가 비용과 page cache 효과도 있다. 단건 API는 S-02의 원자성 문제가 있으므로 이 표를 “안전한 최종 구현이 27배 빨라진다”는 보장으로 읽으면 안 된다. **전체 재작성 없이 같은 container에서 훨씬 작은 범위의 쓰기가 가능하다는 근거**다. [원시 결과](storage-architecture-review-2026-09-08/evidence/package-cost-results.json) + +### 비용이 커지는 위치 + +원고 한 장 길이 C, 전체 현행 프로젝트 P, DB 전체 snapshot 전문 합 H_all, export하는 snapshot 전문 합 H_export, commit 횟수 S로 보면: + +- 일반 DB 저장: 현행 본문 이중 기록 + 전문 revision으로 본문 기록량이 C에 비례한다. revision 누적은 O(S×C). +- 일반 package export: LIMIT 전 전체 snapshot을 읽으므로 수집 비용은 대략 O(P+H_all), 직렬화·기록 비용은 O(P+H_export)다. 기록할 snapshot 본문은 두 번 포함한다. +- 자동 snapshot artifact: 해당 장만의 이력을 만들더라도 프로젝트 본문 전체 수집·압축 비용이 든다. 저장 위치별 파일 쓰기도 추가된다. +- 검색: 앞선 DB-05/06처럼 단일 장 변경이 전체 FTS rebuild로 확대된다. +- 일부 세계관 문서 저장: [worldReplicaService](../../src/main/services/features/worldReplica/worldReplicaService.ts:56)가 DB 저장 후 즉시 전체 package export를 시도하고, [renderer worldPackageStorage](../../src/renderer/src/features/research/services/worldPackageStorage.ts:452)가 성공 뒤 해당 entry를 다시 쓴다. 원고 export와 달리 단건 API도 쓰지만, 정상 경로에서는 전체 저장에 추가되는 중복 쓰기다. 해당 경로도 package write 소유자를 하나로 모아야 한다. 이는 호출 경로 확인이며 별도 UI 시간 측정은 하지 않았다. +- peak memory: DB 결과·DTO·JSON 문자열·압축 buffer·SQLite native cache 등이 일부 동시에 존재한다. byte 상한과 실제 생존 시점을 측정해야 하며 이번 벤치로 process peak RSS를 측정했다고 주장하지 않는다. + +새 프로세스로 옮기면 main 멈춤은 줄일 수 있어도 전체 디스크 기록량은 그대로다. 변경 범위부터 줄이고 남은 큰 작업을 분리한다. + +### `.luie` 직접 갱신의 OS·복구 계약 + +기존 package는 DELETE journal + FULL을 사용한다. 이를 바로 WAL로 바꾸면 `.luie`만 복사했을 때 마지막 commit이 빠질 수 있다. WAL 파일은 데이터베이스의 영속 상태 일부다. [SQLite WAL](https://www.sqlite.org/wal.html) + +DELETE journal도 transaction 중이나 crash 뒤에는 복구 journal이 필요할 수 있다. 정상 commit·close 후 단일 파일을 전달하는 것과, 쓰는 도중 Explorer/Finder/동기화 프로그램이 파일만 복사하는 것은 다른 상황이다. in-place update는 후자의 위험 구간을 만든다. 앱의 복사·Save As·backup은 writer가 멈춘 상태나 SQLite가 지원하는 일관된 복사 경로를 사용해야 한다. [SQLite 파일 복사와 journal](https://www.sqlite.org/howtocorrupt.html), [atomic commit](https://www.sqlite.org/atomiccommit.html) + +현재 package reader는 readonly로 열고, meta read의 비legacy 오류를 corrupt로 분류한다. hot journal 복구가 필요하면 readonly 열기는 `SQLITE_READONLY_ROLLBACK`으로 실패할 수 있다. 증분 갱신을 일반 저장 경로에 넣기 전에 writer 소유권 아래 복구한 뒤 읽는 절차가 필요하다. [SQLite 결과 코드](https://www.sqlite.org/rescode.html#readonly_rollback) + +macOS 외장/동기화 폴더, Windows 파일 잠금·Defender·공유 폴더, Linux 파일시스템·권한에서 같은 fixture로 commit/강제 종료/재열기를 검증한다. JS 예외 주입 성공과 실제 전원 손실 내구성 인증은 다르다. fsync를 없애거나 내구성 설정을 일괄 약화하지 않는다. + +## 5. 권장 설계와 작업 순서 + +### 유지할 구조와 변경할 경계 + +**로컬 작업 DB + SQLite `.luie` + 기존 ProjectExportQueue를 유지한다.** 전체 exporter도 초기 생성·Save As·복구·변경 범위를 모르는 경우의 안전한 경로로 남긴다. 일반 저장에는 변경 집합을 한 transaction으로 package에 반영하는 경로를 추가한다. + +전체 경로로 돌아가는 것은 해당 변경의 원본이 DB/지원 payload에 모두 보존된 경우에 한한다. generic 파일 API로 package에만 기록한 내용이나 exporter가 모르는 entry는 기존 full writer에서 제외될 수 있다. 이런 경우도 자동 재작성 대신 기존 파일을 보존하고 명시적으로 병합·복구한다. + +```mermaid +flowchart LR + E[최신 editor revision flush] --> D[동기 DB transaction] + D --> Q[기존 queue: 변경 범위와 세대 병합] + Q --> V{package 기준 상태와 변경 범위 검증} + V -->|확인됨| P[변경 entry + meta 한 transaction] + V -->|기준 파일 일치 / 변경 범위만 불명| F[일관된 전체 checkpoint] + V -->|외부 파일 불일치| X[양쪽 보존 / 충돌·복구 처리] + P --> A[반영한 revision만 ACK] + F --> A +``` + +이 단계에 별도 event-sourcing DB, 커스텀 WAL, delta 전용 포맷은 필요하지 않다. 다만 **마지막 reason이 chapter:update라는 이유만으로 부분 저장을 허용해서는 안 된다.** 다른 세계관·memory·snapshot write도 project revision을 올린다. 그 변경을 빠뜨리고 최신 project revision을 ACK하면 crash 복구에 필요한 revision gap까지 사라진다. + +필수 계약: + +1. commit 전후 revision과 변경 scope를 같은 쓰기 경계에서 확보한다. 패키지 기준 revision부터 목표까지 모든 변경이 관측됐을 때만 증분 경로를 허용한다. +2. 현재값을 임의의 여러 시점에서 모으지 않고 일관된 읽기 상태를 만든다. mutation의 async 준비를 transaction 밖에서 끝내고 동기 읽기/쓰기 경계를 유지한다. package import도 여러 entry를 각기 다른 연결에서 읽는 대신 같은 read transaction의 자료를 사용한다. +3. 모든 package writer가 같은 queue/lock·기준 상태를 공유한다. entry batch와 metadata의 commit이 성공한 뒤 **그 batch에 포함된 revision만** ACK한다. +4. 쓰는 중 들어온 새 변경은 다음 세대로 남긴다. timeout/실패를 완료로 간주하지 않는다. +5. attach/import·재시작·관측하지 못한 revision 증가에서는 원본을 비교하고 전체 재조정한다. **외부 파일이 기준과 다르면 자동 full overwrite하지 않고 양쪽을 보존한다.** 미확인 scope가 생긴 상태를 이후 단건 요청이 다시 안전하다고 바꾸지 못하게 한다. +6. 파일 안의 checkpoint 식별이 필요하다. 현재 meta에는 revision이 없고 exportedRevision은 로컬 attachment ACK다. 숫자 revision은 동일 project/lineage 안에서만 비교하며, 복사본·다른 기기의 revision을 무조건 대소 비교하지 않는다. + +변경 scope를 RAM에 두는 초기 구현도 가능하다. 재시작 후 `revision > exportedRevision`을 발견하면, 파일 기준 상태를 확인한 뒤 전체 checkpoint로 복구할 수 있기 때문이다. 전제는 정상 실행 중 모든 변경 범위를 증명하지 못했을 때 반드시 전체 경로로 돌아가는 것이다. revision 증가량만으로 변경한 도메인을 추측하지 않는다. 저장과 ACK 두 DB를 하나의 transaction처럼 취급하지 않는다. package commit 뒤 ACK 전 crash는 중복 적용·재검증이 가능하도록 하고, ACK를 먼저 쓰지 않는다. + +### 첫 구현 묶음: 저장 정확성 + +S-01 파일 재열기 보호, DB-01 pending 세대, DB-02 오류 전달, DB-03 동기 transaction을 함께 다룬다. 먼저 현재 복구 테스트의 구 API mock/assertion을 고치고 실제 DB·파일 integration 검사로 고정한다. `.luie` writer의 읽기 가능한 크기와 쓰기 가능한 크기도 같은 계약으로 맞춘다. + +완료 기준: 즉시 저장·저장 중 재입력·DB 실패·package 실패·동일 파일 재열기·종료 취소에서 마지막 본문이 유지되고 실패가 성공으로 표시되지 않는다. 파일이 다른 내용을 담고 있으면 최신 로컬 변경을 조용히 덮지 않는다. + +### 두 번째 구현 묶음: 안전한 package 증분 저장 + +S-02 batch transaction과 hot-journal 복구를 먼저 만든다. 기존 `.luie` 형식을 유지한 채 검증된 chapter 변경부터 적용한다. 미지원 scope는 전체 checkpoint로 처리한다. snapshot SQL LIMIT와 중복 legacy body 조회 같은 작은 수집 비용도 함께 줄인다. + +완료 기준: 한 장 수정에 관련 없는 chapter/world/snapshot entry를 다시 쓰지 않는다. 중간 SQL 실패면 batch 전체가 이전 상태다. 저장 도중 강제 종료 뒤 원고와 metadata가 같은 세대다. 구 파일·외부 수정·삭제/reorder·동시 memory 변경에서 누락 ACK가 없다. + +### 세 번째 구현 묶음: 이력과 파생 작업의 비용 + +ChapterRevision의 현재 소비 여부에 맞춰 새 append 정책을 정하고 기존 이력은 보존한다. MANUAL type·복원 범위·backup 발견을 먼저 고정한 뒤 snapshot 중복을 줄인다. 이후 DB-05/06의 단건 FTS, DB-04/08의 generation 및 불변 chunk 보존을 적용한다. 큰 checkpoint나 압축 작업의 프로세스 이전은 이 다음이다. + +### 이번 검증 결과 + +- 기존 container·atomic replace·export queue·recovery scheduling·persistence policy·export engine 6개 suite 통과. +- 실제 DB의 stale checkpoint 재export, snapshot 목록 projection 2개 검사 통과. +- 기존 `luieDbLossRecovery`는 목록 DTO에 content가 있다고 가정해 실패했다. 임시 검사에서 현재 `getChapter(id)` API로 본문을 읽도록 맞추자 **DB 삭제 → .luie import → 원고·snapshot 본문 복원**이 통과했다. 제품 복구 실패로 오해하지 않는다. +- `snapshotService.packageBehavior.unit`은 9개 중 7개가 구 Prisma mock과 현재 Drizzle API 불일치로 실패했다. 이 suite는 그대로 저장 변경의 안전망으로 쓸 수 없다. +- snapshot projection을 처음에는 `SKIP_DB_TEST_SETUP=1`로 실행해 초기화 오류가 났고, 필요한 DB setup을 켜 재실행해 통과했다. 이는 감사 명령 선택 오류다. +- 새 stale-open 및 단건 writer 실패 주입 검사는 **결함을 관측하는 검사**다. 통과 표시가 정상 동작을 뜻하지 않는다. 수정 회귀검사에서는 정상 보존을 기대하도록 반전해야 한다. +- macOS에서 실제 writer를 분리 계측했다. Electron 전체 앱, Windows/Linux, 전원 차단, 사용자 DB, 외부 동기화 서버는 실행하지 않았다. + +재현 소스와 관측 JSON은 [evidence 안내](storage-architecture-review-2026-09-08/evidence/README.md)에 함께 보관했다. 재현용 `.probe.ts`는 일반 테스트 자동 탐색과 분리했으며 명시적 config로만 실행한다. 원래 임시 경로도 상세 보고서에 기록돼 있다. + +## 6. 후순위 선택 + +- `.luie`를 애플리케이션의 유일한 live DB로 바꾸는 전면 개편은 현재 권장하지 않는다. 다중 프로젝트·검색 cache·utility 연결·이동/backup·마이그레이션까지 함께 바뀐다. +- JSON blob이 커지는 memory/world의 정규화는 해당 domain의 변경량과 읽기 비용을 측정한 뒤 선택한다. 원고 단건 갱신을 위해 먼저 필요한 작업은 아니다. +- content-addressed history·증분 backup은 실제 복구 정책이 확정되고 전문 보관 비용이 여전히 클 때 검토한다. 기준점 없는 delta 체계부터 만드는 것은 피한다. +- debounce를 늘리는 것만으로 전체 재작성 비용을 해결하지 않는다. 다만 안전한 저장 완료 계약을 유지한 상태에서 idle/battery 예산을 조정할 수 있다. + +최초 작업의 성공 조건은 속도 수치가 아니라 **“최신 본문을 잃지 않고, 실패를 성공이라 하지 않으며, 성공한 파일을 다시 읽어 같은 내용을 얻는 것”**이다. 그 조건 위에서 전체 재작성 비용을 줄인다. diff --git a/docs/quality/storage-architecture-review-2026-09-08/authority.md b/docs/quality/storage-architecture-review-2026-09-08/authority.md new file mode 100644 index 00000000..fc5079da --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/authority.md @@ -0,0 +1,206 @@ +# 저장 권위 및 `.luie` 직접 갱신 재검토 + +검토일: 2026-09-08. 읽기 전용 소스 조사와 SQLite 공식 문서 검증. 제품 코드, 사용자 DB, 실행 중 앱은 변경·실행하지 않았다. 기존 감사의 세대 결함을 다시 재현하지 않았다. 아래 stale reopen 관측값은 root가 별도 임시 DB/FS에서 실제 소스로 실행한 결과를 읽어 인용한다. 성능 수치는 새로 측정하지 않았다. + +## 1. 판정과 추천 + +**Risky. 현재 main SQLite를 삭제 가능한 cache로 취급하면 안 된다.** 문서상의 `.luie canonical` 의도와 런타임의 최신 데이터 권위가 다르다. 일반 편집은 main DB에 먼저 commit되고 `.luie` checkpoint가 나중에 수행된다. 그 사이 최신 원고는 main DB와 WAL에만 있을 수 있다. `cacheDb`의 검색·출현 파생 데이터와 main DB를 구분해야 한다. + +추천은 **main DB + `.luie`를 유지하고 기존 `.luie` SQLite entry writer를 단일 batch transaction으로 확장하는 것**이다. 첫 대상은 범위를 확실히 아는 기존 chapter 본문 수정으로 제한한다. 구조 변경, 누락된 mutation 범위, 재시작 후 revision gap 등은 기존 full checkpoint를 사용한다. 별도의 영속 incremental application journal은 처음부터 필요하지 않다. 다만 full fallback도 외부 파일 변경과 충돌하면 자동 덮어쓰기해서는 안 된다. 외부 divergence는 기존 파일을 보존하고 충돌/복구 경로로 분기해야 한다. + +이 최적화의 선행 조건은 기존 세대/트랜잭션 소유권 결함과 explicit `.luie` reopen의 데이터 역행 수정이다. 단순히 `INSERT`를 `UPSERT`로 바꾸거나 마지막 저장 reason만 보고 chapter patch를 실행하면 checkpoint ACK의 의미가 깨질 수 있다. + +## 2. 실제 저장소별 역할 + +| 저장소 | 실제 내용과 권위 | 수명·갱신 | 복구 한계 | +|---|---|---|---| +| Renderer editor/save buffer | 아직 main에 전달되지 않은 가장 최신 사용자 입력 | renderer 메모리 | renderer crash 전에 main에 전달하지 못한 입력은 main mirror로 복구할 수 없음 | +| Main autosave pending map | main이 접수했으나 아직 Chapter commit 전인 본문 | 메모리 + 비동기 mirror enqueue | IPC accepted는 DB commit도 mirror 완료도 아님 | +| Main SQLite + `-wal` | Chapter/ChapterBody, 세계관, canonical memory, snapshots, revision, attachment, 설정 및 작업 상태. 정상 편집에서 현재 durable state의 권위 | `databaseService.ts:88` 장기 연결, `WAL`, `FULL`, busy 5초, auto checkpoint 1000페이지 | `.luie`보다 최신일 수 있음. detached project는 대응 `.luie` 자체가 없음. DB를 cache처럼 지우면 최신 commit 및 비export 로컬 상태가 손실될 수 있음 | +| Cache SQLite + `-wal` | `CharacterAppearance`, `TermAppearance`, `ChapterSearchDocument`, FTS 등 파생 데이터 | `cacheDb.ts:82`, main DB와 별도 파일·연결 | 원고 정본 아님. 검색은 `ensureProjectHydrated`/`rebuildProject` 경로가 있지만 모든 cache 삭제 후 모든 파생 데이터의 즉시 자동 복구를 보장한다고 확대 해석하지 않음 | +| `.luie` SQLite v2 | `LuieContainerEntry(path, content)`에 문서·JSON을 저장하는 portable checkpoint. main DB 정규화 테이블과 다른 스키마 | Full checkpoint는 temp DB transaction → close → target 교체. Renderer world 저장은 FS IPC로 entry를 직접 갱신하는 경로도 있음. `journal_mode=DELETE`, `synchronous=FULL` | 마지막 성공 checkpoint 및 이후 개별 entry 갱신 상태. 최신 DB 수정/전체 로컬 설정/모든 revision history를 담는 DB 백업은 아님 | +| `latest.snap` mirror 및 timestamped mirror | main이 접수한 chapter 본문과 project/chapter ID, 시각을 담은 gzip 파일 | userData mirror 디렉터리, 비동기·latest coalescing | 시작 복구는 적격 mirror를 Snapshot으로 추가할 뿐 live Chapter를 자동 교체하지 않음. DB에 chapter가 없으면 orphan으로 정리 | +| Snapshot DB row + 독립 snapshot artifact | 사용자가 선택할 수 있는 복원 지점, 일부 긴급 보존 | artifact 먼저 기록 후 DB Snapshot insert, package export는 별도 | snapshot 생성 성공 ≠ live Chapter commit. export retention에 따라 일부만 `.luie`에 포함 | + +근거: `src/main/database/main/databaseService.ts:85`, `src/main/database/cache/cacheDb.ts:79`, `src/main/database/cache/cacheSchema.ts:11`, `src/main/services/features/search/chapterSearchCacheService.ts:341`, `src/main/manager/autoSave/autoSaveManager.ts:192`, `src/main/manager/autoSave/autoSaveMirrorStore.ts:47`, `src/main/services/features/snapshot/snapshotService.ts:107`, `src/main/services/io/luieSqliteContainer.ts:29`. + +문서 구별: + +- `docs/architecture/current-main.md:177`의 “`.luie` package는 canonical storage이며 SQLite DB는 rebuild 가능한 cache”는 **portable 문서의 의도**로 한정해야 한다. 실제 저장 보장을 설명하는 사실 문장으로는 부정확하다. +- `docs/architecture/narrative-memory-rag-ssot.md:93`의 “DB transaction, revision, derived jobs”는 **런타임 canonical mutation**과 맞는다. +- 추천 문구: “편집의 현재 commit은 main SQLite/WAL이 보존한다. `.luie`는 마지막 성공 checkpoint의 이동 가능한 프로젝트 문서이며, 해당 checkpoint의 export 범위를 main DB로 다시 가져올 수 있다. cache SQLite는 파생 데이터다.” + +## 3. 저장 완료의 세 단계 + +1. **접수됨:** `AUTO_SAVE` handler는 `triggerSave`를 기다린 후 `{success:true}`를 반환한다(`ipcAutoSaveHandlers.ts:33`). `triggerSave`는 pending map과 mirror queue, debounce timer를 등록한다(`autoSaveManager.ts:192`, `:195`, `:228`). 본문 commit까지 기다리지 않는다. +2. **DB에 commit됨:** `performAutoSave`는 `chapterService.updateChapter` 반환 후 `saved`를 emit한다(`autoSavePerformSave.ts:60`, `:69`). `chapterWriteOperations.ts:241` 이후 Chapter/ChapterBody/revision/job 쓰기가 COMMIT되며, package export reason은 debounce일 수 있다(`projectService.ts:461`). 이 단계의 내구성은 main SQLite와 WAL에 관한 것이다. +3. **프로젝트 파일에 반영됨:** manual save는 renderer buffer와 world mutation flush 후 main `flushAll`과 요청 project의 `exportProjectPackageNow`를 기다린다(`saveCoordinator.ts:25`, `ipcAutoSaveHandlers.ts:56`). export queue는 package writer 성공 후 `exportedRevision`을 갱신한다(`projectExportQueue.ts:175`). 해당 portable checkpoint 성공을 표현하려는 경로다. 기존 세대·누락/실패 처리 결함 때문에 그 호출의 현재 성공값을 “키 입력 시점의 모든 최신 문자가 파일에 있다”는 증명으로 확대하면 안 된다. + +main DB는 WAL+FULL이다. 성공한 SQLite commit의 내구성을 설명할 때 DB 파일 하나가 아니라 WAL을 포함한 상태를 의미해야 한다. `.luie`는 DELETE+FULL이며, SQLite 공식 문서상 rollback DELETE 모드에서 FULL만으로 모든 파일시스템의 직후 전원손실까지 마지막 transaction의 내구성이 보장되는 것은 아니다. EXTRA는 journal 삭제 후 parent directory sync를 추가한다. 정상 commit/close 후 이동 가능한 파일과 전원차단 보장을 구분한다. [SQLite synchronous](https://www.sqlite.org/pragma.html#pragma_synchronous), [SQLite WAL](https://www.sqlite.org/wal.html). + +## 4. 시작·열기·저장·복구 우선순위 + +| 상황 | 현재 실제 선택·방향 | authoritative 데이터 및 주의점 | +|---|---|---| +| 일반 앱 재시작, 최근 project 선택 | main DB bootstrap → project 목록 DB SELECT → 최근 project 객체를 current로 설정 | main DB. 최근 목록 선택은 `.luie` 재import가 아님 (`App.tsx:220`, `projectService.ts:288`) | +| 시작 후 deferred maintenance | `latest.snap`→복구 Snapshot 검토 → pruning/orphan 정리 → revision gap project의 export schedule | live Chapter는 DB. mirror는 선택 가능한 복구본. export는 DB→`.luie` (`deferredStartupMaintenance.ts:24`, `:42`) | +| 명시적 “`.luie` 열기”, 파일 정상/DB project 없음 | meta.projectId로 identity 확정 후 import transaction | 파일의 export 범위를 DB로 hydrate (`App.tsx:333`, `projectImportOpen.ts:233`, `projectImportTransaction.ts:258`) | +| 명시적 열기, local `Project.updatedAt` > package `meta.updatedAt` | import skip, `conflict:'db-newer'` | DB 유지. 비교는 mtime/revision이 아니라 timestamp 둘뿐 (`projectImportOpen.ts:243`) | +| 명시적 열기, package timestamp가 같거나 더 큼; 비교값 invalid | import transaction에서 기존 project 삭제 후 파일을 hydrate | 파일이 DB를 교체. 반환 이름은 `luie-newer`이나 동시각도 이 분기에 포함. 최신 DB revision gap 보호 없음 | +| 명시적 열기, 파일 missing/corrupt, 해당 path에 DB project 있음 | DB를 별도 `.recovered-...luie`로 export → attachment 이동 → export ACK | DB가 복구 원천. world package fallback은 null로 비활성화 (`projectImportOpen.ts:196`, `projectService.ts:192`) | +| 명시적 열기, 파일 missing/corrupt, path와 연결된 DB project 없음 | 읽기 오류 | 독립 mirror 파일만으로 project 전체를 재생성하지 않음 | +| 수동 저장 | renderer flush → main autosave flush → project package checkpoint | 먼저 DB에 남기고 그 DB의 export 범위를 portable 파일로 반영 | +| `attachProjectPackageFile` | 파일 projectId 일치와 path 충돌 검사 → **DB를 선택 파일에 export** → attachment 변경 | attach는 import가 아님. 선택 파일이 같은 ID이지만 더 최신인지는 비교하지 않음 (`projectPackageAttachment.ts:151`, `:185`) | +| materialize/다른 위치에 파일 생성 | DB→target full checkpoint; world fallback은 기존 attachment | 성공 후 attachment target 변경 (`projectPackageAttachment.ts:213`) | +| 정상 종료 | renderer flush·긴급 mirror/snapshot·main save 선택 → mirrors→snapshots → export flush → worker 종료 → DB checkpoint/close | 각각 단계의 성공/취소 판단. package와 mirror는 같은 자료도 같은 보장도 아님 (`shutdown/shutdown.ts:273`, `:294`, 이후 export/finalize) | +| main 종료/강제 kill 후 재시작 | SQLite가 main WAL을 복구, gap export schedule. renderer-only/pending-memory 입력은 이미 기록된 mirror에 달림 | main DB commit 우선. mirror는 newer/different 조건에서 Snapshot 생성; 자동 live body 복원 아님 | +| renderer만 crash | main pending을 `flushCritical`로 mirrors/snapshots에 보존 시도 후 reload/quit 선택 | main이 받은 본문만 대상. `flushCritical`는 Chapter update를 하지 않음 (`rendererCrashRecovery.ts:22`, `autoSaveFlushOps.ts:102`) | +| 사용자가 snapshot restore | Snapshot row content→Chapter와 ChapterBody transaction→package checkpoint | 선택된 snapshot이 명시적 새 live state가 됨 (`snapshotService.ts:308`, `:342`, `:374`) | +| main DB를 잃은 후 `.luie` 명시적 열기 | 마지막 정상 package export 범위를 새 DB에 import | 마지막 checkpoint 이후 수정/비export 설정은 복구 안 됨. mirror는 missing chapter로 정리될 수 있어 DB를 지우는 절차의 보조백업이라고 할 수 없음 | +| 외부에서 같은 projectId `.luie`를 복사해 열기 | 같은 local project identity로 취급. db-newer면 기존 attachment 유지; 그 외면 DB 교체+copy path로 attachment 이동 | 파일 복사 자체는 별도 project 복제가 아님 (`projectImportOpen.ts:103`, `projectImportTransaction.ts:363`) | +| 외부에서 `.luie`를 수정·교체하고 기존 recent project를 선택 | DB를 선택하며 자동 `.luie` import 없음 | 이후 DB checkpoint가 외부 변경을 덮을 수 있음. explicit open에서만 위 timestamp 우선순위 적용 | + +`cacheDb`를 삭제하고 main DB를 유지한 경우 검색 projection은 `ensureProjectHydrated`의 count 비교와 rebuild를 통해 재구성할 수 있다. 반대로 main DB 삭제는 package로 재import 가능한 범위 외에도 `ProjectSettings`, `ProjectLocalState`, queue/revision history 등의 손실을 초래한다. Import는 `ProjectSettings`를 defaults로 만들고(`projectImportTransaction.ts:279`), package snapshots도 export limit에 의해 잘린다(`projectExportPayload.ts:133`). + +`latest.snap`의 생성 시각은 실제 해당 본문이 사용자에게 입력된 세대가 아니라 mirror write 시각이다(`autoSaveMirrorStore.ts:66`). 최신성 판별을 timestamp만으로 강화하는 방식은 기존 세대 역전 결함을 해결하지 못한다. 또한 writer는 에러를 로깅 후 삼키므로(`:71`) `flushCritical`의 `mirrored` 숫자를 실제 디스크 보존 성공 횟수로 단정할 수 없다. + +## 5. 중요 문제와 근거 + +### P1 — explicit stale `.luie` reopen이 최신 DB commit을 과거 본문으로 교체 + +근본 원인은 `Project.revision`과 open 시 최신성 판단 기준이 분리된 것이다. Chapter update는 `Project.updatedAt`을 올리지 않는다(`chapterWriteOperations.ts:241`). trigger는 `Project.revision`만 올린다(`projectRevisionTriggerSql.ts:54`, `:93`). 그런데 import guard는 `Project.updatedAt > meta.updatedAt`만 보고(`projectImportOpen.ts:243`), 실패하면 기존 project를 삭제·재생성한다(`projectImportTransaction.ts:263`). Import 후 새 local revision을 바로 exportedRevision으로 기록한다(`:363`). + +root의 실제 source/native SQLite/임시 FS 분리 실험 관측: `/private/tmp/luie-storage-review/stale-open-observation.json`. + +| 시점 | main revision | exportedRevision | live 본문 | +|---|---:|---:|---| +| checkpoint A 생성 | 5 | 5 | `checkpoint A` | +| B 실제 update commit, debounce export만 보류 | 7 | 5 | `new committed B` | +| 동일 `.luie` explicit reopen 후 | 9 | 9 | `checkpoint A` | + +local Project.updatedAt은 `2026-09-08T08:16:00.228Z`, package meta.updatedAt은 `2026-09-08T08:16:00.231Z`; 반환 conflict는 `luie-newer`였다. 정상 debounce 창을 결정적으로 유지하기 위해 export scheduling만 spy로 보류했으며, 나머지는 실제 서비스/DB/파일이다. 실행 중 사용자 앱 측정이나 OS별 재현은 아니다. 최근 project 선택과 명시적 `.luie` 열기를 구별해야 한다. + +최소 방향: 명시적 import도 동일 project의 writer/export 소유권 아래 처리하고, **pending local mutation/DB revision gap이 있는 상태에서 timestamp만으로 hydrate하지 않는다.** 동일 base 파일이면 local pending commit을 보존한다. 외부 파일이 다른 branch로 변경됐다면 양쪽을 보존하며 충돌 경로로 보낸다. 단순히 Project.updatedAt 갱신을 추가하는 것만으로 동시각·clock skew·외부 copy·부분 checkpoint 문제를 해결했다고 볼 수 없다. + +### P2 — exportedRevision은 파일의 intrinsic revision이 아닌 로컬 ACK + +`ProjectAttachment.exportedRevision`은 main DB의 단조 증가 ACK다. `markProjectExported`는 ACK가 현재 revision 이하이고 기존 ACK 이상인지만 확인한다(`projectRevisionStore.ts:75`). `.luie` meta에는 projectId·format version·timestamps·chapters만 있고 generation/revision/hash가 없다(`projectExportPayload.ts:394`). 따라서 다른 머신/복사본과 exportedRevision 숫자를 직접 비교할 수 없다. Import도 기존 project를 재생성하며 새 local revision에 ACK를 맞춘다. + +현재 queue는 `capturedRevision`을 runExport에 넘기지만 실제 ProjectService callback은 이를 버린다(`projectService.ts:103`). Export는 chapter/entity/snapshot과 canonical memory/world를 서로 다른 async read 구간에서 수집한다(`projectRecord.ts:23`, `:41`, `projectExportEngine.ts:91`). Target 파일 transaction은 원자적이지만 **원본 main DB의 동일 snapshot을 읽었다는 뜻은 아니다.** Generation ACK를 넣을 때 payload가 실제 나타내는 snapshot과 연결해야 한다. + +### 설계 선행 조건 — 기존 direct entry writer는 batch checkpoint로 그대로 쓰기 부족 + +`writeLuieSqliteEntry`는 entry UPSERT, meta UPSERT, container info UPDATE를 각각 수행하고 enclosing transaction이 없다(`luieSqliteContainer.ts:347`, `:349`, `:357`). 지금 재사용하면 중간 실패가 일부만 반영될 수 있다. 또한 meta.updatedAt를 현재 시각으로 바꿔 부분 수정이 파일 전체 최신성을 대표하는 것처럼 보이게 한다(`:336`). + +직접 writer는 **현재 정상 renderer world 저장에서도 호출된다.** 예를 들어 `memoStore.ts:63` → `worldPackageStorage.saveScrapMemos`가 먼저 DB replica 저장을 기다린 뒤(`worldPackageStorage.ts:452`), `.luie` attachment가 있으면 `ensureLuieWorldDocumentSaved`를 호출한다(`:456`). 이후 `worldPackageStorageHelpers/luieStorage.ts:110` → `writeLuieJson`의 `api.fs.writeProjectFile`(`:79`) → preload `projectApi.ts:306`의 `FS_WRITE_PROJECT_FILE` → `ipcFsHandlers.registry.ts:301`/`:309` → `fsPackageOperations.ts:208` → `writeLuieSqliteEntry`로 연결된다. Synopsis/plot/drawing/mindmap 저장도 같은 helper를 사용한다(`worldPackageStorage.ts:159`, `:223`, `:286`, `:351`). 따라서 batch transaction 부재와 hot-journal 복구 경계는 신규 chapter patch에만 해당하는 가상 조건이 아니라, 기존 FS IPC writer를 함께 정리해야 하는 현재 실행 경로다. 중간 실패나 데이터 손상은 이 재검토에서 실제 재현하지 않았다. + +별도 consumer인 `verifyMemoryCanonicalPackageSync`의 `repairSourceIdMismatches:true` 분기도 같은 직접 writer로 이어진다(`memoryCanonicalPackageSyncVerifier.ts:231`). **이 memory repair 옵션은 production status report와 CLI caller가 전달하지 않으며, true를 넘기는 호출은 unit test에서 확인했다.** 이 제한을 정상 world FS writer까지 확대하지 않는다. World 저장의 직접 entry 갱신도 ProjectExportQueue가 전체 revision을 ACK하는 full checkpoint와 같지 않으므로, 모든 writer가 payload capture·base 검증·commit 순서를 공유하도록 포함해야 한다. + +### P2 조건부 — DB WAL 복구용 백업은 writer quiesce 전에 순차 복사 + +`dbRecoveryService.ts:138`이 backup을 만든 뒤 `:149`에서 main db.disconnect한다. Backup은 `.db`→`-wal`→`-shm`을 순차 `copyFile`한다(`:228`). 그 사이 writer가 활동하면 동일 시점 백업이라는 보장이 없다. utility의 독립 연결도 이 서비스의 main singleton disconnect로 함께 닫히지 않는다. 이 보고서에서는 corruption을 실제 관측한 것으로 주장하지 않는다. 복구/백업 개선 시 SQLite Backup API를 사용하거나 모든 writer와 handle을 멈춘 상태를 먼저 확보해야 한다. [SQLite live backup](https://www.sqlite.org/backup.html). + +## 6. `.luie` 직접 transaction update의 실제 위험 + +### 파일은 이미 SQLite이지만 main DB를 곧바로 그 파일로 바꿀 수는 없음 + +현재 `.luie` 스키마는 `LuieContainerInfo`와 `LuieContainerEntry` 둘이다(`luieSqliteContainer.ts:33`). Main의 정규화된 Chapter/ChapterBody/world/memory schema와 다르다. `.luie`를 유일한 live DB로 바꾸려면 main services, migration, 다중 project 조회, detached project, utility DB 경로, import/export 계약이 모두 변한다. 현재 성능 병목을 줄이기 위한 최소 수정은 아니다. + +### 기존 연결과 writer 소유권 + +- Main DB/cache DB는 각 process singleton의 연결이 오래 유지된다. `.luie` read는 entry마다 readonly 연결을 열고 즉시 닫는다(`:183`, `:212`). `.luie`의 장기 pooled handle을 현재 전제로 삼으면 틀린 분석이다. +- Full writer는 temp DB를 commit하고 **닫은 다음** target을 rename한다(`:273`, `:278`). 직접 writer도 동기 SQL 완료 후 close한다. 같은 main JS event loop 안에서 이 동기 구간들이 await 없이 서로 겹치는 것은 아니지만, import의 여러 entry read 사이에는 다른 작업이 끼어들 수 있다. +- 기존 `withPackageWriteLock`은 process-local Map과 `path.resolve` key뿐이다(`luiePackageWriter.ts:33`). 프로젝트 export queue와 full/direct package writer, attachment/recovery/exportWithOptions 경로를 하나의 소유권 하에 합쳐야 한다. 다른 process에 writer를 옮기면 Map도 옮기고 main fallback writer가 동시에 남지 않게 해야 한다. +- 문자열 path.resolve는 Windows/macOS의 대소문자 alias 및 link의 파일 identity까지 같게 만들지 않는다. 기존 승인 경계 내의 실제 파일 identity를 사용하고 같은 파일의 다른 alias가 두 writer를 만들지 않게 해야 한다. + +### Native rollback journal과 readonly recovery + +직접 update는 SQLite의 rollback journal로 중간 실패를 복구한다. 별도 application incremental journal을 만들지 않는 설계와 **SQLite journal을 끄는 것**은 다르다. DELETE/FULL을 유지하며, 강한 전원손실 내구성이 필요하면 EXTRA의 비용을 측정한다. journal_mode=OFF로 성능을 얻는 것은 이 제안이 아니다. + +Crash로 target `.luie-journal`이 hot 상태가 된 뒤 현재 readonly reader가 먼저 접근하면 SQLite는 `SQLITE_READONLY_ROLLBACK`을 낼 수 있다. SQLite는 hot journal rollback 전에 읽기를 완료할 수 없고 recovery는 쓰기 권한이 필요하다. 현재 `readMetaOrMarkCorrupt`는 이 오류도 corrupt로 묶는다(`projectImportOpen.ts:95`). 그러면 복구 가능한 파일을 곧바로 corrupt fallback으로 보낼 수 있다. [SQLite hot-journal recovery](https://www.sqlite.org/lockingv3.html#dealing_with_hot_journals), [SQLITE_READONLY_ROLLBACK](https://www.sqlite.org/rescode.html#readonly_rollback). + +기존 world 직접 writer를 정리하고 chapter patch까지 확장할 때 최소 추가는 approved path에서 동일 writer가 writable open하여 SQLite recovery를 수행하고 닫은 다음, readonly snapshot import로 넘어가는 경로다. 원본이 읽기 전용이면 journal과 DB가 같은 crash 시점의 한 쌍이라는 조건을 보존해 쓰기 가능한 recovery copy로 처리하거나 명시적으로 복구 불가를 표시한다. Hot journal을 지우거나 unrecovered target 위에 새 full file을 교체해서 이전 journal이 새 DB에 적용되게 하면 안 된다. 원본 pair를 보존한 별도 recovery path가 더 단순한 fallback이다. + +### 전체 파일 교체와 in-place write를 혼용할 때 + +SQLite가 연결한 target을 외부에서 rename/overwrite할 때의 위험은 단순 SQLITE_BUSY로 끝나지 않는다. POSIX 계열에서는 오래 열린 handle이 이전 inode를 계속 가리키고 같은 경로에 새 DB가 생길 수 있다. 서로 다른 DB가 이름 기반 journal을 공유하는 상황은 SQLite가 경고하는 손상 조건이다. Windows에서는 열린 DB 교체가 실패할 수 있다. 기존 atomicReplace의 backup→rename 복구는 OS 잠금 문제를 없애지 않으며, fallback의 두 rename 사이에는 target 경로가 일시적으로 없다. [SQLite file rename/corruption](https://www.sqlite.org/howtocorrupt.html#_unlinking_or_renaming_a_database_file_while_in_use). + +직접 writer는 짧은 transaction/open/close를 유지한다. 앱 내부 read/export/attach/materialize/복구가 그 파일에 작업 중인지 통제하고, 파일 identity 검증은 실제 commit 직전 동일 writer 범위에서 다시 한다. “사전에 stat 한 번 확인”만으로 check 이후 외부 replace를 막지는 못한다. 협조하지 않는 외부 editor/cloud sync의 동시 교체까지 애플리케이션 Map으로 안전하게 만들 수 있다고 약속하지 않는다. + +### 읽기 snapshot은 별도 문제 + +기존 import는 meta, collections, chapters를 독립 readonly open으로 읽는다(`projectImportOpen.ts:190`, `:264`, `:270`). 하나의 package write가 atomic이어도 여러 entry read가 그 transaction 앞뒤로 걸치면 서로 다른 세대를 혼합할 수 있다. 최소 reader 변경은 한번 open한 연결의 단일 read transaction에서 필요한 모든 entry를 가져온 후 close하고 그 payload를 검증·hydrate하는 것이다. 원본 main DB의 export read도 동일 snapshot에서 revision과 해당 payload를 같이 취해야 한다. async fs/network 작업을 그 DB transaction 안에 넣지 않는다. + +### Main DB와 package transaction은 하나의 분산 commit이 아님 + +Main WAL transaction과 `.luie` DELETE transaction은 별개다. SQLite `ATTACH`만으로 현재 WAL main과 여러 파일의 crash-atomic commit이 보장되는 것은 아니다. 공식 조건은 main이 memory가 아니고 journal_mode가 WAL이 아니어야 한다. 그래서 기존 main commit→package commit→main ACK 순서를 유지하고 실패 시 idempotent retry한다. [SQLite ATTACH atomicity](https://www.sqlite.org/lang_attach.html). + +## 7. 별도 durable changelog 없이 가능한 최소안 + +### 기존 구조 재사용 + +1. Main DB transaction/세대 소유권을 먼저 확립하고 autosave completion을 실제 generation에 연결한다. Explicit import는 로컬 미반영 수정과 경합하지 않게 한다. +2. 기존 ProjectExportQueue에 chapter ID dirty set과 `forceFull` 상태를 둔다. 세대별 완료 후 현재 queue의 더 새 dirty set을 지우지 않는다. 범위를 모르는 이벤트는 `forceFull`을 true로 유지한다. 새 범용 event log/DB를 만들지 않는다. +3. `writeLuieSqliteEntry`를 entries UPSERT/DELETE + meta + info 단일 transaction으로 확장한다. 초기 chapter 본문 patch는 기존 entry만 수정하도록 좁힌다. meta에는 그 snapshot을 대표하는 checkpoint token을 함께 쓰고, 임의 현재시각을 전체 project 저장 완료 증거로 사용하지 않는다. +4. Main의 같은 read snapshot에서 captured revision과 patch content를 읽는다. Scope가 baseline 이후의 **전체** 변경을 포괄함을 확인한 경우만 그 captured revision 전체를 ACK한다. 다른 canonical writer의 변경을 포괄하지 못하면 full checkpoint로 전환한다. +5. Target project identity와 마지막 인정한 file checkpoint/base를 검증하고 transaction을 commit·close한 뒤 main exportedRevision을 갱신한다. ACK 직후 새 변경이 있으면 기존 queue loop로 다음 generation을 수행한다. +6. 재시작하면 메모리 scope를 신뢰하지 않는다. `revision > exportedRevision`이면 full checkpoint를 예약한다. File commit 성공→main ACK 전에 crash해도 DB에 gap이 남아 같은 상태를 안전하게 다시 쓸 수 있다. Scope 영속 log가 필요한 이유는 아니다. + +첫 patch 대상은 “같은 chapter ID의 본문만 바뀌었고 baseline 이후 모든 변화가 이 dirty set으로 설명되는 구간”이다. 현재 chapter:update는 title/synopsis도 허용하므로 reason 문자열만으로 본문 전용을 판별하지 않는다. create/delete/reorder, title/metadata/world/memory/snapshot 변경, import/attach, 파일 누락/legacy/불확실 generation은 보수적으로 full 경로다. Snapshot 생성과 canonical memory derived write가 자주 동반되면 초기 patch hit rate가 낮을 수 있다. 이 경우 성능 효과를 실제로 측정하고 범위를 조금씩 확장한다. + +File metadata에 generation을 넣을 경우 **project identity와 lineage 안의 비교**로 한정한다. 현재 local revision은 다른 머신이나 reimport 후 숫자와 직접 비교할 수 없다. 임의 외부 SQLite editor가 generation을 유지하고 내용을 바꾸는 것까지 감지해야 한다면 token만으로 부족하고 persisted base content digest 등 실제 내용 검증이 필요하다. 파일 전체 digest는 O(file size)이므로 전체 export보다 저렴하더라도 비용이 0인 검사가 아니다. mtime/size만으로 안전한 외부 수정 탐지를 약속하지 않는다. + +### 최소안의 반례와 대응 + +| 반례 | 단순 chapter patch가 실패하는 이유 | 최소 대응 | +|---|---|---| +| chapter가 dirty인 동안 utility canonical memory가 revision을 증가 | chapter만 쓰고 최신 project revision을 ACK하면 memory 누락이 clean으로 남음 | baseline→captured 사이 미관측 revision/unknown writer가 있으면 full. 모든 exportable mutation이 scope를 남기거나 unknown으로 관측되는 ownership 경계 필요 | +| A write 완료 시 B가 queue에 들어옴 | 전체 dirty set clear 시 B가 사라짐 | captured 세대의 scope만 완료 처리. 최신 dirty를 보존 | +| package commit 뒤 ACK 전에 crash | file은 새 상태, main은 old ACK | 재시작 gap으로 full reconcile. 중복 commit은 안전해야 함 | +| request timeout 후 worker가 계속 씀 | fallback writer가 새 파일을 만든 뒤 old worker가 덮을 수 있음 | 늦은 작업 종료/상태 확인 전 동일 파일 fallback 시작 금지. timeout 자체를 취소로 취급하지 않음 | +| 동일 path가 외부 파일로 교체 | basename/mtime 또는 local revision만 보고 엉뚱한 파일을 patch | identity/base 불일치는 원본 보존+충돌/다른 recovery path. 자동 full overwrite는 해결책 아님 | +| patch 성공 후 오래된 full payload가 순서대로 실행 | SQL writer lock만 있으면 stale payload도 유효하게 마지막 write가 됨 | payload capture부터 generation 검증과 commit까지 같은 ownership 또는 base CAS 검증 | +| 구조 변경을 body patch로 처리 | meta.chapters와 실제 entry가 불일치, 삭제된 entry가 잔존 | 초기에는 full. 추후 patch 확장 시 tombstone/entry DELETE와 meta 갱신을 같은 transaction에 포함 | +| `-journal` hot 상태에서 readonly import | 복구 가능한 DB가 corrupt로 오인 | writable recovery 경계 선행, journal 보존 | +| main DB 자체가 손실 | revision gap도 dirty set도 함께 사라짐 | 마지막 portable checkpoint만 복구 가능. application changelog를 추가해도 동일 저장소를 잃으면 자동 해결되지 않음 | + +**중요:** `Project.revision` 증가량은 scope 개수와 같지 않다. Chapter+ChapterBody, 여러 trigger가 한 mutation에서 다수 증가시킬 수 있으며 canonical memory 테이블도 같은 revision을 올린다(`projectRevisionTriggerSql.ts:15`). 따라서 “revision 차이가 2이니 chapter 하나” 같은 휴리스틱으로 completeness를 증명하지 않는다. Complete scope tracking이 어렵다면 첫 구현은 full snapshot을 유지하는 편이 안전하고 더 작다. + +## 8. 대안 비교 + +| 대안 | 변경량·장점 | 남는 비용/제약 | 선택 | +|---|---|---|---| +| 기존 full checkpoint의 주기·coalescing/worker 실행만 개선 | format/권위 계약 변화가 적고 구현이 가장 작음 | 매 checkpoint 전체 read/JSON/SQLite entry build, 디스크 전체 write 비용 지속 | 세대/ownership 먼저 고칠 때 안전한 1단계 | +| 기존 `.luie`에 검증된 scope를 단일 transaction patch | native SQLite가 변경 페이지와 crash rollback 처리, 기존 entry schema 재사용 | hot journal recovery, base검증, scope completeness, 내부 reader 일관성 필요 | 조건을 갖춘 후 추천 | +| 기존 파일의 일관된 복사본→temp에서 entry patch→원자 교체 | target에 in-place hot journal을 만들지 않고 기존 교체형 publishing 유지 | 복사 O(file size), Windows replacement/외부 handle 문제 유지. 실행 중 DB의 무잠금 raw copy는 안 됨 | 외부 sync/기존복구 계약 때문에 in-place가 부담스러울 때 중간안 | +| `.luie` 하나를 곧바로 유일한 live normalized DB로 변경 | 장기적으로 중복 project state/export 비용 제거 가능 | 서비스와 migration, 다중 project, detached state, utility 연결, portability/backup 계약 대규모 변경 | 현재 최적화의 최소안으로 비추천 | +| 별도 영속 application incremental journal | 정교한 replay/history를 구현할 수 있음 | DB WAL/Project revision/기존 queues와 별도 운영체계 추가; 지금 필요한 crash gap recovery는 기존 revision으로 가능 | 실제 replay 요구가 생기기 전 도입하지 않음 | + +Temp patch 대안의 복사 원본은 닫힌 안정된 파일이거나 SQLite Backup API가 제공하는 일관된 snapshot이어야 한다. SQLite가 지원하는 backup API는 live DB에서도 일관된 복사본을 만든다. 단순 파일 copy는 active transaction의 일부만 가져올 수 있다. [SQLite Backup API](https://www.sqlite.org/backup.html). + +## 9. OS·파일시스템 경계 + +| 환경 | 현재/제안의 관련 차이 | 필요한 검증 | +|---|---|---| +| macOS 로컬 APFS | POSIX open inode와 path rename 분리, 대소문자 비구분 설정의 path alias. Main/cache WAL, `.luie` DELETE는 공통 | in-place commit 직후 crash/hot journal, copy/replace 중 handle 소유권, alias 동일파일 직렬화 | +| Windows 로컬 NTFS | 열린 DB/동기화도구/보안프로그램의 handle이 rename/삭제를 막을 수 있음. atomicReplace fallback도 성공 보장 없음 | Busy/EPERM/rename 실패 때 old file 보존, bounded retry, writer 종료 후 재시도, recovery path | +| Linux 로컬 filesystem | POSIX rename/open inode 분리; 파일시스템별 directory durability와 lock 동작 | commit/rename의 전원손실 단계, inode replacement 감지, tmp/backup recovery | +| SMB/NFS 등 network filesystem | SQLite WAL은 서로 다른 host가 같은 파일을 공유하는 모델을 지원하지 않음. DELETE라고 network locking의 신뢰성이 자동 보장되지도 않음 | 외부 파일을 live WAL DB로 전환하지 않기. 지원 범위를 정하고 export/backup 중심의 파일 이동 경로 검증 | +| cloud sync 폴더·외부 copy | SQLite transaction lock과 cloud agent의 경로 교체/파일 복사가 협조한다는 보장 없음 | 열린 live 파일의 무조건 안전한 동시 복사 보장 금지. manual save 후 정상 commit/close된 portable 파일 생성, 외부 divergence 시 원본 보존 | + +OS 경계는 공식 SQLite 파일 잠금·WAL 제약과 소스 구조를 바탕으로 한 설계 위험이며 세 OS에서 실앱 crash/power-loss 테스트를 실행한 결과가 아니다. [SQLite WAL filesystem 제한](https://www.sqlite.org/wal.html), [SQLite file locking pitfalls](https://www.sqlite.org/howtocorrupt.html). + +## 10. 구현을 진행할 때 필요한 검증 + +이 재검토에서는 새 테스트를 실행하지 않았다. 필요한 검증은 소스 복제 microbenchmark보다 실제 임시 native SQLite/FS와 실제 save/open 서비스의 종단 결과를 중심으로 잡는다. + +- root stale reopen 재현을 회귀 테스트로 고정: export gap에서 explicit open 시 B 보존, recent project 선택과 독립 검증. +- DB commit→package commit→ACK 세 경계의 crash injection: restart 후 DB 최신 body, file generation, exportedRevision 관계 확인. +- Batch entry+meta+info 중간 오류 시 전부 rollback; 기존 hot journal을 현재 readonly reader가 만나도 corrupt fallback 전에 정상 복구. +- Body patch 중 world/memory/snapshot/구조 변경을 끼워 completeness가 불명확하면 full로 내려가고 누락 상태를 ACK하지 않는지 확인. +- 같은 project의 copy/import/attach/materialize와 실행 중 export 경합; 외부 generation/digest 변경 시 원본 보존. +- 내부 import는 한 read snapshot의 모든 entry를 가져오며 full/patch publish 전후 내용 혼합이 없는지 검증. +- macOS/Windows/Linux 실제 packaged runtime에서 단일 native writer, locked file 실패/재시도, rename fallback, crash journal 복구 검증. +- 소규모와 큰 project에서 package read bytes, generated JSON bytes, SQL write bytes, wall time, main event-loop delay 측정. Chapter patch의 hit rate와 `forceFull` 사유도 함께 보아 평균 개선을 판단. + +기존 `luieDbLossRecovery.test.ts`는 먼저 `flushPendingExports()`를 완료한 뒤 DB를 지우고 package를 reimport하는 시나리오다. 이 테스트의 존재가 “아무 때나 main DB를 삭제해도 손실 없음”을 증명하지 않는다. 기존 `projectExportQueue.test.ts`, `manualSaveHandler.test.ts` 등은 queue/호출 계약의 근거로 읽었으나 이번 작업에서 재실행하거나 통과했다고 주장하지 않는다. diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/README.md b/docs/quality/storage-architecture-review-2026-09-08/evidence/README.md new file mode 100644 index 00000000..234ca020 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/README.md @@ -0,0 +1,31 @@ +# 저장 아키텍처 감사 증빙 + +2026-09-08의 실제 source/native SQLite/임시 파일 관측을 보관한다. 제품 수정 결과가 아니다. 사용자 앱·데이터를 사용하지 않았으며, 모든 실행은 설치된 의존성으로 임시 fixture를 만든다. + +## 실행 + +저장소 루트 `/Users/user/Luie`에서 실행한다. package 스크립트는 이 checkout 경로를 사용한다. 다른 checkout에서 실행하려면 각 스크립트의 `repo` 경로를 맞춘다. Node용 native module이 이미 호환되는 환경에서 실행하며 이 문서의 명령은 설치/rebuild하지 않는다. + +```sh +NODE_PATH="$PWD/node_modules" node docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-repro.cjs +NODE_PATH="$PWD/node_modules" node docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-repro.cjs +NODE_PATH="$PWD/node_modules" node docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-repro.cjs +SKIP_DB_TEST_SETUP=0 node node_modules/vitest/vitest.mjs run --config docs/quality/storage-architecture-review-2026-09-08/evidence/probe.config.mjs +``` + +결과는 스크립트에 명시된 `/private/tmp/luie-...json`에 기록된다. 여기에 보관된 JSON은 감사 시점의 결과이며 재실행으로 덮어쓰지 않는다. DB service probe는 기존 `tests/setup.ts`를 사용해 `drizzle/.tmp/vitest-*` DB·cache·userData를 격리하고 종료 후 정리한다. 앱을 기동하지 않는다. + +## 파일 의미 + +| 파일 | 확인 범위 | +|---|---| +| package-cost-repro.cjs / package-cost-results.json | 실제 writer + 이미 생성한 합성 payload, 전체/단건의 3회 median·논리 entry bytes. main DB collector·커널 실제 I/O 제외 | +| package-atomicity-repro.cjs / package-atomicity-results.json | 후속 SQL 실패 trigger로 단건 writer의 부분 commit 관측 | +| package-size-limit-repro.cjs / package-size-limit-results.json | 5MiB+1 entry의 저장 성공 / 같은 reader 거부 | +| stale-open-current-behavior.probe.ts / stale-open-observation.json | 실제 DB·file·서비스, export 예약만 보류하여 미반영 B가 explicit open 때 A로 되돌아가는 관측 | +| db-loss-current-api.probe.ts / db-loss-results.json | 기존 DB-loss 테스트를 현재 단건 본문 조회 API에 맞춘 독립 검사. checkpoint 후 DB 삭제·재import·원고/snapshot 본문 복원 | +| existing-real-tests.json | 기존 DB/FS 검사 3개 결과. DB-loss의 구 목록 DTO assertion 실패 포함 | + +**주의:** stale-open, 원자성, 크기 제한 probe는 결함이 나타남을 assert한다. 실행 성공을 저장 안전성 통과로 해석하면 안 된다. 제품 수정 후 회귀검사에서는 정상 보존·전부 rollback·read/write 허용범위 일치를 기대하도록 바꾸어야 한다. + +일반 테스트 runner에 결함 관측 코드를 자동 편입하지 않도록 `.test.ts`가 아닌 `.probe.ts`와 별도 config를 사용한다. 원본 임시 probe를 보관하면서 src import만 현재 문서 위치 기준 상대 경로로 바꿨다. 성능 결과는 macOS arm64 Node 환경의 관측이며 Windows/Linux 또는 Electron UI 속도를 인증하지 않는다. diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-current-api.probe.ts b/docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-current-api.probe.ts new file mode 100644 index 00000000..1e3c7131 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-current-api.probe.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { app } from "electron"; +import { describe, expect, it } from "vitest"; +import { db } from "../../../../src/main/database/index.js"; +import { projectService } from "../../../../src/main/services/features/project/projectService.js"; +import { chapterService } from "../../../../src/main/services/features/manuscript/chapterService.js"; +import { snapshotService } from "../../../../src/main/services/features/snapshot/snapshotService.js"; +import { readLuieContainerEntry } from "../../../../src/main/services/io/luieContainer.js"; + +describe("`.luie` recovery after local DB loss", () => { + it("reimports project content and snapshots from `.luie` after the sqlite replica is deleted", async () => { + const projectPath = path.join( + app.getPath("userData"), + "db-loss-recovery.luie", + ); + await fs.rm(projectPath, { recursive: true, force: true }); + + const project = await projectService.createProject({ + title: "DB Loss Recovery", + description: "phase-6", + projectPath, + }); + + const chapter = await chapterService.createChapter({ + projectId: project.id, + title: "Recovered Chapter", + }); + + const chapterContent = "폭우가 내리던 밤, 기록은 .luie에 남았다."; + await chapterService.updateChapter({ + id: chapter.id, + content: chapterContent, + }); + + await snapshotService.createSnapshot({ + projectId: project.id, + chapterId: chapter.id, + content: chapterContent, + description: "pre-loss snapshot", + }); + + await projectService.flushPendingExports(); + + const snapshotIndexRaw = await readLuieContainerEntry( + projectPath, + "snapshots/index.json", + ); + const snapshotIndex = JSON.parse(snapshotIndexRaw ?? "{}") as { + snapshots?: Array<{ content?: string }>; + }; + expect(snapshotIndex.snapshots?.length).toBeGreaterThan(0); + expect(snapshotIndex.snapshots?.[0]?.content).toBe(chapterContent); + + const dbPath = db.getDatabasePath(); + await db.disconnect(); + await fs.rm(dbPath, { force: true }); + await fs.rm(`${dbPath}-wal`, { force: true }); + await fs.rm(`${dbPath}-shm`, { force: true }); + await db.initialize(); + + const reopened = await projectService.openLuieProject(projectPath); + const restoredProjectId = reopened.project.id; + + const restoredChapters = + await chapterService.getAllChapters(restoredProjectId); + expect(restoredChapters).toHaveLength(1); + expect(restoredChapters[0]?.content).toBeUndefined(); + const restoredChapter = await chapterService.getChapter(restoredChapters[0]!.id); + expect(restoredChapter.content).toBe(chapterContent); + + const restoredSnapshots = + await snapshotService.getSnapshotsByProject(restoredProjectId); + expect(restoredSnapshots).toHaveLength(1); + expect(restoredSnapshots[0]?.content).toBe(chapterContent); + + await projectService.deleteProject(restoredProjectId); + await fs.rm(projectPath, { recursive: true, force: true }); + }); +}); diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-results.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-results.json new file mode 100644 index 00000000..68a86ac7 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-results.json @@ -0,0 +1 @@ +{"numTotalTestSuites":2,"numPassedTestSuites":2,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1788855166625,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["`.luie` recovery after local DB loss"],"fullName":"`.luie` recovery after local DB loss reimports project content and snapshots from `.luie` after the sqlite replica is deleted","status":"passed","title":"reimports project content and snapshots from `.luie` after the sqlite replica is deleted","duration":118.48654200000004,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1788855167170,"endTime":1788855167288.4866,"status":"passed","message":"","name":"/private/tmp/luie-storage-review/db-loss-current-api.test.ts"}]} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/existing-real-tests.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/existing-real-tests.json new file mode 100644 index 00000000..62e4a102 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/existing-real-tests.json @@ -0,0 +1 @@ +{"numTotalTestSuites":6,"numPassedTestSuites":4,"numFailedTestSuites":2,"numPendingTestSuites":0,"numTotalTests":3,"numPassedTests":2,"numFailedTests":1,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1788855106183,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":["`.luie` recovery after local DB loss"],"fullName":"`.luie` recovery after local DB loss reimports project content and snapshots from `.luie` after the sqlite replica is deleted","status":"failed","title":"reimports project content and snapshots from `.luie` after the sqlite replica is deleted","duration":107.24850000000004,"failureMessages":["AssertionError: expected undefined to be '폭우가 내리던 밤, 기록은 .luie에 남았다.' // Object.is equality\n at /Users/user/Luie/tests/main/services/luieDbLossRecovery.test.ts:68:42\n at file:///Users/user/Luie/node_modules/.pnpm/vitest@5.0.0_@types+node@26.4.1_jsdom@30.0.1_@noble+hashes@2.4.0__vite@8.2.2_@types+nod_2a20d0ac2d241e72ca6887bda1b0c54d/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:2783:20"],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1788855106809,"endTime":1788855106916.2485,"status":"failed","message":"","name":"/Users/user/Luie/tests/main/services/luieDbLossRecovery.test.ts"},{"assertionResults":[{"ancestorTitles":["project save recovery"],"fullName":"project save recovery exports the latest database revision after a stale checkpoint","status":"passed","title":"exports the latest database revision after a stale checkpoint","duration":86.364958,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1788855106785,"endTime":1788855106871.365,"status":"passed","message":"","name":"/Users/user/Luie/tests/main/services/projectSaveRecovery.integration.test.ts"},{"assertionResults":[{"ancestorTitles":["SnapshotService list projection"],"fullName":"SnapshotService list projection returns summary rows without content from getSnapshotsByChapter, and full content only from getSnapshotById","status":"passed","title":"returns summary rows without content from getSnapshotsByChapter, and full content only from getSnapshotById","duration":60.53358300000002,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1788855106810,"endTime":1788855106870.5337,"status":"passed","message":"","name":"/Users/user/Luie/tests/main/services/snapshotListProjection.test.ts"}]} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-repro.cjs b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-repro.cjs new file mode 100644 index 00000000..c49cd0f4 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-repro.cjs @@ -0,0 +1,28 @@ +const {createRequire}=require('node:module'); +const fs=require('node:fs'); +const assert=require('node:assert/strict'); +const repo='/Users/user/Luie'; +const req=createRequire(`${repo}/package.json`); +const esbuild=createRequire(req.resolve('vite'))('esbuild'); +const Database=req('better-sqlite3'); +const tmp=fs.mkdtempSync('/private/tmp/luie-package-atomicity-'); +const logger={info(){},warn(){},debug(){},error(){}}; +const payload={meta:{projectId:'atomicity-audit',title:'Synthetic',createdAt:'2020-01-01T00:00:00.000Z',updatedAt:'2020-01-01T00:00:00.000Z',chapters:[{id:'c0',title:'Chapter',order:0,file:'manuscript/c0.md'}]},chapters:[{id:'c0',content:'before'}],characters:[],terms:[],snapshots:[]}; +function inspect(path){const d=new Database(path,{readonly:true});try{return {body:d.prepare('SELECT content FROM LuieContainerEntry WHERE path=?').get('manuscript/c0.md').content,meta:JSON.parse(d.prepare('SELECT content FROM LuieContainerEntry WHERE path=?').get('meta.json').content),infoUpdatedAt:d.prepare('SELECT updatedAt FROM LuieContainerInfo WHERE id=1').get().updatedAt};}finally{d.close();}} +(async()=>{try{ + await esbuild.build({entryPoints:[`${repo}/src/main/services/io/luieContainer.ts`],outfile:`${tmp}/writer.cjs`,bundle:true,platform:'node',format:'cjs',packages:'external',logLevel:'silent'}); + const api=require(`${tmp}/writer.cjs`);const cases=[]; + for(const stage of ['metadata','container-info']){ + const targetPath=`${tmp}/${stage}.luie`;await api.writeLuieContainer({targetPath,payload,logger}); + const before=inspect(targetPath);const d=new Database(targetPath); + if(stage==='metadata')d.exec(`CREATE TRIGGER audit_fail_meta BEFORE UPDATE ON LuieContainerEntry WHEN NEW.path='meta.json' BEGIN SELECT RAISE(ABORT,'injected metadata failure'); END;`); + else d.exec(`CREATE TRIGGER audit_fail_info BEFORE UPDATE ON LuieContainerInfo BEGIN SELECT RAISE(ABORT,'injected container info failure'); END;`); + d.close();let error=null;try{await api.writeLuieContainerEntry({targetPath,entryPath:'manuscript/c0.md',content:'after',logger});}catch(e){error=e.message;} + const after=inspect(targetPath);assert(error);assert.equal(after.body,'after'); + if(stage==='metadata')assert.equal(after.meta.updatedAt,before.meta.updatedAt);else assert.notEqual(after.meta.updatedAt,before.meta.updatedAt); + assert.equal(after.infoUpdatedAt,before.infoUpdatedAt); + cases.push({stage,error,bodyCommittedDespiteRejectedPromise:after.body==='after',metaChanged:after.meta.updatedAt!==before.meta.updatedAt,infoChanged:after.infoUpdatedAt!==before.infoUpdatedAt,before,after}); + } + const out={runtime:{node:process.version,platform:process.platform,arch:process.arch},scope:'actual current writeLuieContainerEntry source; synthetic temp package; SQLite triggers simulate failure of later statements, not actual power-loss or disk-full',cases}; + fs.writeFileSync('/private/tmp/luie-package-atomicity-results.json',JSON.stringify(out,null,2));console.log(JSON.stringify(out,null,2)); +}finally{fs.rmSync(tmp,{recursive:true,force:true});}})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-results.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-results.json new file mode 100644 index 00000000..264f8d2a --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-results.json @@ -0,0 +1,108 @@ +{ + "runtime": { + "node": "v22.23.0", + "platform": "darwin", + "arch": "arm64" + }, + "scope": "actual current writeLuieContainerEntry source; synthetic temp package; SQLite triggers simulate failure of later statements, not actual power-loss or disk-full", + "cases": [ + { + "stage": "metadata", + "error": "injected metadata failure", + "bodyCommittedDespiteRejectedPromise": true, + "metaChanged": false, + "infoChanged": false, + "before": { + "body": "before", + "meta": { + "projectId": "atomicity-audit", + "title": "Synthetic", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + "chapters": [ + { + "id": "c0", + "title": "Chapter", + "order": 0, + "file": "manuscript/c0.md" + } + ], + "format": "luie", + "container": "sqlite", + "version": 2 + }, + "infoUpdatedAt": "2026-09-08T08:16:29.908Z" + }, + "after": { + "body": "after", + "meta": { + "projectId": "atomicity-audit", + "title": "Synthetic", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + "chapters": [ + { + "id": "c0", + "title": "Chapter", + "order": 0, + "file": "manuscript/c0.md" + } + ], + "format": "luie", + "container": "sqlite", + "version": 2 + }, + "infoUpdatedAt": "2026-09-08T08:16:29.908Z" + } + }, + { + "stage": "container-info", + "error": "injected container info failure", + "bodyCommittedDespiteRejectedPromise": true, + "metaChanged": true, + "infoChanged": false, + "before": { + "body": "before", + "meta": { + "projectId": "atomicity-audit", + "title": "Synthetic", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + "chapters": [ + { + "id": "c0", + "title": "Chapter", + "order": 0, + "file": "manuscript/c0.md" + } + ], + "format": "luie", + "container": "sqlite", + "version": 2 + }, + "infoUpdatedAt": "2026-09-08T08:16:29.911Z" + }, + "after": { + "body": "after", + "meta": { + "projectId": "atomicity-audit", + "title": "Synthetic", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2026-09-08T08:16:29.913Z", + "chapters": [ + { + "id": "c0", + "title": "Chapter", + "order": 0, + "file": "manuscript/c0.md" + } + ], + "format": "luie", + "container": "sqlite", + "version": 2 + }, + "infoUpdatedAt": "2026-09-08T08:16:29.911Z" + } + } + ] +} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-repro.cjs b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-repro.cjs new file mode 100644 index 00000000..c02e3dc7 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-repro.cjs @@ -0,0 +1,45 @@ +const { createRequire } = require('node:module'); +const repo = '/Users/user/Luie'; +const req = createRequire(`${repo}/package.json`); +const esbuild = createRequire(req.resolve('vite'))('esbuild'); +const Database = req('better-sqlite3'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const { performance } = require('node:perf_hooks'); +const assert = require('node:assert/strict'); +const tmp = fs.mkdtempSync('/private/tmp/luie-package-cost-'); +let active = null; +const rounded = x => +x.toFixed(3); +const newCounters = () => ({sqliteOpens:0,sqliteCloses:0,sqlitePrepareCount:0,sqliteRunCount:0,entryUpserts:0,entryContentUtf8Bytes:0,explicitTransactions:0,sqliteRunMs:0,sqliteTransactionMs:0,sqliteSetupMs:0,jsonStringifyCalls:0,jsonStringifyMs:0,jsonStringifyUtf8Bytes:0,jsonParseCalls:0,jsonParseMs:0,fs:{},fsBytesRead:0,fsMs:0,heapBefore:process.memoryUsage().heapUsed,heapAfterSerializationSample:0}); +const nativeStringify = JSON.stringify; +const nativeParse = JSON.parse; +JSON.stringify = function(...args) {const t=performance.now();const value=nativeStringify.apply(this,args);if(active){active.jsonStringifyCalls++;active.jsonStringifyMs+=performance.now()-t;active.jsonStringifyUtf8Bytes+=typeof value==='string'?Buffer.byteLength(value):0;}return value;}; +JSON.parse = function(...args) {const t=performance.now();const value=nativeParse.apply(this,args);if(active){active.jsonParseCalls++;active.jsonParseMs+=performance.now()-t;}return value;}; +global.__auditFsp = new Proxy(fsp,{get(target,key){const original=target[key];if(typeof original!=='function')return original;return async(...args)=>{const c=active,t=performance.now();if(c)c.fs[key]=(c.fs[key]||0)+1;const value=await original.apply(target,args);if(c)c.fsMs+=performance.now()-t;if(key==='open'&&c){const handle=value;const originalRead=handle.read.bind(handle);handle.read=async(...readArgs)=>{const out=await originalRead(...readArgs);c.fsBytesRead+=out.bytesRead;return out;};}return value;};}}); +global.__auditDatabase=function(...args){const c=active,t=performance.now();const d=new Database(...args);if(c){c.sqliteOpens++;c.sqliteSetupMs+=performance.now()-t;} + const originalPrepare=d.prepare.bind(d);d.prepare=sql=>{if(active)active.sqlitePrepareCount++;const stmt=originalPrepare(sql);const originalRun=stmt.run.bind(stmt);stmt.run=(...params)=>{const m=active,t=performance.now();const out=originalRun(...params);if(m){m.sqliteRunCount++;m.sqliteRunMs+=performance.now()-t;if(/INSERT INTO "LuieContainerEntry"/i.test(sql)){m.entryUpserts++;m.entryContentUtf8Bytes+=Buffer.byteLength(String(params[1]??''));}}return out;};return stmt;}; + const originalTransaction=d.transaction.bind(d);d.transaction=fn=>{const native=originalTransaction(fn);return (...params)=>{const m=active;if(m){m.explicitTransactions++;m.heapAfterSerializationSample=process.memoryUsage().heapUsed;}const t=performance.now();const out=native(...params);if(m)m.sqliteTransactionMs+=performance.now()-t;return out;};}; + for(const key of ['pragma','exec']){const original=d[key].bind(d);d[key]=(...params)=>{const t=performance.now();const out=original(...params);if(active)active.sqliteSetupMs+=performance.now()-t;return out;};} + const originalClose=d.close.bind(d);d.close=()=>{const out=originalClose();if(active)active.sqliteCloses++;return out;};return d; +}; +const plugins=[{name:'instrument-only',setup(b){b.onResolve({filter:/^better-sqlite3$/},()=>({path:'sqlite',namespace:'audit'}));b.onResolve({filter:/^(node:)?fs\/promises$/},()=>({path:'fsp',namespace:'audit'}));b.onLoad({filter:/.*/,namespace:'audit'},a=>({contents:a.path==='sqlite'?'module.exports=globalThis.__auditDatabase;':'module.exports=globalThis.__auditFsp;',loader:'js',resolveDir:repo}));}}]; +const logger={info(){},warn(){},debug(){},error(){}}; +function payload(chapters,snapshots=20){const body=('A narrative paragraph about Luie. '.repeat(2100)).slice(0,65536);return {meta:{projectId:'audit-project',title:'Synthetic package audit',createdAt:'2026-09-08T00:00:00.000Z',updatedAt:'2026-09-08T00:00:00.000Z',chapters:Array.from({length:chapters},(_,i)=>({id:`c${i}`,title:`Chapter ${i}`,order:i,file:`manuscript/c${i}.md`}))},chapters:Array.from({length:chapters},(_,i)=>({id:`c${i}`,content:body.slice(0,-6)+String(i).padStart(6,'0')})),characters:[],terms:[],snapshots:Array.from({length:snapshots},(_,i)=>({id:`s${i}`,chapterId:'c0',content:body,description:'snapshot',createdAt:'2026-09-08T00:00:00.000Z'}))};} +function filePagesDiff(a,b,pageSize=4096){let pages=0;for(let pos=0;pos{try{ + await esbuild.build({entryPoints:[`${repo}/src/main/services/io/luieContainer.ts`],outfile:`${tmp}/writer.cjs`,bundle:true,platform:'node',format:'cjs',packages:'external',plugins,logLevel:'silent'}); + const api=require(`${tmp}/writer.cjs`);const all=[]; + for(const count of [50,250,1000]){const p=payload(count);const targetPath=`${tmp}/package-${count}.luie`;await api.writeLuieContainer({targetPath,payload:p,logger});const runs=[];for(let i=0;i<3;i++)runs.push(await runMeasured('full-current',p,targetPath,i+1,api));for(let i=0;i<3;i++)runs.push(await runMeasured('entry-current',p,targetPath,i+4,api));const d=new Database(targetPath,{readonly:true});const index=d.prepare('SELECT content FROM LuieContainerEntry WHERE path=?').get('snapshots/index.json').content;const snap=d.prepare('SELECT content FROM LuieContainerEntry WHERE path=?').get('snapshots/s0.snap').content;assert.equal(JSON.parse(index).snapshots[0].content,JSON.parse(snap).content);d.close();all.push({chapters:count,chapterUtf8Bytes:65536,snapshots:20,snapshotUtf8Bytes:65536,snapshotContentDuplicated:true,runs});} + const med=values=>[...values].sort((a,b)=>a-b)[Math.floor(values.length/2)]; + const summaries=all.map(x=>({chapters:x.chapters,fileBytes:x.runs[0].fileBytes,...Object.fromEntries(['full-current','entry-current'].map(method=>{const rs=x.runs.filter(r=>r.method===method);return [method,{medianWallMs:med(rs.map(r=>r.wallMs)),entryUpserts:rs[0].entryUpserts,entryContentUtf8Bytes:rs[0].entryContentUtf8Bytes,medianSqliteRunMs:med(rs.map(r=>r.sqliteRunMs)),medianSqliteTransactionMs:med(rs.map(r=>r.sqliteTransactionMs)),medianJsonStringifyMs:med(rs.map(r=>r.jsonStringifyMs)),jsonStringifyUtf8Bytes:rs[0].jsonStringifyUtf8Bytes,explicitTransactions:rs[0].explicitTransactions,fileInodeChanged:rs[0].fileInodeChanged,pagesWithDifferentFinalBytes:rs[0].pagesWithDifferentFinalBytes,fs:rs[0].fs}];}))})); + const report={runtime:{node:process.version,platform:process.platform,arch:process.arch},scope:'actual writeLuieContainer/writeLuieContainerEntry source; synthetic payload already materialized; function-level wrappers add instrumentation; no app/main-DB collection measured; logical content bytes and final page differences are not kernel physical write bytes/fsync counts',summaries,all}; + fs.writeFileSync('/private/tmp/luie-package-cost-results.json',nativeStringify(report,null,2));console.log(nativeStringify(summaries,null,2)); +}finally{JSON.stringify=nativeStringify;JSON.parse=nativeParse;fs.rmSync(tmp,{recursive:true,force:true});}})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-results.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-results.json new file mode 100644 index 00000000..106c1e92 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-results.json @@ -0,0 +1,783 @@ +{ + "runtime": { + "node": "v22.23.0", + "platform": "darwin", + "arch": "arm64" + }, + "scope": "actual writeLuieContainer/writeLuieContainerEntry source; synthetic payload already materialized; function-level wrappers add instrumentation; no app/main-DB collection measured; logical content bytes and final page differences are not kernel physical write bytes/fsync counts", + "summaries": [ + { + "chapters": 50, + "fileBytes": 5967872, + "full-current": { + "medianWallMs": 12.318, + "entryUpserts": 81, + "entryContentUtf8Bytes": 5910059, + "medianSqliteRunMs": 1.023, + "medianSqliteTransactionMs": 6.095, + "medianJsonStringifyMs": 4.555, + "jsonStringifyUtf8Bytes": 2633273, + "explicitTransactions": 1, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 76, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + } + }, + "entry-current": { + "medianWallMs": 1.224, + "entryUpserts": 2, + "entryContentUtf8Bytes": 71435, + "medianSqliteRunMs": 0.654, + "medianSqliteTransactionMs": 0, + "medianJsonStringifyMs": 0.014, + "jsonStringifyUtf8Bytes": 5961, + "explicitTransactions": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "fs": { + "stat": 2, + "open": 1 + } + } + }, + { + "chapters": 250, + "fileBytes": 19222528, + "full-current": { + "medianWallMs": 25.369, + "entryUpserts": 281, + "entryContentUtf8Bytes": 19040659, + "medianSqliteRunMs": 6.647, + "medianSqliteTransactionMs": 18.884, + "medianJsonStringifyMs": 4.596, + "jsonStringifyUtf8Bytes": 2656673, + "explicitTransactions": 1, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 279, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + } + }, + "entry-current": { + "medianWallMs": 1.307, + "entryUpserts": 2, + "entryContentUtf8Bytes": 94835, + "medianSqliteRunMs": 0.704, + "medianSqliteTransactionMs": 0, + "medianJsonStringifyMs": 0.052, + "jsonStringifyUtf8Bytes": 29361, + "explicitTransactions": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 7, + "fs": { + "stat": 2, + "open": 1 + } + } + }, + { + "chapters": 1000, + "fileBytes": 68890624, + "full-current": { + "medianWallMs": 80.908, + "entryUpserts": 1031, + "entryContentUtf8Bytes": 68281159, + "medianSqliteRunMs": 47.598, + "medianSqliteTransactionMs": 72.144, + "medianJsonStringifyMs": 4.737, + "jsonStringifyUtf8Bytes": 2745173, + "explicitTransactions": 1, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 1036, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + } + }, + "entry-current": { + "medianWallMs": 2.97, + "entryUpserts": 2, + "entryContentUtf8Bytes": 183335, + "medianSqliteRunMs": 0.976, + "medianSqliteTransactionMs": 0, + "medianJsonStringifyMs": 0.192, + "jsonStringifyUtf8Bytes": 117861, + "explicitTransactions": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 7, + "fs": { + "stat": 2, + "open": 1 + } + } + } + ], + "all": [ + { + "chapters": 50, + "chapterUtf8Bytes": 65536, + "snapshots": 20, + "snapshotUtf8Bytes": 65536, + "snapshotContentDuplicated": true, + "runs": [ + { + "method": "full-current", + "iteration": 1, + "wallMs": 12.318, + "fileBytes": 5967872, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 76, + "finalEntryRows": 81, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 84, + "entryUpserts": 81, + "entryContentUtf8Bytes": 5910059, + "explicitTransactions": 1, + "sqliteRunMs": 0.991, + "sqliteTransactionMs": 6.095, + "sqliteSetupMs": 0.865, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.555, + "jsonStringifyUtf8Bytes": 2633273, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.283, + "heapBefore": 12267600, + "heapAfterSerializationSample": 15428288 + }, + { + "method": "full-current", + "iteration": 2, + "wallMs": 13.124, + "fileBytes": 5967872, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 76, + "finalEntryRows": 81, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 84, + "entryUpserts": 81, + "entryContentUtf8Bytes": 5910059, + "explicitTransactions": 1, + "sqliteRunMs": 1.023, + "sqliteTransactionMs": 6.082, + "sqliteSetupMs": 0.813, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.95, + "jsonStringifyUtf8Bytes": 2633273, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.873, + "heapBefore": 16058848, + "heapAfterSerializationSample": 15415400 + }, + { + "method": "full-current", + "iteration": 3, + "wallMs": 11.918, + "fileBytes": 5967872, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 76, + "finalEntryRows": 81, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 84, + "entryUpserts": 81, + "entryContentUtf8Bytes": 5910059, + "explicitTransactions": 1, + "sqliteRunMs": 1.075, + "sqliteTransactionMs": 6.101, + "sqliteSetupMs": 0.761, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.549, + "jsonStringifyUtf8Bytes": 2633273, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.229, + "heapBefore": 12151000, + "heapAfterSerializationSample": 15251008 + }, + { + "method": "entry-current", + "iteration": 4, + "wallMs": 1.224, + "fileBytes": 5967872, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 81, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 71435, + "explicitTransactions": 0, + "sqliteRunMs": 0.654, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.101, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.014, + "jsonStringifyUtf8Bytes": 5961, + "jsonParseCalls": 1, + "jsonParseMs": 0.021, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.06, + "heapBefore": 15880168, + "heapAfterSerializationSample": 0 + }, + { + "method": "entry-current", + "iteration": 5, + "wallMs": 1.407, + "fileBytes": 5967872, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 81, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 71435, + "explicitTransactions": 0, + "sqliteRunMs": 0.669, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.096, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.014, + "jsonStringifyUtf8Bytes": 5961, + "jsonParseCalls": 1, + "jsonParseMs": 0.016, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.388, + "heapBefore": 16450080, + "heapAfterSerializationSample": 0 + }, + { + "method": "entry-current", + "iteration": 6, + "wallMs": 0.906, + "fileBytes": 5967872, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 81, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 71435, + "explicitTransactions": 0, + "sqliteRunMs": 0.637, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.067, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.013, + "jsonStringifyUtf8Bytes": 5961, + "jsonParseCalls": 1, + "jsonParseMs": 0.013, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.056, + "heapBefore": 12582280, + "heapAfterSerializationSample": 0 + } + ] + }, + { + "chapters": 250, + "chapterUtf8Bytes": 65536, + "snapshots": 20, + "snapshotUtf8Bytes": 65536, + "snapshotContentDuplicated": true, + "runs": [ + { + "method": "full-current", + "iteration": 1, + "wallMs": 25.369, + "fileBytes": 19222528, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 279, + "finalEntryRows": 281, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 284, + "entryUpserts": 281, + "entryContentUtf8Bytes": 19040659, + "explicitTransactions": 1, + "sqliteRunMs": 6.633, + "sqliteTransactionMs": 17.834, + "sqliteSetupMs": 0.835, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.663, + "jsonStringifyUtf8Bytes": 2656673, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.433, + "heapBefore": 33606080, + "heapAfterSerializationSample": 36689768 + }, + { + "method": "full-current", + "iteration": 2, + "wallMs": 25.429, + "fileBytes": 19222528, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 279, + "finalEntryRows": 281, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 284, + "entryUpserts": 281, + "entryContentUtf8Bytes": 19040659, + "explicitTransactions": 1, + "sqliteRunMs": 6.746, + "sqliteTransactionMs": 19.005, + "sqliteSetupMs": 0.787, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.567, + "jsonStringifyUtf8Bytes": 2656673, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.605, + "heapBefore": 27549584, + "heapAfterSerializationSample": 29770280 + }, + { + "method": "full-current", + "iteration": 3, + "wallMs": 25.15, + "fileBytes": 19222528, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 279, + "finalEntryRows": 281, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 284, + "entryUpserts": 281, + "entryContentUtf8Bytes": 19040659, + "explicitTransactions": 1, + "sqliteRunMs": 6.647, + "sqliteTransactionMs": 18.884, + "sqliteSetupMs": 0.778, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.596, + "jsonStringifyUtf8Bytes": 2656673, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.322, + "heapBefore": 26736752, + "heapAfterSerializationSample": 29888224 + }, + { + "method": "entry-current", + "iteration": 4, + "wallMs": 1.307, + "fileBytes": 19222528, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 7, + "finalEntryRows": 281, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 94835, + "explicitTransactions": 0, + "sqliteRunMs": 0.704, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.126, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.052, + "jsonStringifyUtf8Bytes": 29361, + "jsonParseCalls": 1, + "jsonParseMs": 0.064, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.083, + "heapBefore": 26823752, + "heapAfterSerializationSample": 0 + }, + { + "method": "entry-current", + "iteration": 5, + "wallMs": 1.107, + "fileBytes": 19222528, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 281, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 94835, + "explicitTransactions": 0, + "sqliteRunMs": 0.67, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.104, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.05, + "jsonStringifyUtf8Bytes": 29361, + "jsonParseCalls": 1, + "jsonParseMs": 0.055, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.071, + "heapBefore": 26863432, + "heapAfterSerializationSample": 0 + }, + { + "method": "entry-current", + "iteration": 6, + "wallMs": 1.394, + "fileBytes": 19222528, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 281, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 94835, + "explicitTransactions": 0, + "sqliteRunMs": 0.952, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.096, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.052, + "jsonStringifyUtf8Bytes": 29361, + "jsonParseCalls": 1, + "jsonParseMs": 0.065, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 0.075, + "heapBefore": 26845056, + "heapAfterSerializationSample": 0 + } + ] + }, + { + "chapters": 1000, + "chapterUtf8Bytes": 65536, + "snapshots": 20, + "snapshotUtf8Bytes": 65536, + "snapshotContentDuplicated": true, + "runs": [ + { + "method": "full-current", + "iteration": 1, + "wallMs": 82.321, + "fileBytes": 68890624, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 1036, + "finalEntryRows": 1031, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 1034, + "entryUpserts": 1031, + "entryContentUtf8Bytes": 68281159, + "explicitTransactions": 1, + "sqliteRunMs": 50.904, + "sqliteTransactionMs": 74.291, + "sqliteSetupMs": 0.875, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.713, + "jsonStringifyUtf8Bytes": 2745173, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.221, + "heapBefore": 97415704, + "heapAfterSerializationSample": 76486752 + }, + { + "method": "full-current", + "iteration": 2, + "wallMs": 79.384, + "fileBytes": 68890624, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 1036, + "finalEntryRows": 1031, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 1034, + "entryUpserts": 1031, + "entryContentUtf8Bytes": 68281159, + "explicitTransactions": 1, + "sqliteRunMs": 47.598, + "sqliteTransactionMs": 71.36, + "sqliteSetupMs": 0.962, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.737, + "jsonStringifyUtf8Bytes": 2745173, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.774, + "heapBefore": 76600872, + "heapAfterSerializationSample": 76484496 + }, + { + "method": "full-current", + "iteration": 3, + "wallMs": 80.908, + "fileBytes": 68890624, + "fileSizeGrowth": 0, + "fileInodeChanged": true, + "pagesWithDifferentFinalBytes": 1036, + "finalEntryRows": 1031, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 1034, + "entryUpserts": 1031, + "entryContentUtf8Bytes": 68281159, + "explicitTransactions": 1, + "sqliteRunMs": 47.321, + "sqliteTransactionMs": 72.144, + "sqliteSetupMs": 1.639, + "jsonStringifyCalls": 32, + "jsonStringifyMs": 4.763, + "jsonStringifyUtf8Bytes": 2745173, + "jsonParseCalls": 0, + "jsonParseMs": 0, + "fs": { + "stat": 1, + "open": 1, + "mkdir": 1, + "rename": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.83, + "heapBefore": 76487120, + "heapAfterSerializationSample": 76483048 + }, + { + "method": "entry-current", + "iteration": 4, + "wallMs": 3.04, + "fileBytes": 68890624, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 7, + "finalEntryRows": 1031, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 183335, + "explicitTransactions": 0, + "sqliteRunMs": 1.013, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.168, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.192, + "jsonStringifyUtf8Bytes": 117861, + "jsonParseCalls": 1, + "jsonParseMs": 0.191, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.227, + "heapBefore": 76580200, + "heapAfterSerializationSample": 0 + }, + { + "method": "entry-current", + "iteration": 5, + "wallMs": 2.845, + "fileBytes": 68890624, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 1031, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 183335, + "explicitTransactions": 0, + "sqliteRunMs": 0.939, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.155, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.192, + "jsonStringifyUtf8Bytes": 117861, + "jsonParseCalls": 1, + "jsonParseMs": 0.19, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.127, + "heapBefore": 72395064, + "heapAfterSerializationSample": 0 + }, + { + "method": "entry-current", + "iteration": 6, + "wallMs": 2.97, + "fileBytes": 68890624, + "fileSizeGrowth": 0, + "fileInodeChanged": false, + "pagesWithDifferentFinalBytes": 6, + "finalEntryRows": 1031, + "journalMode": "delete", + "sqliteOpens": 1, + "sqliteCloses": 1, + "sqlitePrepareCount": 4, + "sqliteRunCount": 3, + "entryUpserts": 2, + "entryContentUtf8Bytes": 183335, + "explicitTransactions": 0, + "sqliteRunMs": 0.976, + "sqliteTransactionMs": 0, + "sqliteSetupMs": 0.141, + "jsonStringifyCalls": 7, + "jsonStringifyMs": 0.19, + "jsonStringifyUtf8Bytes": 117861, + "jsonParseCalls": 1, + "jsonParseMs": 0.203, + "fs": { + "stat": 2, + "open": 1 + }, + "fsBytesRead": 16, + "fsMs": 1.113, + "heapBefore": 72321296, + "heapAfterSerializationSample": 0 + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-repro.cjs b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-repro.cjs new file mode 100644 index 00000000..79501ae4 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-repro.cjs @@ -0,0 +1,25 @@ +const {createRequire}=require('node:module'); +const fs=require('node:fs'); +const assert=require('node:assert/strict'); +const repo='/Users/user/Luie'; +const req=createRequire(`${repo}/package.json`); +const esbuild=createRequire(req.resolve('vite'))('esbuild'); +const Database=req('better-sqlite3'); +const tmp=fs.mkdtempSync('/private/tmp/luie-package-size-limit-'); +const logger={info(){},warn(){},debug(){},error(){}}; +const content='x'.repeat(5*1024*1024+1); +const payload=body=>({meta:{projectId:'size-audit',title:'Synthetic',chapters:[{id:'c0',title:'Chapter',order:0,file:'manuscript/c0.md'}]},chapters:[{id:'c0',content:body}],characters:[],terms:[],snapshots:[]}); +(async()=>{try{ + await esbuild.build({entryPoints:[`${repo}/src/main/services/io/luieContainer.ts`],outfile:`${tmp}/writer.cjs`,bundle:true,platform:'node',format:'cjs',packages:'external',logLevel:'silent'}); + const api=require(`${tmp}/writer.cjs`);const cases=[]; + for(const mode of ['full','entry']){ + const targetPath=`${tmp}/${mode}.luie`; + if(mode==='full')await api.writeLuieContainer({targetPath,payload:payload(content),logger}); + else {await api.writeLuieContainer({targetPath,payload:payload('small'),logger});await api.writeLuieContainerEntry({targetPath,entryPath:'manuscript/c0.md',content,logger});} + const d=new Database(targetPath,{readonly:true});const storedBytes=d.prepare('SELECT length(CAST(content AS BLOB)) AS n FROM LuieContainerEntry WHERE path=?').get('manuscript/c0.md').n;d.close(); + let error=null;try{await api.readLuieContainerEntry(targetPath,'manuscript/c0.md',logger);}catch(e){error={message:e.message,code:e.code,details:e.details};} + assert.equal(storedBytes,content.length);assert(error);assert.equal(error.message,'SQLite-backed .luie entry is too large'); + cases.push({mode,writerResolved:true,entryBytes:storedBytes,packageBytes:fs.statSync(targetPath).size,readerRejected:error}); + } + const out={runtime:{node:process.version,platform:process.platform,arch:process.arch},scope:'actual current full and entry writer, actual readLuieContainerEntry; 5MiB+1 synthetic ASCII chapter; native files only in temp',cases};fs.writeFileSync('/private/tmp/luie-package-size-limit-results.json',JSON.stringify(out,null,2));console.log(JSON.stringify(out,null,2)); +}finally{fs.rmSync(tmp,{recursive:true,force:true});}})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-results.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-results.json new file mode 100644 index 00000000..2a5fa064 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-results.json @@ -0,0 +1,40 @@ +{ + "runtime": { + "node": "v22.23.0", + "platform": "darwin", + "arch": "arm64" + }, + "scope": "actual current full and entry writer, actual readLuieContainerEntry; 5MiB+1 synthetic ASCII chapter; native files only in temp", + "cases": [ + { + "mode": "full", + "writerResolved": true, + "entryBytes": 5242881, + "packageBytes": 5267456, + "readerRejected": { + "message": "SQLite-backed .luie entry is too large", + "code": "FS_2001", + "details": { + "packagePath": "/private/tmp/luie-package-size-limit-WVOaYU/full.luie", + "entryPath": "manuscript/c0.md", + "maxSizeBytes": 5242880 + } + } + }, + { + "mode": "entry", + "writerResolved": true, + "entryBytes": 5242881, + "packageBytes": 5267456, + "readerRejected": { + "message": "SQLite-backed .luie entry is too large", + "code": "FS_2001", + "details": { + "packagePath": "/private/tmp/luie-package-size-limit-WVOaYU/entry.luie", + "entryPath": "manuscript/c0.md", + "maxSizeBytes": 5242880 + } + } + } + ] +} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/probe.config.mjs b/docs/quality/storage-architecture-review-2026-09-08/evidence/probe.config.mjs new file mode 100644 index 00000000..90a9cf0f --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/probe.config.mjs @@ -0,0 +1,5 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const evidenceDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(evidenceDir, "../../../.."); +export default { root, test: { environment: "node", include: [path.join(evidenceDir, "*.probe.ts")], setupFiles: [path.join(root, "tests/setup.ts")] } }; diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-current-behavior.probe.ts b/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-current-behavior.probe.ts new file mode 100644 index 00000000..ce90e525 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-current-behavior.probe.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import os from "node:os"; +import * as fs from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { db } from "../../../../src/main/database/index.js"; +import * as schema from "../../../../src/main/database/schema/index.js"; +import { projectService } from "../../../../src/main/services/features/project/projectService.js"; +import { chapterService } from "../../../../src/main/services/features/manuscript/chapterService.js"; +import { getProjectRevisionState } from "../../../../src/main/services/core/project/projectRevisionStore.js"; +import { readLuieContainerEntry } from "../../../../src/main/services/io/luieContainer.js"; + +describe("storage authority observation", () => { + it("observes whether reopening an older checkpoint preserves a newer committed body", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "luie-stale-open-")); + const packagePath = path.join(tempRoot, "fixture.luie"); + let scheduleSpy: ReturnType | undefined; + try { + const project = await projectService.createProject({ title: "Authority fixture", projectPath: packagePath }); + const chapter = await chapterService.createChapter({ projectId: project.id, title: "One" }); + await chapterService.updateChapter({ id: chapter.id, content: "checkpoint A" }); + await projectService.flushPendingExports(); + const baseline = await getProjectRevisionState(project.id); + const packageMeta = JSON.parse((await readLuieContainerEntry(packagePath, "meta.json"))!); + + // Hold only export scheduling to make the normal 500 ms checkpoint gap deterministic. + scheduleSpy = vi.spyOn(projectService, "schedulePackageExport").mockImplementation(() => undefined); + await chapterService.updateChapter({ id: chapter.id, content: "new committed B" }); + const beforeOpen = await chapterService.getChapter(chapter.id); + const pending = await getProjectRevisionState(project.id); + const projectRow = db.getClient().select().from(schema.project).where(eq(schema.project.id, project.id)).get(); + expect(beforeOpen.content).toBe("new committed B"); + expect(pending.revision).toBeGreaterThan(pending.exportedRevision); + const opened = await projectService.openLuieProject(packagePath); + const afterOpen = await chapterService.getChapter(chapter.id); + const afterRevision = await getProjectRevisionState(project.id); + const observation = { baseline, pending, localProjectUpdatedAt: projectRow?.updatedAt, packageUpdatedAt: packageMeta.updatedAt, beforeOpen: beforeOpen.content, conflict: opened.conflict ?? null, afterOpen: afterOpen.content, afterRevision }; + await fs.writeFile("/private/tmp/luie-storage-review/stale-open-observation.json", JSON.stringify(observation, null, 2)); + // This assertion records a defect; a regression test after the fix must require B. + expect(afterOpen.content).toBe("checkpoint A"); + } finally { + scheduleSpy?.mockRestore(); + await projectService.flushPendingExports(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-observation.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-observation.json new file mode 100644 index 00000000..7c581e65 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-observation.json @@ -0,0 +1,19 @@ +{ + "baseline": { + "revision": 5, + "exportedRevision": 5 + }, + "pending": { + "revision": 7, + "exportedRevision": 5 + }, + "localProjectUpdatedAt": "2026-09-08T08:16:00.228Z", + "packageUpdatedAt": "2026-09-08T08:16:00.231Z", + "beforeOpen": "new committed B", + "conflict": "luie-newer", + "afterOpen": "checkpoint A", + "afterRevision": { + "revision": 9, + "exportedRevision": 9 + } +} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-test-results.json b/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-test-results.json new file mode 100644 index 00000000..d883424f --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-test-results.json @@ -0,0 +1 @@ +{"numTotalTestSuites":2,"numPassedTestSuites":2,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1788855359681,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["storage authority observation"],"fullName":"storage authority observation observes whether reopening an older checkpoint preserves a newer committed body","status":"passed","title":"observes whether reopening an older checkpoint preserves a newer committed body","duration":64.48716700000006,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1788855360225,"endTime":1788855360289.487,"status":"passed","message":"","name":"/private/tmp/luie-storage-review/stale-open-current-behavior.test.ts"}]} \ No newline at end of file diff --git a/docs/quality/storage-architecture-review-2026-09-08/history.md b/docs/quality/storage-architecture-review-2026-09-08/history.md new file mode 100644 index 00000000..22a1e9a5 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/history.md @@ -0,0 +1,167 @@ +# 저장·이력·복구 구조 재검토 + +조사 범위: autosave → Chapter/ChapterBody/ChapterRevision → snapshot DB/외부 artifact → .luie, mirror/emergency, 검색 및 memory 파생 데이터. 제품 코드·사용자 DB 수정이나 실앱 실행 없이 현재 소스와 호출 경로를 대조했다. code-review-graph 도구가 세션에 없어 scoped rg/read를 사용했다. 모든 경로는 `/Users/user/Luie` 기준이다. 이 문서의 “확정”은 소스 수준 계약 확인이며, 실제 전원 단절·디스크 고장 내구성 측정으로 확대 해석하지 않는다. + +## 1. 사용자의 “나중에 합쳐서 저장하고 스냅샷을 만든다”는 이해는 일부만 맞다 + +합쳐지는 것은 **대기 중인 요청**이다. 같은 챕터의 최신 미처리 문자열, 같은 프로젝트의 export 요청, 동일 대상의 파생 작업을 합친다. 이미 저장된 여러 변경분을 나중에 delta로 합치는 구조는 아니다. 현재는 저장할 때마다 **챕터 전문**을 여러 곳에 기록하며, .luie export와 외부 snapshot은 **프로젝트 데이터를 다시 모아 전체 직렬화**한다. + +정상 경로의 시간 순서: + +1. Renderer에서 전달된 `AUTO_SAVE`는 main `triggerSave`가 받아 pending map의 해당 챕터를 최신 본문으로 바꾼다. `latest.snap` mirror 쓰기도 즉시 비동기 요청한다. IPC 응답은 accepted이며 디스크 저장 완료가 아니다. `ipcAutoSaveHandlers.ts:41`, `autoSaveManager.ts:192`. +2. 마지막 main 요청 뒤 기본 **200 ms**에 `performSave`가 챕터 전문을 `updateChapter`로 전달한다. 같은 챕터 pending 요청은 debounce 동안 병합되지만 이미 시작된 write를 delta로 합치지는 않는다. `autoSaveManager.ts:228`, `autoSavePerformSave.ts:60`. +3. DB transaction은 **Chapter.content 업데이트 + ChapterBody 전문/hash upsert + ChapterRevision 전문 INSERT**를 수행하고 검색 dirty 및 memory 작업을 넣는다. `chapterWriteOperations.ts:235`, `:257`, `:262`, `:272`. +4. DB 저장 경로는 `chapter:update`로 **500 ms** 프로젝트 export를 예약한다. `projectService.ts:48`, `:461`; `interactionTiming.ts:4`. ProjectService 생성자가 이 상수를 queue에 직접 전달한다(`projectService.ts:101`). 이 경로에서 별도 5초 override는 없다. +5. `performSave`는 저장 완료 event를 보내고 같은 전문의 latest mirror를 다시 요청한 뒤 자동 snapshot gate를 검사한다. `autoSavePerformSave.ts:69`. +6. Gate를 통과하면 snapshot job이 **외부 프로젝트 artifact 작성 → DB Snapshot 전문 INSERT → Project.updatedAt 변경 → snapshot:create export 재예약 → retention → timestamp mirror 작성**을 수행한다. `autoSaveSnapshotJobs.ts:78`, `snapshotService.ts:121`. +7. `.luie` queue가 실행될 때 모든 현행 챕터·세계관·canonical memory·선택된 snapshot을 다시 모아 package writer에 준다. 변경된 챕터만 증분 반영하는 호출 계약은 아니다. `projectExportEngine.ts:71`, `:85`, `:154`. + +따라서 “DB는 완전히 버려도 되는 cache”라고 할 수 없다. 마지막 package checkpoint 이후 최신 성공 저장은 DB에 먼저 존재하며, ChapterRevision은 package에 포함되지 않는다. `.luie canonical + DB cache`라는 문서상 목표와 **현재 동작 중 권위**를 분리해야 한다. 현실적으로는 “DB의 현재 작업본 + .luie의 배포/이동 가능한 checkpoint + 미저장 수신분의 recovery mirror”이다. + +## 2. 실제 병합 범위와 전문 쓰기 범위 + +| 계층 | 합치는 내용 | 실행될 때 기록되는 내용 | 현재 정리/휴대 범위 | +|---|---|---|---| +| pendingSaves | 챕터별 대기 중 요청을 마지막 본문으로 교체 | 전문 문자열 1개 | RAM, 종료 전 flush 필요 | +| mirror queue | 쓰기 중 새 요청이 여러 개 오면 다음 대기 payload를 최신으로 교체 | gzip JSON 챕터 전문 | latest 1개/챕터; 본문 hash dedup 없음 | +| Chapter + ChapterBody | 동일 content-only 업데이트는 equality return 가능 | 같은 현행 전문이 두 테이블에 저장 | 로컬 DB, .luie는 챕터 파일 1개로 전송 | +| ChapterRevision | 병합 없음 | 성공 content 업데이트마다 전문 append | reader/prune/export 없음; hard-delete FK cascade | +| SearchDirtyQueue/MemoryBuildJob | 동일 대상 pending/running 작업을 갱신 | 작업 메타데이터, 전문 revision 아님 | 파생 작업; package 미포함 | +| Snapshot | gate로 빈도를 제한할 뿐 이력 본문 병합 없음 | chapter 전문; 프로젝트형은 timestamp marker | 로컬 DB 기본 project 전체 2000개 hard cap | +| 외부 FullSnapshot artifact | 압축 buffer 1개를 재사용해 파일 복제 | 모든 활성 chapter + characters/terms/settings/project + focus 전문 | 최대 3개 경로; DB snapshot 생존 여부에 따라 정리 | +| .luie export queue | 프로젝트별 dirty flag와 timer로 요청 합침 | 전체 현행 project payload + snapshot 선택집합 | 기본 최근 snapshot 50개; revision 미포함 | + +동일 content-only 저장은 DB write를 건너뛸 수 있으나, content에 title/synopsis도 함께 붙는 호출은 `hasOnlyContentUpdate` 조건을 충족하지 않으므로 본문이 같아도 revision이 추가될 수 있다(`chapterWriteOperations.ts:218`). 전부 매 키마다 DB에 쓰는 것은 아니지만, debounce가 통과한 저장 횟수 S, 챕터 길이 C에 대해 revision 보관량은 대략 **O(S × C)**다. + +## 3. 자동/수동 snapshot은 별도 조건이다 + +### 실제 AUTO_SAVE 뒤 gate + +`autoSaveSnapshotGate.ts:42` 및 `shared/constants/storage/snapshot.ts` 기준: + +- key는 **projectId:chapterId**이며 last gate 이후 **60초** 미만이면 skip. +- 길이 **1 이상**일 때만 일반 자동 snapshot. +- 직전 gate의 32-bit rolling hash와 같으면 skip. 서버/DB에 저장된 최근 snapshot을 읽는 gate가 아니며 프로세스 RAM의 map이다. +- 변화량 ratio와 절대길이 조건이 `ratio < 0.0 && diff < 30`이다. ratio는 음수가 될 수 없으므로 **30글자 기준은 현재 실질적으로 작동하지 않는다**. 60초 뒤 다른 hash라면 같은 길이의 한 글자 교체도 snapshot 대상이다. +- 시간/hash/길이를 **생성 성공 전**에 갱신한다(`:64`). 실패해도 60초 gate는 소비되며 성공 watermark와 enqueue watermark가 섞여 있다. +- 첫 저장은 런타임 map이 비어 있으므로 최근 DB snapshot이 있어도 새 snapshot을 만들 수 있다. 이후 60초마다 무조건 만드는 것이 아니라 다음 성공 save가 gate를 다시 통과할 때 만든다. + +### 별도 주기 스케줄러는 정의되어 있지만 실제 생산 호출을 확인하지 못함 + +`setConfig` → `startAutoSave`(기본 30초 pending flush interval) + `startSnapshotSchedule`(즉시 1회, 이후60초 프로젝트 snapshot) 구현은 있다(`autoSaveManager.ts:137`, `:310`, `:349`). 그러나 `src/main`, `src/preload`, `src/renderer`의 호출 검색에서 `setConfig/startAutoSave`를 실제 프로젝트 열기/설정 변경에 연결한 production caller가 없고, 테스트 `snapshotResilience.test.ts`에서만 직접 활성화한다. + +따라서 현재 정상 생산 동작을 “설정된 30초 저장 / 매60초 프로젝트 전체 스냅샷”이라고 단정하면 안 된다. 확실한 경로는 **AUTO_SAVE trigger의 200 ms debounce + 저장 뒤 chapter gate**다. dormant scheduler를 새로 연결하면 기존 작업량에 프로젝트 artifact 생성이 추가된다. + +프로젝트형 scheduled snapshot은 Snapshot.content에 `{timestamp}`만 저장하고(`autoSaveFlushOps.ts:65`) 외부 artifact에 프로젝트 데이터를 담는다. DB `restoreSnapshot`은 chapterId가 없는 project snapshot을 거절한다(`snapshotService.ts:326`). 프로젝트 snapshot과 chapter snapshot은 복원 방식도 다르다. + +### 수동 snapshot / 수동 save / 강제 종료 + +- 수동 snapshot 버튼은 로드된 renderer chapter content를 `type:MANUAL`로 `SNAPSHOT_CREATE`에 보낸다. 직접 gate를 거치지 않는다(`SnapshotList.tsx:185`, `ipcSnapshotHandlers.ts:34`). 수동 snapshot이 Chapter 현재본을 먼저 flush하는 계약은 없다. +- `MANUAL_SAVE`는 main pending 전체 project를 flush한 뒤 지정 project package를 즉시 export한다(`ipcAutoSaveHandlers.ts:56`). snapshot 생성 자체를 의미하지 않는다. 다만 flush로 실행되는 각 autosave가 gate를 통과하면 snapshot job을 만들 수 있다. +- `flushCritical`은 그 시점 main pending에 잡힌 각 챕터를 latest mirror로 쓴 뒤 각 챕터의 Snapshot을 만든다(`autoSaveFlushOps.ts:119`, `:128`). 이 경로는 60초/최소길이 gate나 retention을 직접 적용하지 않는다. main에 아직 도착하지 않은 editor 상태는 이 계층이 구할 수 없다(기존 renderer 결함 보고서 참조). + +## 4. snapshot 생성이 .luie 쓰기를 또 유발하는가 + +**그렇다. 단, 항상 즉시 별도 1회라는 뜻은 아니다.** `snapshotService.ensureImmediatePackageExport`라는 이름과 달리 결국 `persistPackageAfterMutation`로 들어가며, `snapshot:create`는 debounced reason 목록에 있다(`projectService.ts:64`, `:447`). 기존 `chapter:update` export timer가 아직 실행되지 않았으면 500 ms timer 재설정으로 합쳐질 수 있다. + +하지만 artifact 작성이 500 ms보다 오래 걸리거나 package export가 이미 시작된 뒤 snapshot이 DB에 삽입되면 최초 chapter export와 후속 snapshot export가 나뉠 수 있다. 실제 반복 횟수는 프로젝트 크기와 scheduler 시점 측정이 필요하다. queue는 in-flight 동안 dirty/revision이 바뀌면 다음 full export를 수행한다. 자동 retention이 실제 삭제를 수행하면 `snapshot:delete-old`, prune은 `snapshot:prune`으로 즉시 export 경로를 호출한다(`snapshotRetention.ts:69`, `:162`). 수동 snapshot restore/delete도 즉시 export 대상이다. + +`.luie` payload의 snapshot은 `projectExportPayload.ts:137`에서 전체 map을 만든 뒤 기본50개로 slice한다. `snapshotExportLimit <= 0`이면 전부 보낸다. DB의 2000개와 package의50개는 서로 다른 정책이다. + +### package 내 snapshot 본문 이중 보관과 호환성 + +`luieContainerEntries.ts:97`은 `snapshots/index.json`에 **content를 포함한 전체 snapshot 배열**을 저장하며, `:110`은 같은 개별 snapshot 전체를 `snapshots/{id}.snap`에도 저장한다. 즉 .luie 내부의 snapshot 본문이 두 번 표현된다. + +현재 repository importer `importOpen/collections.ts:289`, `:312`, `:332`는 **index.json만** 읽는다. 개별 snapshot file을 따라 읽는 정상 import 경로는 없다. 그러나 index의 content를 바로 제거해서 metadata-only로 만들면 `LuieSnapshotSchema.content`가 optional이라 validation을 통과하고(`projectLuieSchemas.ts:142`), `buildSnapshotCreateRows.ts`가 아니라 **`projectImportCodec.ts:213`**에서 누락 content를 빈 문자열로 바꾼다. 결과는 빈 snapshot으로 조용히 복구될 수 있다. + +최소 위험 순서: 우선 full index를 유지한다. 개별 .snap의 repo importer 소비는 없으나 외부 도구/수동 추출 계약을 확인한 뒤 중복 제거를 검토한다. metadata-only index를 원하면 format version과 reader migration을 같이 설계하고, 과거 full-index package와 신규 per-file-body package의 양방향 읽기를 검증해야 한다. “index만 줄이면 된다”는 변경은 금지해야 한다. + +별도 손실: export snapshot mapping(`projectExportPayload.ts:137`)과 import row(`projectImportCodec.ts:231`) 모두 type을 보존하지 않는다. 로컬 Snapshot.type의 default는 AUTO(`database/schema/snapshot.ts:14`)이므로 .luie로 DB를 재구성하면 **MANUAL 구분이 사라진다**. 수동 이력 보존 정책 설계 전 round-trip에서 type/pin/source를 보존해야 한다. + +## 5. 복구 계층별 실제 용도와 빈틈 + +### Latest / timestamp mirror + +- latest: userData/SNAPSHOT_MIRROR_DIR/project/chapter/latest.snap에 gzip JSON 전문. trigger 직후와 DB 성공 직후 둘 다 요청하므로 첫 쓰기가 완료된 경우 같은 content가 또 기록된다. pending queue 병합은 있지만 저장한 hash 비교는 없다. `autoSaveManager.ts:195`, `autoSavePerformSave.ts:70`, `autoSaveMirrorStore.ts:56`. +- timestamp mirror: 일반 자동 snapshot job 성공 뒤 한 번 + **200글자 이하** 입력에 한해 **5초** gate의 emergency micro mirror. 후자는 동일본문/빈본문도 길이 조건만 통과하면 가능하다(`autoSaveSnapshotJobs.ts:43`). 챕터 폴더별 최근50개이며 latest는 그50개에서 제외한다(`autoSaveMirrorStore.ts:93`). +- startup/shutdown 복구는 **latest.snap만** 찾는다. DB chapter가 없거나 deleted이면 해당 mirror directory를 정리한다. DB본문과 같거나 DB updatedAt보다 오래됐으면 skip, 최신 Snapshot.createdAt보다 오래돼도 skip. 더 새로운 미러는 chapter를 직접 덮어쓰지 않고 **선택 가능한 AUTO Snapshot으로 승격**한다(`autoSaveMirrorStore.ts:138`, `:191`, `:225`). +- 일반 recovery 후보 목록은 artifact roots만 탐색하므로 timestamp mirror는 정상 복원 UI에 연결되지 않는다(`artifacts/paths.ts:64`). 안전한 계층 감축 전에 “썼는가”뿐 아니라 “실제 사용자가 찾고 복구할 수 있는가”를 따져야 한다. +- mirror writer는 내부 catch로 실패를 로그만 남긴다(`autoSaveMirrorStore.ts:71`). `flushCritical`의 mirrored++는 Promise resolve를 success로 취급하므로 파일 내구성 성공의 증거가 될 수 없다(`autoSaveFlushOps.ts:121`). + +### FullSnapshot artifact + +`writeFullSnapshotArtifact`는 **매 Snapshot 생성마다** project/settings/활성chapters/characters/terms를 DB에서 읽고 입력 focus 본문을 추가해 stringify+gzip한다. 압축은 한 번이고 같은 buffer를 최대3곳에 쓴다(`snapshotArtifacts.ts:173`, `:239`, `:256`, `:277`, `:286`). + +경로는 project 인접 `.luie/snapshots/projectid`, userData snapshot backup, project 인접 `backup{projectid}`다. 인접2곳은 대개 같은 volume이라 서로 다른 디스크 고장 영역을 뜻하지 않는다. 별도 이력 파일의 개수·읽기 부담은 대략 snapshot수 ×3이며, 자동 이력 DB hard cap2000까지 가면 해당 project의 생존 artifact도 최대6000개가 가능하다(경로가 모두 유효하고 쓰기 성공 시; 외부 환경 실측 아님). + +**이름은 FullSnapshot이지만 현재 project의 모든 데이터를 담지 않는다.** `artifacts/types.ts:1`, `snapshotArtifacts.ts:194` 기준, factions/events/world entities/relations/world documents/drawing/scrap/canonical memory/ChapterRevision/이전 Snapshot history는 빠진다. 현재 full package serializer와 다른 오래된 스키마다. 독립 backup을 이 형식만으로 대체하면 해당 도메인의 보존이 깨진다. + +또한 artifact `data.focus.content`는 미저장/긴급 본문일 수 있지만 importer는 `snapshot.data.chapters`를 생성하고 해당 content를 복구한다(`snapshotImportFromFile.ts:122`). focus의 실제 소비는 preview(`artifacts/preview.ts:24`)뿐이다. **DB chapter가 A, 긴급 focus가 B이면 복구 미리보기는 B이고 새 프로젝트 본문은 A가 되는 소스 경로**다. 자동 정상 save 뒤 snapshot에서는 A=B일 수 있지만 flushCritical, 미러 승격, 수동 snapshot은 다를 수 있다. 복구 규칙을 통합하기 전 이 차이를 없애야 한다. + +### DB 손실과 orphan 정리의 충돌 가능성 + +Artifact orphan cleanup은 DB Snapshot.id 집합에 없는 UUID artifact를 unlink한다(`snapshotArtifacts.ts:109`, `:135`, `:149`). startup은 mirror 승격 뒤 prune와 전체 orphan cleanup을 병렬 실행한다(`deferredStartupMaintenance.ts:24`). 따라서 DB가 새로 만들어졌거나 package에서50개만 복구된 경우, userData에 남은 과거 backup artifact를 DB의 부재만으로 고아로 판정할 수 있다. 이것은 복구 저장소가 DB 인덱스와 독립적인 안전망이 아니라 DB 생존 집합에 종속된다는 뜻이다. 실제 DB 손실 전체 시나리오는 root의 격리 검증 범위이며, 여기서는 source-level 위험으로 구분한다. + +**불변조건:** 복구 catalog 재발견/검증이 끝나기 전에는 빈 DB를 근거로 backup을 삭제하면 안 된다. 명시적 사용자 삭제와 “인덱스에 없음”을 구분한 tombstone, 유예 기간 또는 격리 디렉터리가 필요하다. + +### Emergency JSON + +Snapshot create 실패 시 userData mirror/_emergency에 전문 input + error를 JSON으로 남긴다. temp-write/file fsync/rename/directory fsync를 시도한다(`snapshotEmergencyFile.ts:18`). 이 파일은 최신 mirror scanner에서 `_emergency`를 제외하고, artifact 후보는 .snap만 찾으므로 정상 복구 발견 경로가 없다. scoped production search에서 이 형식 reader/retention도 확인되지 않았다. 실패 상태에서 무제한 파일 보관이 가능하고, 존재 자체가 사용 가능한 자동 복구를 보장하지 않는다. 당장 삭제하기보다 이 형식과 기존 파일을 읽을 수 있는 recovery importer를 먼저 마련해야 한다. + +## 6. 이력 보존 정책이 서로 다르며 ChapterRevision은 사용되지 않는다 + +- AUTO Snapshot 시간 간격 prune: 24시간 이내 전부, 1–7일은 시간당1개, 7일 이상은 날짜당1개. **project 전체 기준**이며 chapter별 bucket이 아니다. 여러 챕터를 편집하면 오래된 동일 시간대에서 한 챕터의 snapshot만 남을 수 있다(`snapshotRetention.ts:100`, `:117`). +- deleteOld hard cap: project 전체 최근2000개, **MANUAL도 제외하지 않는다**(`snapshotRetention.ts:30`). 시간 prune이 MANUAL을 보호하더라도 hard cap은 삭제한다. +- package: 기본최근50개(프로젝트 전체), 선택 한도0이하는 전부. 오래된 manual snapshot도 자동 제외될 수 있다. +- timestamp mirror: 챕터별50개, latest 별도1개. +- 외부 artifact: 시간 policy 자체가 아니라 DB Snapshot존재/삭제에 연동. +- ChapterRevision: content update마다 전문 INSERT, `reason:manual_save`로 autosave까지 동일 표기. production `ChapterRevision/chapterRevision` 검색은 writer·schema·index metadata만 확인됐고 read/restore/prune/package mapping은 없다. chapter hard delete에는 FK cascade, soft delete는 바로 정리되지 않는다. 따라서 현재 비용은 발생하지만 사용 가능한 복구 기능으로 연결되지 않은 별도 append-only layer다. + +권장 보존 정책은 `AUTO`와 `MANUAL/pinned`를 먼저 구분하고, AUTO는 **챕터별 최근 복구점 최소치 + 시간 버킷 + byte budget**을 같이 적용한다. MANUAL/pinned는 자동 cap/prune에서 제외하거나 명확한 별도 사용자 정책을 둔다. package로 이동할 때 보존되는 범위도 UI/포맷 계약에 명시해야 한다. 전체 프로젝트 snapshot이라면 project별 bucket이 가능하지만 지금 DB Snapshot은 대부분 한 챕터 본문이므로 같은 정책을 섞지 않는다. + +## 7. 검색 cache와 memory는 “전부 버릴 수 있는 메모리”가 아니다 + +검색 `ChapterSearchDocument`는 별도 cacheDb에 title/synopsis/content를 합친 searchText를 저장하고 FTS에도 반영한다(`chapterSearchCacheService.ts:43`, `:91`, `:223`). `rebuildProject`는 ChapterBody 우선/Chapter legacy fallback으로 전체 project를 다시 만들 수 있다(`:373`, `:406`). 이 두 검색 표현은 **재생성 가능한 파생 데이터**이며 canonical 전문이나 이력 보존 역할을 맡기지 않는다. + +다만 현재 hydration은 row 수를 비교하므로 본문만 변경돼 개수가 같으면 stale cache를 감지하지 못한다(`:339`, `:366`). snapshot restore는 Chapter/Body를 직접 변경하지만 일반 update의 검색/메모리 enqueue를 통과하지 않는다(`snapshotService.ts:342`). 복구·import·일반 저장 모두에 공통 **revision 기반 파생 데이터 invalidation**을 적용해야 cache를 안전하게 지우거나 재생성할 수 있다. 동일 count는 동일 버전의 증거가 아니다. + +memory persistence policy는 명시적으로 두 가지를 분리한다(`memoryPersistencePolicy.ts:11`, `:25`): + +- export canonical: MemoryEntity/Alias, Episode/Evidence, Fact/Evidence/Invalidation, EvalCase/Evidence/Entity/Relation 11테이블. Entity/Alias/Fact는 confirmed/rejected/deprecated 상태만 export하고, Fact evidence가 참조하는 Episode/Evidence 등을 함께 보낸다(`internal/buildPayload.ts:37`, `:133`, `:161`). 사람의 확인/기각과 근거 연결은 재생성 cache 취급하면 안 된다. +- regenerable: chunks, embeddings, build/search jobs, chapter summary, entity mention, episode extraction job/participant, state-change candidate, temporal state projections, narrative summary/source, eval run/result. Package에는 포함하지 않는다. 원문·canonical facts·model/version 등의 재생성 입력과 rebuild 진행 상태가 전제다. + +`.luie`의 `memory/canonical.json`은 위 canonical subset을 포함하지만 외부 FullSnapshot artifact는 이 메모리 도메인을 포함하지 않는다. “스냅샷만 남기고 DB 지우기”와 “.luie checkpoint로 이동하기”의 보존 범위가 다르다. + +## 8. 감축 순서와 지켜야 할 불변조건 + +불변조건: + +1. **accepted, DB committed, recovery durable, package checkpointed를 분리**한다. 최신 revision/hash를 같은 요청에 연결하고 성공 UI가 어느 단계를 뜻하는지 명확히 한다. +2. 최신 commit 이후 DB 또는 package 중 적어도 하나의 검증된 복구 원본을 확보하기 전에 중간 layer를 삭제하지 않는다. detached project의 DB도 authoritative working copy다. +3. 원본과 snapshot은 일관된 revision의 모든 필수 도메인을 포함해야 하며 focus override는 실제 restore에도 적용한다. `FullSnapshot` 명칭만으로 보존 범위를 추정하지 않는다. +4. backup은 DB 손실 시에도 발견 가능해야 한다. 누락 인덱스만으로 삭제하지 않고 명시적 삭제/유예/검증을 거친다. +5. MANUAL type/pin, chapter linkage, content hash, schema version, 생성 revision을 round-trip으로 보존한다. index만 줄여 빈본문을 만드는 migration을 피한다. +6. 파생 데이터는 canonical commit과 같은 transaction에서 dirty revision을 남기고, rebuild 결과가 요청한 source revision과 일치할 때만 완료 처리한다. + +실행 순서 제안: + +1. 기존 renderer/main pending flush 결함을 먼저 해결한다(별도 감사 보고서). 저장 단계/오류 전달/세대 식별을 먼저 안정화해야 안전망 삭제 판단이 가능하다. +2. 현재 비용을 기능으로 정당화하지 못하는 **ChapterRevision**을 먼저 결정한다. 쓸 복구 API가 없다면 새 append를 중단하는 최소 변경을 검토하되, 이미 보유한 history는 즉시 삭제하지 말고 보존/추출 정책을 정한다. 쓸 계획이라면 Snapshot과 하나의 이력 모델로 통합해 동일 전문을 매 autosave 두 역사 테이블에 쌓지 않는다. +3. snapshot metadata(type/size/hash) round-trip을 보완하고 package 내 동일본문 이중저장을 포맷 계약 확인 뒤 제거한다. metadata-only index는 reader versioning과 함께 한다. +4. latest mirror는 DB commit 전 수신분을 구하는 목적을 명확히 유지하되, 같은 revision/content를 DB 성공 뒤 다시 쓰는 중복은 hash/revision ack로 제거한다. 반환값/오류를 보존해 emergency flush count가 실제 성공을 나타내게 한다. +5. timestamp mirror와 _emergency의 실제 recovery discoverability를 먼저 완성한다. 이후 duplicate timestamp mirror나 같은 디스크의 세번째 전체 artifact부터 감축한다. 존재하지만 UI에서 못 찾는 파일을 안전망 개수로 세지 않는다. +6. 외부 전체 snapshot과 package serializer를 공통 canonical payload 계약으로 맞춘다. 자동 chapter history는 chapter content-addressed blob + metadata로 작게 유지하고, 독립 프로젝트 backup은 명시적/저빈도 checkpoint로 운영하는 방안을 검토한다. 복구 기준점 없이 처음부터 delta-only 체계를 만드는 복잡한 설계는 피한다. +7. snapshot create + retention의 후속 package 작업을 동일 checkpoint로 묶고, 빈번한 derived-job 상태 변경은 export를 유발하지 않도록 revision domain을 분리한다. 관련 상세 queue/DB trigger 문제는 root/package agent 보고서와 합친다. + +검증은 DB loss→package import, DB loss→외부 artifact, 미저장focus 복구, manual pin/retention, 구포맷full-index→신reader 및 신포맷metadata-index→구reader 거절, save→restore→검색 최신화, mirror write failure→오류전달을 합성 fixture로 확인해야 한다. OS별 실제 디스크/fsync/rename/권한/긴경로 및 전원실패 시험은 별도이며 소스 검토만으로 통과를 선언하지 않는다. + +## 9. 검증 범위 및 기존 테스트 해석 + +이 재조사에서는 source call chain 및 schema/import/export mappings를 확인했고 추가 테스트를 실행하지 않았다(root 요청으로 중복 DB setup/벤치 실행을 피함). 제품 코드 수정 없음. root가 공유한 테스트 상태를 본 보고서의 신규 성공처럼 세지 않는다. + +- `snapshotService.packageBehavior.unit.test.ts`: 현재 서비스는 Drizzle인데 mock은 구 Prisma API여서9중7실패. 이 테스트 실패만으로 package 저장 결함을 확정할 수 없다. +- `snapshotListProjection`은 root가 DB setup을 켜서 통과 확인. +- `luieDbLossRecovery`의 getAllChapters content 기대는 현재 metadata DTO와 맞지 않아 root가 임시 fixture에서 getChapter(id) 조회로 맞춰 원고·snapshot 본문 복원을 재검증했고 통과했다. 기존 assertion 실패를 본문복구 결함의 증거로 사용하지 않는다. +- dormant scheduler를 테스트에서 setConfig로 활성화하는 것은 production wiring의 증거가 아니다. + +핵심 우선순위는 복구 권위와 보존 범위 정리(P1), focus/이력 type/backup orphan 처리의 복구 계약 보완(P1), 사용되지 않는 full revision/중복 full snapshot·package 기록 감축(P2), gate와 보존정책 단순화(P2)다. diff --git a/docs/quality/storage-architecture-review-2026-09-08/package.md b/docs/quality/storage-architecture-review-2026-09-08/package.md new file mode 100644 index 00000000..2aac7606 --- /dev/null +++ b/docs/quality/storage-architecture-review-2026-09-08/package.md @@ -0,0 +1,176 @@ +# .luie 저장 포맷·전체 재작성 비용·증분 저장 설계 재검토 + +조사일: 2026-09-08. 범위: 현재 실제 package writer/export collector/queue/reader와 관련 테스트. 제품 소스 및 사용자 DB는 수정·접근하지 않았고 앱을 실행하지 않았다. 아래 실험은 임시 synthetic `.luie`만 사용했다. 이전 전체 DB 감사와 중복되는 일반 병목은 줄이고, “SQLite로 바꾸었는데 왜 작은 변경도 전체 파일을 다시 쓰는가”에 집중했다. + +**결론: SQLite 자체가 전체 재작성을 요구하는 것이 아니다. 현재 `.luie`는 프로젝트 정규화 DB가 아니라 기존 논리 파일을 SQLite의 `path → content TEXT` 행에 담는 컨테이너이고, exporter가 변경 경로 없이 프로젝트 전체를 수집한 뒤 새 SQLite 파일을 생성·교체하기 때문이다. 현재 v2 포맷을 유지하면서 entry 단위 저장으로 큰 비용을 줄일 수 있다. 다만 기존 단건 API는 여러 statement가 각각 커밋되어 그대로 자동저장에 연결하면 안 된다. 먼저 변경 entry 집합·meta·info를 하나의 동기 SQLite transaction으로 묶고, 수집 revision과 완료 ACK를 연결해야 한다.** + +## 1. 실제 포맷과 JSON이 남는 위치 — 소스 확정 + +`src/main/services/io/luieSqliteContainer.ts:33`의 schema는 다음 두 테이블뿐이다. + +- `LuieContainerInfo`: id=1, format, container, version, createdAt, updatedAt. +- `LuieContainerEntry`: path TEXT PRIMARY KEY, content TEXT, createdAt, updatedAt. updatedAt 보조 인덱스가 있다. + +`Chapter`, `Character`, `Relation` 등을 정규화한 app DB schema가 이 파일 안에 들어가는 구조가 아니다. SQLite 외피 안에서 논리 경로가 기존 파일 이름 역할을 한다. + +| entry | 현재 내용 | 변경 최소 단위 | +|---|---|---| +| `meta.json` | 프로젝트 metadata와 모든 장의 id/title/order/file 목록 | 전체 meta JSON | +| `manuscript/.md` | chapter.content 문자열 그대로, 별도 Markdown 변환 없음 | 장 한 개 본문 | +| `world/characters.json`, `world/terms.json` | 전체 인물·용어 배열 JSON | 해당 collection JSON | +| `world/synopsis.json`, `plot.json`, `drawing.json`, `mindmap.json`, `scrap-memos.json`, `graph.json` | 문서별 JSON | 문서 한 개 JSON | +| `memory/canonical.json` | export 대상 canonical memory tables를 하나로 합친 JSON | 현재는 canonical memory 전체 | +| `snapshots/index.json` | snapshot metadata만이 아니라 **content를 포함한 전체 snapshot 배열** | export 대상 snapshot 배열 전체 | +| `snapshots/.snap` | 개별 snapshot content·metadata JSON | snapshot 한 개 | + +근거: `src/main/services/io/luieContainerEntries.ts:51`, `:97`, `:102`, `:110`; 상수는 `src/shared/constants/storage/paths.ts:41`, `:43`. + +따라서 C개의 장, S개의 snapshot이면 현재 valid payload의 entry 수는 **11+C+S**, JSON.stringify 호출 대상은 **11+S**다. 장 본문은 stringify하지 않지만 모든 본문을 다시 SQLite로 전달한다. snapshot 본문은 index와 개별 .snap에 이중 저장된다. Synthetic package에서 두 entry의 content 일치도 assert했다. world graph에도 일부 canonical entity 속성이 별도로 표현되므로 인물·용어 JSON과 일부 정보 중복이 있다(`projectExportPayload.ts:311`). + +`meta.chapters`에는 id/title/order/file만 들어가고 chapter contentHash/wordCount/synopsis는 없다(`src/main/services/core/project/projectExportPayload.ts:86`). package meta에는 project revision도 없다(`:394`). `luiePackageIntegrity.ts`는 포맷 호환과 meta 정규화를 담당하며 현재 writer에 전체 파일 hash/entry hash/manifest checksum 계산 단계는 없다. 현재 경로에서 “해시 때문에 모든 본문을 다시 읽어야 한다”는 제약은 발견되지 않았다. + +## 2. 한 번의 full export 비용 — 소스 확정 + 복잡도 추론 + +기호: B=모든 장 본문의 합, W=export하는 world/canonical JSON 총량, S=export하는 snapshot 본문의 합, H=DB에 저장된 전체 snapshot 본문의 합, C=장 수. 아래 O 표기는 접근 데이터량 관점이다. + +| 단계 | 실제 동작과 근거 | 비용/제약 | +|---|---|---| +| collector | `projectExportEngine.ts:71`에서 getProjectForExport를 먼저 호출하고 :73 이후 attachment/target 확인 | direct export 경로는 target 없음 확인 전 전체 수집 가능. 일반 queue는 attachment skip guard가 있음 | +| DB 수집 | `exportEngine/projectRecord.ts:23` project 조회 + :41 8개 collection 조회. chapters SELECT는 legacy content와 chapterBody.content 모두 포함(:43), snapshot은 LIMIT 없음(:75) | O(B+H+entities). Drizzle better-sqlite3 adapter이므로 Promise.all은 SQL 병렬 실행 보장이 아님 | +| export DTO | `projectExportPayload.ts:77`, :133 전체 map; snapshot limit는 DB 조회·전체 map 이후 slice(:145) | 유지 설정이 작아도 이미 H를 읽음. object/array 추가 할당; 문자열 reference 공유까지 전부 복제로 셈하면 안 됨 | +| memory/world | `projectExportEngine.ts:91` canonical 전체 export, :92 replica world. replica 누락 문서만 :103 package fallback | valid replica면 fallback 없음. 누락시 최대 6개 문서별 package header/stat/open/read/close/JSON parse 비용 추가 | +| meta/graph 구성 | :130 world graph 구성, :133 전체 updatedAt 후보 계산, :141 meta 생성 | 전체 metadata/collection 순회. change set 없음 | +| entry 직렬화 | `luieSqliteContainer.ts:237` → `luieContainerEntries.ts:51`에서 JSON.stringify pretty2 | O(W+2S+meta), 장 raw 본문은 그대로 entry에 연결 | +| SQLite 생성 | `luieSqliteContainer.ts:228` 새 temp Database, :232 DELETE journal, :233 synchronous FULL, :234 bootstrap | 매 export 새 DB/schema/index 생성. bootstrap DDL은 아래 명시적 data transaction 밖 | +| 삽입 | :242 transaction에서 info+모든 entry INSERT(:267) | O(B+W+2S+metadata)의 논리 content 전달 및 새 파일 생성. 변경되지 않은 entry도 모두 다시 INSERT | +| commit/close | :273 동기 native transaction, :274 close | Promise 반환 함수 안이지만 native SQL/JSON은 현재 호출 thread에서 동기 실행 | +| replace | :278 → `luiePackageWriter.ts:64` rename(temp,target) | 정상 경로는 rename 1회. JS에 copyFile 없음. 기존 파일과 완성 temp가 동시에 존재하는 디스크 공간 필요 | +| fallback | `luiePackageWriter.ts:68` 특정 오류에만 target→bak(:79), temp→target(:81), bak 제거(:82), 실패시 복구 rename(:87) | backup은 **파일 내용 복사 아닌 rename**. 정상 경로에 매 저장 별도 백업 복사 없음 | + +SQLite `synchronous=FULL` 설정은 확인했지만 이번 함수 계측은 커널 `fsync` 호출 수/실제 디스크 write bytes를 측정하지 않았다. JS writer에는 별도 fileHandle.sync/parent directory fsync가 없다. 소스의 atomicReplace 함수명과 rename 실패 테스트만으로 정전 후 디렉터리 변경 내구성까지 보장한다고 할 수 없다. OS별/파일시스템별 crash test가 별도 필요하다. + +현행 full export 비용의 주항은 **전체 데이터 materialization + 전체 새 파일 생성**이다. JSON pretty 공백·배열 map·schema bootstrap 최적화도 유효하지만 변경 1건의 전체 데이터 수집/재삽입을 유지한 채로는 크기에 비례하는 비용을 없애지 못한다. + +## 3. 실제 writer 최소 재현 — 실측 + +재현 script: `/private/tmp/luie-package-cost-repro.cjs` +결과: `/private/tmp/luie-package-cost-results.json` + +```bash +NODE_PATH=/Users/user/Luie/node_modules node /private/tmp/luie-package-cost-repro.cjs +``` + +- Node v22.23.0 / macOS arm64, 현재 설치된 better-sqlite3와 실제 `writeLuieContainer` / `writeLuieContainerEntry` 소스를 esbuild로 임시 bundle해 실행. +- chapter 50/250/1000개, 각각 UTF-8 64KiB. snapshot 20개, 각각 본문64KiB. world/memory는 빈 기본 문서. 기존 package에서 chapter 하나 끝의 **1 byte 변경**. +- 최초 생성은 제외하고 full writer 3회, current entry writer 3회. 표는 각 방식 wall time 중앙값. +- payload는 측정 전에 이미 만들어져 있다. 따라서 **main DB 수집/DTO/실제 풍부한 world/memory 데이터 비용은 포함하지 않는다**. 함수 계측 wrapper overhead가 포함된다. 단건 writer는 안전한 새 구현이 아니라 현행 API다. + +| 장 수 | 최종 파일 크기 | full writer wall | current entry wall | full entry INSERT 수 | full 논리 content bytes | entry 논리 content bytes | +|---:|---:|---:|---:|---:|---:|---:| +| 50 | 5,967,872 B | 12.318 ms | 1.224 ms | 81 | 5,910,059 B | 71,435 B | +| 250 | 19,222,528 B | 25.369 ms | 1.307 ms | 281 | 19,040,659 B | 94,835 B | +| 1000 | 68,890,624 B | 80.908 ms | 2.970 ms | 1031 | 68,281,159 B | 183,335 B | + +1000장 full의 JSON.stringify 중앙값 4.737ms, SQLite data transaction 중앙값72.144ms. entry 방식은 chapter+meta 2개 UPSERT, 추가 info UPDATE가 있고 명시적 transaction은 0개다. full은 data transaction 1개를 사용한다. full 정상 JS FS 호출은 stat1/open1/mkdir1/rename1, current entry는 stat2/open1로 관측됐다(native SQLite I/O 제외). + +full은 매번 inode가 변경됐고 current entry는 동일 inode였다. final 파일끼리 비교한 달라진 4KiB page 수는 1000장 full1036/entry7이었다. **최종 byte 차이 page 수는 실제 쓰인 page 수와 다르다.** full은 unchanged bytes도 새 파일에 기록하므로 final diff로 full 물리 쓰기를 추정하면 과소평가한다. 위 logical content bytes도 native binding에 전달한 문자열 UTF-8 합계이지 커널 disk bytes가 아니다. + +해석: 장 하나의 64KiB 본문을 갱신할 때 full은68.28MB, entry는183KB만 entry bind에 전달했다. entry도 meta 전체 chapter 목록을 갱신하므로 O(changed body+meta(C))이며 완전 O(1)은 아니다. full 대비 약10~27배 wall 차이는 **이 fixture/장치/계측 범위에서만** 확인됐다. Windows/Linux에서 같은 배수를 약속할 수 없고 UI end-to-end 응답성 수치도 아니다. + +## 4. 왜 coalescing이 증분 저장으로 이어지지 않는가 — 소스 확정 + +`src/main/services/features/project/projectService.ts:48`는 chapter create/update, 인물·용어 등 create/update/delete, graph, snapshot:create를 debounce한다. `src/shared/constants/runtime/interactionTiming.ts:4` 설정은500ms. chapter 변경의 실제 caller는 `src/main/services/core/chapter/chapterWriteOperations.ts:319`이다. reason allowlist 밖의 변경은 `projectService.ts:466` 즉시 checkpoint 경로를 탄다. 모든 키 입력이 반드시 export1회를 일으킨다는 뜻은 아니다. 위로 renderer/main autosave coalescing도 있고 여기서 다시500ms로 합친다. + +그러나 `projectExportQueue.ts:30` state는 timer/inFlight/dirty boolean뿐이다. 변경 chapterId/path/delete tombstone이 없다. `.schedule`(:119)는 타이머를 계속 reset한다. `.runNow`(:131)는 source가 동일해도 dirty를 강제로 세운다. export 중 새 mutation은 dirty/revision 증가로 while(:152)에서 추가 **full export**를 유발한다. 이 층에는 debounce maxWait가 없어500ms보다 촘촘한 연속 schedule은 checkpoint를 미룰 수 있다. 실제 발생 빈도는 상위 autosave 주기에 달려 있다. + +queue는 capturedRevision(:159)을 runExport에 넘기지만 `projectService.ts:103` callback은 projectId만 받아 무시한다. 성공 뒤 local attachment exportedRevision을 갱신(:175)하고 latest revision으로 재실행을 결정(:177)한다. 이는 변경 도중의 후속 full export를 보수적으로 보장하는 장점이 있지만, 특정 revision의 일관된 payload를 capture하는 구현은 아니다. + +`getProjectForExport`의 여러 await/query, 이후 memory/world 수집은 한 read snapshot 안에 묶이지 않았다. package path lock은 **이 수집이 끝난 다음 writer 진입**에서 획득한다. 따라서 path lock이 있어도 오래전에 수집한 payload가 더 최근 entry 쓰기 뒤에 replace되는 논리적 순서는 막지 못한다. 실제 사용 빈도/피해는 추가 race 실험이 필요하므로 이 항목은 **설계상 경쟁 가능성**으로 분류한다. + +## 5. 기존 단건 API를 그대로 사용하면 생기는 정확성 문제 — P1 후보, 소스·실제 재현 확정 + +위치: `src/main/services/io/luieSqliteContainer.ts:347`, `:349`, `:357`. + +현재 단건 writer는 (1) 본문 entry UPSERT, (2) meta.updatedAt 갱신 UPSERT, (3) info.updatedAt UPDATE를 **transaction 없이 차례로 실행**한다. 두 번째 또는 세 번째 statement 실패 시 앞의 statement는 이미 커밋되어 있다. Promise reject를 “아무것도 저장하지 못함”으로 해석할 수 없다. + +실제 caller는 `src/main/handler/system/fs/fsPackageOperations.ts:208`의 package entry IPC와 `src/main/services/features/memory/persistence/memoryCanonicalPackageSyncVerifier.ts:239`의 repair다. API는 미사용 코드가 아니다. + +재현 script: `/private/tmp/luie-package-atomicity-repro.cjs` +결과: `/private/tmp/luie-package-atomicity-results.json` + +```bash +NODE_PATH=/Users/user/Luie/node_modules node /private/tmp/luie-package-atomicity-repro.cjs +``` + +실제 source writer에 synthetic SQLite trigger로 later statement 실패를 주입했다. + +| 주입 실패 | Promise | 저장된 chapter | meta.updatedAt | info.updatedAt | +|---|---|---|---|---| +| meta UPDATE | reject | 새 본문 `after` | 이전값 | 이전값 | +| info UPDATE | reject | 새 본문 `after` | 새값 | 이전값 | + +실제 디스크 부족·정전 실험은 아니지만 later statement failure 이후 partial commit은 실제 SQLite 파일을 열어 확인했다. + +최소 수정 설계는 **entry upsert/delete 집합+meta+info를 하나의 native synchronous transaction으로 묶는 batch API**다. 기존 Promise path lock은 유지하되 transaction callback 내부에서 await/외부 I/O를 하지 않는다. begin→모든 entry→meta→info→commit 중 예외면 전부 rollback되어야 한다. 또한 meta에 존재하는 장 추가/삭제/재정렬은 단건 arbitrary entry API가 자동으로 처리하지 않으므로 caller가 연관 entry를 함께 명시해야 한다. + +## 6. v2 포맷 유지 증분 저장에 필요한 계약 + +### 바로 얻을 수 있는 이점 + +기존 reader는 path별 SELECT이므로 같은 schema/논리 경로를 유지한 transactional UPSERT는 format migration 없이 읽을 수 있다. chapter content-only부터 시작하면 다른 모든 chapter/world/snapshot을 읽거나 stringify할 이유가 없어진다. 초기 생성·Save As·import materialization·손상 복구는 기존 full writer를 유지할 수 있다. 전체 normalized project schema를 package에 옮기는 대규모 포맷 개편은 이 1차 개선에 필수가 아니다. + +### 반드시 같이 설계해야 하는 경계 + +1. **변경집합과 generation**: project queue가 chapter/path별 최신 변경 revision 및 delete를 보관한다. batch가 capture한 집합만 성공 후 제거하며 export 중 추가된 generation을 지우지 않는다. reason 문자열이나 global dirty boolean만으로는 어떤 entry가 필요한지 알 수 없다. +2. **내용 수집 시점**: source project revision과 변경 rows를 한 일관된 read snapshot에서 얻는다. native SQLite의 sync transaction에 async callback을 넣지 않는다. 수집을 위해 전체 데이터 clone/해시를 하는 방식이면 DB 측 O(B) 병목이 남는다. +3. **한 revision의 전체 변경을 포괄**: chapter만 최신이라고 전체 project exportedRevision을 올리면 안 된다. 같은 revision 범위의 world/meta/snapshot 변경까지 빠짐없이 commit한 뒤 ACK해야 한다. 미지원 mutation이 하나라도 있으면 해당 batch는 안전한 full export로 fallback한다. +4. **canonical DB와 package는 별도 파일**: package commit과 local attachment exportedRevision 갱신이 지금 하나의 transaction은 아니다. 먼저 package commit, 뒤에 local ACK를 유지하여 crash시 같은 batch 재실행이 가능하게 한다. 반대 순서는 미export 상태를 완료로 오인하게 한다. +5. **복구**: 초기에는 in-memory dirty set을 잃은 경우 existing `Project.revision > exportedRevision` 복구 경로(`projectRevisionStore.ts:108`)로 full checkpoint를 수행할 수 있다. 재시작에서도 증분 replay가 필요해질 때 durable dirty-entry outbox를 추가한다. 단, root가 확인한 open/import의 timestamp 기반 역방향 덮어쓰기 문제를 먼저 수정하지 않으면 stale `.luie`가 미checkpoint DB를 덮을 수 있다. 현재 app DB를 언제나 버려도 되는 cache로 취급하면 안 된다. +6. **package의 revision 식별**: 현재 meta에는 revision이 없고 `updatedAt`만 있다. `LuieMetaSchema`는 top-level passthrough(`projectLuieSchemas.ts:30`)이므로 optional package checkpoint marker를 추가하는 것은 읽기 호환성 여지가 있다. 그러나 import/relink/다른 기기의 로컬 revision 리셋까지 같은 숫자로 비교하면 안 된다. project/attachment generation 또는 epoch와 revision의 의미를 함께 정해야 한다. 단순 timestamp 비교로 완전성을 판단하지 않는다. +7. **writer 소유권**: generic FS entry 쓰기, memory repair, full export, incremental export가 같은 coordinator를 따라야 한다. `withPackageWriteLock`은 path.resolve 문자열 기준 process/module-local Promise lock이다. 여러 process, 다른 symlink/대소문자 alias, 외부 편집까지 배타 제어하지 않는다. payload를 lock 밖에서 수집하는 stale write도 별도 처리해야 한다. +8. **삭제/중복 관계**: chapter 삭제는 manuscript row 삭제+meta 목록 변경; snapshot prune은 index 변경+개별 .snap 삭제; entity 변경은 collection JSON과 graph 등 해당 파생 package 문서가 함께 변경될 수 있다. dirty mapping을 기능별로 정해야 한다. + +meta 전체 목록을 매번 stringify하는 O(C) 비용은 body 전체 O(B)에 비해 작지만 남는다. 먼저 current reader 호환을 유지한 chapter+meta delta로 측정하고, meta가 실제 병목일 때 개별 metadata 정규화 또는 업데이트 시각 분리 등을 후속 포맷 변경으로 검토하는 편이 비용 대비 효과가 좋다. + +snapshot 중복 본문 제거 역시 유효하지만 현재 importer는 `snapshots/index.json`의 content 배열을 이용한다(`src/main/services/core/project/importOpen/collections.ts:289`, `:408`; `projectImportCodec.ts:195`). index를 manifest-only로 바꾸려면 reader도 .snap 로드하도록 고쳐야 한다. 단순히 content를 지워서는 안 된다. canonical memory/world를 개별 row로 정규화하는 변경은 migration/구버전 호환/외부 도구 영향을 포함하므로 1차 chapter delta와 분리해 비용을 판단한다. + +## 7. 구현 선택지의 비용과 권장 순서 — 설계 추론 + +| 선택지 | 기대효과 | 구현·검증 부담 | 판단 | +|---|---|---|---| +| snapshot SELECT에 실제 export limit 적용, chapter legacy/body 중복 SELECT 축소, no-op export skip | full collector 낭비 감소 | 작음~중간; limit=0 의미·정렬·timestamp 계산 확인 | 의미 보존이 쉬운 보조 개선 | +| 현행 full 유지, JSON 공백 제거만 | aggregate JSON byte 감소 | 작음이나 포맷 가독성 변화; 본문 B 비용 유지 | 주요 병목 해결책 아님 | +| 기존 단건 writer를 자동저장에서 바로 호출 | 실험상 큰 비용 감소 | partial commit/ACK 불일치/관련 문서 누락 | 채택 불가 | +| transactional entry batch + chapter dirty set + revision ACK + full fallback | 가장 흔한 작은 변경에서 전체 수집/재작성 제거 | 중간~큼; 저장 신뢰성 회귀 테스트 필수 | 우선 권장 | +| 모든 domain의 durable dirty-entry outbox | crash 이후도 선택적 replay, 정확한 pending 추적 | 큼; source mutation transaction에 결합 필요 | 1차 결과·복구 요구에 따라 확장 | +| package 내부를 완전히 정규화한 project DB로 재설계 | world/memory 부분 변경과 질의도 세분화 가능 | 가장 큼; format migration, import/export, plugin/reader 호환 | 지금 full rewrite 해소의 필수 선행작업 아님 | +| package SQL/serialize를 worker/utility process로 이동 | main thread stall 완화 | IPC payload 복제·writer owner·crash restart 설계 필요 | 작업량 축소 후 병행 검토. full payload IPC 전달은 O(B) 복제 위험 | + +크로스플랫폼 공통 구현은 동일한 SQLite transaction/dirty revision 계약을 쓴다. portable `.luie`는 standalone 파일 요구와 기존 테스트가 있으므로 무조건 WAL로 전환하는 것을 권하지 않는다. DELETE rollback journal도 transient journal을 사용하며 transaction page 변경만으로 증분 commit 가능하다. WAL을 택한다면 checkpoint/close와 sidecar 생명주기까지 별도 계약이 필요하다. 단건 증분은 inplace 파일의 유휴 freelist가 남을 수 있으므로 파일 축소는 주기적 명시적 compaction/full checkpoint로 처리하고 매 저장 VACUUM하지 않는다. + +OS별 실측 대상은 macOS/APFS 및 arm64/x64, Windows/NTFS의 file-handle/백신/동기화 클라이언트 영향, Linux/ext4의 실제 저장 장치다. 이번 실험은 macOS arm64 한 환경만 검증했다. 위 수정의 차수 개선은 공통이지만 fsync/rename 지연, 권한·외부 파일 잠금에 대한 동작은 환경별 검증이 필요하다. + +## 8. 추가로 드러난 제한과 검증 계획 + +**현재 read/write 크기 계약 불일치(소스·실제 재현 확정):** `luieSqliteContainer.ts:30` entry read limit5MiB, :31 package read limit256MiB, :198 row를 materialize한 뒤 크기를 검사한다. full writer는 동일 entry/최종 package 상한 사전검사를 하지 않고, 단건 writer는 기존 package 크기만 검사(:303)한 뒤 신규 content를 넣는다. 큰 `memory/canonical.json` 또는 `snapshots/index.json`이 한계 초과 상태로 저장될 수 있다. 증분화와 별개로 write와 read의 허용범위를 맞추거나 aggregate 포맷을 나누어야 한다. 실제 writer/read API에 ASCII chapter 5MiB+1=5,242,881 bytes를 넣었다. full writer와 단건 writer 모두 정상 resolve하고 SQLite에 정확히5,242,881 bytes가 저장됐으나, 같은 앱 reader는 `FS_2001: SQLite-backed .luie entry is too large`로 거부했다. 생성 파일은5,267,456 bytes로 package256MiB 제한과 무관하다. 이 결과는 chapter 단일 entry로 확인했으며 snapshot/index 또는 canonical/import 후속 손실은 별도 caller 분석이 필요하다. + +재현: `/private/tmp/luie-package-size-limit-repro.cjs`, 결과 `/private/tmp/luie-package-size-limit-results.json`. + +```bash +NODE_PATH=/Users/user/Luie/node_modules node /private/tmp/luie-package-size-limit-repro.cjs +``` + +이미 갖춘 방어는 유지할 가치가 있다: per-path write 직렬화, full writer의 data transaction, temp 완성 후 replace, 실패시 temp 정리·rename rollback, source revision 기반 재export, path traversal 검증, legacy container 거부, read size 제한. 기존 `tests/main/services/luiePackageWriter.rollback.test.ts:44`는 실제 temp 파일과 rename 오류 주입으로 복구를 확인하며 :90은 pre-replace 실패의 tmp 정리를 확인한다. `luieContainer.extreme.test.ts`는 실제 entry 읽기/쓰기·경로/JSON 검증·sidecar 부재를 다룬다. `projectExportQueue.test.ts`는 mock source revision 변동 재실행·실패 dirty 유지·detached skip을 다룬다. 이번 감사에서 이 테스트 전체를 재실행했다고 주장하지 않는다. + +증분 구현 전에 명확히 해야 할 통과 기준: + +- 같은 revision의 full export와 delta export를 실제 reader/importer로 읽으면 전체 프로젝트 의미가 같을 것. +- 중간 entry/meta/info 실패 시 전 batch rollback, success ACK 없음. package commit 뒤 local ACK 전에 crash하면 idempotent replay될 것. +- export 중 동일 chapter 신규 generation과 다른 world 변경이 도착해도 pending이 사라지지 않을 것. +- 장 생성/삭제/순서 변경, snapshot prune, graph와 canonical entity 동시 변경을 누락하지 않을 것. +- stale package open이 미checkpoint DB를 timestamp만 보고 덮어쓰지 않을 것(root 별도 실제 재현). +- 동일 내용 저장은 package write 생략; 수동 저장은 현재 revision의 durable completion을 기다릴 것. +- macOS/Windows/Linux에서 normal commit, replace 오류, 외부 read handle, 강제종료 후 reopen을 실제 temp package로 검증할 것. +- perf 회귀는 장50/250/1000의 단건 변경에서 SQL rows/논리 content bytes, main event-loop delay, RSS peak, wall p50/p95, 실제 OS I/O를 함께 기록할 것. fixture의 숫자를 제품 성능 보장으로 사용하지 않을 것. + +이번 범위에서 소스 확정과 실제 synthetic 재현은 구별해 기재했다. 제품 구조 변경, native module rebuild, 사용자 파일 변경은 수행하지 않았다. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eeb32738..da825245 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -33,55 +33,87 @@ const mainExternal = [ ]; const isNodeModule = (id: string): boolean => id.includes("/node_modules/"); + +// pnpm 레이아웃(node_modules/.pnpm/@_/node_modules//...)에서 +// 마지막 node_modules 뒤의 실제 패키지 디렉터리를 패키지 경계 단위로 추출한다. +// substring 매칭(id.includes("/react/"))은 "/@tiptap/react/" 같은 경로도 흡수해 +// @tiptap/react가 vendor-react에 편입되고 prosemirror까지 부트 청크로 당겨지는 +// 버그의 원인이었다(docs/architecture/startup-pipeline-dissection.md §5b). +const resolvePackageName = (id: string): string | undefined => { + const lastNodeModules = id.lastIndexOf("/node_modules/"); + if (lastNodeModules === -1) { + return undefined; + } + const [first, second] = id.slice(lastNodeModules + "/node_modules/".length).split("/"); + if (!first) { + return undefined; + } + return first.startsWith("@") ? `${first}/${second}` : first; +}; + +const VENDOR_REACT_PACKAGES = new Set([ + "react", + "react-dom", + "scheduler", + "zustand", + "i18next", + "react-i18next", + "i18next-browser-languagedetector", +]); +const VENDOR_GRAPH_PACKAGES = new Set(["reactflow", "dagre"]); +const VENDOR_WORKSPACE_PACKAGES = new Set([ + "@dnd-kit/core", + "@dnd-kit/sortable", + "@dnd-kit/utilities", + "react-resizable-panels", +]); +const VENDOR_UI_PACKAGES = new Set(["lucide-react", "clsx", "tailwind-merge"]); +const VENDOR_DATA_PACKAGES = new Set([ + "@supabase/supabase-js", + "@supabase/auth-js", + "@supabase/realtime-js", + "@supabase/storage-js", + "@supabase/functions-js", + "@supabase/gotrue-js", + "jszip", + "docx", + "dompurify", + "diff", +]); +const VENDOR_SCHEMA_PACKAGES = new Set(["zod"]); + const rendererManualChunks = (id: string): string | undefined => { - if (isNodeModule(id)) { - if ( - id.includes("/react/") || - id.includes("/react-dom/") || - id.includes("/scheduler/") || - id.includes("/zustand/") || - id.includes("/i18next") || - id.includes("/react-i18next") - ) { - return "vendor-react"; - } - if (id.includes("/@tiptap/")) { - return "vendor-tiptap"; - } - if (id.includes("/prosemirror-")) { - return "vendor-prosemirror"; - } - if ( - id.includes("/reactflow/") || - id.includes("/dagre/") - ) { - return "vendor-graph"; - } - if ( - id.includes("/@dnd-kit/") || - id.includes("/react-resizable-panels/") - ) { - return "vendor-workspace"; - } - if ( - id.includes("/lucide-react/") || - id.includes("/clsx/") || - id.includes("/tailwind-merge/") - ) { - return "vendor-ui"; - } - if ( - id.includes("/@supabase/") || - id.includes("/zod/") || - id.includes("/jszip/") || - id.includes("/docx/") || - id.includes("/dompurify/") || - id.includes("/diff/") - ) { - return "vendor-data"; - } + if (!isNodeModule(id)) { + return undefined; + } + const packageName = resolvePackageName(id); + if (!packageName) { return undefined; } + if (VENDOR_REACT_PACKAGES.has(packageName)) { + return "vendor-react"; + } + if (packageName.startsWith("@tiptap/")) { + return "vendor-tiptap"; + } + if (packageName.startsWith("prosemirror-")) { + return "vendor-prosemirror"; + } + if (VENDOR_GRAPH_PACKAGES.has(packageName)) { + return "vendor-graph"; + } + if (VENDOR_WORKSPACE_PACKAGES.has(packageName)) { + return "vendor-workspace"; + } + if (VENDOR_UI_PACKAGES.has(packageName)) { + return "vendor-ui"; + } + if (VENDOR_DATA_PACKAGES.has(packageName)) { + return "vendor-data"; + } + if (VENDOR_SCHEMA_PACKAGES.has(packageName)) { + return "vendor-schema"; + } return undefined; }; diff --git a/package.json b/package.json index e54bc9e0..8771426d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "author": "Luie Team", "license": "MIT", "private": true, - "packageManager": "pnpm@11.25.0", + "packageManager": "pnpm@12.3.4", "scripts": { "predev": "pnpm rebuild:electron", "dev": "electron-vite", @@ -61,8 +61,11 @@ "check:preload-contract-regression": "node scripts/check-preload-contract-regression.mjs", "check:core-complexity": "node scripts/check-core-complexity.mjs", "check:source-loc": "node scripts/check-source-loc.mjs", + "check:render-boot-budget": "node scripts/check-render-boot-budget.mjs", + "check:startup-wizard-bundle": "node scripts/check-startup-wizard-bundle.mjs", "verify:packaged-drizzle": "node scripts/verify-packaged-drizzle.mjs", "bench:derived-db": "node scripts/benchmark-derived-db.mjs", + "bench:startup": "node scripts/benchmark-startup.mjs", "bench:writing-loop:profiles": "node scripts/run-writing-loop-profiles.mjs", "bench:writing-loop:profiles:all": "node scripts/run-writing-loop-profiles.mjs --include-500", "bench:writing-loop:repeat": "node scripts/run-writing-loop-repeat.mjs", @@ -105,7 +108,7 @@ "memory:assess-writer-benchmark": "tsx scripts/assess-memory-writer-benchmark-thresholds.ts", "lint-all": "pnpm run check:deps && pnpm run check:no-native-dialogs && pnpm run check:i18n-parity && pnpm run check:no-escape-hatches && pnpm run check:version-pins && pnpm run check:ipc-contract-map && pnpm run check:ipc-handler-schemas && pnpm run check:security-profile && pnpm run check:utility-process-boundary && pnpm run check:renderer-store-usage && pnpm run check:persist-contracts && pnpm run check:preload-contract-regression && pnpm run check:source-loc && eslint src --ext .ts,.tsx", "lint": "eslint src --ext .ts,.tsx", - "qa:core": "pnpm run typecheck && pnpm run lint && pnpm run check:utility-process-boundary && pnpm run check:renderer-store-usage && pnpm run check:ipc-handler-schemas && pnpm run check:persist-contracts && pnpm run check:core-complexity && pnpm run check:source-loc && pnpm run check:target-file-drift && pnpm run check:build-warning-regression && pnpm run check:derived-db-bench && pnpm run check:design-tokens && SKIP_DB_TEST_SETUP=1 pnpm vitest tests/main/lifecycle/deepLink.test.ts tests/main/handler/ipcWindowHandlers.test.ts tests/main/handler/ipcInputValidation.test.ts tests/main/services/syncAuthService.test.ts tests/main/services/syncService.test.ts tests/main/services/syncMapper.test.ts tests/main/services/syncRepository.test.ts tests/main/services/exportService.test.ts tests/renderer/hooks/useTermDragDrop.test.ts tests/renderer/services/exportEntryService.test.ts tests/renderer/services/worldPackageStorage.test.ts tests/renderer/stores/projectLayoutStore.test.ts tests/renderer/stores/projectLayoutStore.persist.test.ts tests/renderer/stores/uiStore.persist.test.ts tests/renderer/stores/memoStore.test.ts tests/renderer/utils/sanitizeHtml.test.ts tests/shared/utils/exportContentNormalization.test.ts tests/dom/focusHoverSidebar.test.tsx tests/dom/rendererRerenderRegression.test.tsx tests/dom/appOperationalScenarios.test.tsx tests/dom/projectInitOperationalScenarios.test.tsx tests/scripts/buildWarningNormalization.test.ts tests/scripts/preloadContractRegression.test.ts tests/scripts/rendererCspPolicy.test.ts tests/scripts/ipcContractMap.test.ts tests/scripts/ipcHandlerSchemas.test.ts tests/scripts/dependencyIntegrity.test.ts tests/scripts/securityProfile.test.ts tests/scripts/rendererStoreUsage.test.ts tests/scripts/persistContracts.test.ts tests/scripts/utilityProcessBoundary.test.ts", + "qa:core": "pnpm run typecheck && pnpm run lint && pnpm run check:utility-process-boundary && pnpm run check:renderer-store-usage && pnpm run check:ipc-handler-schemas && pnpm run check:persist-contracts && pnpm run check:core-complexity && pnpm run check:source-loc && pnpm run check:target-file-drift && pnpm run check:build-warning-regression && pnpm run check:derived-db-bench && pnpm run check:design-tokens && SKIP_DB_TEST_SETUP=1 pnpm vitest tests/main/lifecycle/deepLink.test.ts tests/main/handler/ipcWindowHandlers.test.ts tests/main/handler/ipcWindowResize.test.ts tests/main/handler/ipcInputValidation.test.ts tests/main/services/syncAuthService.test.ts tests/main/services/syncService.test.ts tests/main/services/syncMapper.test.ts tests/main/services/syncRepository.test.ts tests/main/services/exportService.test.ts tests/renderer/hooks/useTermDragDrop.test.ts tests/renderer/services/exportEntryService.test.ts tests/renderer/services/worldPackageStorage.test.ts tests/renderer/stores/projectLayoutStore.test.ts tests/renderer/stores/projectLayoutStore.persist.test.ts tests/renderer/stores/uiStore.persist.test.ts tests/renderer/stores/memoStore.test.ts tests/renderer/utils/sanitizeHtml.test.ts tests/shared/utils/exportContentNormalization.test.ts tests/dom/startupWizardResize.test.tsx tests/dom/focusHoverSidebar.test.tsx tests/dom/rendererRerenderRegression.test.tsx tests/dom/appOperationalScenarios.test.tsx tests/dom/projectInitOperationalScenarios.test.tsx tests/scripts/buildWarningNormalization.test.ts tests/scripts/preloadContractRegression.test.ts tests/scripts/rendererCspPolicy.test.ts tests/scripts/ipcContractMap.test.ts tests/scripts/ipcHandlerSchemas.test.ts tests/scripts/dependencyIntegrity.test.ts tests/scripts/securityProfile.test.ts tests/scripts/rendererStoreUsage.test.ts tests/scripts/persistContracts.test.ts tests/scripts/utilityProcessBoundary.test.ts", "format": "prettier --write \"src/**/*.{ts,tsx,json}\"", "test": "pnpm rebuild better-sqlite3 && vitest", "test:ui-mode-integrity": "SKIP_DB_TEST_SETUP=1 vitest tests/renderer/services/uiModeIntegrity.test.ts tests/renderer/services/layoutModeActions.integrity.test.ts tests/renderer/stores/editorStore.uiModeIntegrity.test.ts", @@ -116,7 +119,7 @@ "test:smoke": "pnpm test:prepare && pnpm build && node node_modules/@playwright/test/cli.js test --project=smoke" }, "dependencies": { - "@google/genai": "2.20.0", + "@google/genai": "2.21.0", "better-sqlite3": "13.0.3", "docx": "9.7.1", "drizzle-orm": "0.45.2", @@ -138,58 +141,62 @@ "@electron/rebuild": "4.2.0", "@eslint/js": "10.0.1", "@fontsource-variable/inter": "5.3.0", - "@playwright/test": "1.62.1", + "@playwright/test": "1.63.0", + "@rolldown/binding-win32-arm64-msvc": "^1.2.7", + "@tailwindcss/oxide-win32-arm64-msvc": "^4.3.3", "@tailwindcss/postcss": "4.3.3", - "@tiptap/core": "3.31.0", - "@tiptap/extension-color": "3.31.0", - "@tiptap/extension-details": "3.31.0", - "@tiptap/extension-focus": "3.31.0", - "@tiptap/extension-highlight": "3.31.0", - "@tiptap/extension-placeholder": "3.31.0", - "@tiptap/extension-task-item": "3.31.0", - "@tiptap/extension-task-list": "3.31.0", - "@tiptap/extension-text-align": "3.31.0", - "@tiptap/extension-text-style": "3.31.0", - "@tiptap/extension-underline": "3.31.0", - "@tiptap/pm": "3.31.0", - "@tiptap/react": "3.31.0", - "@tiptap/starter-kit": "3.31.0", - "@tiptap/suggestion": "3.31.0", + "@tiptap/core": "3.31.3", + "@tiptap/extension-color": "3.31.3", + "@tiptap/extension-details": "3.31.3", + "@tiptap/extension-focus": "3.31.3", + "@tiptap/extension-highlight": "3.31.3", + "@tiptap/extension-placeholder": "3.31.3", + "@tiptap/extension-task-item": "3.31.3", + "@tiptap/extension-task-list": "3.31.3", + "@tiptap/extension-text-align": "3.31.3", + "@tiptap/extension-text-style": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", + "@tiptap/starter-kit": "3.31.3", + "@tiptap/suggestion": "3.31.3", "@types/better-sqlite3": "9.6.0", "@types/dagre": "0.7.54", "@types/diff": "8.0.0", - "@types/node": "26.4.1", + "@types/node": "26.5.0", "@types/react": "19.2.18", - "@types/react-dom": "19.2.5", + "@types/react-dom": "19.2.7", "@types/yauzl": "3.4.0", "@types/yazl": "3.3.1", - "@typescript-eslint/eslint-plugin": "8.69.0", - "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", "@vitejs/plugin-react": "6.1.1", - "autoprefixer": "10.5.4", + "autoprefixer": "10.5.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cross-env": "10.1.0", "dagre": "0.8.5", "diff": "9.0.0", - "dompurify": "3.4.14", + "dompurify": "3.4.15", "dotenv": "17.4.2", "dotenv-cli": "11.0.0", "drizzle-kit": "0.31.10", - "electron": "44.1.1", + "electron": "44.2.0", "electron-builder": "26.15.3", "electron-vite": "5.0.0", - "eslint": "10.9.1", + "eslint": "10.10.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", - "eslint-plugin-react-refresh": "0.5.5", + "eslint-plugin-react-refresh": "0.5.6", "globals": "17.12.0", - "i18next": "26.4.1", + "i18next": "26.4.2", "i18next-browser-languagedetector": "8.2.1", "jsdom": "30.0.1", - "lucide-react": "1.39.0", - "postcss": "8.5.26", + "lightningcss": "^1.33.0", + "lightningcss-win32-arm64-msvc": "^1.33.0", + "lucide-react": "1.42.0", + "postcss": "8.5.28", "prettier": "3.9.6", "prosemirror-model": "1.25.11", "prosemirror-state": "1.4.4", @@ -198,17 +205,17 @@ "react": "19.2.8", "react-dom": "19.2.8", "react-i18next": "17.0.13", - "react-resizable-panels": "4.12.3", - "react-virtuoso": "4.18.12", + "react-resizable-panels": "4.12.4", + "react-virtuoso": "4.18.13", "reactflow": "11.11.4", - "supabase": "2.116.0", + "supabase": "2.117.0", "tailwind-merge": "3.6.0", "tailwindcss": "4.3.3", "typescript": "npm:@typescript/typescript6@6.0.2", "typescript-7": "npm:typescript@7.0.2", - "typescript-eslint": "8.69.0", + "typescript-eslint": "8.70.0", "vite": "8.2.2", - "vitest": "4.1.11", + "vitest": "5.0.0", "zustand": "5.0.15" }, "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9889856..90c3c2e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.3.4 + version: 12.3.4 + +packages: + + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} + cpu: [x64] + os: [win32] + + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.3.4': + optional: true + + '@pnpm/exe.darwin-x64@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64@12.3.4': + optional: true + + '@pnpm/exe.linux-x64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-x64@12.3.4': + optional: true + + '@pnpm/exe.win32-arm64@12.3.4': + optional: true + + '@pnpm/exe.win32-x64@12.3.4': + optional: true + + pnpm@12.3.4: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 + +--- lockfileVersion: '9.0' settings: @@ -9,8 +110,8 @@ importers: .: dependencies: '@google/genai': - specifier: 2.20.0 - version: 2.20.0(supports-color@7.2.0) + specifier: 2.21.0 + version: 2.21.0(supports-color@7.2.0) better-sqlite3: specifier: 13.0.3 version: 13.0.3 @@ -37,7 +138,7 @@ importers: version: 0.1.9 tiptap-markdown: specifier: 0.9.0 - version: 0.9.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + version: 0.9.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) yauzl: specifier: 3.4.0 version: 3.4.0 @@ -65,61 +166,67 @@ importers: version: 4.2.0(supports-color@7.2.0) '@eslint/js': specifier: 10.0.1 - version: 10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) + version: 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) '@fontsource-variable/inter': specifier: 5.3.0 version: 5.3.0 '@playwright/test': - specifier: 1.62.1 - version: 1.62.1 + specifier: 1.63.0 + version: 1.63.0 + '@rolldown/binding-win32-arm64-msvc': + specifier: ^1.2.7 + version: 1.2.7 + '@tailwindcss/oxide-win32-arm64-msvc': + specifier: ^4.3.3 + version: 4.3.3 '@tailwindcss/postcss': specifier: 4.3.3 version: 4.3.3 '@tiptap/core': - specifier: 3.31.0 - version: 3.31.0(@tiptap/pm@3.31.0) + specifier: 3.31.3 + version: 3.31.3(@tiptap/pm@3.31.3) '@tiptap/extension-color': - specifier: 3.31.0 - version: 3.31.0(@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))) '@tiptap/extension-details': - specifier: 3.31.0 - version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)))(@tiptap/pm@3.31.0) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))(@tiptap/pm@3.31.3) '@tiptap/extension-focus': - specifier: 3.31.0 - version: 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-highlight': - specifier: 3.31.0 - version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-placeholder': - specifier: 3.31.0 - version: 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-task-item': - specifier: 3.31.0 - version: 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-task-list': - specifier: 3.31.0 - version: 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-text-align': - specifier: 3.31.0 - version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-text-style': - specifier: 3.31.0 - version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-underline': - specifier: 3.31.0 - version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/pm': - specifier: 3.31.0 - version: 3.31.0 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/react': - specifier: 3.31.0 - version: 3.31.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tiptap/starter-kit': - specifier: 3.31.0 - version: 3.31.0 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/suggestion': - specifier: 3.31.0 - version: 3.31.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@types/better-sqlite3': specifier: 9.6.0 version: 9.6.0 @@ -130,14 +237,14 @@ importers: specifier: 8.0.0 version: 8.0.0 '@types/node': - specifier: 26.4.1 - version: 26.4.1 + specifier: 26.5.0 + version: 26.5.0 '@types/react': specifier: 19.2.18 version: 19.2.18 '@types/react-dom': - specifier: 19.2.5 - version: 19.2.5(@types/react@19.2.18) + specifier: 19.2.7 + version: 19.2.7(@types/react@19.2.18) '@types/yauzl': specifier: 3.4.0 version: 3.4.0 @@ -145,17 +252,17 @@ importers: specifier: 3.3.1 version: 3.3.1 '@typescript-eslint/eslint-plugin': - specifier: 8.69.0 - version: 8.69.0(@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + specifier: 8.70.0 + version: 8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) '@typescript-eslint/parser': - specifier: 8.69.0 - version: 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + specifier: 8.70.0 + version: 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 6.1.1(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) autoprefixer: - specifier: 10.5.4 - version: 10.5.4(postcss@8.5.26) + specifier: 10.5.5 + version: 10.5.5(postcss@8.5.28) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -164,7 +271,7 @@ importers: version: 2.1.1 cmdk: specifier: 1.1.1 - version: 1.1.1(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) cross-env: specifier: 10.1.0 version: 10.1.0 @@ -175,8 +282,8 @@ importers: specifier: 9.0.0 version: 9.0.0 dompurify: - specifier: 3.4.14 - version: 3.4.14 + specifier: 3.4.15 + version: 3.4.15 dotenv: specifier: 17.4.2 version: 17.4.2 @@ -187,44 +294,50 @@ importers: specifier: 0.31.10 version: 0.31.10 electron: - specifier: 44.1.1 - version: 44.1.1(supports-color@7.2.0) + specifier: 44.2.0 + version: 44.2.0(supports-color@7.2.0) electron-builder: specifier: 26.15.3 version: 26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@7.2.0) electron-vite: specifier: 5.0.0 - version: 5.0.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 5.0.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) eslint: - specifier: 10.9.1 - version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + specifier: 10.10.0 + version: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) + version: 7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-react-hooks: specifier: 7.1.1 - version: 7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + version: 7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-react-refresh: - specifier: 0.5.5 - version: 0.5.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) + specifier: 0.5.6 + version: 0.5.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) globals: specifier: 17.12.0 version: 17.12.0 i18next: - specifier: 26.4.1 - version: 26.4.1(@typescript/typescript6@6.0.2) + specifier: 26.4.2 + version: 26.4.2(@typescript/typescript6@6.0.2) i18next-browser-languagedetector: specifier: 8.2.1 version: 8.2.1 jsdom: specifier: 30.0.1 version: 30.0.1(@noble/hashes@2.4.0) + lightningcss: + specifier: ^1.33.0 + version: 1.33.0 + lightningcss-win32-arm64-msvc: + specifier: ^1.33.0 + version: 1.33.0 lucide-react: - specifier: 1.39.0 - version: 1.39.0(react@19.2.8) + specifier: 1.42.0 + version: 1.42.0(react@19.2.8) postcss: - specifier: 8.5.26 - version: 8.5.26 + specifier: 8.5.28 + version: 8.5.28 prettier: specifier: 3.9.6 version: 3.9.6 @@ -239,7 +352,7 @@ importers: version: 1.42.3 radix-ui: specifier: 1.6.7 - version: 1.6.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -248,19 +361,19 @@ importers: version: 19.2.8(react@19.2.8) react-i18next: specifier: 17.0.13 - version: 17.0.13(@typescript/typescript6@6.0.2)(i18next@26.4.1(@typescript/typescript6@6.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 17.0.13(@typescript/typescript6@6.0.2)(i18next@26.4.2(@typescript/typescript6@6.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-resizable-panels: - specifier: 4.12.3 - version: 4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 4.12.4 + version: 4.12.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-virtuoso: - specifier: 4.18.12 - version: 4.18.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 4.18.13 + version: 4.18.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8) reactflow: specifier: 11.11.4 version: 11.11.4(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) supabase: - specifier: 2.116.0 - version: 2.116.0 + specifier: 2.117.0 + version: 2.117.0 tailwind-merge: specifier: 3.6.0 version: 3.6.0 @@ -274,14 +387,14 @@ importers: specifier: npm:typescript@7.0.2 version: typescript@7.0.2 typescript-eslint: - specifier: 8.69.0 - version: 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + specifier: 8.70.0 + version: 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) vite: specifier: 8.2.2 - version: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + version: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) vitest: - specifier: 4.1.11 - version: 4.1.11(@types/node@26.4.1)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + specifier: 5.0.0 + version: 5.0.0(@types/node@26.5.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) zustand: specifier: 5.0.15 version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) @@ -385,6 +498,12 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@csstools/color-helpers@6.1.1': resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} engines: {node: '>=20.19.0'} @@ -990,8 +1109,8 @@ packages: resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.7.2': - resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@exodus/bytes@1.15.1': @@ -1021,8 +1140,8 @@ packages: '@fontsource-variable/inter@5.3.0': resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} - '@google/genai@2.20.0': - resolution: {integrity: sha512-kFEzARfA064oCNAmYTyOaAeJ9KSkbZ2Upxz36oO5eVNlcxAXsixLlGbwY1oy+Ab8HJ6nMWMD9hg+NdIron6wJw==} + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -1070,6 +1189,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -1098,8 +1226,8 @@ packages: resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} engines: {node: '>= 20.19.0'} - '@oxc-project/types@0.147.0': - resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} @@ -1115,8 +1243,8 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@playwright/test@1.62.1': - resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} hasBin: true @@ -1873,98 +2001,98 @@ packages: react: '>=17' react-dom: '>=17' - '@rolldown/binding-android-arm-eabi@1.2.6': - resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.6': - resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.6': - resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.6': - resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.6': - resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': - resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.6': - resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.6': - resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.6': - resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.6': - resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.6': - resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.6': - resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.6': - resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.6': - resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.6': - resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1976,50 +2104,47 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@supabase/cli-darwin-arm64@2.116.0': - resolution: {integrity: sha512-Mvfxf5q7oQ1KR59ndFFyGkh12IfwKH5ZOv7OWtHsFkBuwHtHiJgY6Zwd3w09tnat4spkpDTFavclBlLsOQnh2A==} + '@supabase/cli-darwin-arm64@2.117.0': + resolution: {integrity: sha512-MQVIxL+B5/U9xiXcUtky0vJQ5Qe0qhGkK2JLGwi5BAo5XxcC+iP+KCsVB1/2zzYT+d+fOlZnKyTFFWYO6Ut1sw==} cpu: [arm64] os: [darwin] - '@supabase/cli-darwin-x64@2.116.0': - resolution: {integrity: sha512-dxKmIPcVunC8sPTuU+eVWj2SOB5tLoRTE5FX6J/KMZhGH03khTn6ptHvaanZp0YwaACbm//uoffUlJKZrAgt0w==} + '@supabase/cli-darwin-x64@2.117.0': + resolution: {integrity: sha512-NMuY3n8Bfe+sPCdrUQQSFkOLFqlQYkg1C0SuqvJG4HVU8ukZ7IH2gy+HFGXNuVitFPapmHTPT8NjbZ+YrunZQw==} cpu: [x64] os: [darwin] - '@supabase/cli-linux-arm64-musl@2.116.0': - resolution: {integrity: sha512-6lYrbKFJT5NKbEKGBJTArEc1F3oMfWxnQeq8+RZ4wSLjCq4uwluh6+fzKCsLVxZgPOg4r+RZRqDdb+/cLi0yyg==} + '@supabase/cli-linux-arm64-musl@2.117.0': + resolution: {integrity: sha512-n5yZuBbBD5MnWqczezH72GAEfdTBHBk9Ei/pgpCnAJtClei9QQsiN1UZNw2A7K1EP4VWxgO/YZr3gFYgFFH1WQ==} cpu: [arm64] os: [linux] libc: [musl] - '@supabase/cli-linux-arm64@2.116.0': - resolution: {integrity: sha512-ZmV96NQqcgx1MH4jWdfyqqjLghy57mRI5bysy6lM7MezsirQh+eXaOdWI0xCy7r7FA09k2fKLGh+r7r0X3mxBg==} + '@supabase/cli-linux-arm64@2.117.0': + resolution: {integrity: sha512-CcMHEB8znY9usMJ2dwAKQV06XIYHzLgnq1KY6HzVT9XmXo0qZxTifC/oWgHX39SmCFm7a3HR4K5J95ysYz+8Hg==} cpu: [arm64] os: [linux] libc: [glibc] - '@supabase/cli-linux-x64-musl@2.116.0': - resolution: {integrity: sha512-EtPJPHUvLHvXHkvZHAEr+i6w/bDVm5BOPD+09uXgUffsUbNAzfZ8r7Fb94+SfWI+dQwivw4WmijsX7tlx61Zcg==} + '@supabase/cli-linux-x64-musl@2.117.0': + resolution: {integrity: sha512-rlVDN5kXTOe+50wFN7DywFFx956XoYpEJpDkADvZ3k+qL5+7jGxSzQN/7xhHqlhjtSlHfx4QlxajfOerJNnrlg==} cpu: [x64] os: [linux] libc: [musl] - '@supabase/cli-linux-x64@2.116.0': - resolution: {integrity: sha512-o0PvHKyQSKEuC3jJqeV2qorgyMIFGDWQ1Bj+OXf0p80ddgktnJFlDElCU+VDKZkuwLC6vO/LMoBql34zFHzXhw==} + '@supabase/cli-linux-x64@2.117.0': + resolution: {integrity: sha512-qDJEURD/YajKD/OQ91O9XNHB/LHK9V8xZ9qGiUQCFhP9OAvMQjUT12j8k/mgTe4PqIxigGpfvm+eNxRcKnU9Rw==} cpu: [x64] os: [linux] libc: [glibc] - '@supabase/cli-windows-arm64@2.116.0': - resolution: {integrity: sha512-IiglNMXXssDiZbeSRvixYH7eYDDvhiEa2CrOSj419jO5vLrMKvzi1ATxe8E4i7MpKuI9S5U/3tA3rFlIMHtwrg==} + '@supabase/cli-windows-arm64@2.117.0': + resolution: {integrity: sha512-JNjI4dHhogjx1994jNoV9uUMioCrdzfdDJDeq0rZhYEepBGE9W+2gLniBQfQRzGxXK81mg5K3p9CTJYBZzbAKg==} cpu: [arm64] os: [win32] - '@supabase/cli-windows-x64@2.116.0': - resolution: {integrity: sha512-pz4zNDs3KCEx0l9JS9Xaiuzd5WXrISajVlBSxC5/2Jyo2+g+N/ftQJDYTHQ6Jir5fNelIqSHIXZelmGds14upw==} + '@supabase/cli-windows-x64@2.117.0': + resolution: {integrity: sha512-oFmeLMwgWAyQhzPoR6w+eUOfyJcRMNHN2Ju4BxBtpZ9vW+dO1bAsWd7aVVcpn1sgHA/ptM8U48P+se5yZR7BdQ==} cpu: [x64] os: [win32] @@ -2119,209 +2244,209 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tiptap/core@3.31.0': - resolution: {integrity: sha512-SY3yE2ACharFvk0BKgotV6SbwV8R4tN5fGtWOM6deY4JNF3v6CXOD78RFwjs2HSohGWBRyJVPDvrX9nfi8Nsxg==} + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': 3.31.0 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.31.0': - resolution: {integrity: sha512-E1EcYp28NOEKLkMqf9Z4t/ZjVO5hOX/c5r9nu/sfp5+KePj4TaxGDcruijP5DYJuKxlsZx2lr6/lldiK9KilXw==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.31.0': - resolution: {integrity: sha512-KB3V3/8/XSrXNM0m7Svgg8xjwG/mr9nYh0krGznmDK1eArxj2GQd5h2g0QiBy8e+G//NMEbWQxSKbhKUKz7aoQ==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.31.0': - resolution: {integrity: sha512-CNuyfYH2KfcREASRh280UkGqYTwuKlMNcyBR7KsZrpzAVwFVo9D6qMkE6/q7I8mNMt2+/Xebe9UFZIYnVccCJA==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.31.0': - resolution: {integrity: sha512-jLrzWDzBhfeOK/idnrpyncTuRamBNGCscDC7omG1S+CD3CFuDrp5Ws2tcv5fgASt2Vc/f9Y9zC+gOPkeIEpYFA==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block@3.31.0': - resolution: {integrity: sha512-y/A6N96gBPjEHqVYZ3u/yO/Anyo92iDWgYqShMXAvHprn2Ipmn32wIkX05Kv983fa6j6ySXF7A1nJdHcGCl5aQ==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.31.0': - resolution: {integrity: sha512-QWo9oItNzyzNMXoCOG4/PiCNYoTjhUKtI5NE7iRJhd9op3KsLoyIO+og+P+0ORxG59Ic76SkXl2/lILaVsB48g==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-color@3.31.0': - resolution: {integrity: sha512-5gfS5jl8DBZumwrBckWMsZLuMq1SBmcZHt/xlCwPbtUMFogE068YJq+BbxcgbJKx333G+2JWAO9ea6GyEkih8A==} + '@tiptap/extension-color@3.31.3': + resolution: {integrity: sha512-rsZ/HlCYagMoyqn8kKxLYaZwHvowGUTeHVVxEuMJqTantIrnnQMSQUA3a7+X22l2eD7ec/LlG4bBGPjw+gf3tw==} peerDependencies: - '@tiptap/extension-text-style': 3.31.0 + '@tiptap/extension-text-style': 3.31.3 - '@tiptap/extension-details@3.31.0': - resolution: {integrity: sha512-j1ZDrXRPPvCoOrKrVV1e2BXFpHVsHYmikr2ORtI0+gDh/+4qruqrm7ftGic2lm77B2hzKtm2adoSSi9XL6UQew==} + '@tiptap/extension-details@3.31.3': + resolution: {integrity: sha512-J5kdZy31wb97iKUR4sDsX0sEyRPlTijKKRAnVbInY2aNdFuNBTBdMPmblEJztxfMN+TmDrU2kCVv72J9B1SrJg==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/extension-text-style': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/extension-text-style': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-document@3.31.0': - resolution: {integrity: sha512-C2eDFoBlcRAb4sgbc9ZH+mrFFbjIS9VyoERGeDum3QRdqLEKU1h14Th7isHpGQzTvTywhXQxqXC64mgeSGXLsA==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.31.0': - resolution: {integrity: sha512-hIZOPfM0sGkAhPV69IK1zWMbWBd3wmhS0XCKBRcXUWrE2MW4kkaSyYprSFqtqFuUa3lVIEFrlME1WOEBJ2/2rg==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': 3.31.0 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.31.0': - resolution: {integrity: sha512-NFwv1Kah3zGLmjj53Z+pA2584GdgMDClgvebJPqSunsSdHwHlFwfu+Op1u73xuEYOd6wGgCgzzDZ3V4CKwkJqg==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-focus@3.31.0': - resolution: {integrity: sha512-O8TlKiugjMNVcQYTJxxJWRjqo9FGv54LqO7bqIwP/37Cn3mX9DBO/XVuBNvOPfmCr/t8J0gmGV1uYVoeh1GbSg==} + '@tiptap/extension-focus@3.31.3': + resolution: {integrity: sha512-2sA9o+4F1pSwtZ2BMhoU31zsrTytUKI6SNXVrcABrljW4EwX3LWSNAyAUu2uNc98BL3acQEBLdf7dDs6nS/pRw==} peerDependencies: - '@tiptap/extensions': 3.31.0 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-gapcursor@3.31.0': - resolution: {integrity: sha512-TY1S9D9x86iIHiPiDU4KtRmLYTgE6RKa54jXubFyekUYFmgNfHuUunWmpK/Rvy6tkG1CKHNL7X+wdo8QXi+hcw==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': 3.31.0 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.31.0': - resolution: {integrity: sha512-e3e3YwTSxzO1bYalphs3A3zr+nYRWASMKFPzviO0C2P7vKE4kT4rvCYCWuUxIpfit8Vnh6TFVIkhrQ/6IFlDzw==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.31.0': - resolution: {integrity: sha512-ryQnvq2iUW2IwjbLniGlRZliNA5YserRTX2mCKyQcv8z/u062J01JDLlX73o47tu4DjuBzmkx6t5SsiXNODRcg==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-highlight@3.31.0': - resolution: {integrity: sha512-Kc5attKuiHdrowCPBJt8aqceYDeUTEdSJctEf9MxiYvFmt4Vk9fptvZ5cRH+nxSy2TvYoYUJgye1NZg1uub4Tw==} + '@tiptap/extension-highlight@3.31.3': + resolution: {integrity: sha512-97m1nDzTAjJaJZWe5hVOBMC2rjBZvEwTXA39zhjnaE/snb8Cy4H9A3+/sw9HQJBXMoG7tdOPG9kUlM3weYtLcQ==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-horizontal-rule@3.31.0': - resolution: {integrity: sha512-RTKLnuEmGJOlxzLOmKo85hstXfuLlg+zQSKAggdD6BrSTDR5fdPZXnZTb/gDCo6chCR5BoKHu7Xv1spnPzRmEw==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-italic@3.31.0': - resolution: {integrity: sha512-8Top75aMS9feCtKYADWOYLrJjx3SGvazETWZdEwDYDGZtEOB7q2+FxcRu2xi+K6Lx9LhzxS9ChULtNdBuRevkQ==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.31.0': - resolution: {integrity: sha512-arzZYxyf6in2BzdB3GR53VhSlE3ZwmY8CflXRo1xHpSpxq393myLP6FnaxLPxcfcScfOOsaYh2KAED0HJoBZfA==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list-item@3.31.0': - resolution: {integrity: sha512-ayZa+KEMigJis0s8n3X3pqRxzsMuom9JoqE6byJ5NP2oaI2HOQ95yfcuILNKukg1nXG8vkgifEsXxjXz4O2o2g==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.31.0': - resolution: {integrity: sha512-bmZZmQyH0PibpIUA2thA3Cgm82cnrcC4oU4eNqiaoUqrva1NuOIqb71L2Br9keBnmtQKEtkzdODs4x+eI53kBg==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list@3.31.0': - resolution: {integrity: sha512-cED2TPewS3CtzZUdCZNG/qbc6vHJunKRzkFiT8x3RF+pmtIQlrPRH5Y9sbkzLt3yg0ua5KMGFtBH37AHaKUFDw==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.31.0': - resolution: {integrity: sha512-KLCax7pI/3JTCkMY01n2+CXwZRPPsIVR1fvSmkFMryUPonkkMYWSUlXCuYKvF22+kM0gU0kiXsVjqsn/YFdeDg==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-paragraph@3.31.0': - resolution: {integrity: sha512-78l9D/jpKvvWXrBBzYPfgqwonBY76+1MMgSovkPffIbya+jaYDJdZhIpeCZXtSBDnw8cYE/rheAeusN1QG3SVQ==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-placeholder@3.31.0': - resolution: {integrity: sha512-zH1Z8xZX72P7wZ3HD6fUGJ7XTVWMgyN5Sy+OiD3tuuxfr/oUvS6ykaoGBdHGTMBdrXZpp+GfAcch1MrvLGn8oA==} + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} peerDependencies: - '@tiptap/extensions': 3.31.0 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-strike@3.31.0': - resolution: {integrity: sha512-M1X94ve0TNU7LrHCDn6nKf6vM8PEaKQt3FLTwhTIw5uTbx2Imw+uyRrySDdxLWHY/ul8x17I2w9MVLqK/D+Zvw==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-task-item@3.31.0': - resolution: {integrity: sha512-hGodDipjRCOsZjffTuChLWIzJLrxvPI5VI7rYHyN40qRCR3Re9OgO52iJCJxR+W2+ZBSLZi4fSYsYf6L9VYVHA==} + '@tiptap/extension-task-item@3.31.3': + resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==} peerDependencies: - '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-task-list@3.31.0': - resolution: {integrity: sha512-82Scqfcwi8ZZ4bAKeOa0CUwuwsQKFduh0ZRu27P/BdUJIcvMTxV5x9t+JWnumBUZunBixs6EIg4P/4dY1R60LQ==} + '@tiptap/extension-task-list@3.31.3': + resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==} peerDependencies: - '@tiptap/extension-list': 3.31.0 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-text-align@3.31.0': - resolution: {integrity: sha512-40hsX0anPQv87M3rD8vncqBIgED2xWUDTU/uL1fKTgeOm5BeiPJupxAV41ttI96T1suEAlghaaYrjs28ElK1fQ==} + '@tiptap/extension-text-align@3.31.3': + resolution: {integrity: sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text-style@3.31.0': - resolution: {integrity: sha512-eY8mIb/NS22sO1xJQqfzHtSdpL2UCQy1nbSCEohRUoW7eaP/nwp29x097+jjJnxg4LjLMsolyw11Q4mswbXDQQ==} + '@tiptap/extension-text-style@3.31.3': + resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.31.0': - resolution: {integrity: sha512-K+8nIKgNNBiL1+AarcuzvPoRpjuFk5qOe9MM/n9O3HyTTLeWNfRV/zFifcpSoHmhf0QvEAXqmnxDiIpyCpy9+g==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.31.0': - resolution: {integrity: sha512-B4VK5nu0UdKl7OiMvIcjDPMYmhe8+v1B763Is/GzPSJOMstUHRqAqPx2AsPla6A4UGuZwTx//ajVa5QAGionZQ==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': 3.31.0 + '@tiptap/core': 3.31.3 - '@tiptap/extensions@3.31.0': - resolution: {integrity: sha512-WOv9Y1VjhzAYycrIGPygwmLkoO/UdFYFE5uDWN+FJWMui5sLmLrmfKtOOYzeWIevxomFGix/wl5hqaVGIev1+g==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.31.0': - resolution: {integrity: sha512-JGPUSPLDFPjgFZHcNSghVJmEfvYp2SRPZQ1K8AZOuviEsUNvkYmJsBdudeYwUYNzYO38L4Yy0G6UMSuniBDIMg==} + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/react@3.31.0': - resolution: {integrity: sha512-s3NfUSqXfkGpjQ6iL8In7jdTIlIdZ+Ll4bO30C54kokiNq6t8mct7JPt3qGeCCeH3FTqNMLzaBDAU8EfcZw+PQ==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.31.0': - resolution: {integrity: sha512-P80nhDEsBf6EMTaRb/ld3BaQW7iqITPR6BiIjOjbO3QDivCz/rGDKSS6q4ngsXfXOpSBc/clo/g0OyAdA8+Q4Q==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} - '@tiptap/suggestion@3.31.0': - resolution: {integrity: sha512-p/m8K4jmF+b5JdRcZ5onOKxJf3dsTcXhnV6qAjDu1xrvpY8lXZe4DXgtuAUvh5Q4Msmq6IYuMAvleR4WAkIXIQ==} + '@tiptap/suggestion@3.31.3': + resolution: {integrity: sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.31.0 - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/better-sqlite3@9.6.0': resolution: {integrity: sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==} @@ -2489,8 +2614,11 @@ packages: '@types/node@26.4.1': resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} - '@types/react-dom@19.2.5': - resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + '@types/node@26.5.0': + resolution: {integrity: sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: '@types/react': ^19.2.0 @@ -2515,63 +2643,63 @@ packages: '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} - '@typescript-eslint/eslint-plugin@8.69.0': - resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.69.0 + '@typescript-eslint/parser': ^8.70.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.69.0': - resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.69.0': - resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.69.0': - resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.69.0': - resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.69.0': - resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.69.0': - resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.69.0': - resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.69.0': - resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.69.0': - resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -2714,11 +2842,8 @@ packages: oxc-transform-react: optional: true - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2728,20 +2853,8 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} @@ -2859,8 +2972,8 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} - autoprefixer@10.5.4: - resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + autoprefixer@10.5.5: + resolution: {integrity: sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -2883,8 +2996,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.20: - resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2892,8 +3005,8 @@ packages: resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} engines: {node: '>=22'} - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2915,8 +3028,8 @@ packages: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2954,6 +3067,9 @@ packages: resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} engines: {node: '>=8'} + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3198,8 +3314,8 @@ packages: resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==} engines: {node: '>=10'} - dompurify@3.4.14: - resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} + dompurify@3.4.15: + resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} dot-prop@10.2.0: resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==} @@ -3355,8 +3471,8 @@ packages: resolution: {integrity: sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==} engines: {node: '>=20'} - electron-to-chromium@1.5.420: - resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} + electron-to-chromium@1.5.423: + resolution: {integrity: sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==} electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} @@ -3377,8 +3493,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@44.1.1: - resolution: {integrity: sha512-N2WCq2sbOkqQgvXJYx2lS6UiO8bF+Yr67trDnS6JKa2WxTCRQsAjGl57SUtdW9h6r5PlduBFjIhxhgd3dzv1hg==} + electron@44.2.0: + resolution: {integrity: sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==} engines: {node: '>= 22.12.0'} hasBin: true @@ -3396,8 +3512,8 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} env-paths@2.2.1: @@ -3482,8 +3598,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - eslint-plugin-react-refresh@0.5.5: - resolution: {integrity: sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==} + eslint-plugin-react-refresh@0.5.6: + resolution: {integrity: sha512-uZnh24On2bk478AkaDAKcpRiYhS3qFSaSNvpXxV6/Ek/BBDICkWGG7MnBSfjnIlCVBei5vsLlIeQYxbF22+Udg==} peerDependencies: eslint: ^9 || ^10 @@ -3505,8 +3621,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.9.1: - resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} + eslint@10.10.0: + resolution: {integrity: sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -3551,8 +3667,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.4.1: - resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==} + fast-equals@5.4.2: + resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} engines: {node: '>=6.0.0'} fast-json-stable-stringify@2.1.0: @@ -3577,9 +3693,8 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -3588,9 +3703,8 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} @@ -3637,11 +3751,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3772,6 +3881,10 @@ packages: hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -3782,6 +3895,12 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -3811,8 +3930,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@26.4.1: - resolution: {integrity: sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==} + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: @@ -3990,8 +4109,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4062,6 +4181,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -4255,14 +4377,17 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lucide-react@1.39.0: - resolution: {integrity: sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA==} + lucide-react@1.42.0: + resolution: {integrity: sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + markdown-it-task-lists@2.1.1: resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} @@ -4486,9 +4611,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -4507,13 +4629,13 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} hasBin: true - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} engines: {node: '>=20'} hasBin: true @@ -4528,8 +4650,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} postject@1.0.0-alpha.6: @@ -4603,8 +4725,8 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-transform@1.12.0: - resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + prosemirror-transform@1.12.1: + resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} prosemirror-view@1.42.3: resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} @@ -4631,6 +4753,10 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -4692,8 +4818,8 @@ packages: '@types/react': optional: true - react-resizable-panels@4.12.3: - resolution: {integrity: sha512-GHMJWnDXui/3RX4bT+cgBP+N3N2nkwcoZATr/2xLFpqQQe7TlBrE0cGM0dUa9ceT7A90tUrSRsiqgRG+g0/2AA==} + react-resizable-panels@4.12.4: + resolution: {integrity: sha512-SOsSj4e9U7vxL15MbgTfl35IO4vxSW4Xb0b1gtJVbRPWv7FOezcgOA00Mi0jP8XqC3y7jOQxpsGs/9zLKHPitA==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -4708,8 +4834,8 @@ packages: '@types/react': optional: true - react-virtuoso@4.18.12: - resolution: {integrity: sha512-6c1SnRicSBfG+WnbhcyJUxzDHvvxD3vsux/EpcfVbgB7clyP1Od2r81TAGTgwbL7U2qP889RR4u+CH6WjgpW0g==} + react-virtuoso@4.18.13: + resolution: {integrity: sha512-M4kP7zbV7JmLrnJYs0X2FBA8gnNlHisSYuKMyeJVYtWz31CAJeP57dgPJzfneUdKDKmL9SiqvcBQ32si8CfQSQ==} peerDependencies: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' @@ -4782,8 +4908,8 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rolldown@1.2.6: - resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4992,8 +5118,8 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} - supabase@2.116.0: - resolution: {integrity: sha512-cMUHkpjBacq4oLGWnMM2HC2drmUlAlfN/PQb31RARoIdYJ8sqA0xONvqBR6yd5v7w8dXuCPwvfd4N1NTHjBKEw==} + supabase@2.117.0: + resolution: {integrity: sha512-V4UDf/kgrqllHbc22vs89bL7pdBj3xhVolvDflByS5FuJb+tXpWhHZxYEbBQspxA0fBzaj+lpiedB72xTAZA4w==} hasBin: true supports-color@7.2.0: @@ -5035,8 +5161,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} @@ -5046,20 +5173,16 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.1: - resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} - engines: {node: '>=14.0.0'} - tiptap-markdown@0.9.0: resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==} peerDependencies: '@tiptap/core': ^3.0.1 - tldts-core@7.4.11: - resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} - tldts@7.4.11: - resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} hasBin: true tmp-promise@3.0.3: @@ -5122,8 +5245,8 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.69.0: - resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -5159,16 +5282,19 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@6.28.0: - resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} - undici@7.29.0: - resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} - undici@8.10.1: - resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} universalify@0.1.2: @@ -5265,23 +5391,23 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -5505,7 +5631,7 @@ snapshots: '@asamuzakjp/dom-selector@8.3.2': dependencies: - bidi-js: 1.0.3 + bidi-js: 1.1.0 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 @@ -5550,7 +5676,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -5623,6 +5749,18 @@ snapshots: dependencies: css-tree: 3.2.1 + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@csstools/color-helpers@6.1.1': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -5715,7 +5853,7 @@ snapshots: semver: 7.8.5 sumchecker: 3.0.1(supports-color@7.2.0) optionalDependencies: - undici: 7.29.0 + undici: 7.29.1 transitivePeerDependencies: - supports-color @@ -6013,9 +6151,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -6036,13 +6174,13 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))': + '@eslint/js@10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.7.2': + '@eslint/plugin-kit@0.7.3': dependencies: '@eslint/core': 1.2.1 levn: 0.4.1 @@ -6070,7 +6208,7 @@ snapshots: '@fontsource-variable/inter@5.3.0': {} - '@google/genai@2.20.0(supports-color@7.2.0)': + '@google/genai@2.21.0(supports-color@7.2.0)': dependencies: google-auth-library: 10.9.1(supports-color@7.2.0) p-retry: 4.6.2 @@ -6120,6 +6258,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -6145,7 +6291,7 @@ snapshots: '@noble/hashes@2.4.0': {} - '@oxc-project/types@0.147.0': {} + '@oxc-project/types@0.148.0': {} '@peculiar/asn1-schema@2.8.0': dependencies: @@ -6169,9 +6315,9 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@playwright/test@1.62.1': + '@playwright/test@1.63.0': dependencies: - playwright: 1.62.1 + playwright: 1.63.0 '@protobufjs/aspromise@1.1.2': {} @@ -6197,68 +6343,68 @@ snapshots: '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-aspect-ratio@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-aspect-ratio@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-avatar@1.2.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-avatar@1.2.6(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) @@ -6266,50 +6412,50 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6317,18 +6463,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6336,18 +6482,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) @@ -6357,7 +6503,7 @@ snapshots: react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6365,33 +6511,33 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6399,47 +6545,47 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6448,31 +6594,31 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 @@ -6481,58 +6627,58 @@ snapshots: react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) @@ -6541,15 +6687,15 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) @@ -6557,21 +6703,21 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 @@ -6580,15 +6726,15 @@ snapshots: react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8) @@ -6598,72 +6744,72 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) @@ -6672,73 +6818,73 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) aria-hidden: 1.2.6 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) @@ -6747,7 +6893,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6756,117 +6902,117 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-toolbar@1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toolbar@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6929,14 +7075,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/rect@1.1.3': {} @@ -7018,79 +7164,76 @@ snapshots: - '@types/react' - immer - '@rolldown/binding-android-arm-eabi@1.2.6': + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true - '@rolldown/binding-android-arm64@1.2.6': + '@rolldown/binding-android-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-arm64@1.2.6': + '@rolldown/binding-darwin-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-x64@1.2.6': + '@rolldown/binding-darwin-x64@1.2.7': optional: true - '@rolldown/binding-freebsd-x64@1.2.6': + '@rolldown/binding-freebsd-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.6': + '@rolldown/binding-linux-arm64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.6': + '@rolldown/binding-linux-arm64-musl@1.2.7': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.6': + '@rolldown/binding-linux-ppc64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.6': + '@rolldown/binding-linux-s390x-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.6': + '@rolldown/binding-linux-x64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-musl@1.2.6': + '@rolldown/binding-linux-x64-musl@1.2.7': optional: true - '@rolldown/binding-openharmony-arm64@1.2.6': + '@rolldown/binding-openharmony-arm64@1.2.7': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.6': - optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.7': {} - '@rolldown/binding-win32-x64-msvc@1.2.6': + '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true '@rolldown/pluginutils@1.0.1': {} '@sindresorhus/is@4.6.0': {} - '@standard-schema/spec@1.1.0': {} - - '@supabase/cli-darwin-arm64@2.116.0': + '@supabase/cli-darwin-arm64@2.117.0': optional: true - '@supabase/cli-darwin-x64@2.116.0': + '@supabase/cli-darwin-x64@2.117.0': optional: true - '@supabase/cli-linux-arm64-musl@2.116.0': + '@supabase/cli-linux-arm64-musl@2.117.0': optional: true - '@supabase/cli-linux-arm64@2.116.0': + '@supabase/cli-linux-arm64@2.117.0': optional: true - '@supabase/cli-linux-x64-musl@2.116.0': + '@supabase/cli-linux-x64-musl@2.117.0': optional: true - '@supabase/cli-linux-x64@2.116.0': + '@supabase/cli-linux-x64@2.117.0': optional: true - '@supabase/cli-windows-arm64@2.116.0': + '@supabase/cli-windows-arm64@2.117.0': optional: true - '@supabase/cli-windows-x64@2.116.0': + '@supabase/cli-windows-x64@2.117.0': optional: true '@szmarczak/http-timer@4.0.6': @@ -7137,8 +7280,7 @@ snapshots: '@tailwindcss/oxide-wasm32-wasi@4.3.3': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': {} '@tailwindcss/oxide-win32-x64-msvc@4.3.3': optional: true @@ -7163,161 +7305,161 @@ snapshots: '@alloc/quick-lru': 5.3.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.26 + postcss: 8.5.28 tailwindcss: 4.3.3 - '@tiptap/core@3.31.0(@tiptap/pm@3.31.0)': + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.31.0 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-color@3.31.0(@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)))': + '@tiptap/extension-color@3.31.3(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))': dependencies: - '@tiptap/extension-text-style': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-details@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)))(@tiptap/pm@3.31.0)': + '@tiptap/extension-details@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-text-style': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-document@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.31.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-focus@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-focus@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-gapcursor@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-hard-break@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-highlight@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-highlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-italic@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-placeholder@3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-task-item@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-task-list@3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))': + '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-text-align@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-text-align@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text-style@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.31.0': + '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.2 prosemirror-commands: 1.7.2 @@ -7330,62 +7472,62 @@ snapshots: prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.3 - '@tiptap/react@3.31.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@types/use-sync-external-store': 0.0.6 - fast-equals: 5.4.1 + fast-equals: 5.4.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-floating-menu': 3.31.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.31.0': - dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/extension-blockquote': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-bold': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-bullet-list': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) - '@tiptap/extension-code': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-code-block': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-document': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-dropcursor': 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) - '@tiptap/extension-gapcursor': 3.31.0(@tiptap/extensions@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) - '@tiptap/extension-hard-break': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-heading': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-horizontal-rule': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-italic': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-link': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/extension-list-item': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) - '@tiptap/extension-list-keymap': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) - '@tiptap/extension-ordered-list': 3.31.0(@tiptap/extension-list@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)) - '@tiptap/extension-paragraph': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-strike': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-text': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extension-underline': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)) - '@tiptap/extensions': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 - - '@tiptap/suggestion@3.31.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)': + '@tiptap/starter-kit@3.31.3': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.8.0 - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) - '@tiptap/pm': 3.31.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/better-sqlite3@9.6.0': dependencies: - '@types/node': 26.4.1 + '@types/node': 26.5.0 '@types/cacheable-request@6.0.3': dependencies: @@ -7578,7 +7720,11 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/react-dom@19.2.5(@types/react@19.2.18)': + '@types/node@26.5.0': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -7599,21 +7745,21 @@ snapshots: '@types/yauzl@3.4.0': dependencies: - '@types/node': 26.4.1 + '@types/node': 26.5.0 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.4.1 + '@types/node': 26.5.0 - '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/type-utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - '@typescript-eslint/visitor-keys': 8.69.0 - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/visitor-keys': 8.70.0 + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.8 natural-compare: 1.4.0 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) @@ -7621,56 +7767,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': + '@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@7.2.0) - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': + '@typescript-eslint/project-service@8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.70.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.70.0 debug: 4.4.3(supports-color@7.2.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.69.0': + '@typescript-eslint/scope-manager@8.70.0': dependencies: - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 - '@typescript-eslint/tsconfig-utils@8.69.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.70.0(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': + '@typescript-eslint/type-utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) - '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0) - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/types@8.70.0': {} - '@typescript-eslint/typescript-estree@8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': + '@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': dependencies: - '@typescript-eslint/project-service': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) - '@typescript-eslint/tsconfig-utils': 8.69.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/project-service': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/tsconfig-utils': 8.70.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 @@ -7680,20 +7826,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': + '@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.69.0': + '@typescript-eslint/visitor-keys@8.70.0': dependencies: - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -7760,51 +7906,21 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) - '@vitest/expect@4.1.11': + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - chai: 6.2.2 - tinyrainbow: 3.1.1 - - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': - dependencies: - '@vitest/spy': 4.1.11 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 estree-walker: 3.0.3 - magic-string: 0.30.21 + magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) - '@vitest/pretty-format@4.1.11': - dependencies: - tinyrainbow: 3.1.1 - - '@vitest/runner@4.1.11': - dependencies: - '@vitest/utils': 4.1.11 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.11': {} - - '@vitest/utils@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.1 + '@vitest/spy@5.0.0': {} '@xmldom/xmldom@0.8.13': {} @@ -7976,13 +8092,13 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - autoprefixer@10.5.4(postcss@8.5.26): + autoprefixer@10.5.5(postcss@8.5.28): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 caniuse-lite: 1.0.30001810 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -7997,13 +8113,13 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.20: {} + baseline-browser-mapping@2.11.21: {} better-sqlite3@13.0.3: dependencies: node-addon-api: 8.9.2 - bidi-js@1.0.3: + bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 @@ -8027,13 +8143,13 @@ snapshots: dependencies: balanced-match: 4.0.4 - browserslist@4.28.8: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.20 + baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.420 + electron-to-chromium: 1.5.423 node-releases: 2.0.54 - update-browserslist-db: 1.3.2(browserslist@4.28.8) + update-browserslist-db: 1.3.2(browserslist@4.28.9) buffer-crc32@1.0.0: {} @@ -8083,6 +8199,14 @@ snapshots: normalize-url: 6.1.0 responselike: 2.0.1 + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -8135,12 +8259,12 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + cmdk@1.1.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: @@ -8342,7 +8466,7 @@ snapshots: xml: 1.0.1 xml-js: 1.6.11 - dompurify@3.4.14: + dompurify@3.4.15: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -8450,9 +8574,9 @@ snapshots: conf: 15.1.0 type-fest: 5.9.0 - electron-to-chromium@1.5.420: {} + electron-to-chromium@1.5.423: {} - electron-vite@5.0.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + electron-vite@5.0.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) @@ -8460,7 +8584,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) transitivePeerDependencies: - supports-color @@ -8481,7 +8605,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@44.1.1(supports-color@7.2.0): + electron@44.2.0(supports-color@7.2.0): dependencies: '@electron-internal/extract-zip': 1.0.5 '@electron/get': 5.1.0(supports-color@7.2.0) @@ -8502,7 +8626,7 @@ snapshots: entities@4.5.0: {} - entities@8.0.0: {} + entities@8.1.0: {} env-paths@2.2.1: {} @@ -8713,22 +8837,22 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.8 - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) hermes-parser: 0.25.1 zod: 4.5.4 zod-validation-error: 4.0.2(zod@4.5.4) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-react-refresh@0.5.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-react@7.37.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -8736,7 +8860,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.4.0 - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -8761,14 +8885,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0): + eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 + '@eslint/plugin-kit': 0.7.3 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -8783,7 +8907,7 @@ snapshots: esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 + file-entry-cache: 11.1.5 find-up: 5.0.0 glob-parent: 6.0.2 ignore: 5.3.2 @@ -8828,7 +8952,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-equals@5.4.1: {} + fast-equals@5.4.2: {} fast-json-stable-stringify@2.1.0: {} @@ -8845,9 +8969,9 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - file-entry-cache@8.0.0: + file-entry-cache@11.1.5: dependencies: - flat-cache: 4.0.1 + flat-cache: 6.1.23 filelist@1.0.6: dependencies: @@ -8858,10 +8982,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@4.0.1: + flat-cache@6.1.23: dependencies: + cacheable: 2.5.0 flatted: 3.4.4 - keyv: 4.5.4 + hookified: 1.15.1 flatted@3.4.4: {} @@ -8922,9 +9047,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -9090,6 +9212,10 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -9100,6 +9226,10 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hookified@1.15.1: {} + + hookified@2.2.0: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -9137,7 +9267,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 - i18next@26.4.1(@typescript/typescript6@6.0.2): + i18next@26.4.2(@typescript/typescript6@6.0.2): optionalDependencies: typescript: '@typescript/typescript6@6.0.2' @@ -9307,7 +9437,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.10: {} + jose@6.2.12: {} js-tokens@4.0.0: {} @@ -9332,7 +9462,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.2 - undici: 8.10.1 + undici: 8.10.2 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -9401,6 +9531,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + lazy-val@1.0.5: {} levn@0.4.1: @@ -9469,8 +9603,7 @@ snapshots: lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.33.0: - optional: true + lightningcss-win32-arm64-msvc@1.33.0: {} lightningcss-win32-x64-msvc@1.32.0: optional: true @@ -9540,7 +9673,7 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@1.39.0(react@19.2.8): + lucide-react@1.42.0(react@19.2.8): dependencies: react: 19.2.8 @@ -9548,6 +9681,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + markdown-it-task-lists@2.1.1: {} markdown-it@14.3.1: @@ -9657,7 +9794,7 @@ snapshots: semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 - undici: 6.28.0 + undici: 6.28.1 which: 6.0.1 node-int64@0.4.0: {} @@ -9749,7 +9886,7 @@ snapshots: parse5@8.0.1: dependencies: - entities: 8.0.0 + entities: 8.1.0 path-exists@4.0.0: {} @@ -9759,8 +9896,6 @@ snapshots: path-parse@1.0.7: {} - pathe@2.0.3: {} - pe-library@0.4.1: {} pend@1.2.0: {} @@ -9778,13 +9913,11 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 - playwright-core@1.62.1: {} + playwright-core@1.63.0: {} - playwright@1.62.1: + playwright@1.63.0: dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 plist@3.1.0: dependencies: @@ -9796,7 +9929,7 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.26: + postcss@8.5.28: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -9836,18 +9969,18 @@ snapshots: prosemirror-changeset@2.4.2: dependencies: - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-commands@1.7.2: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-dropcursor@1.8.3: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.3 prosemirror-gapcursor@1.4.1: @@ -9860,14 +9993,14 @@ snapshots: prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.3 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-keymap@1.2.3: dependencies: @@ -9888,12 +10021,12 @@ snapshots: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-state@1.4.4: dependencies: prosemirror-model: 1.25.11 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.3 prosemirror-tables@1.8.5: @@ -9901,10 +10034,10 @@ snapshots: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.3 - prosemirror-transform@1.12.0: + prosemirror-transform@1.12.1: dependencies: prosemirror-model: 1.25.11 @@ -9912,7 +10045,7 @@ snapshots: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 protobufjs@7.6.6: dependencies: @@ -9925,7 +10058,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 26.4.1 + '@types/node': 26.5.0 long: 5.3.2 pump@3.0.4: @@ -9943,57 +10076,61 @@ snapshots: pvutils@1.1.5: {} + qified@0.10.1: + dependencies: + hookified: 2.2.0 + quick-lru@5.1.1: {} - radix-ui@1.6.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + radix-ui@1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-menubar': 1.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-select': 2.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slider': 1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menubar': 1.1.24(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': 2.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slider': 1.4.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-switch': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toast': 1.2.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-switch': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toast': 1.2.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) @@ -10001,23 +10138,23 @@ snapshots: '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@types/react-dom': 19.2.7(@types/react@19.2.18) react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 scheduler: 0.27.0 - react-i18next@17.0.13(@typescript/typescript6@6.0.2)(i18next@26.4.1(@typescript/typescript6@6.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-i18next@17.0.13(@typescript/typescript6@6.0.2)(i18next@26.4.2(@typescript/typescript6@6.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 4.0.1 - i18next: 26.4.1(@typescript/typescript6@6.0.2) + i18next: 26.4.2(@typescript/typescript6@6.0.2) react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: @@ -10045,7 +10182,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-resizable-panels@4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-resizable-panels@4.12.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -10058,7 +10195,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-virtuoso@4.18.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-virtuoso@4.18.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -10158,26 +10295,26 @@ snapshots: sprintf-js: 1.1.3 optional: true - rolldown@1.2.6: + rolldown@1.2.7: dependencies: - '@oxc-project/types': 0.147.0 + '@oxc-project/types': 0.148.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.6 - '@rolldown/binding-android-arm64': 1.2.6 - '@rolldown/binding-darwin-arm64': 1.2.6 - '@rolldown/binding-darwin-x64': 1.2.6 - '@rolldown/binding-freebsd-x64': 1.2.6 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 - '@rolldown/binding-linux-arm64-gnu': 1.2.6 - '@rolldown/binding-linux-arm64-musl': 1.2.6 - '@rolldown/binding-linux-ppc64-gnu': 1.2.6 - '@rolldown/binding-linux-s390x-gnu': 1.2.6 - '@rolldown/binding-linux-x64-gnu': 1.2.6 - '@rolldown/binding-linux-x64-musl': 1.2.6 - '@rolldown/binding-openharmony-arm64': 1.2.6 - '@rolldown/binding-win32-arm64-msvc': 1.2.6 - '@rolldown/binding-win32-x64-msvc': 1.2.6 + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 rope-sequence@1.3.4: {} @@ -10415,19 +10552,19 @@ snapshots: transitivePeerDependencies: - supports-color - supabase@2.116.0: + supabase@2.117.0: dependencies: eciesjs: 0.5.0 - jose: 6.2.10 + jose: 6.2.12 optionalDependencies: - '@supabase/cli-darwin-arm64': 2.116.0 - '@supabase/cli-darwin-x64': 2.116.0 - '@supabase/cli-linux-arm64': 2.116.0 - '@supabase/cli-linux-arm64-musl': 2.116.0 - '@supabase/cli-linux-x64': 2.116.0 - '@supabase/cli-linux-x64-musl': 2.116.0 - '@supabase/cli-windows-arm64': 2.116.0 - '@supabase/cli-windows-x64': 2.116.0 + '@supabase/cli-darwin-arm64': 2.117.0 + '@supabase/cli-darwin-x64': 2.117.0 + '@supabase/cli-linux-arm64': 2.117.0 + '@supabase/cli-linux-arm64-musl': 2.117.0 + '@supabase/cli-linux-x64': 2.117.0 + '@supabase/cli-linux-x64-musl': 2.117.0 + '@supabase/cli-windows-arm64': 2.117.0 + '@supabase/cli-windows-x64': 2.117.0 supports-color@7.2.0: dependencies: @@ -10467,7 +10604,7 @@ snapshots: dependencies: semver: 5.7.2 - tinybench@2.9.0: {} + tinybench@6.1.4: {} tinyexec@1.3.0: {} @@ -10476,21 +10613,19 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - tinyrainbow@3.1.1: {} - - tiptap-markdown@0.9.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0)): + tiptap-markdown@0.9.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)): dependencies: - '@tiptap/core': 3.31.0(@tiptap/pm@3.31.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@types/markdown-it': 13.0.9 markdown-it: 14.3.1 markdown-it-task-lists: 2.1.1 prosemirror-markdown: 1.13.7 - tldts-core@7.4.11: {} + tldts-core@7.4.12: {} - tldts@7.4.11: + tldts@7.4.12: dependencies: - tldts-core: 7.4.11 + tldts-core: 7.4.12 tmp-promise@3.0.3: dependencies: @@ -10500,7 +10635,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.11 + tldts: 7.4.12 tr46@6.0.0: dependencies: @@ -10566,13 +10701,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + typescript-eslint@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - '@typescript-eslint/parser': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - '@typescript-eslint/typescript-estree': 8.69.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) - '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -10619,12 +10754,14 @@ snapshots: undici-types@8.3.0: {} - undici@6.28.0: {} + undici-types@8.9.0: {} - undici@7.29.0: + undici@6.28.1: {} + + undici@7.29.1: optional: true - undici@8.10.1: {} + undici@8.10.2: {} universalify@0.1.2: {} @@ -10638,9 +10775,9 @@ snapshots: graceful-fs: 4.2.11 node-int64: 0.4.0 - update-browserslist-db@1.3.2(browserslist@4.28.8): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -10671,44 +10808,38 @@ snapshots: util-deprecate@1.0.2: {} - vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): + vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 - rolldown: 1.2.6 + postcss: 8.5.28 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.1 + '@types/node': 26.5.0 esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 - vitest@4.1.11(@types/node@26.4.1)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@26.5.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 0.30.21 + magic-string: 1.2.3 obug: 2.1.4 - pathe: 2.0.3 picomatch: 4.0.7 std-env: 4.2.0 - tinybench: 2.9.0 + tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.4.1 + '@types/node': 26.5.0 jsdom: 30.0.1(@noble/hashes@2.4.0) transitivePeerDependencies: - msw diff --git a/scripts/benchmark-startup.mjs b/scripts/benchmark-startup.mjs new file mode 100644 index 00000000..c179ed00 --- /dev/null +++ b/scripts/benchmark-startup.mjs @@ -0,0 +1,386 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function parseOptions(args) { + const options = { + runs: 3, + profile: "fresh", + timeoutMs: 30_000, + scenario: "intro", + }; + for (let i = 0; i < args.length; i += 2) { + const [key, value] = [args[i], args[i + 1]]; + if (key === "--profile" && ["fresh", "repeat"].includes(value)) + options.profile = value; + else if (key === "--scenario" && ["intro", "resize"].includes(value)) + options.scenario = value; + else if ( + key === "--runs" && + /^\d+$/.test(value ?? "") && + +value >= 1 && + +value <= 100 + ) + options.runs = +value; + else if ( + key === "--timeout-ms" && + /^\d+$/.test(value ?? "") && + +value >= 1000 && + +value <= 120000 + ) + options.timeoutMs = +value; + else throw new Error(`Invalid option: ${key} ${value ?? ""}`); + } + return options; +} + +export function summarize(runs) { + const successful = runs.filter((run) => run.status === "passed"); + const values = successful + .map((run) => run.introObservedMs) + .sort((a, b) => a - b); + if (values.some((value) => !Number.isFinite(value) || value < 0)) + throw new Error("Invalid measurement"); + const n = values.length; + return { + attempted: runs.length, + passed: n, + failed: runs.length - n, + // 실패는 별도 계수하며 성공 표본 통계를 전체 실행의 통과로 해석하지 않는다. + successfulIntroMedianMs: n + ? (values[Math.floor((n - 1) / 2)] + values[Math.floor(n / 2)]) / 2 + : null, + successfulIntroP95Ms: n >= 20 ? values[Math.ceil(n * 0.95) - 1] : null, + }; +} + +async function deadline(promise, ms) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timeout after ${ms}ms`)), + ms, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function waitForOpaqueHeading(page, heading) { + const element = await heading.elementHandle(); + try { + await page.waitForFunction((target) => { + let opacity = 1; + for (let current = target; current; current = current.parentElement) { + opacity *= Number(getComputedStyle(current).opacity); + } + return target.isConnected && opacity >= 0.99; + }, element); + } finally { + await element.dispose(); + } +} + +async function main() { + const options = parseOptions(process.argv.slice(2)); + const repo = path.resolve(fileURLToPath(new URL("..", import.meta.url))); + const artifacts = {}; + for (const name of [ + "out/main/index.js", + "out/preload/index.cjs", + "out/renderer/index.html", + "pnpm-lock.yaml", + "scripts/benchmark-startup.mjs", + "scripts/startup-probe.cjs", + ]) { + artifacts[name] = createHash("sha256") + .update(await readFile(path.join(repo, name))) + .digest("hex"); + } + const { _electron: electron } = await import("@playwright/test"); + const output = await mkdtemp(path.join(os.tmpdir(), "luie-p0-")); + console.log(`P0 evidence: ${output}`); + const report = { + schemaVersion: 2, + startedAt: new Date().toISOString(), + kind: "instrumented-unpackaged-controlled", + options, + artifacts, + commit: execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf8", + }).trim(), + dirty: !!execFileSync("git", ["status", "--porcelain"], { + cwd: repo, + encoding: "utf8", + }).trim(), + host: { + platform: os.platform(), + arch: os.arch(), + release: os.release(), + cpu: os.cpus()[0]?.model, + logicalCpus: os.cpus().length, + totalMemoryBytes: os.totalmem(), + }, + controls: [ + "isolated-userData-and-DB", + "protocol-registration-stubbed-false", + "sync-disabled", + "llmfit-release-503", + "Playwright-inspector", + "empty-profile-app-exit-no-shutdown-certification", + ], + runs: [], + }; + // launch 실패도 기록하되 Node의 기본 실패 종료는 억제하지 않는다. + process.once("uncaughtExceptionMonitor", (error) => { + report.fatalError = String(error); + const currentRun = report.runs.at(-1); + if (currentRun) currentRun.status = "failed"; + report.summary = summarize(report.runs); + writeFileSync( + path.join(output, "report.json"), + JSON.stringify(report, null, 2), + ); + }); + const token = randomUUID(); + for (let index = 0; index < options.runs; index++) { + const profile = path.join( + output, + options.profile === "fresh" ? `profile-${index + 1}` : "profile-repeat", + ); + await mkdir(profile, { recursive: true }); + await writeFile(path.join(profile, ".luie-p0-owner"), token); + const run = { + index: index + 1, + status: "failed", + phase: "launch", + profile, + }; + report.runs.push(run); + await writeFile( + path.join(output, "report.json"), + JSON.stringify(report, null, 2), + ); + let application; + const started = performance.now(); + try { + // 기존 shell의 인증/테스트/DB 플래그를 이어받지 않는다. OS 실행 필수 환경은 유지한다. + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !/^(LUIE_|E2E_|DATABASE_URL$|CACHE_DATABASE_URL$|ELECTRON_|NODE_OPTIONS$|VITE_)/.test( + key, + ), + ), + ); + application = await electron.launch({ + cwd: repo, + args: [path.join(repo, "scripts/startup-probe.cjs")], + env: { + ...env, + LUIE_P0_CONFIG: JSON.stringify({ repo, profile, token }), + }, + timeout: options.timeoutMs, + }); + run.automationConnectedMs = performance.now() - started; + await deadline( + (async () => { + run.phase = "intro"; + const page = await application.firstWindow({ + timeout: options.timeoutMs, + }); + page.setDefaultTimeout(options.timeoutMs); + const introHeading = page.getByRole("heading", { + name: /^(Luie에 오신 것을 환영합니다|Welcome to Luie|Luieへようこそ)$/, + }); + await introHeading.waitFor(); + run.introObservedMs = performance.now() - started; + await waitForOpaqueHeading(page, introHeading); + run.introOpaqueObservedMs = performance.now() - started; + run.renderer = await page.evaluate(() => ({ + timeOrigin: performance.timeOrigin, + observedAtMs: performance.now(), + navigation: performance + .getEntriesByType("navigation") + .map((entry) => entry.toJSON()), + paint: performance + .getEntriesByType("paint") + .map((entry) => entry.toJSON()), + width: innerWidth, + height: innerHeight, + devicePixelRatio, + })); + run.phase = "start-button"; + const clicked = performance.now(); + await page + .getByRole("button", { name: /^(시작하기|Get Started|はじめる)$/ }) + .click(); + const modelHeading = page.getByRole("heading", { + name: /^(Luie가 이야기를 기억할 수 있게 준비할게요|Let's get Luie ready to remember your story|Luieが物語を覚えられるように準備します)$/, + }); + await modelHeading.waitFor(); + run.modelObservedAfterClickRequestMs = performance.now() - clicked; + await waitForOpaqueHeading(page, modelHeading); + run.modelOpaqueObservedAfterClickRequestMs = + performance.now() - clicked; + if (options.scenario === "resize") { + run.phase = "resize-preview"; + run.resize = { + before: await application.evaluate( + ({ BrowserWindow, systemPreferences }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.once("resized", () => { + globalThis.__luieStartupProbe.resizeCompletedAt = + Date.now(); + }); + return { + bounds: win.getBounds(), + animationSettings: systemPreferences.getAnimationSettings(), + }; + }, + ), + }; + await page.evaluate(() => { + const states = []; + const capture = () => { + const state = { + busy: !!document.querySelector('[aria-busy="true"]'), + editor: !!document.querySelector(".ProseMirror"), + }; + const last = states.at(-1); + if ( + states.length < 16 && + (!last || + state.busy !== last.busy || + state.editor !== last.editor) + ) { + states.push({ ...state, at: Date.now() }); + } + }; + const observer = new MutationObserver(capture); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["aria-busy"], + }); + capture(); + globalThis.__luieResizeSmoke = { states, observer }; + }); + await page + .getByRole("button", { name: /^(건너뛰기|Skip|スキップ)$/ }) + .click(); + await page + .locator(".ProseMirror") + .first() + .waitFor({ state: "visible" }); + run.resize.states = await page.evaluate(() => { + const { states, observer } = globalThis.__luieResizeSmoke; + observer.disconnect(); + return states; + }); + run.resize.after = await application.evaluate( + ({ BrowserWindow }) => ({ + bounds: BrowserWindow.getAllWindows()[0].getBounds(), + resizedAt: + globalThis.__luieStartupProbe.resizeCompletedAt ?? null, + }), + ); + if ( + !run.resize.states.some((state) => state.busy && !state.editor) || + run.resize.states.some((state) => state.busy && state.editor) || + !run.resize.states.at(-1)?.editor + ) { + throw new Error( + "Background-only resize / preview transition not observed", + ); + } + } + run.phase = "snapshot"; + run.main = await application.evaluate(({ app, BrowserWindow }) => ({ + versions: process.versions, + arch: process.arch, + userData: app.getPath("userData"), + probe: globalThis.__luieStartupProbe, + gpuFeatures: globalThis.__luieStartupProbe.gpuStatusReady + ? app.getGPUFeatureStatus() + : null, + // 1회 CPU 값은 구간 비교에 부적합하므로 메모리만 남긴다. GPU utilization이 아니다. + processes: app + .getAppMetrics() + .map(({ pid, type, creationTime, memory }) => ({ + pid, + type, + creationTime, + memory, + })), + windows: BrowserWindow.getAllWindows().map((window) => ({ + id: window.id, + visible: window.isVisible(), + focused: window.isFocused(), + bounds: window.getBounds(), + })), + })); + if (path.resolve(run.main.userData) !== profile) + throw new Error("userData isolation mismatch"); + await page.screenshot({ + path: path.join(output, `run-${index + 1}.png`), + }); + run.status = "passed"; + run.phase = "complete"; + })(), + options.timeoutMs, + ); + } catch (error) { + run.error = String(error); + } finally { + if (application) { + try { + // 빈 테스트 프로필만 종료한다. 실제 저장/flush/종료 검증을 대체하지 않는다. + run.cleanupMode = "empty-profile-app-exit"; + const closed = application.waitForEvent("close", { timeout: 10000 }); + await Promise.all([ + application.evaluate(({ app }) => { + setTimeout(() => app.exit(0), 50); + }), + closed, + ]); + } catch (error) { + run.status = "failed"; + run.cleanupError = String(error); + application.process().kill("SIGKILL"); + } + } + report.summary = summarize(report.runs); + await writeFile( + path.join(output, "report.json"), + JSON.stringify(report, null, 2), + ); + } + console.log(JSON.stringify(run)); + if (run.cleanupError) break; + } + console.log(JSON.stringify(report.summary)); + if (report.summary.failed || report.runs.length !== options.runs) + process.exitCode = 1; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/check-render-boot-budget.mjs b/scripts/check-render-boot-budget.mjs new file mode 100644 index 00000000..c7b2ea74 --- /dev/null +++ b/scripts/check-render-boot-budget.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// 렌더러 부트 페이로드 예산 게이트. +// index.html이 modulepreload/script/stylesheet로 첫 로드에 강제하는 자산 합계를 실측해 +// docs/architecture/startup-pipeline-dissection.md §5b의 SLO 예산과 대조한다. +// 사전 조건: `pnpm build` 완료(out/renderer 존재). qa 파이프라인에는 넣지 않고 +// 빌드 산출물 회귀 확인용으로만 사용한다. + +import { statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const RENDERER_DIR = path.resolve(process.cwd(), "out", "renderer"); + +// Phase B-1(2026-09-05)에서 manualChunks segment 매칭 수정 + zod 분리로 +// 1,111.1 KB → 573.1 KB로 축소됐다. 개선 수치를 기준선으로 고정한다. +const BUDGET = { + bootJsBytes: 600 * 1024, + bootCssBytes: 170 * 1024, +}; + +// Phase B 이후 하드 게이트로 승격할 항목: 현재는 존재만 보고한다. +const REPORT_ONLY_CHUNKS = ["vendor-prosemirror", "vendor-data"]; + +const collectAssetPaths = (html) => { + const moduleScripts = [...html.matchAll(/]+src="\.\/(assets\/[^"]+\.js)"/g)].map( + (m) => m[1], + ); + const preloads = [...html.matchAll(/]+modulepreload[^>]+href="\.\/(assets\/[^"]+\.js)"/g)].map( + (m) => m[1], + ); + const stylesheets = [...html.matchAll(/]+stylesheet[^>]+href="\.\/(assets\/[^"]+\.css)"/g)].map( + (m) => m[1], + ); + return { + bootJs: [...new Set([...moduleScripts, ...preloads])], + bootCss: [...new Set(stylesheets)], + }; +}; + +const sumBytes = (paths) => + paths.reduce((total, relative) => total + statSync(path.join(RENDERER_DIR, relative)).size, 0); + +const formatKb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`; + +const main = async () => { + const htmlPath = path.join(RENDERER_DIR, "index.html"); + let html; + try { + html = await readFile(htmlPath, "utf8"); + } catch { + console.error("out/renderer/index.html not found. Run `pnpm build` first."); + process.exit(2); + } + + const { bootJs, bootCss } = collectAssetPaths(html); + const bootJsBytes = sumBytes(bootJs); + const bootCssBytes = sumBytes(bootCss); + + console.log(`Boot JS : ${formatKb(bootJsBytes)} (${bootJs.length} files, budget ${formatKb(BUDGET.bootJsBytes)})`); + console.log(`Boot CSS : ${formatKb(bootCssBytes)} (${bootCss.length} files, budget ${formatKb(BUDGET.bootCssBytes)})`); + + const offenders = REPORT_ONLY_CHUNKS.filter((name) => + bootJs.some((asset) => asset.includes(name)), + ); + if (offenders.length > 0) { + console.warn( + `[report-only] boot preload에 에디터/export 계열 청크 포함(Phase B에서 하드 게이트로 승격 예정): ${offenders.join(", ")}`, + ); + } + + const failures = []; + if (bootJsBytes > BUDGET.bootJsBytes) { + failures.push(`boot JS ${formatKb(bootJsBytes)} exceeds budget ${formatKb(BUDGET.bootJsBytes)}`); + } + if (bootCssBytes > BUDGET.bootCssBytes) { + failures.push(`boot CSS ${formatKb(bootCssBytes)} exceeds budget ${formatKb(BUDGET.bootCssBytes)}`); + } + + if (failures.length > 0) { + console.error(`RENDER_BOOT_BUDGET_FAILED: ${failures.join("; ")}`); + process.exit(1); + } + console.log("RENDER_BOOT_BUDGET_OK"); +}; + +main(); diff --git a/scripts/check-startup-wizard-bundle.mjs b/scripts/check-startup-wizard-bundle.mjs new file mode 100644 index 00000000..55e11191 --- /dev/null +++ b/scripts/check-startup-wizard-bundle.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +export function staticModuleSpecifiers(text, filename = "chunk.js") { + const source = ts.createSourceFile( + filename, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + assert.equal(source.parseDiagnostics.length, 0, `Invalid JS: ${filename}`); + return source.statements.flatMap((statement) => { + if ( + !ts.isImportDeclaration(statement) && + !ts.isExportDeclaration(statement) + ) + return []; + if (!statement.moduleSpecifier) return []; + assert(ts.isStringLiteral(statement.moduleSpecifier)); + return [statement.moduleSpecifier.text]; + }); +} + +export function analyzeStartupBundle(directory) { + const root = path.resolve(directory); + const html = readFileSync(path.join(root, "index.html"), "utf8"); + const entries = [ + ...new Set( + [...html.matchAll(/(?:src|href)="\.\/(assets\/[^"?#]+\.js)"/g)].map( + (match) => path.join(root, match[1]), + ), + ), + ]; + assert(entries.length > 0, "No renderer HTML JS entries found"); + const chunks = path.join(root, "assets/chunks"); + const allChunks = readdirSync(chunks); + assert( + allChunks.some((name) => /^vendor-prosemirror-.*\.js$/.test(name)), + "Missing editor sentinel chunk; review build naming before using this gate", + ); + const names = allChunks.filter((name) => /^StartupWizard-.*\.js$/.test(name)); + assert.equal( + names.length, + 1, + "Expected one StartupWizard chunk; build first", + ); + const closure = (seeds) => { + const files = new Set(); + const visit = (file) => { + const relative = path.relative(root, file); + assert( + !relative.startsWith("..") && !path.isAbsolute(relative), + "Import outside renderer output", + ); + if (files.has(file)) return; + files.add(file); + for (const specifier of staticModuleSpecifiers( + readFileSync(file, "utf8"), + file, + )) { + assert( + specifier.startsWith("."), + `Unexpected external import: ${specifier}`, + ); + visit(path.resolve(path.dirname(file), specifier)); + } + }; + seeds.forEach(visit); + return files; + }; + const boot = closure(entries); + const wizard = closure([path.join(chunks, names[0])]); + const union = new Set([...boot, ...wizard]); + const extra = new Set([...wizard].filter((file) => !boot.has(file))); + const summary = (files) => ({ + files: files.size, + bytes: [...files].reduce((sum, file) => sum + statSync(file).size, 0), + }); + return { + boot: summary(boot), + wizard: summary(wizard), + extra: summary(extra), + union: summary(union), + eagerEditorChunks: [...union] + .map((file) => path.relative(root, file)) + .filter((file) => + /(?:vendor-prosemirror|vendor-tiptap|EditorToolbar|WizardEditor|LayoutLivePreview)-/.test( + path.basename(file), + ), + ), + limits: + "Unique emitted static JS only; excludes dynamic imports, runtime fetch/evaluation, CSS, locale, images and OS timing. Chunk-name guard must be updated if build naming changes.", + }; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + assert( + process.argv.slice(2).every((arg) => arg === "--report"), + "Only --report is supported", + ); + const report = analyzeStartupBundle( + fileURLToPath(new URL("../out/renderer", import.meta.url)), + ); + console.log(JSON.stringify(report, null, 2)); + if (!process.argv.includes("--report")) { + assert.equal( + report.eagerEditorChunks.length, + 0, + "Wizard initial static graph includes editor preview chunks", + ); + } +} diff --git a/scripts/startup-probe.cjs b/scripts/startup-probe.cjs new file mode 100644 index 00000000..84084904 --- /dev/null +++ b/scripts/startup-probe.cjs @@ -0,0 +1,85 @@ +// 테스트 전용 진입점. 제품 main을 import하기 전에 저장소를 격리한다. +const fs = require("node:fs"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +function configureProbe(app, config) { + if (app.isPackaged || !path.isAbsolute(config.profile)) { + throw new Error( + "P0 requires an unpackaged app and an absolute owned profile", + ); + } + if ( + fs.readFileSync(path.join(config.profile, ".luie-p0-owner"), "utf8") !== + config.token + ) { + throw new Error("P0 profile ownership mismatch"); + } + app.setPath("userData", config.profile); + app.setPath("sessionData", config.profile); + app.setAppPath(config.repo); + process.env.LUIE_USER_DATA_PATH = config.profile; + process.env.DATABASE_URL = `file:${path.join(config.profile, "main.db")}`; + process.env.LUIE_RUNTIME_DATABASE_URL = process.env.DATABASE_URL; + process.env.LUIE_RUNTIME_CACHE_DATABASE_URL = `file:${path.join(config.profile, "cache.db")}`; + process.env.LUIE_DISABLE_SYNC = "1"; + process.env.NODE_ENV = "production"; + + // 테스트가 실제 OS의 luie:// 연결을 덮어쓰지 않게 한다. 성공으로 위장하지 않는다. + app.setAsDefaultProtocolClient = () => false; +} + +module.exports = { configureProbe }; + +if (process.versions.electron) { + const { app } = require("electron"); + const config = JSON.parse(process.env.LUIE_P0_CONFIG); + configureProbe(app, config); + const started = performance.now(); + const probe = { marks: [], gpuStatusReady: false }; + globalThis.__luieStartupProbe = probe; + const mark = (event, windowId) => { + // ponytail: 시작 시점만 최대 64개 보존. 긴 세션 분석은 별도 trace를 사용한다. + if (probe.marks.length < 64) { + probe.marks.push({ + event, + windowId, + elapsedMs: performance.now() - started, + }); + } + }; + mark("probe-entry"); + app.once("ready", () => mark("app-ready")); + app.once("gpu-info-update", () => { + probe.gpuStatusReady = true; + }); + app.on("browser-window-created", (_event, window) => { + mark("window-created", window.id); + window.once("show", () => mark("window-show", window.id)); + for (const event of [ + "dom-ready", + "did-finish-load", + "render-process-gone", + ]) { + window.webContents.once(event, () => mark(event, window.id)); + } + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + const url = String(input instanceof Request ? input.url : input); + // llmfit의 자동 설치가 측정 중 새 바이너리를 내려받지 않게 하는 통제 실험이다. + if ( + url === "https://api.github.com/repos/AlexsJones/llmfit/releases/latest" + ) { + mark("llmfit-release-blocked"); + return Promise.resolve(new Response(null, { status: 503 })); + } + return originalFetch(input, init); + }; + import(pathToFileURL(path.join(config.repo, "out/main/index.js")).href).catch( + (error) => { + console.error(error); + app.exit(1); + }, + ); +} diff --git a/src/main/handler/system/window/ipcWindowHandlers.ts b/src/main/handler/system/window/ipcWindowHandlers.ts index 1ecb7f00..ec1aa22f 100644 --- a/src/main/handler/system/window/ipcWindowHandlers.ts +++ b/src/main/handler/system/window/ipcWindowHandlers.ts @@ -1,4 +1,4 @@ -import { app, screen } from "electron"; +import { app, BrowserWindow } from "electron"; import { windowManager } from "../../../app/windows/index.js"; import { applyTrafficLightPosition } from "../../../manager/window/windowChrome.js"; import { IPC_CHANNELS } from "../../../../shared/ipc/channels.js"; @@ -17,103 +17,27 @@ import { windowSetStartupWizardSizeArgsSchema, windowSetTrafficLightVisibilityArgsSchema, } from "../../../../shared/schemas/index.js"; -import type { BrowserWindow } from "electron"; -import { - calculateStartupWizardExpandedBounds, - calculateStartupWizardInitialBounds, -} from "../../../manager/window/windowStartupWizard.js"; - -// 위저드 단계 전환(A 인트로 → B 테마)의 창 확장 애니메이션. 창이 커지는 동안 내용물이 -// 재배치되는 걸 따라갈 수 있도록 여유 있는 길이로 보간하고(easeOutCubic), 프레임마다 -// 시작 중심점 기준으로 bounds를 잡아 폭이 커질 때 창이 한쪽으로 늘어나 보이지 않게 한다. -const WIZARD_RESIZE_ANIMATION_MS = 800; -const WIZARD_RESIZE_TICK_MS = 16; - -let wizardResizeTimer: ReturnType | null = null; - -// 디스플레이 작업 영역(workArea) 내에서 대상 영역을 계산한다. -// 1. 음수 크기(-1, -1 등): A 인트로/모델 단계의 초기 콤팩트 bounds로 복원 -// 2. 최대 크기(4000 이상 또는 workArea 이상): 여백 없이 workArea 100% 채움 -// 3. 가로형 확장 프리뷰(1200 이상): 화면 82% 비율 + clamp 기반 동적 bounds 적용 -// 4. 기타 크기: workArea 내 중앙 정렬 -const getTargetWizardBounds = ( - win: BrowserWindow, - width: number, - height: number, -): { x: number; y: number; width: number; height: number } => { - if (width < 0 || height < 0) { - const initial = calculateStartupWizardInitialBounds(win); - return { - x: initial.x, - y: initial.y, - width: initial.width, - height: initial.height, - }; - } +import { resizeStartupWizardWindow } from "../../../manager/window/windowStartupWizardResize.js"; - const workArea = screen.getDisplayMatching(win.getBounds()).workArea; - - if (width >= 4000 || (width >= workArea.width && height >= workArea.height)) { - return { - x: workArea.x, - y: workArea.y, - width: workArea.width, - height: workArea.height, - }; +// NOTE: 창 제어 채널(minimize/maximize/unmaximize/close)은 "호출한 렌더러가 속한 +// 창"을 대상으로 해야 한다(메인·내보내기 창 동시 실행 대응). 핸들러 규약상 event를 +// 받지 못하므로 포커스된 창으로 호출 주체를 판정한다 — 버튼/단축키는 클릭·포커스된 +// 창의 렌더러에서 발화하므로 성립한다. 포커스를 잃은 비정상 경로의 폴백으로, 시작 +// 위저드는 메인 창 이전에 단독으로 뜨고(appReady의 wizard-first 플로우) 완료 후에는 +// 메인 창 준비 직전까지 공존하는 짧은 교체 전환 구간이 있다 — 이때는 포커스 판정이 +// 우선이고 위저드 폴백은 그 다음이다. +const resolveWindowControlTarget = (): BrowserWindow | null => { + if (typeof BrowserWindow.getFocusedWindow === "function") { + const focused = BrowserWindow.getFocusedWindow(); + if (focused && !focused.isDestroyed()) { + return focused; + } } - - if (width >= 1200 || (width === 0 && height === 0)) { - return calculateStartupWizardExpandedBounds(win); + const wizard = windowManager.getStartupWizardWindow(); + if (wizard && !wizard.isDestroyed()) { + return wizard; } - - const clampedWidth = Math.min(width, workArea.width); - const clampedHeight = Math.min(height, workArea.height); - - return { - x: Math.round(workArea.x + (workArea.width - clampedWidth) / 2), - y: Math.round(workArea.y + (workArea.height - clampedHeight) / 2), - width: clampedWidth, - height: clampedHeight, - }; -}; - -const animateWizardResize = ( - win: BrowserWindow, - target: { x: number; y: number; width: number; height: number }, -): void => { - if (wizardResizeTimer) clearTimeout(wizardResizeTimer); - - const [startWidth, startHeight] = win.getSize(); - const [startX, startY] = win.getPosition(); - const startedAt = Date.now(); - - const tick = () => { - const progress = Math.min( - 1, - (Date.now() - startedAt) / WIZARD_RESIZE_ANIMATION_MS, - ); - const eased = 1 - Math.pow(1 - progress, 3); - const width = Math.round(startWidth + (target.width - startWidth) * eased); - const height = Math.round( - startHeight + (target.height - startHeight) * eased, - ); - const x = Math.round(startX + (target.x - startX) * eased); - const y = Math.round(startY + (target.y - startY) * eased); - - win.setBounds({ - x, - y, - width, - height, - }); - - if (progress < 1) { - wizardResizeTimer = setTimeout(tick, WIZARD_RESIZE_TICK_MS); - } else { - wizardResizeTimer = null; - } - }; - tick(); + return windowManager.getMainWindow(); }; export function registerWindowIPCHandlers(logger: LoggerLike): void { @@ -124,7 +48,7 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { failMessage: "Failed to close window", handler: () => { logger.info("WINDOW_CLOSE requested from renderer"); - const win = windowManager.getMainWindow(); + const win = resolveWindowControlTarget(); if (!win) return false; win.close(); return true; @@ -198,12 +122,23 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { return getBootstrapStatus(); }, }, + { + channel: IPC_CHANNELS.WINDOW_MINIMIZE, + logTag: "WINDOW_MINIMIZE", + failMessage: "Failed to minimize window", + handler: () => { + const win = resolveWindowControlTarget(); + if (!win) return false; + win.minimize(); + return true; + }, + }, { channel: IPC_CHANNELS.WINDOW_MAXIMIZE, logTag: "WINDOW_MAXIMIZE", failMessage: "Failed to maximize window", handler: () => { - const win = windowManager.getMainWindow(); + const win = resolveWindowControlTarget(); if (!win) return false; if (!win.isMaximized()) { win.maximize(); @@ -212,6 +147,20 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { return true; }, }, + { + channel: IPC_CHANNELS.WINDOW_UNMAXIMIZE, + logTag: "WINDOW_UNMAXIMIZE", + failMessage: "Failed to unmaximize window", + handler: () => { + const win = resolveWindowControlTarget(); + if (!win) return false; + if (win.isMaximized()) { + win.unmaximize(); + } + win.focus(); + return true; + }, + }, { channel: IPC_CHANNELS.WINDOW_TOGGLE_FULLSCREEN, logTag: "WINDOW_TOGGLE_FULLSCREEN", @@ -300,24 +249,14 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { logTag: "WINDOW_SET_STARTUP_WIZARD_SIZE", failMessage: "Failed to resize startup wizard window", argsSchema: windowSetStartupWizardSizeArgsSchema, - handler: (width: number, height: number, animate: boolean) => { - // NOTE: 위저드 단계 전환(A 인트로 → B 테마)에 맞춘 리사이즈다. 메인 창이 - // 아니라 위저드 창을 움직여야 하므로 전용 접근자를 쓴다. 애니메이션은 - // renderer의 enableAnimations(및 OS reduced-motion) 판정을 그대로 받는다. - const win = windowManager.getStartupWizardWindow(); - if (!win) return false; - const targetBounds = getTargetWizardBounds(win, width, height); - if (!animate) { - if (wizardResizeTimer) { - clearTimeout(wizardResizeTimer); - wizardResizeTimer = null; - } - win.setBounds(targetBounds); - return true; - } - animateWizardResize(win, targetBounds); - return true; - }, + handler: (width: number, height: number, animate: boolean) => + resizeStartupWizardWindow( + windowManager.getStartupWizardWindow(), + width, + height, + animate, + logger, + ), }, ]); } diff --git a/src/main/index.ts b/src/main/index.ts index 5996b33c..cc29df34 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -98,7 +98,6 @@ if (!registerSingleInstance(bootstrapLogger)) { { extractAuthCallbackUrl, handleDeepLinkUrl }, { registerShutdownHandlers }, { syncService }, - { utilityProcessBridge }, ] = await Promise.all([ import("./prismaEnv.js"), import("./lifecycle/index.js"), @@ -106,7 +105,6 @@ if (!registerSingleInstance(bootstrapLogger)) { import("./lifecycle/index.js"), import("./lifecycle/index.js"), import("./domains/sync/index.js"), - import("./infra/utility-process/index.js"), ]); registerCrashReporting(logger); @@ -145,10 +143,8 @@ if (!registerSingleInstance(bootstrapLogger)) { }, }); - void app.whenReady().then(async () => { - const utilityStarted = await utilityProcessBridge.start(); - logger.info("Startup checkpoint: utility process", { utilityStarted }); - }); - + // NOTE: 유틸리티 프로세스는 whenReady에서 즉시 fork하지 않는다. embed/RAG/텍스트 + // 생성 요청 시점에 lazy로 시작한다(bridge의 각 메서드가 start()를 보장) — 임베딩 + // 모델이나 LLM을 쓰지 않는 부팅에서 fork+헬스체크 비용과 프로세스 상주를 없앤다. registerShutdownHandlers(logger); } diff --git a/src/main/lifecycle/app-ready/appReady.ts b/src/main/lifecycle/app-ready/appReady.ts index 32e41d56..88fdce19 100644 --- a/src/main/lifecycle/app-ready/appReady.ts +++ b/src/main/lifecycle/app-ready/appReady.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, session } from "electron"; import { windowManager } from "../../app/windows/index.js"; import { settingsManager } from "../../domains/settings/index.js"; -import { isDevEnv } from "../../utils/env/index.js"; +import { isDevEnv, isStartupWizardForced } from "../../utils/env/index.js"; import type { createLogger } from "../../../shared/logger/index.js"; import { applyApplicationMenu } from "../menu/index.js"; import { ensureBootstrapReady } from "../bootstrap/index.js"; @@ -70,22 +70,45 @@ export const registerAppReady = ( logger.info("App is ready", { startupElapsedMs: Date.now() - startupStartedAtMs, }); - const bootstrapStatus = await ensureBootstrapReady(); - if (!bootstrapStatus.isReady) { - logger.error("App bootstrap did not complete", bootstrapStatus); - } + + // 첫-창-우선(P0): bootstrap(DB 마이그레이션·integrity 스캔·세션 네트워크 최대 5초)을 + // 기다리지 않고 창을 띄우는 게 목표다. 위저드 필요 여부는 completedAt/강제 플래그만으로 + // 1차 판정한다 — blocking 검사 실패는 이 판정에 위저드를 추가할 뿐 반대로 만들지는 + // 않으므로, 놓친 실패는 뒤의 전체 평가와 위저드 완료 게이트가 보정한다. + // docs/architecture/startup-pipeline-dissection.md §4 진단 1번. + const cheapNeedsWizard = + isStartupWizardForced() || + !settingsManager.getStartupSettings().completedAt; + + void ensureBootstrapReady() + .then((status) => { + if (!status.isReady) { + logger.error("App bootstrap did not complete", status); + } + }) + .catch((error) => { + logger.error("App bootstrap did not complete", error); + }); const isDev = isDevEnv(); const cspPolicy = resolveCspPolicy(isDev); let rendererReadyForCurrentMainWindow = false; let firstRendererStartupHookTriggered = false; + // 위자드 완료 후 메인 창의 렌더러가 준비될 때까지 위자드를 화면에 남기기 위한 + // 플래그 — "닫고 생성"의 창 0개 빈 화면 구간(non-darwin quit 위험 + 콜드 부트 + // 동안 공백)을 "교체" 전환으로 바꾼다. + let wizardClosePending = false; let fallbackTimer: NodeJS.Timeout | null = null; const triggerFirstRendererReady = (reason: string): void => { if (!rendererReadyForCurrentMainWindow) { rendererReadyForCurrentMainWindow = true; windowManager.showMainWindow(); + if (wizardClosePending) { + wizardClosePending = false; + windowManager.closeStartupWizardWindow(); + } logger.info("Startup checkpoint: renderer ready", { reason, startupElapsedMs: Date.now() - startupStartedAtMs, @@ -94,13 +117,19 @@ export const registerAppReady = ( reason, startupElapsedMs: Date.now() - startupStartedAtMs, }); - if (bootstrapStatus.isReady) { - void loadDerivedJobWorker() - .then((derivedJobWorker) => derivedJobWorker.start()) - .catch((error) => { - logger.warn("Failed to start derived job worker", error); - }); - } + // bootstrapStatus 캡처 대신 캐시된 bootstrap promise를 다시 읽는다 — 위자드 + // 경로에서는 창 표시가 bootstrap 완료보다 빠를 수 있어 mutable 값만으로는 + // derived job worker 시작이 유실될 수 있다. + void ensureBootstrapReady() + .then((status) => { + if (!status.isReady) return; + void loadDerivedJobWorker() + .then((derivedJobWorker) => derivedJobWorker.start()) + .catch((error) => { + logger.warn("Failed to start derived job worker", error); + }); + }) + .catch(() => undefined); } if (firstRendererStartupHookTriggered || !options.onFirstRendererReady) { @@ -290,20 +319,41 @@ export const registerAppReady = ( applyApplicationMenu(settingsManager.getMenuBarMode()); - const readiness = await startupReadinessService.getReadiness(); - logger.info("Startup readiness evaluated", { - mustRunWizard: readiness.mustRunWizard, - reasons: readiness.reasons, - completedAt: readiness.completedAt, - }); - - if (readiness.mustRunWizard) { + if (cheapNeedsWizard) { + // 위저드가 필요한 부팅이다. 무거운 readiness 평가를 기다리지 않고 위저드 창을 + // 먼저 띄운다 — bootstrap/readiness는 백그라운드로 계속 진행되고, 위저드 UI는 + // startup.getReadiness IPC(캐시 병합)로 결과를 받아 단계를 연다. windowManager.createStartupWizardWindow(); - logger.info("Startup wizard requested before main window", { - reasons: readiness.reasons, + logger.info("Startup wizard requested before readiness evaluation", { + startupElapsedMs: Date.now() - startupStartedAtMs, }); + void startupReadinessService + .getReadiness() + .then((readiness) => { + logger.info("Startup readiness evaluated (wizard-first)", { + mustRunWizard: readiness.mustRunWizard, + reasons: readiness.reasons, + }); + }) + .catch((error) => { + logger.error("Startup readiness evaluation failed", error); + }); } else { - startMainWindowFlow("readiness-pass"); + const readiness = await startupReadinessService.getReadiness(); + logger.info("Startup readiness evaluated", { + mustRunWizard: readiness.mustRunWizard, + reasons: readiness.reasons, + completedAt: readiness.completedAt, + }); + + if (readiness.mustRunWizard) { + windowManager.createStartupWizardWindow(); + logger.info("Startup wizard requested before main window", { + reasons: readiness.reasons, + }); + } else { + startMainWindowFlow("readiness-pass"); + } } startupReadinessService.onWizardCompleted((nextReadiness) => { @@ -314,7 +364,10 @@ export const registerAppReady = ( if (nextReadiness.mustRunWizard) { return; } - windowManager.closeStartupWizardWindow(); + // NOTE: 위자드를 먼저 닫지 않는다 — startMainWindowFlow가 deferShow로 메인 + // 창을 만들고, 렌더러 준비(did-finish-load 또는 폴백 타이머) 시점의 + // triggerFirstRendererReady에서 show와 함께 위자드를 닫는다(교체 전환). + wizardClosePending = true; startMainWindowFlow("wizard-complete", { fitWorkArea: true }); }); diff --git a/src/main/manager/window/windowChrome.ts b/src/main/manager/window/windowChrome.ts index 3c4fa4fe..b9f63ed2 100644 --- a/src/main/manager/window/windowChrome.ts +++ b/src/main/manager/window/windowChrome.ts @@ -2,6 +2,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron" import { app } from "electron" import { existsSync } from "fs" import { join } from "path" +import { IPC_CHANNELS } from "../../../shared/ipc/channels.js" import { WINDOW_TRAFFIC_LIGHT_X, WINDOW_TRAFFIC_LIGHT_Y, @@ -69,6 +70,46 @@ export const getTitleBarOptions = (): Partial = } } +/** + * Windows에서 네이티브 타이틀바 영역(타이틀·버튼 포함)을 통째로 제거한다. + * 최소화/최대화/닫기는 렌더러가 macOS traffic lights와 같은 방식으로 앱 안에 직접 + * 그린다(WindowsWindowControls). 메인 창과 시작 위저드가 이 옵션을 쓴다 — 두 창의 + * 렌더러 모두 인앱 창 버튼을 렌더한다. export/world graph 등 보조 창은 네이티브 + * 프레임을 유지한다. + */ +export const getWindowsFramelessTitleBarOptions = (): Partial => { + if (process.platform !== "win32") { + return {} + } + + return { + titleBarStyle: "hidden", + } +} + +/** + * 창의 최대화 상태를 렌더러에 알린다. Windows 커스텀 창 버튼이 최대화/복원 + * 아이콘을 전환하는 근원. 버튼 클릭뿐 아니라 Aero Snap·더블클릭 등 시스템 경로로 + * 상태가 바뀌어도 따라가야 해서 이벤트로 흘린다. 최초 로드 시점 동기화는 창 상태 + * 복원으로 이미 최대화된 경우를 위한 것이다. 메인 창·시작 위저드 양쪽에 붙인다. + */ +export const attachMaximizedStateEvents = (win: BrowserWindow): void => { + const sendState = () => { + if (win.isDestroyed()) return + win.webContents.send( + IPC_CHANNELS.WINDOW_MAXIMIZED_CHANGED, + win.isMaximized(), + ) + } + + win.on("maximize", sendState) + win.on("unmaximize", sendState) + // NOTE: 테스트용 BrowserWindow mock은 webContents 이벤트 API가 부분 구현이다. + if (typeof win.webContents?.on === "function") { + win.webContents.on("did-finish-load", sendState) + } +} + /** * 커스텀 트래픽 라이트 좌표를 다시 못박는다. * diff --git a/src/main/manager/window/windowManager.ts b/src/main/manager/window/windowManager.ts index 1a43564b..d29bcbf0 100644 --- a/src/main/manager/window/windowManager.ts +++ b/src/main/manager/window/windowManager.ts @@ -12,8 +12,10 @@ import { import { settingsManager } from "../settings/index.js" import { applyWindowMenuBarMode, + attachMaximizedStateEvents, createSecureWebPreferences, getTitleBarOptions, + getWindowsFramelessTitleBarOptions, resolveWindowIconPath, shouldShowMenuBar, resolveWindowBackgroundColor, @@ -183,11 +185,14 @@ class WindowManager { backgroundColor: resolveWindowBackgroundColor(), ...withWindowIcon(windowIconPath), ...getTitleBarOptions(), + ...getWindowsFramelessTitleBarOptions(), ...(process.platform !== "darwin" ? { autoHideMenuBar: !shouldShowMenuBar(this.getMenuBarMode()) } : {}), }) + attachMaximizedStateEvents(this.mainWindow) + this.applyMenuBarMode(this.mainWindow) windowState.manage(this.mainWindow) if (options.fitWorkArea) { diff --git a/src/main/manager/window/windowSecondaryWindows.ts b/src/main/manager/window/windowSecondaryWindows.ts index d715f24c..b43c08ad 100644 --- a/src/main/manager/window/windowSecondaryWindows.ts +++ b/src/main/manager/window/windowSecondaryWindows.ts @@ -4,7 +4,9 @@ import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron" import { applyWindowMenuBarMode, + attachMaximizedStateEvents, getTitleBarOptions, + getWindowsFramelessTitleBarOptions, resolveWindowIconPath, shouldShowMenuBar, resolveWindowBackgroundColor, @@ -83,9 +85,13 @@ export const createExportBrowserWindow = ( backgroundColor: resolveWindowBackgroundColor(), ...withWindowIcon(resolveWindowIconPath()), ...getTitleBarOptions(), + // NOTE: 내보내기 창의 렌더러도 인앱 창 버튼(WindowsWindowControls)을 그린다. + ...getWindowsFramelessTitleBarOptions(), ...secondaryWindowMenuOptions(menuBarMode), }) + // Windows frameless 창 버튼의 최대화/복원 전환용 상태 이벤트를 공급한다. + attachMaximizedStateEvents(window) applyWindowMenuBarMode(window, menuBarMode) const route = { hash: "export", search: `?chapterId=${chapterId}` } void loadSecondaryWindowRoute({ diff --git a/src/main/manager/window/windowStartupWizard.ts b/src/main/manager/window/windowStartupWizard.ts index d173b067..3abb363a 100644 --- a/src/main/manager/window/windowStartupWizard.ts +++ b/src/main/manager/window/windowStartupWizard.ts @@ -2,7 +2,9 @@ import { screen, type BrowserWindow, type BrowserWindowConstructorOptions } from import { APP_NAME } from "../../../shared/constants/index.js" import { applyWindowMenuBarMode, + attachMaximizedStateEvents, getTitleBarOptions, + getWindowsFramelessTitleBarOptions, resolveWindowIconPath, withWindowIcon, } from "./windowChrome.js" @@ -158,9 +160,13 @@ export const createStartupWizardBrowserWindow = ( backgroundColor: "#212123", ...withWindowIcon(resolveWindowIconPath()), ...getTitleBarOptions(), + ...getWindowsFramelessTitleBarOptions(), ...(process.platform !== "darwin" ? { autoHideMenuBar: true } : {}), }) + // Windows frameless 위저드도 인앱 창 버튼(WindowsWindowControls)을 렌더하므로 + // 최대화 상태 이벤트를 동일하게 공급한다. + attachMaximizedStateEvents(window) applyWindowMenuBarMode(window, input.getMenuBarMode()) void loadRendererRoute({ label: "startup wizard", diff --git a/src/main/manager/window/windowStartupWizardResize.ts b/src/main/manager/window/windowStartupWizardResize.ts new file mode 100644 index 00000000..7f126fb9 --- /dev/null +++ b/src/main/manager/window/windowStartupWizardResize.ts @@ -0,0 +1,189 @@ +import { + screen, + systemPreferences, + type BrowserWindow, + type Rectangle, +} from "electron"; +import { + calculateStartupWizardExpandedBounds, + calculateStartupWizardInitialBounds, +} from "./windowStartupWizard.js"; + +// ponytail: Windows OS 보간 미지원. 200ms/최대 13회는 작업 예산이며 실기 trace 후 조정한다. +const WINDOWS_RESIZE_DURATION_MS = 200; +const WINDOWS_RESIZE_INTERVAL_MS = 16; +const WINDOWS_RESIZE_MAX_STEPS = Math.ceil( + WINDOWS_RESIZE_DURATION_MS / WINDOWS_RESIZE_INTERVAL_MS, +); +// macOS 완료 이벤트 누락에만 쓰는 안전 상한이며 애니메이션 길이가 아니다. +const NATIVE_RESIZE_FALLBACK_MS = 2_000; +const pendingResizes = new WeakMap void>(); + +const sameBounds = (a: Rectangle, b: Rectangle): boolean => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +// 디스플레이 작업 영역(workArea) 내에서 대상 영역을 계산한다. +// 1. 음수 크기(-1, -1 등): A 인트로/모델 단계의 초기 콤팩트 bounds로 복원 +// 2. 최대 크기(4000 이상 또는 workArea 이상): 여백 없이 workArea 100% 채움 +// 3. 가로형 확장 프리뷰(1200 이상): 화면 82% 비율 + clamp 기반 동적 bounds 적용 +// 4. 기타 크기: workArea 내 중앙 정렬 +const getTargetWizardBounds = ( + win: BrowserWindow, + width: number, + height: number, +): { x: number; y: number; width: number; height: number } => { + if (width < 0 || height < 0) { + const initial = calculateStartupWizardInitialBounds(win); + return { + x: initial.x, + y: initial.y, + width: initial.width, + height: initial.height, + }; + } + + const workArea = screen.getDisplayMatching(win.getBounds()).workArea; + + if (width >= 4000 || (width >= workArea.width && height >= workArea.height)) { + return { + x: workArea.x, + y: workArea.y, + width: workArea.width, + height: workArea.height, + }; + } + + if (width >= 1200 || (width === 0 && height === 0)) { + return calculateStartupWizardExpandedBounds(win); + } + + const clampedWidth = Math.min(width, workArea.width); + const clampedHeight = Math.min(height, workArea.height); + + return { + x: Math.round(workArea.x + (workArea.width - clampedWidth) / 2), + y: Math.round(workArea.y + (workArea.height - clampedHeight) / 2), + width: clampedWidth, + height: clampedHeight, + }; +}; + +/** 위저드 전용 전환. 완료는 프리뷰 마운트 허용 시점이며 OS paint 완료 보장은 아니다. */ +export function resizeStartupWizardWindow( + win: BrowserWindow | null, + width: number, + height: number, + animate: boolean, + logger: { debug: (message: string) => void }, +): boolean | Promise { + if (!win || win.isDestroyed()) return false; + const cancelPrevious = pendingResizes.get(win); + cancelPrevious?.(); + const target = getTargetWizardBounds(win, width, height); + const start = win.getBounds(); + if (!cancelPrevious && sameBounds(start, target)) return true; + + const supported = + process.platform === "darwin" || process.platform === "win32"; + const motion = + animate && supported ? systemPreferences.getAnimationSettings() : null; + if ( + !motion || + motion.prefersReducedMotion || + !motion.shouldRenderRichAnimation + ) { + win.setBounds(target, false); + return true; + } + + const nativeAnimate = process.platform === "darwin"; + return new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (success: boolean, error?: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + win.removeListener("resized", onResized); + win.removeListener("closed", onClosed); + pendingResizes.delete(win); + if (error) reject(error); + else resolve(success); + }; + const onResized = () => finish(true); + const onClosed = () => finish(false); + pendingResizes.set(win, onClosed); + win.once("closed", onClosed); + + if (nativeAnimate) { + timer = setTimeout(() => { + logger.debug("Wizard resize completion fallback"); + try { + if (win.isDestroyed()) return finish(false); + win.setBounds(target, false); + finish(true); + } catch (error) { + finish(false, error); + } + }, NATIVE_RESIZE_FALLBACK_MS); + win.once("resized", onResized); + try { + win.setBounds(target, true); + } catch (error) { + finish(false, error); + } + return; + } + + const startedAt = performance.now(); + let steps = 0; + let last = start; + const tick = () => { + if (settled) return; + if (win.isDestroyed()) return finish(false); + const elapsed = performance.now() - startedAt; + const progress = + ++steps >= WINDOWS_RESIZE_MAX_STEPS + ? 1 + : Math.min(1, elapsed / WINDOWS_RESIZE_DURATION_MS); + const eased = 1 - (1 - progress) ** 3; + const next = + progress === 1 + ? target + : { + x: Math.round(start.x + (target.x - start.x) * eased), + y: Math.round(start.y + (target.y - start.y) * eased), + width: Math.round( + start.width + (target.width - start.width) * eased, + ), + height: Math.round( + start.height + (target.height - start.height) * eased, + ), + }; + try { + if (!sameBounds(last, next)) { + win.setBounds(next, false); + last = next; + } + } catch (error) { + finish(false, error); + return; + } + // setBounds 중 closed 등으로 취소됐어도 다음 타이머가 다시 생기면 안 된다. + if (settled) return; + if (progress === 1) return finish(true); + // 이전 프레임을 따라잡지 않는다. native 호출 비용도 다음 지연 계산에 포함한다. + timer = setTimeout( + tick, + Math.min( + WINDOWS_RESIZE_INTERVAL_MS, + Math.max( + 1, + WINDOWS_RESIZE_DURATION_MS - (performance.now() - startedAt), + ), + ), + ); + }; + timer = setTimeout(tick, WINDOWS_RESIZE_INTERVAL_MS); + }); +} diff --git a/src/main/services/features/startup/startupReadinessService.ts b/src/main/services/features/startup/startupReadinessService.ts index 943ac452..699433b3 100644 --- a/src/main/services/features/startup/startupReadinessService.ts +++ b/src/main/services/features/startup/startupReadinessService.ts @@ -26,6 +26,14 @@ const logger = createLogger("StartupReadinessService"); const STARTUP_WIZARD_EVENT = "startup:wizard-completed"; const STARTUP_SESSION_CHECK_TIMEOUT_MS = 5_000; +// getReadiness는 무거운 검사(integrity_check 전체 스캔, 네트워크 세션 체크)를 +// 포함한다. 한 번의 스타트업에서 appReady·렌더러 IPC·completeWizard가 잇달아 +// 호출하면 전체 재평가가 최대 4~5회 반복되므로, 짧은 TTL 캐시로 수렴시킨다. +// completedAt 변경(completeWizard) 시에는 즉시 무효화한다. TTL 만료 후 재호출은 +// 재평가하므로 늦은 재시도 경로가 낡은 결과를 보지 않는다. +// docs/architecture/startup-pipeline-dissection.md §4 진단 2번. +const READINESS_CACHE_TTL_MS = 5_000; + const loadCacheDb = async () => (await import("../../../infra/database/cache.js")).cacheDb; @@ -46,8 +54,28 @@ const buildCheck = ( class StartupReadinessService { private readonly events = new EventEmitter(); + private cachedReadiness: { + value: StartupReadiness; + expiresAtMs: number; + } | null = null; + private readinessInFlight: Promise | null = null; async getReadiness(): Promise { + const now = Date.now(); + if (this.cachedReadiness && this.cachedReadiness.expiresAtMs > now) { + return this.cachedReadiness.value; + } + // 무거운 검사가 진행 중일 때 동시 호출이 검사를 복제하지 않도록 병합한다. + if (this.readinessInFlight) { + return this.readinessInFlight; + } + this.readinessInFlight = this.computeReadiness(now).finally(() => { + this.readinessInFlight = null; + }); + return this.readinessInFlight; + } + + private async computeReadiness(nowMs: number): Promise { const checks = await this.runChecks(); const reasons = checks .filter((check) => check.blocking && !check.ok) @@ -57,12 +85,21 @@ class StartupReadinessService { // 먼저 띄운다. UI 개편 작업용 dev 진입점이라 패키지 빌드 흐름도 전부 통과시킨다. const mustRunWizard = isStartupWizardForced() || !completedAt || reasons.length > 0; - return { + const readiness = { mustRunWizard, checks, reasons, completedAt, }; + this.cachedReadiness = { + value: readiness, + expiresAtMs: nowMs + READINESS_CACHE_TTL_MS, + }; + return readiness; + } + + private invalidateReadiness(): void { + this.cachedReadiness = null; } async completeWizard(): Promise { @@ -71,6 +108,7 @@ class StartupReadinessService { // appReady의 wizard-completed 리스너가 위저드 창을 닫고 메인 흐름을 시작하게 한다. if (isStartupWizardForced()) { const readiness = await this.getReadiness(); + this.invalidateReadiness(); const result: StartupReadiness = { ...readiness, mustRunWizard: false, @@ -83,6 +121,7 @@ class StartupReadinessService { return before; } settingsManager.setStartupCompletedAt(nowIso()); + this.invalidateReadiness(); const after = await this.getReadiness(); this.events.emit(STARTUP_WIZARD_EVENT, after); return after; @@ -98,16 +137,22 @@ class StartupReadinessService { } private async runChecks(): Promise { - const checks: StartupCheck[] = []; - checks.push(await this.checkSafeStorage()); - checks.push(await this.checkDataDirRW()); - checks.push(await this.checkDefaultLuiePath()); - checks.push(await this.checkSqliteConnect()); - checks.push(await this.checkSqliteIntegrity()); - checks.push(await this.checkSqliteWal()); - checks.push(await this.checkSupabaseRuntimeConfig()); - checks.push(await this.checkSupabaseSession()); - return checks; + // 검사들은 독립 자원(각자 DB/FS/네트워크를 자체 초기화)을 쓰므로 병렬로 + // 실행한다 — 순차 체인이면 네트워크 세션 체크(최대 5초)가 무거운 integrity + // 스캔 뒤에 전부 가산됐다. 반환 배열은 기존 순서를 유지해 소비자(진단 UI)의 + // 표시 순서를 바꾸지 않는다. integrity/WAL은 DB 파일이 존재해야 의미가 있어 + // checkSqliteConnect(db.initialize 포함) 완료 후에 시작한다. + const sqliteConnect = this.checkSqliteConnect(); + return Promise.all([ + this.checkSafeStorage(), + this.checkDataDirRW(), + this.checkDefaultLuiePath(), + sqliteConnect, + sqliteConnect.then(() => this.checkSqliteIntegrity()), + sqliteConnect.then(() => this.checkSqliteWal()), + this.checkSupabaseRuntimeConfig(), + this.checkSupabaseSession(), + ]); } private async checkSafeStorage(): Promise { diff --git a/src/main/utils/env/index.ts b/src/main/utils/env/index.ts index 2646002c..982c2417 100644 --- a/src/main/utils/env/index.ts +++ b/src/main/utils/env/index.ts @@ -1,3 +1,3 @@ export { isAppPackaged } from "./appEnv.js"; -export { isDevEnv, isProdEnv, isTestEnv } from "./environment.js"; +export { isDevEnv, isProdEnv, isStartupWizardForced, isTestEnv } from "./environment.js"; export { resolveUserDataPath } from "./userDataPath.js"; diff --git a/src/preload/api/windowApi.ts b/src/preload/api/windowApi.ts index 2d11223b..dfa4efa3 100644 --- a/src/preload/api/windowApi.ts +++ b/src/preload/api/windowApi.ts @@ -14,6 +14,16 @@ export function createWindowApi({ safeInvoke, safeInvokeCore, }: PreloadApiModuleContext): Pick { + const onChannel = (channel: string, callback: (payload: T) => void) => { + const listener = (_event: IpcRendererEvent, payload: T) => { + callback(payload); + }; + ipcRenderer.on(channel, listener); + return () => { + ipcRenderer.removeListener(channel, listener); + }; + }; + return { lifecycle: { setDirty: (dirty) => { @@ -44,11 +54,15 @@ export function createWindowApi({ }, }, window: { + minimize: () => safeInvoke(IPC_CHANNELS.WINDOW_MINIMIZE), maximize: () => safeInvoke(IPC_CHANNELS.WINDOW_MAXIMIZE), + unmaximize: () => safeInvoke(IPC_CHANNELS.WINDOW_UNMAXIMIZE), close: () => safeInvoke(IPC_CHANNELS.WINDOW_CLOSE), toggleFullscreen: () => safeInvoke(IPC_CHANNELS.WINDOW_TOGGLE_FULLSCREEN), setFullscreen: (flag) => safeInvoke(IPC_CHANNELS.WINDOW_SET_FULLSCREEN, flag), + onMaximizeChanged: (callback) => + onChannel(IPC_CHANNELS.WINDOW_MAXIMIZED_CHANGED, callback), setTrafficLightVisibility: (visible) => safeInvoke(IPC_CHANNELS.WINDOW_SET_TRAFFIC_LIGHT_VISIBILITY, visible), openExport: (chapterId) => diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 94ae91b5..7cef73ef 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -36,6 +36,7 @@ import { useThemeAttributes, useUiModeIntegrityDevCheck, useWindowMode, + WindowsWindowControls, } from "./shell"; const ExportWindow = lazy( @@ -410,6 +411,8 @@ export default function App() { if (windowMode === "export") { return ( <> + {/* Windows frameless 내보내기 창도 메인 창과 같은 인앱 창 버튼을 쓴다. */} + @@ -462,6 +465,8 @@ export default function App() { if (windowMode === "startup-wizard") { return ( <> + {/* Windows frameless 위저드도 메인 창과 같은 인앱 창 버튼을 쓴다. */} + @@ -473,6 +478,8 @@ export default function App() { if (!bootstrapStatus.isReady) { return ( <> + {/* NOTE: 메인 창(Windows frameless)은 어느 게이트 화면에서도 창 버튼이 필요하다. */} + + + diff --git a/src/renderer/src/app/shell/WindowsWindowControls.tsx b/src/renderer/src/app/shell/WindowsWindowControls.tsx new file mode 100644 index 00000000..5f876454 --- /dev/null +++ b/src/renderer/src/app/shell/WindowsWindowControls.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { createPortal } from "react-dom"; +import { Copy, Minus, Square, X } from "lucide-react"; +import { api } from "@shared/api"; +import { useWindowsWindowControlsStore } from "./windowsWindowControlsStore"; + +const IS_WINDOWS = navigator.userAgent.toLowerCase().includes("win"); + +const NO_DRAG = { WebkitAppRegion: "no-drag" } as CSSProperties; +const DRAG = { WebkitAppRegion: "drag" } as CSSProperties; + +const CONTROL_BUTTON_CLASS = + "flex w-11 items-center justify-center text-muted transition-colors hover:bg-surface-hover hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring cursor-default"; + +/** + * Windows 인앱 창 버튼(최소화/최대화/닫기). 네이티브 타이틀바가 제거된 창(메인·시작 + * 위저드·내보내기, 모두 `titleBarStyle: "hidden"`)에서 macOS traffic lights와 같은 + * 역할을 한다. + * + * 기본 인스턴스는 App 셸에 고정으로 마운트되며, 레이아웃은 + * useWindowsWindowControlsStore로 표시/위치를 제어한다(EditorLayout은 툴바 hover + * 때만 노출, Canvas는 헤더 높이 정렬 등). `embedded` 변형은 부모 영역 안에 직접 + * 넣을 때 쓴다 — 고정 위치·드래그 밴드·z-index 없이 버튼 묶음만 렌더한다. + * + * 좌측 투명 밴드는 frameless 창의 이동 확보용 드래그 영역이다. 드래그 영역은 + * pointer 이벤트를 무시하므로(EditorLayout 센티넬 NOTE 참고) 버튼이 놓일 우상단 + * 코너로 폭을 최소화한다. + */ +export function WindowsWindowControls({ embedded = false }: { embedded?: boolean }) { + const { t } = useTranslation(); + const [isMaximized, setIsMaximized] = useState(false); + const visible = useWindowsWindowControlsStore((state) => state.visible); + const topInset = useWindowsWindowControlsStore((state) => state.topInset); + const rightInset = useWindowsWindowControlsStore((state) => state.rightInset); + + useEffect(() => { + if (!IS_WINDOWS) return; + // NOTE: 부분 api mock(dom 테스트)에서도 깨지지 않게 옵셔널로 구독한다(App.tsx 패턴). + if (typeof api.window?.onMaximizeChanged !== "function") return; + return api.window.onMaximizeChanged(setIsMaximized); + }, []); + + if (!IS_WINDOWS) return null; + if (!embedded && !visible) return null; + + const handleToggleMaximize = () => { + if (isMaximized) { + void api.window?.unmaximize?.(); + return; + } + void api.window?.maximize?.(); + }; + + const buttons = ( + <> + + + + + + + ); + + if (embedded) { + return ( +
+ {buttons} +
+ ); + } + + // NOTE: fixed 변형은 document.body로 portal한다. Chromium은 draggable region을 DOM + // pre-order로 누적하고, 나중에 만난 drag 영역이 앞서 누적된 no-drag 영역을 다시 + // 덮는다(z-index는 지역 계산에 무관). 화면 콘텐츠는 풀폭 drag 헤더를 이 컴포넌트보다 + // 늦게 렌더하는 경우가 많다(위저드 h-12 헤더, export 툴바, scrivener 리본). #root + // 안에 두면 버튼의 no-drag가 그 drag 헤더에 덮여 클릭이 창 드래그로 흡수된다. + // body 끝에 붙이면 문서에서 가장 늦게 계산되므로 어떤 화면 헤더 위에서도 no-drag가 + // 확정된다. + return createPortal( +
+ , + document.body, + ); +} diff --git a/src/renderer/src/app/shell/index.ts b/src/renderer/src/app/shell/index.ts index 634759b5..d64e1d5c 100644 --- a/src/renderer/src/app/shell/index.ts +++ b/src/renderer/src/app/shell/index.ts @@ -1,6 +1,7 @@ export { BootstrapGate } from "./BootstrapGate"; export { parseBootstrapStatus } from "./bootstrapStatus"; export { QuitOverlay } from "./QuitOverlay"; +export { WindowsWindowControls } from "./WindowsWindowControls"; export { useThemeAttributes } from "./useThemeAttributes"; export { useUiModeIntegrityDevCheck } from "./useUiModeIntegrityDevCheck"; export { useWindowMode } from "./windowMode"; diff --git a/src/renderer/src/app/shell/windowsWindowControlsStore.ts b/src/renderer/src/app/shell/windowsWindowControlsStore.ts new file mode 100644 index 00000000..e1b9d380 --- /dev/null +++ b/src/renderer/src/app/shell/windowsWindowControlsStore.ts @@ -0,0 +1,38 @@ +import { create } from "zustand"; + +/** + * Windows 인앱 창 버튼(WindowsWindowControls)의 배치를 레이아웃이 제어한다. + * + * 버튼은 App 셸에 하나만 마운트되지만, 레이아웃마다 우상단에 두는 레이아웃 고유 + * UI(AI 뷰 토글·캔버스 인스펙터 토글·docs 우측 패널 헤더)가 달라 충돌이 다르다. + * macOS가 레이아웃에서 setTrafficLightVisibility로 네이티브 트래픽 라이트를 제어하듯, + * Windows는 레이아웃이 이 스토어로 인앱 버튼을 제어한다. 레이아웃은 unmount 시 + * 반드시 resetPlacement로 되돌려야 한다(안 되면 다른 화면에 값이 새어 나간다). + */ +interface WindowsWindowControlsPlacement { + /** 표시 여부. EditorLayout은 툴바 hover 때만 노출한다(macOS 트래픽 라이트 패리티). */ + visible: boolean; + /** 우측에서 띄울 px. 레이아웃 고유 우상단 UI 폭에 맞춘다. */ + rightInset: number; + /** 상단에서 내릴 px. 헤더 높이에 세로 정렬할 때 쓴다. */ + topInset: number; +} + +const DEFAULT_PLACEMENT: WindowsWindowControlsPlacement = { + visible: true, + rightInset: 0, + topInset: 0, +}; + +interface WindowsWindowControlsState extends WindowsWindowControlsPlacement { + setPlacement: (placement: Partial) => void; + resetPlacement: () => void; +} + +export const useWindowsWindowControlsStore = create( + (set) => ({ + ...DEFAULT_PLACEMENT, + setPlacement: (placement) => set(placement), + resetPlacement: () => set(DEFAULT_PLACEMENT), + }), +); diff --git a/src/renderer/src/features/canvas/components/shell/document/CanvasDocumentChrome.tsx b/src/renderer/src/features/canvas/components/shell/document/CanvasDocumentChrome.tsx index d485e68c..6b0b0f56 100644 --- a/src/renderer/src/features/canvas/components/shell/document/CanvasDocumentChrome.tsx +++ b/src/renderer/src/features/canvas/components/shell/document/CanvasDocumentChrome.tsx @@ -1,10 +1,13 @@ import type { CSSProperties, ReactNode } from "react"; +import { useEffect } from "react"; import { GitBranch, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "lucide-react"; import { useTranslation } from "react-i18next"; import { formatDate, getString, getStringArray, getTagValues } from "./canvasDocumentModel"; import { useUIStore } from "@renderer/features/workspace/stores/uiStore"; +import { useWindowsWindowControlsStore } from "@renderer/app/shell/windowsWindowControlsStore"; const isMacOS = navigator.userAgent.toLowerCase().includes("mac"); +const isWindows = navigator.userAgent.toLowerCase().includes("win"); /** * MainLayout 좌측 토글 버튼이 사이드바 닫힐 때 `left-[92px]`로 이동한다. * 이 헤더도 같은 위치에 맞추기 위해 px-3(12px)를 뺀 80px을 marginLeft로 준다. @@ -25,6 +28,22 @@ export function DocumentShell({ const isContextOpen = useUIStore((state) => state.regions.rightPanel.open); const toggleLeftSidebar = useUIStore((state) => state.toggleLeftSidebar); const setRegionOpen = useUIStore((state) => state.setRegionOpen); + const setPlacement = useWindowsWindowControlsStore( + (state) => state.setPlacement, + ); + const resetPlacement = useWindowsWindowControlsStore( + (state) => state.resetPlacement, + ); + + // NOTE: Windows 인앱 창 버튼을 이 헤더(h-12)의 세로 중앙에 맞춘다. 이 크롬이 + // unmount되면 배치를 되돌려 다른 화면에 topInset이 새지 않게 한다. + useEffect(() => { + if (!isWindows) return; + setPlacement({ topInset: 8 }); + return () => { + resetPlacement(); + }; + }, [setPlacement, resetPlacement]); // NOTE: macOS hiddenInset 타이틀바에서 사이드바가 닫히면 콘텐츠 영역이 // 창 왼쪽 끝까지 확장되어 트래픽 라이트 버튼(16,16 / 너비 ~52px)과 겹친다. @@ -60,7 +79,13 @@ export function DocumentShell({ {title}
-
+
)} diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index d4edf813..40a85b72 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -39,15 +39,19 @@ const ensureLanguageResources = async ( i18n.addResourceBundle(language, "common", resources.common, true, true); }; -const loadAllLocaleResources = async (): Promise< - Record -> => { - const [ko, en, ja] = await Promise.all([ - loadLocaleResources("ko"), - loadLocaleResources("en"), - loadLocaleResources("ja"), - ]); - return { ko, en, ja }; +// NOTE: 첫 paint는 initI18n의 await 뒤에 온다. 감지 언어 + fallback(ko) 번들만 +// 초기 주입하고 나머지 로케일은 setLanguage/loadSavedLanguagePreference의 +// ensureLanguageResources 경로로 지연 로딩한다 — 미로딩 번들 도착 전에는 +// useSuspense:false 덕분에 렌더가 막히지 않는다. +const loadInitialLocaleResources = async ( + language: SupportedLanguage, +): Promise> => { + const languages: SupportedLanguage[] = + language === "ko" ? [language] : [language, "ko"]; + const loaded = await Promise.all( + languages.map(async (lng) => [lng, await loadLocaleResources(lng)] as const), + ); + return Object.fromEntries(loaded); }; const loadSavedLanguagePreference = async (): Promise => { @@ -76,18 +80,14 @@ export async function initI18n(): Promise { } const initialLanguage = detectInitialLanguage(); - const allResources = await loadAllLocaleResources(); + const initialResources = await loadInitialLocaleResources(initialLanguage); initPromise = i18n .use(initReactI18next) .use(LanguageDetector) .init({ lng: initialLanguage, - resources: { - ko: allResources.ko, - en: allResources.en, - ja: allResources.ja, - }, + resources: initialResources, fallbackLng: "ko", supportedLngs: SUPPORTED_LANGUAGES, defaultNS: "common", diff --git a/src/renderer/src/i18n/locales/en/base/core.ts b/src/renderer/src/i18n/locales/en/base/core.ts index e197239e..2e70e5a0 100644 --- a/src/renderer/src/i18n/locales/en/base/core.ts +++ b/src/renderer/src/i18n/locales/en/base/core.ts @@ -7,6 +7,12 @@ export const enBaseCore = { undo: "Undo", clear: "Clear", exit: "Exit", + windowControls: { + minimize: "Minimize", + maximize: "Maximize", + restore: "Restore", + close: "Close", + }, menu: { file: "File", edit: "Edit", diff --git a/src/renderer/src/i18n/locales/ja/base/core.ts b/src/renderer/src/i18n/locales/ja/base/core.ts index 9cf1fa2d..10db7a6e 100644 --- a/src/renderer/src/i18n/locales/ja/base/core.ts +++ b/src/renderer/src/i18n/locales/ja/base/core.ts @@ -7,6 +7,12 @@ export const jaBaseCore = { undo: "元に戻す", clear: "クリア", exit: "終了", + windowControls: { + minimize: "最小化", + maximize: "最大化", + restore: "元に戻す", + close: "閉じる", + }, menu: { file: "ファイル", edit: "編集", diff --git a/src/renderer/src/i18n/locales/ko/base/core.ts b/src/renderer/src/i18n/locales/ko/base/core.ts index c58bac82..60db4c99 100644 --- a/src/renderer/src/i18n/locales/ko/base/core.ts +++ b/src/renderer/src/i18n/locales/ko/base/core.ts @@ -7,6 +7,12 @@ export const koBaseCore = { undo: "실행 취소", clear: "지우기", exit: "나가기", + windowControls: { + minimize: "최소화", + maximize: "최대화", + restore: "이전 크기로 복원", + close: "닫기", + }, menu: { file: "파일", edit: "편집", diff --git a/src/renderer/src/styles/global.tokens.css b/src/renderer/src/styles/global.tokens.css index 3c829060..3b021750 100644 --- a/src/renderer/src/styles/global.tokens.css +++ b/src/renderer/src/styles/global.tokens.css @@ -223,6 +223,9 @@ --z-index-toolbar: 120; --z-index-toast: 150; --z-index-modal: 1000; + /* NOTE: Windows 인앱 창 버튼(WindowsWindowControls)은 창 크로미다. 모달이 떠도 + 네이티브 타이틀바처럼 클릭 가능해야 하므로 modal(1000) 위, 종료 오버레이 아래. */ + --z-index-window-controls: 1100; /* NOTE: 종료 오버레이(QuitOverlay)는 앱을 닫는 절차 동안 모든 모달 위에 떠야 한다. modal(1000)보다 위여야 하므로 별도 계층으로 둔다. */ --z-index-quit: 9999; @@ -243,6 +246,9 @@ @utility z-modal { z-index: var(--z-index-modal); } +@utility z-window-controls { + z-index: var(--z-index-window-controls); +} @utility z-quit { z-index: var(--z-index-quit); } diff --git a/src/shared/api/settings.contract.ts b/src/shared/api/settings.contract.ts index cd634365..7b2826d2 100644 --- a/src/shared/api/settings.contract.ts +++ b/src/shared/api/settings.contract.ts @@ -180,10 +180,14 @@ export type SettingsRendererApi = { ) => Promise>; }; window: { + minimize: () => Promise>; maximize: () => Promise>; + unmaximize: () => Promise>; close: () => Promise>; toggleFullscreen: () => Promise>; setFullscreen: (flag: boolean) => Promise>; + /** 메인 창 최대화 상태 변경 이벤트. Windows 인앱 창 버튼의 아이콘 전환에 쓴다. */ + onMaximizeChanged: (callback: (maximized: boolean) => void) => () => void; /** macOS 전용. 다른 플랫폼에서는 no-op으로 응답한다. */ setTrafficLightVisibility: ( visible: boolean, diff --git a/src/shared/ipc/channels.ts b/src/shared/ipc/channels.ts index bd013b8e..e2494699 100644 --- a/src/shared/ipc/channels.ts +++ b/src/shared/ipc/channels.ts @@ -191,6 +191,7 @@ export const IPC_CHANNELS = { WINDOW_OPEN_EXPORT: "window:open-export", WINDOW_OPEN_WORLD_GRAPH: "window:open-world-graph", WINDOW_SET_STARTUP_WIZARD_SIZE: "window:set-startup-wizard-size", + WINDOW_MAXIMIZED_CHANGED: "window:maximized-changed", APP_GET_VERSION: "app:get-version", APP_CHECK_UPDATE: "app:check-update", diff --git a/tests/dom/startupPreviewLazy.test.tsx b/tests/dom/startupPreviewLazy.test.tsx new file mode 100644 index 00000000..cb00644b --- /dev/null +++ b/tests/dom/startupPreviewLazy.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +// TEST_LEVEL: DOM_INTEGRATION +// PROVES: preview import는 렌더 시점까지 지연되고, pending/rejected에서도 dock은 조작 가능하다. +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const deferred = vi.hoisted(() => ({ + wizardLoads: 0, + layoutLoads: 0, + resolveWizard: undefined as (() => void) | undefined, + rejectLayout: undefined as ((reason: Error) => void) | undefined, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@shared/ui/GlobalDragContext", () => ({ + GlobalDragContext: ({ children }: { children: ReactNode }) => children, +})); +vi.mock( + "../../src/renderer/src/features/startup/components/preview/WizardEditor", + async () => { + deferred.wizardLoads++; + await new Promise((resolve) => { + deferred.resolveWizard = resolve; + }); + return { + WizardEditor: () =>
loaded theme preview
, + }; + }, +); +vi.mock( + "../../src/renderer/src/features/startup/components/preview/LayoutLivePreview", + async () => { + deferred.layoutLoads++; + await new Promise((_resolve, reject) => { + deferred.rejectLayout = reject; + }); + return { LayoutLivePreview: () => null }; + }, +); + +import { ThemeStep } from "../../src/renderer/src/features/startup/components/steps/ThemeStep"; +import { LayoutStep } from "../../src/renderer/src/features/startup/components/steps/LayoutStep"; + +afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); +}); + +describe("P1 deferred previews", () => { + it("P1-DOM-01: module import does not load previews; theme controls survive pending and resolution", async () => { + expect(deferred.wizardLoads).toBe(0); + expect(deferred.layoutLoads).toBe(0); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); + const root = createRoot(container); + const onPrevious = vi.fn(); + const onNext = vi.fn(); + const onThemeChange = vi.fn(); + try { + await act(async () => { + root.render( + , + ); + }); + expect(deferred.wizardLoads).toBe(1); + expect(deferred.layoutLoads).toBe(0); + expect(container.querySelector("article")).toBeNull(); + const button = (label: string) => { + const match = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent === `startupWizard.onboarding.${label}`, + ); + if (!match) throw new Error(`Missing button: ${label}`); + return match; + }; + act(() => { + button("themeDark").click(); + button("previous").click(); + button("next").click(); + }); + expect(onThemeChange).toHaveBeenCalledWith("dark"); + expect(onPrevious).toHaveBeenCalledOnce(); + expect(onNext).toHaveBeenCalledOnce(); + await act(async () => { + deferred.resolveWizard?.(); + }); + expect(container.textContent).toContain("loaded theme preview"); + expect(deferred.wizardLoads).toBe(1); + } finally { + act(() => root.unmount()); + } + }); + + it("P1-DOM-02: rejected layout import retains fallback and previous/finish controls", async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + // 예상된 chunk 실패만 이 테스트에서 수집한다. 제품 오류 로깅은 유지한다. + const errors = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const container = document.createElement("div"); + const root = createRoot(container); + const onPrevious = vi.fn(); + const onFinish = vi.fn(); + try { + await act(async () => { + root.render( + , + ); + }); + expect(deferred.layoutLoads).toBe(1); + await act(async () => { + deferred.rejectLayout?.(new Error("P1_TEST_CHUNK_FAILURE")); + }); + expect(errors).toHaveBeenCalled(); + expect(container.textContent).toContain( + "startupWizard.onboarding.layoutTitle", + ); + for (const label of ["previous", "finish"]) { + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent === `startupWizard.onboarding.${label}`, + ); + expect(button).toBeDefined(); + act(() => button?.click()); + } + expect(onPrevious).toHaveBeenCalledOnce(); + expect(onFinish).toHaveBeenCalledOnce(); + } finally { + act(() => root.unmount()); + } + }); +}); diff --git a/tests/dom/startupWizardFlow.test.tsx b/tests/dom/startupWizardFlow.test.tsx index 9f5cd02b..094905c8 100644 --- a/tests/dom/startupWizardFlow.test.tsx +++ b/tests/dom/startupWizardFlow.test.tsx @@ -12,7 +12,7 @@ import { DialogProvider } from "../../src/shared/ui/DialogProvider.js"; import { ToastProvider } from "../../src/shared/ui/Toast.js"; const mocked = vi.hoisted(() => ({ - setStartupWizardSize: vi.fn(async () => undefined), + setStartupWizardSize: vi.fn(async () => ({ success: true, data: true })), getEditor: vi.fn(async () => ({ success: true, data: null })), setEditor: vi.fn(async () => ({ success: true, data: null })), getReadiness: vi.fn(async () => ({ success: true, data: null })), @@ -126,6 +126,7 @@ describe("startup wizard flow", () => { globalThis.Worker = MockWorker as unknown as typeof Worker; vi.clearAllMocks(); + mocked.setStartupWizardSize.mockReset().mockResolvedValue({ success: true, data: true }); document.documentElement.removeAttribute("data-theme"); document.documentElement.removeAttribute("data-temp"); document.documentElement.removeAttribute("data-accent"); @@ -182,32 +183,6 @@ describe("startup wizard flow", () => { }); }); - it("시작하기는 창을 확장하지 않고, 모델 단계 건너뛰기 때 가로형으로 확장하며 테마를 반영한다", async () => { - const { root, container } = await renderWizard(); - - await act(async () => { - findButton(root, "startupWizard.onboarding.startCta").click(); - }); - // A' 모델 단계에서는 창 크기를 유지한다. - expect(mocked.setStartupWizardSize).not.toHaveBeenCalled(); - - await act(async () => { - findButton(root, "startupWizard.onboarding.modelLater").click(); - }); - - expect(mocked.setStartupWizardSize).toHaveBeenCalledWith(1300, 800, true); - expect(container.textContent).toContain("startupWizard.onboarding.themeTitle"); - expect(mocked.getEditor).toHaveBeenCalled(); - expect(document.documentElement.getAttribute("data-theme")).toBe("sepia"); - expect(document.documentElement.getAttribute("data-temp")).toBe("warm"); - expect(document.documentElement.getAttribute("data-accent")).toBe("rose"); - - const unmount = () => root.unmount(); - act(() => { - unmount(); - }); - }); - it("테마 카드는 라이브로 data-theme을 바꾸고, 완료 시 저장→전체화면 확장→프로젝트 준비→완료로 이어진다", async () => { const { root, container } = await renderWizard(); @@ -280,6 +255,8 @@ describe("startup wizard flow", () => { }); expect(useUIStore.getState().regions.rightPanel.open).toBe(false); + const maximize = Promise.withResolvers<{ success: boolean; data: boolean }>(); + mocked.setStartupWizardSize.mockReturnValueOnce(maximize.promise); await act(async () => { findButton(root, "startupWizard.onboarding.finish").click(); }); @@ -297,7 +274,7 @@ describe("startup wizard flow", () => { }), ); - // 완료 알림 및 창 확장 애니메이션 대기 후 prepare 단계 열림 + // 완료 알림 대기와 native resize 응답을 별도로 제어한다. await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1600)); }); @@ -306,6 +283,9 @@ describe("startup wizard flow", () => { 4096, true, ); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(container.querySelector("input")).toBeNull(); + await act(async () => maximize.resolve({ success: true, data: true })); expect(container.textContent).toContain( "startupWizard.onboarding.prepareTitle", ); diff --git a/tests/dom/startupWizardModelStep.test.tsx b/tests/dom/startupWizardModelStep.test.tsx index abb17699..f7eca23e 100644 --- a/tests/dom/startupWizardModelStep.test.tsx +++ b/tests/dom/startupWizardModelStep.test.tsx @@ -17,7 +17,7 @@ const mocked = vi.hoisted(() => { const listeners: Array<(payload: ProgressPayload) => void> = []; return { listeners, - setStartupWizardSize: vi.fn(async () => undefined), + setStartupWizardSize: vi.fn(async () => ({ success: true, data: true })), getEditor: vi.fn(async () => ({ success: true, data: null })), getEmbeddingModelStatus: vi.fn(async () => ({ success: true, diff --git a/tests/dom/startupWizardResize.test.tsx b/tests/dom/startupWizardResize.test.tsx new file mode 100644 index 00000000..af77f30b --- /dev/null +++ b/tests/dom/startupWizardResize.test.tsx @@ -0,0 +1,328 @@ +// @vitest-environment jsdom +// TEST_LEVEL: DOM_INTEGRATION +// PROVES: 시작 위저드가 A(인트로) → A'(모델, 창 크기 유지 — 상세는 +// startupWizardModelStep.test.tsx) → B(테마, 창 리사이즈) → B-3(레이아웃) → +// 완료 대기 → 프로젝트 준비 → 완료(completeWizard)로 진행한다. completeWizard는 +// 메인 창 플로우를 여는 신호라 반드시 마지막에 호출된다. + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DialogProvider } from "../../src/shared/ui/DialogProvider.js"; +import { ToastProvider } from "../../src/shared/ui/Toast.js"; + +const mocked = vi.hoisted(() => ({ + setStartupWizardSize: vi.fn(async () => ({ success: true, data: true })), + getEditor: vi.fn(async () => ({ success: true, data: null })), + setEditor: vi.fn(async () => ({ success: true, data: null })), + getReadiness: vi.fn(async () => ({ success: true, data: null })), + completeWizard: vi.fn(async () => ({ success: true, data: null })), + // 모델 미설치로 응답해 A' 단계가 "나중에 받기"를 노출하게 만든다. + getEmbeddingModelStatus: vi.fn(async () => ({ + success: true, + data: { installed: false, downloading: false, progressPct: null }, + })), + onEmbeddingModelDownloadProgress: vi.fn(() => () => undefined), + createProject: vi.fn(async () => ({ + success: true, + data: { + id: "project-1", + title: "", + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }, + })), + markOpened: vi.fn(async () => ({ success: true, data: null })), + createChapter: vi.fn(async () => ({ + success: true, + data: { id: "chapter-1" }, + })), + updateChapter: vi.fn(async () => ({ + success: true, + data: { id: "chapter-1" }, + })), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@shared/api", () => ({ + api: { + settings: { + getEditor: mocked.getEditor, + setEditor: mocked.setEditor, + getEmbeddingModelStatus: mocked.getEmbeddingModelStatus, + onEmbeddingModelDownloadProgress: mocked.onEmbeddingModelDownloadProgress, + }, + window: { + setStartupWizardSize: mocked.setStartupWizardSize, + }, + startup: { + getReadiness: mocked.getReadiness, + completeWizard: mocked.completeWizard, + }, + project: { + create: mocked.createProject, + markOpened: mocked.markOpened, + }, + chapter: { + create: mocked.createChapter, + update: mocked.updateChapter, + }, + }, +})); + +import StartupWizard from "../../src/renderer/src/features/startup/components/StartupWizard.js"; + +const baseEditorSettings = { + theme: "sepia", + themeContrast: "soft", + themeTemp: "warm", + themeAccent: "rose", + uiMode: "default", +}; + +const findButton = (root: Root, text: string): HTMLButtonElement => { + const container = ( + root as unknown as { _internalRoot: { containerInfo: HTMLElement } } + )._internalRoot.containerInfo; + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent === text, + ); + if (!button) throw new Error(`button not found: ${text}`); + return button; +}; + +const renderWizard = async (): Promise<{ + root: Root; + container: HTMLElement; +}> => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + // 실제 앱(main.tsx)과 동일하게 전역 Provider 아래에서 마운트해야 테마 단계의 + // Editor와 레이아웃 단계의 실제 레이아웃이 useDialog에 접근할 수 있다. + root.render( + + + + + , + ); + }); + return { root, container }; +}; + +describe("startup wizard resize", () => { + beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + class MockWorker { + postMessage = vi.fn(); + terminate = vi.fn(); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + onmessage = null; + onerror = null; + } + globalThis.Worker = MockWorker as unknown as typeof Worker; + + vi.clearAllMocks(); + mocked.setStartupWizardSize + .mockReset() + .mockResolvedValue({ success: true, data: true }); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("data-temp"); + document.documentElement.removeAttribute("data-accent"); + document.documentElement.removeAttribute("data-contrast"); + + const { resetPreviewWorkspaceState } = + await import("../../src/renderer/src/features/startup/components/preview/LayoutLivePreview.js"); + resetPreviewWorkspaceState(); + + mocked.getEditor.mockResolvedValue({ + success: true, + data: { ...baseEditorSettings }, + }); + mocked.getReadiness.mockResolvedValue({ + success: true, + data: { + mustRunWizard: true, + checks: [], + reasons: [], + completedAt: null, + }, + }); + mocked.completeWizard.mockResolvedValue({ + success: true, + data: { + mustRunWizard: false, + checks: [], + reasons: [], + completedAt: "2026-09-01T00:00:00.000Z", + }, + }); + }); + + afterEach(() => { + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("시작하기는 창을 확장하지 않고, 모델 단계 건너뛰기 때 가로형으로 확장하며 테마를 반영한다", async () => { + const { root, container } = await renderWizard(); + expect( + container.querySelector("main")?.classList.contains("animate-in"), + ).toBe(false); + + await act(async () => { + findButton(root, "startupWizard.onboarding.startCta").click(); + }); + // A' 모델 단계에서는 창 크기를 유지한다. + expect(mocked.setStartupWizardSize).not.toHaveBeenCalled(); + const modelClasses = container.querySelector("main")?.classList; + expect(modelClasses?.contains("fade-in")).toBe(true); + expect(modelClasses?.contains("duration-200")).toBe(true); + expect(modelClasses?.contains("slide-in-from-bottom-2")).toBe(false); + + await act(async () => { + findButton(root, "startupWizard.onboarding.modelLater").click(); + }); + + expect(mocked.setStartupWizardSize).toHaveBeenCalledWith(1300, 800, true); + expect(container.textContent).toContain( + "startupWizard.onboarding.themeTitle", + ); + expect(mocked.getEditor).toHaveBeenCalled(); + expect(document.documentElement.getAttribute("data-theme")).toBe("sepia"); + expect(document.documentElement.getAttribute("data-temp")).toBe("warm"); + expect(document.documentElement.getAttribute("data-accent")).toBe("rose"); + + const unmount = () => root.unmount(); + act(() => { + unmount(); + }); + }); + + it("resize 응답 전에는 배경만 표시하고, 완료 후에만 다음 단계로 이동한다", async () => { + const { root, container } = await renderWizard(); + try { + await act(async () => + findButton(root, "startupWizard.onboarding.startCta").click(), + ); + const expansion = Promise.withResolvers<{ + success: boolean; + data: boolean; + }>(); + mocked.setStartupWizardSize.mockImplementationOnce(() => { + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(container.querySelector("button")).toBeNull(); + return expansion.promise; + }); + await act(async () => { + const next = findButton(root, "startupWizard.onboarding.modelLater"); + next.click(); + next.click(); + }); + expect(mocked.setStartupWizardSize).toHaveBeenCalledTimes(1); + expect(container.querySelector(".bg-wizard-bootstrap")).not.toBeNull(); + expect(container.querySelector(".ProseMirror")).toBeNull(); + expect(container.textContent).not.toContain( + "startupWizard.onboarding.themeTitle", + ); + await act(async () => expansion.resolve({ success: true, data: true })); + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + expect(container.textContent).toContain( + "startupWizard.onboarding.themeTitle", + ); + + const contraction = Promise.withResolvers<{ + success: boolean; + data: boolean; + }>(); + mocked.setStartupWizardSize.mockReturnValueOnce(contraction.promise); + await act(async () => + findButton(root, "startupWizard.onboarding.previous").click(), + ); + expect( + container.querySelector('[aria-busy="true"].bg-app'), + ).not.toBeNull(); + expect(container.querySelector(".ProseMirror")).toBeNull(); + expect(document.documentElement.getAttribute("data-theme")).toBe("sepia"); + await act(async () => contraction.resolve({ success: true, data: true })); + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + expect( + findButton(root, "startupWizard.onboarding.modelLater"), + ).toBeDefined(); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + it.each(["false", "error", "reject"])( + "resize %s 실패 후 재시도는 완료가 아니라 같은 전환을 재시도한다", + async (failure) => { + const { root, container } = await renderWizard(); + try { + await act(async () => + findButton(root, "startupWizard.onboarding.startCta").click(), + ); + if (failure === "reject") + mocked.setStartupWizardSize.mockRejectedValueOnce( + new Error("resize rejected"), + ); + else if (failure === "error") + mocked.setStartupWizardSize.mockResolvedValueOnce({ + success: false, + data: false, + }); + else + mocked.setStartupWizardSize.mockResolvedValueOnce({ + success: true, + data: false, + }); + await act(async () => + findButton(root, "startupWizard.onboarding.modelLater").click(), + ); + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + await act(async () => + findButton(root, "startupWizard.actions.retry").click(), + ); + expect(mocked.setStartupWizardSize).toHaveBeenLastCalledWith( + 1300, + 800, + true, + ); + expect(mocked.setStartupWizardSize).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain( + "startupWizard.onboarding.themeTitle", + ); + expect(mocked.completeWizard).not.toHaveBeenCalled(); + } finally { + act(() => root.unmount()); + container.remove(); + } + }, + ); + + it("resize 대기 중 unmount 후 늦은 완료는 화면이나 startup을 변경하지 않는다", async () => { + const { root, container } = await renderWizard(); + await act(async () => + findButton(root, "startupWizard.onboarding.startCta").click(), + ); + const pending = Promise.withResolvers<{ + success: boolean; + data: boolean; + }>(); + mocked.setStartupWizardSize.mockReturnValueOnce(pending.promise); + await act(async () => + findButton(root, "startupWizard.onboarding.modelLater").click(), + ); + act(() => root.unmount()); + await act(async () => pending.resolve({ success: true, data: true })); + expect(container.childElementCount).toBe(0); + expect(mocked.completeWizard).not.toHaveBeenCalled(); + container.remove(); + }); +}); diff --git a/tests/main/handler/ipcWindowResize.test.ts b/tests/main/handler/ipcWindowResize.test.ts new file mode 100644 index 00000000..b76ed5cc --- /dev/null +++ b/tests/main/handler/ipcWindowResize.test.ts @@ -0,0 +1,462 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCode } from "../../../src/shared/constants/errors/index.js"; +import { IPC_CHANNELS } from "../../../src/shared/ipc/channels.js"; + +const mocked = await vi.hoisted(async () => { + const { EventEmitter } = await import("node:events"); + const handlerMap = new Map< + string, + (event: unknown, ...args: unknown[]) => Promise + >(); + const createExportWindow = vi.fn(); + const wizard = Object.assign(new EventEmitter(), { + isDestroyed: vi.fn(() => false), + getBounds: vi.fn(() => ({ x: 100, y: 100, width: 500, height: 600 })), + setBounds: + vi.fn< + ( + bounds: { x: number; y: number; width: number; height: number }, + animate?: boolean, + ) => void + >(), + }); + const getStartupWizardWindow = vi.fn<() => typeof wizard | null>( + () => wizard, + ); + const getDisplayMatching = vi.fn(() => ({ + workArea: { x: -1920, y: 30, width: 1920, height: 1080 }, + })); + const appQuit = vi.fn(); + const getAnimationSettings = vi.fn(() => ({ + prefersReducedMotion: false, + shouldRenderRichAnimation: true, + })); + const appGetVersion = vi.fn(() => "1.2.3-test"); + const appGetPath = vi.fn(() => `/tmp/luie-vitest-${process.pid}`); + let appIsPackaged = false; + const logger = { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }; + + return { + handlerMap, + createExportWindow, + wizard, + getStartupWizardWindow, + getDisplayMatching, + appQuit, + getAnimationSettings, + appGetVersion, + appGetPath, + get appIsPackaged() { + return appIsPackaged; + }, + set appIsPackaged(next: boolean) { + appIsPackaged = next; + }, + logger, + }; +}); + +vi.mock("electron", () => ({ + app: { + quit: (...args: unknown[]) => mocked.appQuit(...args), + getVersion: (...args: unknown[]) => mocked.appGetVersion(...args), + getPath: (...args: unknown[]) => mocked.appGetPath(...args), + get isPackaged() { + return mocked.appIsPackaged; + }, + }, + BrowserWindow: { + getAllWindows: () => [], + }, + screen: { getDisplayMatching: mocked.getDisplayMatching }, + systemPreferences: { getAnimationSettings: mocked.getAnimationSettings }, + ipcMain: { + handle: vi.fn( + ( + channel: string, + handler: (event: unknown, ...args: unknown[]) => Promise, + ) => { + mocked.handlerMap.set(channel, handler); + }, + ), + }, +})); + +vi.mock("../../../src/main/app/windows/index.js", () => ({ + windowManager: { + getMainWindow: () => null, + getStartupWizardWindow: mocked.getStartupWizardWindow, + createExportWindow: (...args: unknown[]) => + mocked.createExportWindow(...args), + }, +})); + +describe("ipcWindowResize", () => { + beforeEach(() => { + mocked.handlerMap.clear(); + mocked.createExportWindow.mockReset(); + mocked.wizard.isDestroyed.mockReset().mockReturnValue(false); + mocked.wizard.getBounds + .mockReset() + .mockReturnValue({ x: 100, y: 100, width: 500, height: 600 }); + mocked.wizard.removeAllListeners(); + mocked.wizard.setBounds + .mockReset() + .mockImplementation((bounds, animate) => { + mocked.wizard.getBounds.mockReturnValue({ ...bounds }); + if (animate) mocked.wizard.emit("resized"); + }); + mocked.getStartupWizardWindow.mockReset().mockReturnValue(mocked.wizard); + mocked.getDisplayMatching.mockClear(); + mocked.appQuit.mockReset(); + mocked.getAnimationSettings.mockReset().mockReturnValue({ + prefersReducedMotion: false, + shouldRenderRichAnimation: true, + }); + mocked.appGetVersion.mockReset(); + mocked.appGetVersion.mockReturnValue("1.2.3-test"); + mocked.appGetPath.mockReset(); + mocked.appGetPath.mockReturnValue(`/tmp/luie-vitest-${process.pid}`); + mocked.appIsPackaged = false; + delete process.env.LUIE_UPDATE_FEED_URL; + vi.unstubAllGlobals(); + mocked.logger.info.mockReset(); + mocked.logger.debug.mockReset(); + mocked.logger.error.mockReset(); + }); + + describe("wizard resize work budget", () => { + const registerResize = async () => { + const { registerWindowIPCHandlers } = + await import("../../../src/main/handler/system/window/index.js"); + registerWindowIPCHandlers(mocked.logger); + const handler = mocked.handlerMap.get( + IPC_CHANNELS.WINDOW_SET_STARTUP_WIZARD_SIZE, + ); + if (!handler) throw new Error("resize handler missing"); + return handler; + }; + + it.each([ + ["darwin", true, false, true, "native"], + ["darwin", false, false, true, "instant"], + ["darwin", true, true, true, "instant"], + ["darwin", true, false, false, "instant"], + ["win32", true, false, true, "stepped"], + ["win32", false, false, true, "instant"], + ["win32", true, true, true, "instant"], + ["win32", true, false, false, "instant"], + ["linux", true, false, true, "instant"], + ] as const)( + "respects motion policy: OS=%s animate=%s reduced=%s rich=%s mode=%s", + async (platform, animate, reduced, rich, mode) => { + const handler = await registerResize(); + const platformDescriptor = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + Object.defineProperty(process, "platform", { value: platform }); + mocked.getAnimationSettings.mockReturnValue({ + prefersReducedMotion: reduced, + shouldRenderRichAnimation: rich, + }); + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "performance"], + }); + try { + const response = handler({}, 800, 600, animate); + if (mode === "stepped") { + expect(mocked.wizard.setBounds).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + expect(mocked.wizard.setBounds.mock.calls.length).toBeGreaterThan( + 1, + ); + expect( + mocked.wizard.setBounds.mock.calls.length, + ).toBeLessThanOrEqual(13); + } else { + expect(mocked.wizard.setBounds).toHaveBeenCalledTimes(1); + } + expect(await response).toMatchObject({ + success: true, + data: true, + }); + expect(mocked.wizard.setBounds).toHaveBeenLastCalledWith( + { x: -1360, y: 270, width: 800, height: 600 }, + mode === "native", + ); + expect(vi.getTimerCount()).toBe(0); + const calls = mocked.wizard.setBounds.mock.calls.length; + await handler({}, 800, 600, animate); + expect(mocked.wizard.setBounds).toHaveBeenCalledTimes(calls); + expect(mocked.getAnimationSettings).toHaveBeenCalledTimes( + platform !== "linux" && animate ? 1 : 0, + ); + } finally { + vi.useRealTimers(); + Object.defineProperty(process, "platform", platformDescriptor); + } + }, + ); + + it.each([ + "complete", + "closed", + "destroyed", + "superseded", + "error", + "closed-in-setBounds", + "late", + "rounding", + ])("bounds Windows work and clears lifecycle on %s", async (outcome) => { + const handler = await registerResize(); + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32" }); + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "performance"], + }); + const now = vi.spyOn(performance, "now"); + try { + if (outcome === "rounding") { + mocked.wizard.getBounds.mockReturnValue({ + x: -1361, + y: 270, + width: 800, + height: 600, + }); + } + if (outcome === "closed-in-setBounds") { + mocked.wizard.setBounds.mockImplementation(() => { + mocked.wizard.isDestroyed.mockReturnValue(true); + mocked.wizard.emit("closed"); + }); + } + const settled = vi.fn(); + const response = handler({}, 800, 600, true).then((result) => { + settled(); + return result; + }); + expect(mocked.wizard.setBounds).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + expect(mocked.wizard.listenerCount("resized")).toBe(0); + if (outcome === "late") now.mockReturnValue(600); + await vi.advanceTimersByTimeAsync(16); + if (outcome === "complete" || outcome === "rounding") { + if (outcome === "rounding") + expect(mocked.wizard.setBounds).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(183); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + } else if (outcome === "closed") { + mocked.wizard.emit("closed"); + } else if (outcome === "destroyed") { + mocked.wizard.isDestroyed.mockReturnValue(true); + await vi.advanceTimersByTimeAsync(16); + } else if (outcome === "superseded") { + const replacement = handler({}, 900, 700, true); + await vi.advanceTimersByTimeAsync(200); + expect(await replacement).toMatchObject({ + success: true, + data: true, + }); + expect(mocked.wizard.getBounds()).toEqual({ + x: -1410, + y: 220, + width: 900, + height: 700, + }); + } else if (outcome === "error") { + mocked.wizard.setBounds.mockImplementation(() => { + throw new Error("native frame failed"); + }); + await vi.advanceTimersByTimeAsync(16); + } + const completed = ["complete", "late", "rounding"].includes(outcome); + expect(await response).toMatchObject( + outcome === "error" + ? { success: false } + : { success: true, data: completed }, + ); + expect(mocked.wizard.listenerCount("closed")).toBe(0); + expect(mocked.wizard.listenerCount("resized")).toBe(0); + expect(vi.getTimerCount()).toBe(0); + if (completed) { + expect(mocked.wizard.setBounds).toHaveBeenLastCalledWith( + { x: -1360, y: 270, width: 800, height: 600 }, + false, + ); + expect(mocked.wizard.setBounds.mock.calls.length).toBeLessThanOrEqual( + 13, + ); + if (outcome !== "complete") + expect(mocked.wizard.setBounds).toHaveBeenCalledTimes(1); + } + const calls = mocked.wizard.setBounds.mock.calls.length; + await vi.advanceTimersByTimeAsync(3000); + expect(mocked.wizard.setBounds).toHaveBeenCalledTimes(calls); + } finally { + now.mockRestore(); + vi.useRealTimers(); + Object.defineProperty(process, "platform", descriptor); + } + }); + + it.each(["resized", "closed", "timeout", "superseded", "timeout-error"])( + "cleans up native resize waiting on %s", + async (outcome) => { + const handler = await registerResize(); + const descriptor = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + Object.defineProperty(process, "platform", { value: "darwin" }); + vi.useFakeTimers(); + try { + mocked.wizard.setBounds.mockImplementation((bounds) => { + mocked.wizard.getBounds.mockReturnValue({ ...bounds }); + }); + const settled = vi.fn(); + const response = handler({}, 800, 600, true).then((result) => { + settled(); + return result; + }); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + expect(mocked.wizard.listenerCount("resized")).toBe(1); + expect(mocked.wizard.listenerCount("closed")).toBe(1); + expect(vi.getTimerCount()).toBe(1); + if (outcome === "resized") mocked.wizard.emit("resized"); + else if (outcome === "closed") { + mocked.wizard.isDestroyed.mockReturnValue(true); + mocked.wizard.emit("closed"); + } else if (outcome === "superseded") { + expect(await handler({}, 900, 700, false)).toMatchObject({ + success: true, + data: true, + }); + } else { + await vi.advanceTimersByTimeAsync(1999); + expect(settled).not.toHaveBeenCalled(); + if (outcome === "timeout-error") { + mocked.wizard.setBounds.mockImplementation(() => { + throw new Error("fallback failed"); + }); + } + await vi.advanceTimersByTimeAsync(1); + } + expect(await response).toMatchObject( + outcome === "timeout-error" + ? { success: false } + : { + success: true, + data: outcome === "resized" || outcome === "timeout", + }, + ); + expect(mocked.wizard.listenerCount("resized")).toBe(0); + expect(mocked.wizard.listenerCount("closed")).toBe(0); + expect(vi.getTimerCount()).toBe(0); + if (outcome === "timeout") { + expect(mocked.wizard.setBounds).toHaveBeenLastCalledWith( + { x: -1360, y: 270, width: 800, height: 600 }, + false, + ); + } + const calls = mocked.wizard.setBounds.mock.calls.length; + await vi.advanceTimersByTimeAsync(3000); + expect(mocked.wizard.setBounds).toHaveBeenCalledTimes(calls); + } finally { + vi.useRealTimers(); + Object.defineProperty(process, "platform", descriptor); + } + }, + ); + + it("settles rapid requests with the last bounds and no delayed overwrite", async () => { + const handler = await registerResize(); + vi.useFakeTimers(); + try { + const results = await Promise.all([ + handler({}, 800, 600, true), + handler({}, 900, 700, false), + ]); + expect(results).toMatchObject([ + { success: true, data: process.platform !== "win32" }, + { success: true, data: true }, + ]); + expect(mocked.wizard.getBounds()).toEqual({ + x: -1410, + y: 220, + width: 900, + height: 700, + }); + expect(mocked.wizard.setBounds).toHaveBeenCalledTimes( + process.platform === "win32" ? 1 : 2, + ); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["missing", "destroyed"])( + "does not resize a %s window", + async (state) => { + const handler = await registerResize(); + if (state === "missing") + mocked.getStartupWizardWindow.mockReturnValue(null); + else mocked.wizard.isDestroyed.mockReturnValue(true); + expect(await handler({}, 800, 600, true)).toMatchObject({ + success: true, + data: false, + }); + expect(mocked.wizard.setBounds).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [-1, -1, { x: -1229, y: 219, width: 538, height: 702 }], + [1300, 800, { x: -1747, y: 127, width: 1574, height: 886 }], + [4096, 4096, { x: -1920, y: 30, width: 1920, height: 1080 }], + ])( + "preserves compact/preview/work-area sizing for %s×%s", + async (width, height, expected) => { + const handler = await registerResize(); + expect(await handler({}, width, height, true)).toMatchObject({ + success: true, + data: true, + }); + expect(mocked.wizard.setBounds).toHaveBeenLastCalledWith( + expected, + process.platform === "darwin", + ); + }, + ); + + it.each([ + [NaN, 600, true], + [800, Infinity, true], + [800, 600, "true"], + ])("rejects invalid resize arguments %s/%s/%s", async (...args) => { + const handler = await registerResize(); + expect(await handler({}, ...args)).toMatchObject({ + success: false, + error: { code: ErrorCode.INVALID_INPUT }, + }); + expect(mocked.wizard.setBounds).not.toHaveBeenCalled(); + }); + + it("returns an IPC error if native bounds application throws", async () => { + const handler = await registerResize(); + mocked.wizard.setBounds.mockImplementation(() => { + throw new Error("window closed during resize"); + }); + expect(await handler({}, 800, 600, true)).toMatchObject({ + success: false, + }); + }); + }); +}); diff --git a/tests/scripts/startupBenchmark.test.ts b/tests/scripts/startupBenchmark.test.ts new file mode 100644 index 00000000..7ca45886 --- /dev/null +++ b/tests/scripts/startupBenchmark.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { parseOptions, summarize } from "../../scripts/benchmark-startup.mjs"; + +const { configureProbe } = createRequire(import.meta.url)( + "../../scripts/startup-probe.cjs", +); +const directories: string[] = []; +const initialEnv = { ...process.env }; +afterEach(() => { + process.env = { ...initialEnv }; + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true }); +}); + +describe("P0 measurement contract", () => { + it("P0-CLI-01: accepts boundary values and defaults", () => { + expect(parseOptions([])).toEqual({ + runs: 3, + profile: "fresh", + timeoutMs: 30000, + scenario: "intro", + }); + expect( + parseOptions([ + "--runs", + "1", + "--profile", + "repeat", + "--timeout-ms", + "1000", + ]).runs, + ).toBe(1); + expect( + parseOptions(["--runs", "100", "--timeout-ms", "120000"]).timeoutMs, + ).toBe(120000); + expect(parseOptions(["--scenario", "resize"]).scenario).toBe("resize"); + }); + it.each([ + ["--runs", "0"], + ["--runs", "101"], + ["--runs", "1.5"], + ["--runs"], + ["--timeout-ms", "999"], + ["--timeout-ms", "120001"], + ["--profile", "cold"], + ["--scenario", "unknown"], + ["--user-data", "/real/profile"], + ["--unknown", "1"], + ])("P0-CLI-02: rejects invalid options %j", (...args) => { + expect(() => parseOptions(args)).toThrow("Invalid option"); + }); + it("P0-STAT-01: keeps failed runs and does not publish small-sample p95", () => { + expect( + summarize([ + { status: "passed", introObservedMs: 3000 }, + { status: "failed" }, + { status: "passed", introObservedMs: 1000 }, + ]), + ).toEqual({ + attempted: 3, + passed: 2, + failed: 1, + successfulIntroMedianMs: 2000, + successfulIntroP95Ms: null, + }); + expect( + summarize([{ status: "failed" }]).successfulIntroMedianMs, + ).toBeNull(); + expect(summarize([]).attempted).toBe(0); + expect( + summarize( + Array.from({ length: 20 }, (_, index) => ({ + status: "passed", + introObservedMs: index + 1, + })), + ).successfulIntroP95Ms, + ).toBe(19); + expect(() => + summarize([{ status: "passed", introObservedMs: NaN }]), + ).toThrow("Invalid measurement"); + }); + it("P0-SAFE-01: isolates before app import and never registers the OS protocol", () => { + const profile = mkdtempSync(path.join(os.tmpdir(), "luie-p0-test-")); + directories.push(profile); + writeFileSync(path.join(profile, ".luie-p0-owner"), "owned"); + const registrar = vi.fn(); + const app = { + isPackaged: false, + setPath: vi.fn(), + setAppPath: vi.fn(), + setAsDefaultProtocolClient: registrar, + }; + configureProbe(app, { profile, token: "owned", repo: "/repo" }); + expect(app.setPath.mock.calls).toEqual([ + ["userData", profile], + ["sessionData", profile], + ]); + expect(process.env.LUIE_RUNTIME_DATABASE_URL).toBe( + `file:${path.join(profile, "main.db")}`, + ); + expect(process.env.LUIE_RUNTIME_CACHE_DATABASE_URL).toBe( + `file:${path.join(profile, "cache.db")}`, + ); + expect(process.env.LUIE_USER_DATA_PATH).toBe(profile); + expect(process.env.LUIE_DISABLE_SYNC).toBe("1"); + expect(app.setAsDefaultProtocolClient()).toBe(false); + expect(registrar).not.toHaveBeenCalled(); + }); + it("P0-SAFE-02: rejects unowned, relative and packaged profiles before mutation", () => { + const profile = mkdtempSync(path.join(os.tmpdir(), "luie-p0-test-")); + directories.push(profile); + writeFileSync(path.join(profile, ".luie-p0-owner"), "someone-else"); + const app = { isPackaged: false, setPath: vi.fn() }; + expect(() => configureProbe(app, { profile, token: "owned" })).toThrow( + "ownership mismatch", + ); + expect(() => configureProbe(app, { profile: "relative" })).toThrow( + "absolute owned profile", + ); + expect(() => + configureProbe({ ...app, isPackaged: true }, { profile }), + ).toThrow("unpackaged"); + expect(app.setPath).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/scripts/startupWizardBundle.test.ts b/tests/scripts/startupWizardBundle.test.ts new file mode 100644 index 00000000..4e23e581 --- /dev/null +++ b/tests/scripts/startupWizardBundle.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + analyzeStartupBundle, + staticModuleSpecifiers, +} from "../../scripts/check-startup-wizard-bundle.mjs"; + +describe("P1 wizard static loading boundary", () => { + it("P1-BUNDLE-01: parses imports/re-exports but not dynamic imports or strings", () => { + expect( + staticModuleSpecifiers( + 'import "./a.js"; export { x } from "./b.js"; export * from "./c.js"; import("./lazy.js"); const s = "import fake";', + ), + ).toEqual(["./a.js", "./b.js", "./c.js"]); + expect(() => staticModuleSpecifiers("import {")).toThrow("Invalid JS"); + }); + it("P1-BUNDLE-02: handles shared dependencies/cycles and detects eager preview regression", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "luie-p1-bundle-")); + const chunks = path.join(root, "assets/chunks"); + mkdirSync(chunks, { recursive: true }); + const wizard = path.join(chunks, "StartupWizard-test.js"); + try { + writeFileSync( + path.join(root, "index.html"), + '', + ); + writeFileSync( + path.join(root, "assets/boot.js"), + 'import "./chunks/shared.js";', + ); + writeFileSync(path.join(chunks, "shared.js"), 'import "../boot.js";'); + writeFileSync( + path.join(chunks, "vendor-prosemirror-test.js"), + "export const x = 1;", + ); + writeFileSync( + wizard, + 'import "./shared.js"; import("./vendor-prosemirror-test.js");', + ); + const deferred = analyzeStartupBundle(root); + expect(deferred.boot.files).toBe(2); + expect(deferred.eagerEditorChunks).toEqual([]); + expect(deferred.union.bytes).toBe( + deferred.boot.bytes + deferred.extra.bytes, + ); + writeFileSync(wizard, 'export * from "./vendor-prosemirror-test.js";'); + expect(analyzeStartupBundle(root).eagerEditorChunks).toHaveLength(1); + writeFileSync(wizard, 'import "../../../outside.js";'); + expect(() => analyzeStartupBundle(root)).toThrow("outside renderer"); + } finally { + rmSync(root, { recursive: true }); + } + }); +}); diff --git a/todo.md b/todo.md index da7820d6..dbed1493 100644 --- a/todo.md +++ b/todo.md @@ -1,3 +1,172 @@ +## 저장 로직 개선 — 최우선 작업 (2026-09-08) + +### 합의한 범위와 현재 상태 + +- [x] DB·저장 구조 재조사와 실제 결함 재현을 기록했다. [설계 감사](docs/quality/storage-architecture-review-2026-09-08.md), [기존 DB 감사](docs/quality/performance-audit-2026-09-08/database.md), [재현 소스·관측 결과](docs/quality/storage-architecture-review-2026-09-08/evidence/README.md). +- [x] 아래에 요구사항·위험·테스트 조건·상황 가정·기대 결과·실행 기록 규칙을 설계했다. +- [ ] 제품 저장 로직을 수정하고 정상 동작을 기대하는 회귀 테스트를 통과시킨다. **문서 작성이나 결함 관측 probe의 통과를 구현 완료로 체크하지 않는다.** + +사용자 합의: **저장 안전성을 먼저 보장하고, `.luie` 형식을 유지하면서 일반 저장을 변경된 장과 필요한 정보만 갱신하도록 바꾼다.** 초기 생성·다른 이름으로 저장·복구에는 전체 파일 생성 경로를 유지한다. main DB는 최신 작업본이고 검색 cache DB와 다르므로 삭제 가능한 cache로 취급하지 않는다. + +스냅샷은 과거 상태 보존, 일반 저장은 현재 상태 반영이다. 증분 저장 도입을 이유로 스냅샷 생성·보관 정책을 임의 변경하거나 기존 이력을 삭제하지 않는다. 큰 삭제 직전 본문 보존은 아래 정책 결정 항목에서 별도로 다룬다. + +### ISTQB 관점과 테스트 근거 + +위험에 따라 우선순위를 정하고, 요구사항 → 테스트 조건 → 케이스 → 실행 결과 → 결함을 연결한다. 동등 분할·경계값·결정 테이블·상태 전이와 기존 결함에 대한 오류 추정을 적용한다. 이는 이 프로젝트의 테스트 설계이며 ISTQB 인증이나 전체 무결성 보장을 의미하지 않는다. 근거: [ISTQB CTFL v4.0.1](https://istqb.org/wp-content/uploads/2024/11/ISTQB_CTFL_Syllabus_v4.0.1.pdf), §1.4.1/1.4.4, §4.2, §5.2. + +| 요구 ID | 요구사항과 실패 영향 | 발생 가능성 평가 근거 | 우선순위 | 연결 테스트 | +|---|---|---|---|---| +| SAVE-R1 | 최신 본문과 chapterId를 보존한다. 실패 시 원고 유실/다른 장 오염 | pending·전환·재열기의 분리 재현 있음 | P1 | T01~T04, T09, T13 | +| SAVE-R2 | DB/package 쓰기는 각 transaction 내 전부 성공 또는 전부 rollback, 실패를 성공으로 표시하지 않는다 | 공유 연결 transaction 혼입·단건 partial commit 재현, 오류 전파 경로 확인 | P1 | T05~T08, T10, T13 | +| SAVE-R3 | 저장 성공한 지원 형식을 같은 reader로 다시 읽을 수 있다 | 5MiB+1 entry 저장 성공/읽기 실패 재현 | P1 | T11~T12, T18 | +| SAVE-R4 | 변경 범위 전체를 반영한 revision만 완료 처리하고 다른 원본을 보존한다 | 전체 revision ACK와 부분 scope의 불일치 위험, 외부 파일은 동시 변경 가능 | P1 | T08~T10, T14~T16 | +| SAVE-R5 | 스냅샷 정책을 몰래 바꾸지 않고 보존 대상 본문·종류를 복원한다 | 중복 저장, MANUAL type 누락, focus/복원 차이 등 소스 확인 | P1 | T17~T19 | +| SAVE-R6 | DB 손실·crash·외부 파일 오류에서 남은 정상 원본으로 복구한다 | checkpoint 후 DB 손실 복원 확인, 일부 오류/OS는 미검증 | P1 | T07~T10, T18~T20 | +| SAVE-R7 | 작은 변경의 작업량을 줄이고 지원 OS에서 저장 계약을 유지한다 | macOS writer 분리 벤치 있음; Windows/Linux 실기 미실시 | P2, 안전성은 P1 | T15~T16, T20~T21 | + +P1/P2는 작업 우선순위다. 재현 가능성이 높다는 평가를 실제 사용자 장애 빈도 통계로 표현하지 않는다. 데이터 손실 위험은 속도 개선보다 먼저 검사한다. + +### 실행 순서와 완료 조건 + +#### 1단계 — 최신 원고와 저장 성공의 의미 고정 + +- [ ] STOR-01: 기존 테스트의 구 Prisma mock/목록 DTO 본문 기대를 현재 계약에 맞추고 실제 DB·파일 검사를 준비한다. 정상 기대값으로 현재 결함을 재현하는 실패 검사를 먼저 남긴다. +- [ ] STOR-02: main pending의 처리 세대만 완료한다. A 저장 중 B 도착 시 B를 남긴다. raw editor flush와 이전 chapterId도 수동 저장의 종단 범위에 포함한다. +- [ ] STOR-03: 공유 연결의 `BEGIN`~`COMMIT` 사이 비동기 양보를 제거하고, 저장 실패를 flush/manual save까지 전달한다. +- [ ] STOR-04: 명시적 `.luie` 재열기가 미반영 DB 변경을 timestamp만으로 덮지 않도록 한다. 외부 변경과 로컬 변경이 충돌하면 양쪽을 보존한다. +- [ ] STOR-05: reader/writer의 entry·전체 파일 크기 계약을 맞춘다. 실패 시 이전 정상 파일을 보존한다. + +완료 조건: T01~T05, T07~T09, T11~T13 통과. 마지막 입력/대상 장 보존, 실패의 성공 오인 없음, DB/파일 재열기로 실제 내용 확인. T06의 새 batch transaction과 T10의 package/ACK crash 경계는 2단계에서 완료한다. 테스트 환경 문제로 실행하지 못한 케이스는 통과로 세지 않는다. + +#### 2단계 — `.luie` 형식을 유지한 안전한 부분 갱신 + +- [ ] STOR-06: 변경 entry·삭제 entry·meta·container info를 하나의 동기 SQLite transaction으로 반영한다. 기존 세계관 단건 쓰기도 같은 계약으로 묶는다. +- [ ] STOR-07: 기존 export queue에 변경 scope/세대를 연결한다. 변경 범위가 온전히 설명되는 경우만 부분 갱신하고, 반영한 revision만 ACK한다. +- [ ] STOR-08: 원본 DB와 package 각각 일관된 read snapshot을 사용한다. full export/단건 writer/import/attach가 같은 파일의 저장 순서를 따르게 한다. +- [ ] STOR-09: hot journal 복구·package commit 후 ACK 전 crash·재시작 revision gap을 처리한다. 외부 base 불일치나 package에만 있는 entry는 자동 full overwrite하지 않는다. +- [ ] STOR-10: 기존 형식의 읽기/복원 호환성과 생성·삭제·이름/순서 변경을 검증한다. 미지원 scope의 전체 checkpoint는 원본이 지원 payload에 보존된 경우만 사용한다. + +완료 조건: T06~T16, T18, T20 통과. full/부분 저장 후 동일한 프로젝트 의미를 복원한다. 반영하지 않은 domain을 clean으로 표시하지 않는다. 큰 파일 전체를 매 저장 해시/복사하는 것으로 작업량만 다른 곳에 옮기지 않았는지 T21에서 측정한다. + +#### 3단계 — 스냅샷·이력·파생 작업 비용 정리 + +- [ ] STOR-11: 아래 스냅샷 정책 미정 항목을 확정하고 기존 동작 검사와 목표 정책 검사를 분리한다. +- [ ] STOR-12: snapshot 본문·MANUAL/AUTO type·chapter 연결·복원 범위를 round-trip으로 보존한다. metadata-only index는 reader 변경과 함께 다룬다. +- [ ] STOR-13: ChapterRevision 새 append의 필요/보관 정책, snapshot 중복·외부 artifact의 역할을 정한 뒤 줄인다. 기존 이력은 일괄 삭제하지 않는다. +- [ ] STOR-14: 단건 FTS 갱신, 파생 작업 generation, 불변 chunk 보존을 적용한다. 남은 큰 작업의 process 이전은 실제 비용을 본 뒤 수행한다. + +완료 조건: T17~T21의 확정된 정책과 복구 조건 통과. 미정 정책을 임의 기대값으로 채워 통과 처리하지 않는다. + +### 명시적인 상황 가정과 테스트 데이터 + +| 가정 ID | 가정·fixture | 가정이 바뀌면 추가할 검사 | +|---|---|---| +| A1 | 앱 한 인스턴스, 프로젝트 2개와 각 장 2개 이상. A/B는 다른 본문 세대이며 id를 명시한다 | 다중 process/utility writer는 T10/T14/T20에서 별도 경합. process-local lock만으로 외부 배타성을 가정하지 않음 | +| A2 | main/cache DB·userData·`.luie`·mirror·snapshot을 임시 경로로 격리. 사용자 데이터/설정 사용 금지 | OS packaged 검사는 동일하게 userData까지 격리. native ABI가 안 맞으면 실행불가로 기록 | +| A3 | 동시성 순서는 deferred barrier로, debounce/gate는 제어 가능한 clock으로 만든다 | 임의 sleep으로 우연히 통과시키지 않음. 실제 DB transaction·파일 I/O는 mock으로 대체하지 않음 | +| A4 | 기존 sqlite-v2 파일, detached project, missing/corrupt file, 외부 변경 파일, 미지 entry를 각각 준비 | 같은 projectId라도 다른 복사본/기기 revision을 무조건 대소 비교하지 않음 | +| A5 | 빈 문자열·한글·ASCII·이모지·줄바꿈과 크기 경계. 파일 상한 단위는 UTF-8 bytes | 삭제 정책의 '글자' 단위는 별도 정의. UTF-16 length와 사용자 인식 글자를 혼동하지 않음 | +| A6 | 정상 DB commit과 portable package 반영은 다른 상태. 임시 filesystem의 성공을 정전 내구성으로 보지 않음 | SQL 예외 주입, 자식 process 강제 종료, 실제 OS/전원 손실은 서로 다른 시험/증거로 기록 | + +### 테스트 조건과 케이스 설계 + +아래는 **정상 목표 동작을 판정하는 설계**다. 초기 상태는 전제 A1~A6를 따르고, 각 변형은 별도 실행 결과를 남긴다. 저장 완료는 API 응답만 확인하지 않고 재열기한 본문·metadata·revision·남은 pending을 검사한다. + +| ID / 기법·수준 | 테스트 조건·상황 가정 | 동작·실패 주입 | 기대 결과 / 판정 근거 | +|---|---|---|---| +| T01 / 상태 전이·DOM+integration | A1/A3, editor raw buffer만 B이고 전달 debounce 전 | 입력 즉시 Ctrl/Cmd+S; 일반/분할 editor 각각 | 현재 editor B가 올바른 장의 DB와 package에 반영. raw buffer가 남았는데 저장 완료 표시 금지 | +| T02 / 상태 전이·DOM+integration | A1/A3, A장 미저장 입력, B장 기존 본문 | A→B 전환, unmount, 다른 프로젝트 전환 | A 입력은 A로 저장, B는 불변. 이전 chapterId 인자가 호출 끝까지 유지 | +| T03 / 상태 전이·service | A1/A3, pending A 저장이 시작됨 | DB update 대기 지점에 동일 장의 B enqueue 후 A 완료 | B pending 보존·후속 저장, A 완료 이벤트가 B 저장 완료를 뜻하지 않음 | +| T04 / 동등 분할·integration | A1/A5, 같은 본문 / 다른 본문 / 본문은 같고 제목만 변경 | 각각 저장, 반복 저장 | 같은 내용은 불필요한 본문 write 없음. 제목 변경은 필요한 metadata만 반영. 서로 다른 장/프로젝트는 독립 | +| T05 / 오류 추정·native DB | A2/A3, 기존 A, 새 B 저장 중 공유 연결 | chapter transaction 오류 주입과 unrelated domain write를 제어한 순서로 실행 | chapter는 전부 rollback. 다른 작업이 성공으로 반환됐다면 chapter 실패에 함께 취소되지 않음. transaction 안 async 양보 없음 | +| T06 / 오류 추정·native DB+FS | A2, 기존 정상 package | batch 첫 entry/중간 entry/meta/info 각각 실패; COMMIT 실패는 가능한 주입 경계를 별도 기록 | 한 transaction의 내용·meta·info가 모두 이전 상태 또는 모두 새 상태. 실패 시 ACK 없음. 파일을 새 연결로 읽어 확인 | +| T07 / 결정 테이블·handler integration | A2, DB B write 실패; 옛 A package export 자체는 가능 | performSave→flush→manual save 실경로 실행 | 수동 저장 실패 전달, B pending/복구 가능한 자료 보존. 옛 파일 export 성공으로 전체 성공 처리 금지 | +| T08 / 상태 전이·DB+FS | A2/A3, B는 DB commit 완료, 파일은 A | package write 오류/권한 오류/잠금 후 재시도 | DB B 유지, exportedRevision 미진행, 실패 표시. 재시도 성공 후 같은 B 반영. 실제 ENOSPC와 mock 오류는 구분 | +| T09 / 결정 테이블·DB+FS | A2/A4, revision gap 있고 DB B/package A | 명시적 같은 파일 열기. recent project 선택은 별도 변형. timestamp 과거/같음/미래도 실행 | DB B 보존. 외부 divergence면 양쪽 보존. 이전 파일로 덮고 clean 처리 금지 | +| T10 / 상태 전이·자식 process+FS | A2/A6, DB commit 전/후, package commit 전/후, ACK 전/후 경계 | child process 종료 후 새 process 재열기; timeout 뒤 기존 writer가 아직 살아 있는 변형 | 마지막 성공 commit 보존, 미완료는 pending/재조정. package commit 후 ACK 전 crash는 안전한 재적용. 늦은 writer가 새 상태를 덮지 않음 | +| T11 / 경계값·writer/reader | A5, entry 상한 L=5×1024×1024 bytes(현행값) | L−1/L/L+1 payload를 full/부분 경로로 저장 후 읽기. 한글·이모지로도 정확한 byte 경계 구성 | 허용 범위는 write/read 왕복 일치. 초과는 기존 정상 상태 보존하며 명시 실패. 프로젝트/FS/IPC의 더 작은 입력 제한은 경계별로 별도 기록 | +| T12 / 경계값·native FS | A5, package 상한 K=256×1024×1024 bytes(현행값) | controlled fixture로 K−1/K/K+1 파일을 검사하고, 기존 파일은 한도 안/수정 후 한도 밖인 쓰기 실행 | 교체 전에 최종 크기 검증, 읽을 수 없는 성공 파일 생성 금지. SQLite 페이지 정렬로 정상 파일에서 도달 불가능한 exact size는 validator fixture와 실제 파일시험을 구분 | +| T13 / 상태 전이·lifecycle integration | A1/A3, 입력/DB 저장/package 저장 중 | 창 닫기, 종료, 종료 취소, renderer crash | 전달 가능한 최신 입력 flush, 실패 시 정상 저장 종료로 오인하지 않음. 종료 취소 후 queue 재개. renderer-only 미전달 자료의 한계를 별도 기록 | +| T14 / 결정 테이블·DB+FS | A1/A3, chapter patch 수집 중 world/memory/snapshot revision 증가 | 알려진 scope와 관측하지 못한 writer 변경을 각각 끼워 넣기 | 범위가 완전할 때만 전체 revision ACK. 미관측 변경은 보존 가능한 전체 checkpoint로 전환; 누락 상태 clean 금지 | +| T15 / 동등 분할·DB+FS | A4, 기존 chapter 수정 / 생성 / 삭제 / 제목 / 순서 변경 / world 변경 | 각 mutation 후 full 또는 지원되는 부분 저장, 실제 import | 본문·meta 목록·삭제 entry 일치. full/부분 경로의 복원 의미가 같음. 무관한 문서는 불변 | +| T16 / 결정 테이블·FS | A4, 외부 copy 교체/동일 id 다른 base/미지 entry/package-only 변경 | 저장·attach·import·Save As와 경합 | 양쪽 원본 보존 또는 명시 충돌. unknown scope라는 이유로 미지 entry를 제거하는 full overwrite 금지 | +| T17 / 경계값·gate unit+service | A3/A5, 일반 자동 snapshot과 아래 정책 결정값 | 현재 60초 gate의 59,999/60,000/60,001ms, 본문 길이 0/1, 동일/변경 hash, 생성 실패 후 재시도 | 현재 동작을 먼저 기록하고 확정한 목표와 비교. 증분 저장 때문에 생성 횟수/본문 세대가 달라지지 않음. 삭제 전 보존은 미정 정책 확정 후 별도 변형 | +| T18 / 동등 분할·DB loss+FS | A4/A5, 구 full-index package, MANUAL/AUTO snapshot과 원고 B | checkpoint 완료→임시 DB 삭제→실제 importer→본문/스냅샷 복원 | export 계약에 포함된 본문·type·장 연결 보존. index를 metadata-only로 바꿔 빈 본문이 되는 회귀 금지. 포함되지 않은 이력이 복구됐다고 주장하지 않음 | +| T19 / 경계값·복구/보관 integration | A3/A4, DB chapter A와 artifact focus B, 빈 DB+기존 backup, 수동/자동 이력 | artifact 복원·orphan cleanup·정책 cap N−1/N/N+1·시간 bucket 전/정각/후 실행 | preview와 실제 복원 대상이 계약대로 일치. DB 인덱스 부재만으로 유일한 backup 삭제 금지. MANUAL 보관은 확정 정책에 따름 | +| T20 / 오류 추정·OS packaged | A2/A4/A6, macOS/Windows/Linux의 실제 packaged native runtime | hot journal 뒤 읽기, 읽기 전용 파일, replace 실패, 잠금, 한글 경로, suspend/resume | 복구 가능한 journal을 corrupt로 오인하지 않음. 원본 보존·실패 전달·재시도. 실제 OS/arch/FS별 결과를 남기고 미실시는 미실시로 표시 | +| T21 / 성능·실제 writer+앱 | A1/A2, 50/250/1,000장×64KiB, snapshot 0/20/50, 1장 1byte 수정 | 동일 fixture의 before/after, no-op, 연속 입력, snapshot 동반 저장 | 무관한 entry/body 재삽입 없음, scope fallback 사유 기록. SQL 수·논리 bytes·wall p50/p95·main long task·메모리 측정. 반복 수/전원/장치 명시, 합성 수치를 OS 공통 보장으로 확대 금지 | + +### 부분 갱신/파일 열기 결정 테이블 + +위에서 먼저 해당되는 규칙을 적용한다. 이 표의 '전체'는 원본이 DB의 지원 payload에 모두 보존되고 외부 파일 충돌이 없는 경우에만 허용한다. + +| 규칙 | 조건 | 선택/금지 동작 | 검증 | +|---|---|---|---| +| D1 | target identity/base 불일치 또는 package-only/미지 entry 보존 불확실 | 양쪽 보존·충돌/복구. 자동 full overwrite 금지 | T09/T16 | +| D2 | 파일 열기 요청이고 pending/local revision gap 존재 | 최신 로컬 자료 보호·동일 writer 아래 재조정. timestamp만으로 import 금지 | T01/T03/T09 | +| D3 | hot journal 또는 진행 중 writer 존재 | recovery/기존 작업 settle 후 읽기·쓰기. timeout만으로 새 writer 시작 금지 | T10/T20 | +| D4 | 정상 최초 생성/새 target이며 보존해야 할 기존 파일 없음 | 전체 생성 | T15/T18 | +| D5 | base 일치, 범위 완전, 지원 mutation, 변경 있음 | 변경 집합+meta+info 단일 transaction | T06/T14/T15 | +| D6 | base 일치, 미지원/불완전 scope 또는 재시작 gap, 원본은 DB에 보존됨 | 일관된 전체 checkpoint | T10/T14/T15 | +| D7 | base와 완료 revision 일치, 변경 없음 | 불필요한 write 생략. 수동 저장은 실제 완료 상태 확인 | T04/T21 | + +### 저장 상태 전이와 금지 전이 + +한 revision의 상태를 `editor에만 있음 → main 접수 → DB commit → package commit → 완료 ACK`로 구분한다. detached project는 DB commit까지만 보장하며 package까지 저장됐다고 표시하지 않는다. + +- T01/T02: main 접수 전에도 수동 저장/전환이 raw buffer를 포함하는지 확인한다. +- T03/T14: 진행 중인 r보다 새로운 r+1은 별도 pending이다. r 완료로 r+1 완료를 표시하지 않는다. +- T05~T08: DB 실패는 접수/복구 상태를 남기고, package 실패는 DB commit을 유지한다. `실패 → 정상 완료 ACK`는 금지다. +- T10: `package commit → ACK 전 crash`는 실제 파일 상태를 검사하고 재조정한다. `ACK 먼저 → package 나중`은 금지다. +- T09/T16: 외부 충돌은 원본 보존 상태로 전환한다. `충돌 → 자동 덮어쓰기 완료`는 금지다. +- 유효 전이뿐 아니라 위 금지 전이도 명시적으로 실행해 거부/보존 결과를 검사한다. + +### 스냅샷 정책 결정 항목 — 구현 전에 기준을 확정할 것 + +- [ ] POL-01: '큰 삭제'의 삭제 글자 수 N, 비율 R, 글자 계산 단위, 삭제 전 기준 본문을 정한다. 숫자를 임의로 30자 등으로 확정하지 않는다. 확정 후 N−1/N/N+1, R 바로 아래/같음/위, 전체 삭제·같은 길이 교체·undo/redo를 T17에 추가한다. +- [ ] POL-02: 큰 삭제 보호가 필요하면 **삭제 전 본문**을 어느 시점에 반드시 보존할지, 60초 gate와 독립인지, snapshot 저장 실패 시 원고 변경을 어떻게 처리할지 정한다. 현재 저장 후 snapshot과 동일 기능으로 간주하지 않는다. +- [ ] POL-03: MANUAL/AUTO의 cap·시간 보관·package 포함 범위와 실패 시 재시도 기준을 정한다. 사용자 생성 이력이 자동 cap에서 사라지지 않는 목표를 먼저 명시하고 검증한다. + +이 항목이 미정이면 관련 목표 정책 테스트는 `정책 미정` 상태로 둔다. 현재 동작을 확인하는 테스트와 정책 변경 후 정상 기대값을 섞지 않는다. 다른 저장 안전성 수정은 독립적으로 진행할 수 있다. + +### 테스트 구현·실행 방법과 기록 규칙 + +- 기존 Vitest/DOM/native SQLite/FS 도구를 재사용한다. mock은 clock·실패·경합 순서를 제어하는 데 쓰고, transaction/복원 보장은 실제 DB와 파일에서 검사한다. +- 기존 결함 probe는 결함 발생을 assert한다. 정상 회귀검사로 옮길 때 기대값을 **최신 본문 보존·전부 rollback·읽기 가능**으로 바꾸고, 수정 전 실패/수정 후 성공 증거를 남긴다. +- 경로/본문/포맷 검증을 유지하며 테스트 편의를 위해 내구성·IPC guard·기존 정책 검사를 끄지 않는다. native ABI/환경 문제는 별도 실행불가 사유다. +- 실제 저장 변경마다 관련 targeted test와 `pnpm run typecheck`, `pnpm run lint`, `pnpm run qa:core` 및 영향받는 `check:persist-contracts`/IPC/Drizzle/utility 검사를 실행한다. 현재 기존 실패는 별도 기록하고 전체 통과로 표현하지 않는다. 이번 문서 변경에는 런타임 검사 완료를 주장하지 않는다. +- 문서 체크박스는 연결 케이스/실행 결과/잔여 결함이 기록돼야 완료 처리한다. OS 시험을 mock으로 대체해 완료 처리하지 않는다. + +실행마다 다음 항목을 남긴다. 최종 결과를 덮어쓰지 않고 실패·재시도 이력도 유지한다. + +```text +실행 ID / 날짜: +요구 ID / TODO ID / 테스트 ID / 변형 / 연결 결함: +commit + 작업 트리 상태 / 실행 명령: +OS·arch·filesystem / Electron·Node·SQLite / native ABI: +fixture·seed·본문 UTF-8 bytes / main·cache·userData·package 격리 경로: +가정 A* / 정책 버전 POL-* / 초기 본문·revision·exportedRevision: +행동 순서 / barrier·clock / 실패 주입 위치 / mock·실제 I/O 범위: +기대 결과 / 실제 본문·meta·revision·pending·복원 결과: +상태: 통과 | 실패 | 환경상 실행불가 | 정책 미정 | 미실시 +로그·JSON·DB/파일 증빙 경로 / 잔여 위험 / 다음 조치: +``` + +### 기존 감사 실행 기록 — 새 정상 회귀검사 통과와 구별 + +| 기록 | 실제 가정·범위 | 관측 결과 | 근거·다음 처리 | +|---|---|---|---| +| AUD-01 / T09 | macOS Node, 실제 DB·FS·서비스, export 예약만 보류 | DB B(7/5)가 explicit reopen 후 A(9/9)로 되돌아감: 결함 재현 | [관측 JSON](docs/quality/storage-architecture-review-2026-09-08/evidence/stale-open-observation.json). 정상 기대 B로 회귀검사 전환 | +| AUD-02 / T06 | 실제 단건 writer+SQLite, meta/info 실패 trigger | reject인데 앞선 본문 또는 meta가 이미 commit: 결함 재현 | [원자성 JSON](docs/quality/storage-architecture-review-2026-09-08/evidence/package-atomicity-results.json). 전체 rollback 기대 추가 | +| AUD-03 / T11 | 실제 full/단건 writer, ASCII 5MiB+1 chapter | 쓰기 성공 후 같은 reader 거부: 결함 재현 | [크기 JSON](docs/quality/storage-architecture-review-2026-09-08/evidence/package-size-limit-results.json). write/read 경계 일치 기대 추가 | +| AUD-04 / T18 | checkpoint 완료 후 임시 main DB 삭제, 현행 getChapter API로 본문 조회 | 원고·snapshot 본문 복원 통과. type/모든 domain/미checkpoint 상태까지 증명하지 않음 | [복구 JSON](docs/quality/storage-architecture-review-2026-09-08/evidence/db-loss-results.json). 보존 필드 범위 확장 | +| AUD-05 / T21 | macOS arm64 Node, 1,000장×64KiB+snapshot20, 실제 writer 3회, collector 제외 | full median80.91ms / 기존 단건2.97ms. 단건 원자성 결함 있어 개선 완료 아님 | [비용 JSON](docs/quality/storage-architecture-review-2026-09-08/evidence/package-cost-results.json). 수정 후 같은 조건 비교 | +| AUD-06 / STOR-01 | 기존 DB-loss의 목록 DTO assertion, snapshot package suite의 구 Prisma mock | 현재 계약과 맞지 않는 테스트 실패 확인 | [설계 감사의 검증 기록](docs/quality/storage-architecture-review-2026-09-08.md). 제품 결함과 fixture 결함 구분하여 정비 | + +현재 이 표는 앞선 감사 결과를 연결한 기록이다. **이번 TODO 작성에서 제품 코드 변경·신규 런타임 테스트 실행·세 OS 내구성 검증을 완료한 것은 아니다.** + +--- + ## 파생 작업 안정화 - [x] 삭제된 챕터를 가리키는 `rebuild_embedding` 작업을 종료 처리한다. chunk가 없는 작업은 `skipped`로 전환해 `embeddingQueued: 1`, `embeddingProcessed: 0` 반복을 막는다.