feat(trash): #4 휴지통 + migration v3 (v0.2.3 2/7) #14

Merged
altair823 merged 26 commits from feat/v023-trash into main 2026-05-01 14:06:24 +00:00
Owner

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

데이터 모델

  • migration v3 — deleted_at TEXT NULL + last_recalled_at TEXT NULL (#6 dormant) + recall_dismissed_at TEXT NULL (#6 dormant) + idx_notes_deleted_at index
  • pre-v3.bak snapshot 자동 (v0.2.1 메커니즘 그대로)

핵심 invariant

  • Active query 일괄 WHERE deleted_at IS NULLlist / listAll / countToday / getPendingCount (NoteRepository) + ContinuityService.get + ExportService (transitive). MediaGc 는 의도적 non-filter (restore 시 media 보존).
  • trash() atomic — UPDATE deleted_at + DELETE pending_jobs row 한 transaction.
  • AiWorker.processJob deletedAt 가드 — race window cover.
  • CaptureService idempotency — restore/trash/permanent 의 의미 없는 호출 시 telemetry emit skip (회수율 ratio 오염 방지).

사용자 surface

  • Inbox 상단 탭 toggle: Inbox(N) · 휴지통(M)
  • 휴지통 카드: read-only mode (편집 hidden) + `🔄 복구` + `🗑 영구 삭제` 두 버튼
  • bulk `휴지통 비우기 (N개)` 버튼 + native dialog confirm
  • per-card 영구 삭제도 native dialog confirm

Telemetry

  • 4 신규 kind: trash / restore / permanent_delete / empty_trash
  • 모두 zod .strict() payload — privacy invariant 유지 (rawText/title/summary/userIntent/tagNames 미포함)
  • stats.md 의 휴지통 회수율 = restore / trash ratio

Spec / Plan

  • spec: `docs/superpowers/specs/2026-05-01-v023-trash-design.md`
  • plan: `docs/superpowers/plans/2026-05-01-v023-trash.md` (15 task TDD)

Gates

  • typecheck: 0 errors
  • 단위 테스트: 245 → 292 (+47, schema/repo/AiWorker/CaptureService/Continuity/ImportService/ExportService/store 전반)
  • e2e smoke: 1/1
  • 신규 npm dep: 0

Cross-task fix (T5 review 발견)

  • ContinuityService streak 가 trash 노트 무시 (regression 닫음 — 이전엔 trash 노트가 weekly streak 에 잘못 카운트)
  • getPendingCount 가 trash 노트 무시 (drift 방지)
  • MediaGc intentional non-filter 인라인 주석

Test Plan

  • dev 실행 후 노트 캡처 → `🗑 삭제` → 헤더 `휴지통(1)` 표시
  • 휴지통 탭 → 노트 보임 (read-only) → `🔄 복구` → Inbox 로 복귀, AI 결과 보존 확인
  • 다시 trash → 휴지통 → `🗑 영구 삭제` → confirm dialog → 노트 + media 디렉터리 모두 사라짐
  • N 개 trash → 휴지통 → `휴지통 비우기 (N개)` → confirm → 모두 사라짐
  • 트레이 → `사용 로그 내보내기...` → `stats.md` 에 휴지통 회수율 라인 + events.jsonl 에 4 신규 kind 라인 + privacy grep 0 hits
  • ollama 끄고 노트 캡처 → trash → 다시 active → ai_status='pending' 그대로 (deferred backlog #10 동작 확인)
  • migration: v0.2.3 (post-#7) 인스톨러 → v0.2.3 (post-#4) → 첫 실행 시 pre-v3.bak 생성 + v3 columns 추가
## 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 에 추가됨. ### 데이터 모델 - migration v3 — `deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` (#6 dormant) + `recall_dismissed_at TEXT NULL` (#6 dormant) + `idx_notes_deleted_at` index - pre-v3.bak snapshot 자동 (v0.2.1 메커니즘 그대로) ### 핵심 invariant - **Active query 일괄 `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. - **AiWorker.processJob deletedAt 가드** — race window cover. - **CaptureService idempotency** — restore/trash/permanent 의 의미 없는 호출 시 telemetry emit skip (회수율 ratio 오염 방지). ### 사용자 surface - Inbox 상단 탭 toggle: `Inbox(N) · 휴지통(M)` - 휴지통 카드: read-only mode (편집 hidden) + \`🔄 복구\` + \`🗑 영구 삭제\` 두 버튼 - bulk \`휴지통 비우기 (N개)\` 버튼 + native dialog confirm - per-card 영구 삭제도 native dialog confirm ### Telemetry - 4 신규 kind: `trash` / `restore` / `permanent_delete` / `empty_trash` - 모두 zod `.strict()` payload — privacy invariant 유지 (rawText/title/summary/userIntent/tagNames 미포함) - `stats.md` 의 휴지통 회수율 = `restore / trash` ratio ## Spec / Plan - spec: \`docs/superpowers/specs/2026-05-01-v023-trash-design.md\` - plan: \`docs/superpowers/plans/2026-05-01-v023-trash.md\` (15 task TDD) ## Gates - typecheck: 0 errors - 단위 테스트: 245 → **292** (+47, schema/repo/AiWorker/CaptureService/Continuity/ImportService/ExportService/store 전반) - e2e smoke: 1/1 - 신규 npm dep: 0 ## Cross-task fix (T5 review 발견) - ContinuityService streak 가 trash 노트 무시 (regression 닫음 — 이전엔 trash 노트가 weekly streak 에 잘못 카운트) - getPendingCount 가 trash 노트 무시 (drift 방지) - MediaGc intentional non-filter 인라인 주석 ## Test Plan - [ ] dev 실행 후 노트 캡처 → \`🗑 삭제\` → 헤더 \`휴지통(1)\` 표시 - [ ] 휴지통 탭 → 노트 보임 (read-only) → \`🔄 복구\` → Inbox 로 복귀, AI 결과 보존 확인 - [ ] 다시 trash → 휴지통 → \`🗑 영구 삭제\` → confirm dialog → 노트 + media 디렉터리 모두 사라짐 - [ ] N 개 trash → 휴지통 → \`휴지통 비우기 (N개)\` → confirm → 모두 사라짐 - [ ] 트레이 → \`사용 로그 내보내기...\` → \`stats.md\` 에 휴지통 회수율 라인 + events.jsonl 에 4 신규 kind 라인 + privacy grep 0 hits - [ ] ollama 끄고 노트 캡처 → trash → 다시 active → ai_status='pending' 그대로 (deferred backlog #10 동작 확인) - [ ] migration: v0.2.3 (post-#7) 인스톨러 → v0.2.3 (post-#4) → 첫 실행 시 pre-v3.bak 생성 + v3 columns 추가
altair823 added 25 commits 2026-05-01 13:29:45 +00:00
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>
15 task TDD plan — migration v3, Note type extension, NoteRepository 신규
4메서드 + active query 일괄 변경, AiWorker deletedAt guard, telemetry 4 new
kinds + stats.md 회수율 ratio, CaptureService soft delete + 3 신규 메서드
+ 4 emit, ImportService deletedAt 보존, ExportService 회귀 가드, IPC 5 신규
채널 + native dialog confirm, zustand store + 5 actions, Inbox 탭 toggle +
NoteCard mode prop, 게이트 + closure marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
T5 reviewer identified 2 reads outside NoteRepository that were missing the
'WHERE deleted_at IS NULL' filter, breaking the silent invariant beyond the
3 originally-listed methods.

- ContinuityService.get() now excludes trashed notes from streak / weekCount
  / lastNoteAt / recovery-toast math. A trashed note no longer counts toward
  weekly streak (regression: streak felt fake after trash).
- NoteRepository.getPendingCount() now excludes trashed-but-still-pending
  notes. trash() removes the pending_jobs row but leaves notes.ai_status='pending';
  the count would have drifted upward as users trashed pending notes.
- MediaGc.run() gets an inline comment documenting why it intentionally does
  NOT filter — trashed notes still own their media until permanentDelete /
  emptyTrash. Removing here would defeat restore.

Also: migrations.due_date.test.ts had 2 brittle assertions
(latestVersion()===2, user_version===2) that broke with v3. Migration-system
version assertions belong in migrations.test.ts (already covered there);
m002-specific test keeps the due_date column assertion which is version-stable.

Tests: 245 → 265 (+20). typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
review T9 flagged 2 service-layer defenses:

#1: deleteNote/restoreNote/permanentDeleteNote 의 idempotency. 이미 trash 인
노트를 trash 하거나, 이미 active 인 노트를 restore 하거나, 존재하지 않는 노트를
permanentDelete 시 telemetry 가 spurious 하게 emit → restore/trash ratio (T8)
오염. findById 가드로 의미 없는 emit skip.

#2: permanentDeleteNote 의 disk cleanup unguarded. store.deleteNoteDirectory
실패 시 (Windows file-lock 등) telemetry 가 영영 emit 안 되고 IPC 호출자가
이미 성공한 작업에 에러 propagate. emptyTrash 와 동일하게 try/catch 로 감싸
best-effort. orphan dir 은 future janitor 가 정리.

Tests: 12/12 still pass. typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- I1: trashCount 가 upsertNote 안에서 항상 trashNotes.length 로 덮어써져
  server 값 (refreshMeta) 손상. showTrash=true (trashNotes cache-loaded)
  일 때만 local recompute.
- I2: restoreNote 의 "fallback for missed event" 주석 부정확 — main 은
  trash/restore 시 pushNoteUpdated 안 보냄. 자가 갱신이 primary mechanism.
  주석 정정.
- M5: restoreNote 테스트가 IPC 호출만 검증, 노트 이동 미검증. trashNotes
  → notes 라우팅 + deletedAt=null 어설션 추가. + I1 회귀 가드 테스트 신규.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.2.3 #4 휴지통 (soft delete + migration v3) 종료.

게이트:
- typecheck: 0 errors
- 단위 테스트: 245 → 292 (+47, schema/repo/AiWorker/CaptureService/Continuity/
  ImportService/ExportService/store 전반)
- e2e smoke: 1/1 PASS

기능:
- migration v3 — deleted_at + last_recalled_at + recall_dismissed_at
- NoteRepository: trash/restore/permanentDelete/emptyTrash/listTrashed
- AiWorker.processJob deletedAt 가드
- CaptureService 4 신규 메서드 + idempotency 가드 + 4 telemetry emit
- telemetryStats: 4 신규 컬럼 + 휴지통 회수율 ratio
- ImportService: deletedAt 보존 + skip-merge 정책
- ExportService 회귀 가드 (T5 listAll filter 자동 동작)
- IPC 5 신규 채널 + native dialog confirm
- zustand store: showTrash/trashNotes/trashCount + 5 actions
- App.tsx 헤더 탭 + 휴지통 view + bulk 비우기
- NoteCard mode='trash' read-only

기타 fix (cross-task):
- ContinuityService streak 가 trash 노트 무시
- getPendingCount 가 trash 노트 무시 (drift 방지)
- MediaGc intentional non-filter 주석 (restore 시 media 보존)

deferred (v0.2.4 backlog):
- exhaustiveness check on stats union
- restore 시 pending_jobs 재생성 정책
- inbox:trashCount cap 200 → repo.countTrashed()
- inbox:delete 채널 rename
- 탭 ARIA role="tab" 정정
- per-note 영구 삭제 텔레메트리 기반 retire 검토

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-01 13:43:47 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

회차 1 — 휴지통 + migration v3 — soft delete invariant 잘 잡힘, 200-cap UI 정확성만 다듬으면 머지 가능.

전반적으로 v0.2.3 #4 의 핵심 invariant — deleted_at IS NULL active 쿼리 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 하는 비효율 + emptyTrash dialog 가 200 cap 에 mismatch 되어 사용자가 trash 500개 보유 시 "200개 영구 삭제합니다" 로 보게 됨 (실제 SQL DELETE 는 무한정이라 정확히 동작, 표시만 거짓말). 둘 다 v0.2.4 backlog #12repo.countTrashed() (간단한 SELECT COUNT(*)) 추가로 한 번에 해결 가능. AiWorker race guard 보강과 매직 넘버 상수화는 deferrable.

회차 1 — 휴지통 + migration v3 — soft delete invariant 잘 잡힘, 200-cap UI 정확성만 다듬으면 머지 가능. 전반적으로 v0.2.3 #4 의 핵심 invariant — `deleted_at IS NULL` active 쿼리 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 하는 비효율 + `emptyTrash` dialog 가 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 명시 차원.

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.

주석에서 인지한 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 #12repo.countTrashed() (간단한 SELECT COUNT(*) FROM notes WHERE deleted_at IS NOT NULL) 추가로 해결. 같은 issue 가 line 83 emptyTrash dialog 에도 있음.

단순 카운트 조회를 위해 `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 83 `emptyTrash` dialog 에도 있음.
@@ -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.

`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 elsewhere

Praise — DELETE ... RETURNING id single-statement atomic 선택과 그 근거 (per-row prepare 회피 + RETURNING house-style) 를 주석에 명시한 점, emptyTrash 가 service 레이어 media cleanup loop 와 깨끗하게 분리된 점이 좋음. 트랜잭션 wrapping 없이도 SQLite 의 single-statement atomicity 로 충분하다는 판단도 정확.

Praise — `DELETE ... RETURNING id` single-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 — importNote skip 케이스에서 trash() 를 재사용해 pending_jobs cleanup invariant 까지 자동 만족시키는 선택은 깔끔함. 다만 동일 케이스에서 findRawTextById + 별도 SELECT deleted_at 두 번 쿼리하는 대신 첫 SELECT 에서 raw_text, deleted_at 동시 반환하면 round-trip 1 회 절약 가능. 마이크로 개선이라 deferrable.

Praise — `importNote` skip 케이스에서 `trash()` 를 재사용해 `pending_jobs` cleanup 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 를 함부로 제거하지 못하게 한 것도 좋음.

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 가 코드 옆에 그대로 살아있는 모범 예시.

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 도 좋음.

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.

`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=pending
expect(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 신뢰도를 크게 끌어올림.

Praise — `getPendingCount() excludes trashed pending notes (drift guard)` 는 매우 영리한 테스트. trash() 가 pending_jobs row 만 정리하고 notes.ai_status 는 'pending' 으로 남기는 설계 선택이 query side 에서 제대로 보정되는지 명시적으로 잠그는 회귀 가드. 이런 invariant 테스트가 PR 신뢰도를 크게 끌어올림.
altair823 added 1 commit 2026-05-01 13:45:57 +00:00
PR #14 회차 1 review actionable — `inbox:trashCount` 와 `emptyTrash` dialog
가 `listTrashed({limit:200})` 로 카운트를 도출하면서 (a) hot path 에서 N rows
+ tags/media JOIN hydrate 비효율 (b) trash > 200 시 dialog message 가
실제 SQL DELETE 동작과 mismatch ('200개 영구 삭제합니다' 표시 vs 500개
실제 삭제) 발생.

NoteRepository.countTrashed() — `SELECT COUNT(*) FROM notes WHERE deleted_at
IS NOT NULL` 단일 쿼리. hydrate 없이 정확한 카운트만 반환. 두 IPC 핸들러를
이 메서드 호출로 교체.

테스트: 3 신규 단위 테스트 (0 trash / 부분 trash / 200 cap 초과 범위)
292 → 295 (+3). typecheck 0 errors.

deferrable (v0.2.4 backlog 그대로): AiWorker race guard 강화, restore self-guard,
limit 200 매직 넘버 상수화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-01 13:47:38 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — countTrashed() 분리로 hot path + dialog mismatch 동시 해결, 신규 이슈 없음 (수렴).

회차 1 actionable 두 건 모두 정확히 해결됐습니다 — countTrashed() 가 단일 COUNT(*) 쿼리로 hydrate 없이 hot path 에서 호출되며, inbox:trashCountemptyTrash dialog 양쪽 호출 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 버튼을 누르면 됩니다.

회차 2 — countTrashed() 분리로 hot path + dialog mismatch 동시 해결, 신규 이슈 없음 (수렴). 회차 1 actionable 두 건 모두 정확히 해결됐습니다 — `countTrashed()` 가 단일 COUNT(*) 쿼리로 hydrate 없이 hot path 에서 호출되며, `inbox:trashCount` 와 `emptyTrash` dialog 양쪽 호출 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 버튼을 누르면 됩니다.
altair823 merged commit df60c5a5b2 into main 2026-05-01 14:06:24 +00:00
altair823 deleted branch feat/v023-trash 2026-05-01 14:06:25 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/inkling#14