feat(trash): #4 휴지통 + migration v3 (v0.2.3 2/7) #14
Reference in New Issue
Block a user
Delete Branch "feat/v023-trash"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
v0.2.3 두 번째 항목 — soft delete 도입 + Inbox 상단 탭 toggle 로 휴지통 view + bulk emptyTrash. F5 export 가 trash 제외, F6-L3 import 가 deletedAt 보존, AiWorker 가 trash 노트 race-safe skip. 휴지통 회수율 ratio 가 stats.md 에 추가됨.
데이터 모델
deleted_at TEXT NULL+last_recalled_at TEXT NULL(#6 dormant) +recall_dismissed_at TEXT NULL(#6 dormant) +idx_notes_deleted_atindex핵심 invariant
WHERE deleted_at IS NULL—list/listAll/countToday/getPendingCount(NoteRepository) +ContinuityService.get+ ExportService (transitive). MediaGc 는 의도적 non-filter (restore 시 media 보존).trash()atomic — UPDATE deleted_at + DELETE pending_jobs row 한 transaction.사용자 surface
Inbox(N) · 휴지통(M)Telemetry
trash/restore/permanent_delete/empty_trash.strict()payload — privacy invariant 유지 (rawText/title/summary/userIntent/tagNames 미포함)stats.md의 휴지통 회수율 =restore / trashratioSpec / Plan
Gates
Cross-task fix (T5 review 발견)
Test Plan
회차 1 — 휴지통 + migration v3 — soft delete invariant 잘 잡힘, 200-cap UI 정확성만 다듬으면 머지 가능.
전반적으로 v0.2.3 #4 의 핵심 invariant —
deleted_at IS NULLactive 쿼리 5+ 사이트 적용,trash()atomic transaction, telemetry idempotency guard, MediaGc trash 보존 — 모두 정확하게 구현되어 있고, 5+1 개의 단위 테스트 그룹으로 invariant 마다 드리프트 가드 케이스까지 커버되어 있어 회귀 신호가 단단함.다만 actionable:
inbox:trashCount가 hot path 에서listTrashed({limit:200})로 N rows + tags/media JOIN 을 매번 hydrate 하는 비효율 +emptyTrashdialog 가 200 cap 에 mismatch 되어 사용자가 trash 500개 보유 시 "200개 영구 삭제합니다" 로 보게 됨 (실제 SQL DELETE 는 무한정이라 정확히 동작, 표시만 거짓말). 둘 다 v0.2.4 backlog #12 의repo.countTrashed()(간단한SELECT COUNT(*)) 추가로 한 번에 해결 가능. AiWorker race guard 보강과 매직 넘버 상수화는 deferrable.@@ -122,3 +122,3 @@try {const note = this.repo.findById(job.noteId);if (!note || note.aiStatus !== 'pending') return;if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;Race guard 가
provider.generate()호출 직전 단일 시점만 검사. 네트워크 latency (1~30s) 동안 사용자가 trash 하면updateAiResult가 trashed 노트에 ai_status='done' 을 그대로 써서 deleted_at IS NOT NULL + ai_status='done' 모순 상태 발생 (DB 자체는 일관, telemetry ai_succeeded 만 '낭비'). updateAiResult 직전에findById재확인 +deletedAt !== null시 early return 추가 권장. 빈도 낮지만 invariant 명시 차원.@@ -55,0 +80,4 @@// limit 200 한 번 — UI 표시 cap. count > 200 시 dialog message 만 부정확하지만// emptyTrash() 내부 SQL 은 LIMIT 없으므로 실제 삭제는 모든 trash 노트 적용.// v0.2.4 에서 repo.countTrashed() 추가 시 둘 다 정확해짐.const trashed = deps.repo.listTrashed({ limit: 200 });주석에서 인지한 trash > 200 시 dialog message 부정확 케이스가 실제로 존재함 — 사용자가 '500개 영구 삭제합니다' 를 '200개' 로 보게 됨. 코드는 정확히 동작 (SQL DELETE 무한정) 하지만 표시가 거짓말.
repo.countTrashed()가 추가되면 dialog 은 정확한 count 로, listTrashed 호출은 제거 가능. 사용자 신뢰 측면에서 우선순위 끌어올릴 가치 있음 — REQUEST_CHANGES driver.@@ -55,0 +106,4 @@);ipcMain.handle('inbox:trashCount', () =>deps.repo.listTrashed({ limit: 200 }).length단순 카운트 조회를 위해
listTrashed({limit:200})가 매번 N rows × (note + tags JOIN + media JOIN) 을 hydrate 함.getTrashCount는 hot path (loadInitial/refreshMeta/upsertNote 후속) 라 비용이 누적됨. v0.2.4 backlog #12 의repo.countTrashed()(간단한SELECT COUNT(*) FROM notes WHERE deleted_at IS NOT NULL) 추가로 해결. 같은 issue 가 line 83emptyTrashdialog 에도 있음.@@ -224,0 +245,4 @@this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);}restore()가 active 노트에 대해서도 무조건 UPDATE 를 실행해updated_at만 변동시킴. CaptureService 레벨 idempotency guard 가 있어서 정상 ���름에선 문제 없으나, repo 직접 호출 (테스트/import) 시 의도치 않은 updated_at touch 가 발생함.WHERE id=? AND deleted_at IS NOT NULL추가 시 repo 자체가 self-guarding 됨. v0.2.4 backlog candidate.@@ -224,0 +253,4 @@emptyTrash(): { noteIds: string[] } {// Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)// and avoids per-row prepare overhead. RETURNING is house-style elsewherePraise —
DELETE ... RETURNING idsingle-statement atomic 선택과 그 근거 (per-row prepare 회피 + RETURNING house-style) 를 주석에 명시한 점,emptyTrash가 service 레이어 media cleanup loop 와 깨끗하게 분리된 점이 좋음. 트랜잭션 wrapping 없이도 SQLite 의 single-statement atomicity 로 충분하다는 판단도 정확.@@ -248,1 +301,4 @@if (existing === input.rawText) {// skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).// trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.if (input.deletedAt != null) {Praise —
importNoteskip 케이스에서trash()를 재사용해pending_jobscleanup invariant 까지 자동 만족시키는 선택은 깔끔함. 다만 동일 케이스에서findRawTextById+ 별도SELECT deleted_at두 번 쿼리하는 대신 첫 SELECT 에서raw_text, deleted_at동시 반환하면 round-trip 1 회 절약 가능. 마이크로 개선이라 deferrable.@@ -67,0 +69,4 @@// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).// 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.const note = this.repo.findById(noteId);if (!note || note.deletedAt !== null) return;Praise — 3 메서드 모두 idempotency guard (
!note || note.deletedAt !== null등) 를 통해 telemetry ratio (회수율, trash count) 오염을 막는 패턴이 일관되게 적용됨. 주석에서 'restore/trash ratio 오염 방지' 동기를 명시해 미래 reviewer 가 guard 를 함부로 제거하지 못하게 한 것도 좋음.@@ -8,1 +8,4 @@const dirs = await this.store.listNoteDirs();// Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own// their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted// notes here would defeat restore.Praise — 'deliberately does NOT filter deleted_at IS NULL' 주석으로 의도를 명시한 점이 매우 좋음. 미래 maintainer 가 무심코
WHERE deleted_at IS NULL추가해서 trash 의 media 를 GC 로 날려버리는 회귀를 막아줌. invariant 가 코드 옆에 그대로 살아있는 모범 예시.@@ -27,1 +36,4 @@z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict()]);Praise — 4 신규 kind 모두
discriminatedUnion + .strict()로 추가되어 rawText/title/summary/userIntent/tagNames 가 payload 에 들어가면 zod 가 reject 하는 privacy invariant 가 구조적으로 유지됨.validateEvent가 emit/read 양쪽에서 재사용되어 defense-in-depth 도 좋음.@@ -70,0 +114,4 @@if (next) await get().loadTrash();},async loadTrash() {const trashNotes = await inboxApi.listTrash({ limit: 200 });limit: 200매직 넘버가 preload (listTrash), App.tsx (loadTrash), IPC (inbox:trashCount) 3 곳에 흩어져 있음. spec/plan 어디에도 이 200 의 근거가 없으므로TRASH_VIEW_LIMIT = 200같은 상수로 끌어내고 향후 paging 도입 시 한 곳만 고치도록 정리하면 좋겠음. nit.@@ -218,0 +409,4 @@repo.create({ rawText: 'b' }); // ai_status=pendingexpect(repo.getPendingCount()).toBe(2);// trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.// getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.Praise —
getPendingCount() excludes trashed pending notes (drift guard)는 매우 영리한 테스트. trash() 가 pending_jobs row 만 정리하고 notes.ai_status 는 'pending' 으로 남기는 설계 선택이 query side 에서 제대로 보정되는지 명시적으로 잠그는 회귀 가드. 이런 invariant 테스트가 PR 신뢰도를 크게 끌어올림.회차 2 — countTrashed() 분리로 hot path + dialog mismatch 동시 해결, 신규 이슈 없음 (수렴).
회차 1 actionable 두 건 모두 정확히 해결됐습니다 —
countTrashed()가 단일 COUNT(*) 쿼리로 hydrate 없이 hot path 에서 호출되며,inbox:trashCount와emptyTrashdialog 양쪽 호출 site 가 깔끔하게 교체돼 trash > 200 시에도 dialog 가 실제 SQL DELETE 동작과 일치합니다.listTrashed({limit:200})는 카드 렌더링 1 곳에만 남아 200 cap 이 의도대로 보존됐고, 회귀 위험 없음. Deferrable 4 건은 commit body 에 v0.2.4 backlog 로 명시 이동, 신규 단위 테스트 3건 (0 / 부분 / cap 초과 시뮬) 도 의미 있는 경계 커버.머지 가능. 사람이 Gitea UI 에서 merge 버튼을 누르면 됩니다.