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>
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 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
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 에서 컬럼만 추가,Notetype 노출 + 사용은 #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 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 메서드 변경
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.ts 의 TelemetryEmitter 인터페이스에 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.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 체크 추가:
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 확장
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 옆):
<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_jobsrow 정리 (atomic — 한 transaction 내 두 쿼리)restore(id)가deleted_atNULL 복원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 호출 + telemetrytrashemit (media 미삭제)restoreNote가 restore 호출 + telemetryrestoreemitpermanentDeleteNote가 hard delete + media 디렉터리 정리 + telemetrypermanent_deleteemitemptyTrash가 모든 trash 노트 hard delete + 각 media 정리 + telemetryempty_trashemit (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 / trashratio 계산
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채널 확장 명시. |