# #4 휴지통 (soft delete + migration v3) 설계

**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 로드맵의 두 번째 항목. mini-brainstorm 결과를 잠그고 구현 계획 (`writing-plans`) 으로 넘기는 분기 spec. 본 문서는 **데이터 모델·외부 API·UI 결정** 만 정의. 세부 코드 토폴로지는 plan 단계에서.

**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #4 — 본 항목의 In/Out 라인 + cross-cutting 정책
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1 — schema migration v3, trash↔backup/export B 정책
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` §5.1 — pre-v<N>.bak snapshot 메커니즘 (v0.2.1 도입)
- v0.2.3 #7 telemetry skeleton (merged at `6f8ae75`) — 본 항목이 emit hook 대상

---

## 1. 결정 요약

| 영역 | 값 | 근거 |
|------|-----|------|
| Schema | **migration v3** — `deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` + `recall_dismissed_at TEXT NULL` (#6 도 같이) | 한 migration 으로 #4+#6 cover. 별 v4 회피. |
| UI 위치 | **Inbox 상단 탭 toggle** (`Inbox(N) · 휴지통(M)`) | 현재 router 없음, single-page 구조 일관. v0.2.1 F2 태그 필터 패턴 (`tagFilter` zustand) 동일 흐름. |
| 쿼리 필터 전략 | **명시적 WHERE** — 모든 active query 에 `WHERE deleted_at IS NULL` 직접 박음 | 기존 SQL prepare 패턴 일관. grep audit 가능. C (silent at hydration) 의 AiWorker race window 회피. |
| AiWorker race | **C — pending_jobs cleanup + processJob 가드** (둘 다) | atomic + 이미 dequeue 한 race window 도 가드. result 적용 직전 재체크는 의도적 skip — restore 시 AI 결과 보존이 UX 유리. |
| 휴지통 액션 | **per-card 복구 + per-card 영구 삭제 + bulk 휴지통 비우기** | per-card 영구 삭제는 fine-grained 삭제 욕구 대응. roadmap §3 #4 의 4채널 → 5채널 (`permanentDelete` 추가) 으로 확장. |
| Confirm UX | **Electron `dialog.showMessageBox`** — F5/F6 패턴 일관 | 신규 React 모달 회피. native = 운영체제 톤. |
| 정렬 | **`deleted_at DESC`** | 회수 의도 매칭 (최근 삭제 먼저). |
| Card 차이 | **휴지통 카드 = read-only** — edit 액션 hidden, raw text 토글은 보존 | roadmap §3 #4 Out (`trash 안 노트 편집`) 일관. |
| F5 export | **`deleted_at IS NOT NULL` 제외** | trash B 정책 (roadmap §1). |
| F6-L1 backup | **byte-for-byte 자동 포함** | SQLite copy. 무수정. |
| F6-L3 import | **`deleted_at` source/dest 중 IS NOT NULL 우선** | 삭제 보존 invariant. |
| Restore 시 AI 결과 | **그대로 살아있음** (race window self-healing) | trash 도중 AI 결과 박힌 경우 restore 시 노트가 결과까지 함께 회수. UX positive. |

### 1.1 v0.2.3 #4 roadmap 와의 차이

| 항목 | roadmap §3 #4 | 본 spec |
|------|---------------|---------|
| 휴지통 액션 | 복구 + bulk emptyTrash (4 IPC 채널) | + per-card 영구 삭제 (5 IPC 채널) |
| Telemetry kinds | `trash` / `restore` / `emptyTrash` (3) | + `permanent_delete` (4) |

**근거:** mini-brainstorm 에서 사용자 결정 (B 옵션 — fine-grained 영구 삭제 추가). 본 spec 의 결정이 roadmap 보다 우선.

---

## 2. Data model

### 2.1 Migration v3 — `m003_soft_delete.ts`

```sql
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX idx_notes_deleted_at ON notes(deleted_at);
```

- `deleted_at`: ISO timestamp (UTC). `NULL` = active, IS NOT NULL = trashed.
- `last_recalled_at`: #6 가 사용. v3 에서 컬럼만 추가, `Note` type 노출 + 사용은 #6.
- `recall_dismissed_at`: #6 가 사용. 위와 동일.
- `idx_notes_deleted_at`: `WHERE deleted_at IS NULL` 쿼리 다수, partial index 효과 기대. SQLite 가 NULL 스파스 인덱스 효율 잘 처리.

m001/m002 와 같이 `version = 3` export 후 `migrations/index.ts` 의 array 에 등록. transaction 내 실행. 실패 시 트랜잭션 롤백 + 사용자에게 보고.

**pre-v3 snapshot:** `<dbFile>.pre-v3.bak` 자동 생성 (v0.2.1 메커니즘 그대로). v0.2.2 → v0.2.3 첫 실행 시 한 번.

### 2.2 `Note` 타입 (`@shared/types`) 확장

```typescript
export interface Note {
  // ... 기존 필드 ...
  deletedAt: string | null;        // #4 가 사용
  lastRecalledAt: string | null;   // #6 가 사용 (v0.2.3 #4 단계엔 항상 null 으로 hydrate)
  recallDismissedAt: string | null; // #6 가 사용 (위와 동일)
}
```

세 필드 모두 v3 부터 schema 에 존재하므로 hydration 코드는 한 번에 추가. 사용은 단계별.

### 2.3 Schema invariant 추가

slice §1.3 silent invariant 후보 (roadmap §6.3 에서 #4 머지 시 동봉 갱신):

> **`deleted_at IS NULL` 망각 0회** — 모든 active query (Inbox / countToday / findByTag / search / F5 export / AiWorker 처리) 가 `WHERE deleted_at IS NULL` 을 빠뜨리지 않는다. 위반 시 dogfood-feedback 즉시 재오픈.

---

## 3. NoteRepository 변경

### 3.1 신규 메서드

| 메서드 | SQL | 부수 효과 |
|--------|-----|-----------|
| `trash(id, deletedAt: string): void` | `UPDATE notes SET deleted_at=?, updated_at=? WHERE id=?` + `DELETE FROM pending_jobs WHERE note_id=?` (한 transaction) | AI 큐 깨끗. atomic. |
| `restore(id): void` | `UPDATE notes SET deleted_at=NULL, updated_at=? WHERE id=?` | 노트 active 복귀. AI 결과 보존됨. |
| `permanentDelete(id): void` | `DELETE FROM notes WHERE id=?` | cascade FK (`note_tags` / `media` / `pending_jobs`) 자동 정리. media 파일 정리는 caller (`CaptureService`) 책임. |
| `emptyTrash(): { noteIds: string[] }` | `SELECT id FROM notes WHERE deleted_at IS NOT NULL` → 각 id `permanentDelete` (한 transaction). 반환된 `noteIds` 로 caller 가 media 정리. |
| `listTrashed(opts: {limit, cursor?}): Note[]` | `WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` | cursor = `deleted_at` 값 기준. |

### 3.2 기존 메서드 변경

`delete(id)` 는 **deprecate** (호출 site 0건 보장). hard delete 는 `permanentDelete()` 로만. 단계적 cleanup — `delete()` 를 즉시 제거하지 않고 `@deprecated` 로 표시 후 v0.2.4 cut 시 삭제.

### 3.3 Active query 일괄 변경 (`WHERE deleted_at IS NULL` 추가)

| 메서드 | 현재 | 변경 후 |
|--------|------|---------|
| `list(opts)` | `ORDER BY created_at DESC LIMIT ?` | `WHERE deleted_at IS NULL ORDER BY ... LIMIT ?` |
| `listAll()` | `ORDER BY created_at ASC` | `WHERE deleted_at IS NULL ORDER BY ...` |
| `countToday(now?)` | KST today filter | `WHERE deleted_at IS NULL AND ...` |
| `getAllPendingJobs()` | `pending_jobs` 직접 select | **변경 없음** — `trash()` 가 atomic 하게 `pending_jobs` row 정리하는 invariant 가 자연 보장. AiWorker `processJob` 의 `deletedAt` 가드는 이미 dequeue 한 race 만 처리. |
| `findById(id)` | **변경 없음** — 휴지통 카드도 같은 메서드 사용. `Note.deletedAt` 으로 호출자가 분기. |

NoteRepository 에는 현재 `findByTag` / search 메서드가 없다 — 태그 필터링은 renderer 의 `selectFilteredNotes` 에서 client-side 로 수행 (zustand `tagFilter` state). 따라서 active query 변경은 위 표의 3 메서드 (`list`, `listAll`, `countToday`) + AiWorker 가드 + `getAllPendingJobs` 의 invariant 보존이 전부.

---

## 4. CaptureService 변경

### 4.1 메서드 변경

```typescript
async deleteNote(noteId: string): Promise<void> {
  this.repo.trash(noteId, new Date().toISOString());
  // media 는 그대로 둔다 (restore 시 필요)
  if (this.deps.telemetry) {
    await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
  }
}
```

### 4.2 신규 메서드

```typescript
async restoreNote(noteId: string): Promise<void> {
  this.repo.restore(noteId);
  if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}

async permanentDeleteNote(noteId: string): Promise<void> {
  this.repo.permanentDelete(noteId);
  await this.store.deleteNoteDirectory(noteId);
  if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}

async emptyTrash(): Promise<{ count: number }> {
  const { noteIds } = this.repo.emptyTrash();
  for (const id of noteIds) {
    try { await this.store.deleteNoteDirectory(id); }
    catch (e) { /* best-effort */ }
  }
  if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
  return { count: noteIds.length };
}
```

### 4.3 Telemetry interface 확장

`CaptureService.ts` 의 `TelemetryEmitter` 인터페이스에 4 union 멤버 추가:

```typescript
export interface TelemetryEmitter {
  emit(input:
    | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
    | { kind: 'trash'; payload: { noteId: string } }
    | { kind: 'restore'; payload: { noteId: string } }
    | { kind: 'permanent_delete'; payload: { noteId: string } }
    | { kind: 'empty_trash'; payload: { count: number } }
  ): Promise<void>;
}
```

`TelemetryService.ts` 의 `EmitInput` union 도 같은 4 추가. `telemetryEvents.ts` 의 zod `discriminatedUnion` 에도 4 새 멤버, 각 payload `.strict()`. **Privacy invariant** 그대로 — payload 에 `noteId` / `count` 만, raw text/title/summary/intent/tag name 절대 미포함. zod 가 거부.

`stats.md` 집계 (`telemetryStats.aggregateStats`) 도 4 신규 카운트 컬럼 추가:

```
| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |
```

핵심 ratio:
- `restore / trash` — 휴지통이 회수 도구로 동작?

---

## 5. AiWorker 가드

`processJob` 진입 시 deletedAt 체크 추가:

```typescript
const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
```

`pending_jobs` 정리는 `trash()` 가 atomic 하게 처리하므로 정상 흐름에서 dead row 미발생. AiWorker 가 이미 dequeue 한 후 trash 된 race 만 본 가드가 cover.

result 적용 (`updateAiResult`) 직전 재체크는 의도적으로 skip — restore 시 AI 결과 살아있어 UX 유리.

---

## 6. IPC

### 6.1 신규 채널 (5개)

`src/main/ipc/inboxApi.ts` 의 `registerInboxApi` 에 추가:

| 채널 | 핸들러 | 응답 |
|------|--------|------|
| `inbox:trash` | `(_, id: string) => capture.deleteNote(id)` | `void` |
| `inbox:restore` | `(_, id: string) => capture.restoreNote(id)` | `void` |
| `inbox:permanentDelete` | `(_, id: string) => capture.permanentDeleteNote(id)` | `void` |
| `inbox:emptyTrash` | `() => capture.emptyTrash()` | `{ count: number }` |
| `inbox:listTrash` | `(_, opts) => repo.listTrashed(opts)` | `Note[]` |

confirm dialog (per-card 영구 삭제 / bulk emptyTrash) 는 main 프로세스에서 `dialog.showMessageBox` 호출 (트레이 export/import 와 동일 패턴). 사용자 confirm 후에야 IPC 가 실제 작업 수행.

### 6.2 기존 `inbox:delete` 처리

기존 `inbox:delete` 는 그대로 유지하되 내부적으로 `capture.deleteNote(id)` 가 trash 호출 (변경된 동작). 채널 이름은 유지 — renderer 에서 `inboxApi.deleteNote` 호출하던 곳 (`NoteCard` 의 "🗑 삭제" 버튼) 이 그대로 동작 (의미만 hard → soft 로 변경). 단계적 마이그레이션 — v0.2.4 에서 `inbox:trash` 로 rename 검토.

---

## 7. Renderer (Inbox)

### 7.1 zustand store 확장

```typescript
interface InboxState {
  // 기존 ...
  showTrash: boolean;       // false = Inbox view, true = 휴지통 view
  trashNotes: Note[];       // 휴지통 노트 cache
  trashCount: number;       // 헤더 탭 라벨 (`휴지통(M)`)
  
  toggleShowTrash(): void;
  loadTrash(): Promise<void>;
  restoreNote(id: string): Promise<void>;
  permanentDeleteNote(id: string): Promise<void>;
  confirmEmptyTrash(): Promise<void>;
}
```

`toggleShowTrash` 가 `showTrash` 토글 + 진입 시 `loadTrash()` 호출.

`confirmEmptyTrash` 는 IPC `inbox:emptyTrash` 호출 (main 이 dialog 띄움). 사용자 cancel 시 `count: 0` 반환.

`upsertNote(note)` / `removeNote(id)` 가 `notes` 와 `trashNotes` 양쪽 다 갱신 — note 의 `deletedAt` 값으로 어느 list 에 들어갈지 결정.

### 7.2 UI 추가

`App.tsx` 헤더 영역 (h1 + ContinuityBadge 옆):

```tsx
<button onClick={() => setShowTrash(false)} aria-pressed={!showTrash}>
  Inbox({notes.length})
</button>
<button onClick={() => setShowTrash(true)} aria-pressed={showTrash}>
  휴지통({trashCount})
</button>
```

`showTrash === true` 시:
- 상단에 "휴지통 비우기 (M개)" 버튼 (M=0 이면 disabled). 클릭 → `confirmEmptyTrash()`.
- `trashNotes.map(n => <NoteCard note={n} mode="trash" />)`

### 7.3 NoteCard prop `mode`

```tsx
type NoteCardProps = { note: Note; mode?: 'inbox' | 'trash' };
```

`mode === 'trash'` 시:
- DueDateBadge: read-only (날짜 텍스트만 표시, 클릭 무반응)
- IntentBanner: hidden
- 태그 chip: ✕ 버튼 hidden, 클릭 시 필터링 동작 X
- "🗑 삭제" 버튼 → "🔄 복구" + "🗑 영구 삭제" 두 버튼으로 교체
- raw text 토글 (`▸ 원문 보기`): 보존 (read-only 도 본문 확인 필요)
- EditableField (title / summary): read-only 모드 (input 비활성)

빈 휴지통 상태 (`trashNotes.length === 0` AND `showTrash`):
> "휴지통이 비어있습니다."

### 7.4 Confirm dialog 카피

`dialog.showMessageBox` 옵션:

**bulk emptyTrash:**
- type: `question`
- buttons: `['휴지통 비우기', '취소']`
- defaultId: 1, cancelId: 1
- title: `Inkling`
- message: `휴지통의 노트 ${count}개를 영구 삭제합니다`
- detail: `이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.`

**per-card 영구 삭제:**
- buttons: `['영구 삭제', '취소']`
- message: `이 노트를 영구 삭제합니다`
- detail: 위와 동일

---

## 8. F5 export / F6-L3 import / F6-L1 backup

### 8.1 ExportService

`repo.listAll()` 자체에 `WHERE deleted_at IS NULL` 추가 (active query exclusion 의 일환, §3.3 표 그대로). ExportService 코드는 무수정 — `repo.listAll()` 호출이 자동으로 trash 제외하게 됨. 휴지통 export 는 본 cut 범위 외 (Out, §10).

### 8.2 ImportService

`ImportNoteInput` interface 에 `deletedAt?: string | null` 추가. INSERT statement 에 컬럼 + 값 추가. fork 케이스 (raw_text 다름) 에서도 `deletedAt` 보존.

충돌 해결 — id 동일 + raw_text 동일 (skip) 또는 raw_text 상이 (fork) 가 기존 정책. `deletedAt` 머지는 그 위에 추가:

- **id 동일 + raw_text 동일** (skip 케이스): source 가 `deletedAt IS NOT NULL` 이고 dest 가 `IS NULL` 이면 dest 의 `deleted_at` 을 source 값으로 **갱신** (삭제 보존). 그 외는 그대로 skip.
- **id 동일 + raw_text 상이** (fork 케이스): source 의 `deletedAt` 을 새 fork 노트에 그대로 넣음 (raw_text invariant 보존이 우선이라 fork 자체는 기존대로).
- **id 신규** (insert 케이스): source 의 `deletedAt` 을 그대로 INSERT.
- **양쪽 IS NOT NULL** (skip 케이스 의 corner case): 단순화 — dest 값 유지 (skip). roadmap §1 의 "IS NOT NULL 우선" 은 한쪽이 NULL 일 때만 결정 영향, 양쪽 IS NOT NULL 시엔 dest 가 이미 trash 라 "삭제 보존" 자체는 만족.

### 8.3 BackupService

무수정. SQLite `db.backup()` 가 byte-for-byte 카피 — `deleted_at IS NOT NULL` 노트도 자동 포함.

---

## 9. 단위 테스트 (TDD 가이드)

### 9.1 Migration v3
- 빈 DB v0 → v3 migrate 후 `deleted_at` / `last_recalled_at` / `recall_dismissed_at` 컬럼 + `idx_notes_deleted_at` 존재 확인
- v2 DB → v3 migrate 시 기존 노트의 새 컬럼 모두 NULL
- migrate idempotent (이미 v3 인 DB 재실행 시 변경 없음)
- pre-v3.bak snapshot 자동 생성 (한 번만)

### 9.2 NoteRepository
- `trash(id, deletedAt)` 가 `deleted_at` 설정 + `pending_jobs` row 정리 (atomic — 한 transaction 내 두 쿼리)
- `restore(id)` 가 `deleted_at` NULL 복원
- `permanentDelete(id)` 가 cascade FK 통해 `note_tags` / `media` / `pending_jobs` 정리
- `emptyTrash()` 가 IS NOT NULL 노트 모두 hard delete + 반환된 noteIds 정확
- `listTrashed()` 가 `deleted_at DESC` 정렬, IS NOT NULL 만 반환
- `list()` / `listAll()` / `countToday()` 가 `deleted_at IS NULL` 만 반환 (active query exclusion)
- `findById()` 는 휴지통 노트도 반환 (모든 노트)
- `getAllPendingJobs()` 가 trash 노트 미반환 (join 또는 trash cleanup invariant)

### 9.3 AiWorker
- `processJob` 가 `deletedAt IS NOT NULL` 노트 즉시 return (provider.generate 미호출)
- 정상 노트는 그대로 처리 (회귀 없음)

### 9.4 CaptureService
- `deleteNote` 가 trash 호출 + telemetry `trash` emit (media 미삭제)
- `restoreNote` 가 restore 호출 + telemetry `restore` emit
- `permanentDeleteNote` 가 hard delete + media 디렉터리 정리 + telemetry `permanent_delete` emit
- `emptyTrash` 가 모든 trash 노트 hard delete + 각 media 정리 + telemetry `empty_trash` emit (count 정확)

### 9.5 ExportService (F5)
- 활성 노트만 export, trash 노트 제외 (frontmatter 마크다운 파일 미생성)
- `index.jsonl` 도 trash 미포함

### 9.6 ImportService (F6-L3)
- source 의 `deletedAt` 값이 import 후 보존
- 충돌 해결 — source/dest 중 IS NOT NULL 우선 4가지 조합 모두

### 9.7 Telemetry events
- 4 신규 kind (`trash` / `restore` / `permanent_delete` / `empty_trash`) zod 검증 통과
- payload `.strict()` 가 `rawText` / `title` / `summary` / `userIntent` / `tagNames` 포함 시 거부 (기존 invariant 유지)
- `aggregateStats` 가 4 신규 컬럼 카운트 정확, `restore / trash` ratio 계산

### 9.8 e2e smoke (Playwright)
- 노트 캡처 → trash 클릭 → Inbox 에서 사라지고 휴지통 탭(1) 표시
- 휴지통 탭 진입 → 노트 보임, 복구 클릭 → 다시 Inbox
- per-card 영구 삭제 confirm → 노트 영구 사라짐, media 디렉터리 정리

---

## 10. Out (deferred to v0.2.4+)

- 자동 비우기 정책 (사용자 트리거만 — 30일 자동 비우기 등은 차후)
- 휴지통 검색 (full-text 또는 태그 필터)
- trash 안 노트 편집 (read-only invariant 깨지면 회귀)
- per-note 영속 보호 플래그 (lock 같은 것)
- restore 시 AI 결과 보존 invariant 명시 — 본 spec 에 짧게 언급, 별 spec 화는 v0.2.4 reason 분포 본 후
- `inbox:delete` 채널 → `inbox:trash` 로 rename (단계적 마이그레이션)
- 휴지통에서 다중 선택 (멀티 복구 / 멀티 영구 삭제)
- `last_recalled_at` / `recall_dismissed_at` 활용 — #6 가 사용

---

## 11. 변경 이력

| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — UI=A (Inbox 탭), 필터=A (명시적 WHERE), AiWorker race=C (cleanup+가드), 액션=B (per-card 영구 삭제 추가, 5 IPC 채널), confirm/정렬/카드차이 모두 A. roadmap §3 #4 의 4채널 → 5채널 확장 명시. |
