Files
inkling/docs/superpowers/specs/2026-05-01-v023-trash-design.md
altair823 61e277f36c docs(spec): #4 휴지통 (soft delete + migration v3) 설계
v0.2.3 두 번째 항목의 mini-brainstorm 결과 lock.

UI=A (Inbox 탭 toggle), 필터=A (명시적 WHERE deleted_at IS NULL),
AiWorker race=C (pending_jobs cleanup + processJob 가드),
액션=B (per-card 영구 삭제 추가 — IPC 4채널 → 5채널, telemetry 3 → 4 events),
confirm/정렬/카드차이 모두 A.

self-review 후 ExportService/ImportService 충돌 정책 ambiguity 명시화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:04:47 +09:00

19 KiB

#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.bak snapshot 메커니즘 (v0.2.1 도입)
  • v0.2.3 #7 telemetry skeleton (merged at 6f8ae75) — 본 항목이 emit hook 대상

1. 결정 요약

영역 근거
Schema migration v3deleted_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

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) 확장

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 processJobdeletedAt 가드는 이미 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 메서드 변경

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 신규 메서드

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.tsTelemetryEmitter 인터페이스에 4 union 멤버 추가:

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.tsEmitInput 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 체크 추가:

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.tsregisterInboxApi 에 추가:

채널 핸들러 응답
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 확장

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>;
}

toggleShowTrashshowTrash 토글 + 진입 시 loadTrash() 호출.

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

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

7.2 UI 추가

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

<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

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

  • processJobdeletedAt 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채널 확장 명시.