From 468ea90d6c1d3bd29b0954a1c41b6279d114172c Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 21:20:03 +0900 Subject: [PATCH] fix(trash): idempotency guards on delete/restore/permanent (review T9 important #1+#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/services/CaptureService.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index d42188a..63e0b76 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -67,6 +67,9 @@ export class CaptureService { async deleteNote(noteId: string): Promise { // 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; this.repo.trash(noteId, new Date().toISOString()); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {}); @@ -74,6 +77,9 @@ export class CaptureService { } async restoreNote(noteId: string): Promise { + // 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지. + const note = this.repo.findById(noteId); + if (!note || note.deletedAt === null) return; this.repo.restore(noteId); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {}); @@ -81,8 +87,14 @@ export class CaptureService { } async permanentDeleteNote(noteId: string): Promise { + // 존재하지 않는 노트는 emit skip — 메트릭 오염 방지. + const note = this.repo.findById(noteId); + if (!note) return; this.repo.permanentDelete(noteId); - await this.store.deleteNoteDirectory(noteId); + // best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir + // 은 future janitor 가 정리). emptyTrash 와 동일 패턴. + try { await this.store.deleteNoteDirectory(noteId); } + catch { /* best-effort */ } if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {}); }