From 87b6d71628fcdff26d5bf8088021e9c2288600e9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 22:45:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(trash):=20add=20repo.countTrashed()=20?= =?UTF-8?q?=E2=80=94=20fix=20UI=20200-cap=20mismatch=20(review=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/ipc/inboxApi.ts | 12 +++------- src/main/repository/NoteRepository.ts | 12 ++++++++++ tests/unit/NoteRepository.test.ts | 34 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 374095d..c83e73e 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -77,12 +77,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void { }); ipcMain.handle('inbox:emptyTrash', async () => { - // limit 200 한 번 — UI 표시 cap. count > 200 시 dialog message 만 부정확하지만 - // emptyTrash() 내부 SQL 은 LIMIT 없으므로 실제 삭제는 모든 trash 노트 적용. - // v0.2.4 에서 repo.countTrashed() 추가 시 둘 다 정확해짐. - const trashed = deps.repo.listTrashed({ limit: 200 }); - if (trashed.length === 0) return { confirmed: true, count: 0 }; - const fullCount = trashed.length; + const fullCount = deps.repo.countTrashed(); + if (fullCount === 0) return { confirmed: true, count: 0 }; const win = deps.getInboxWindow(); const opts: Electron.MessageBoxOptions = { type: 'question', @@ -105,9 +101,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void { deps.repo.listTrashed(opts) ); - ipcMain.handle('inbox:trashCount', () => - deps.repo.listTrashed({ limit: 200 }).length - ); + ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed()); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 366e681..f7b74b1 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -269,6 +269,18 @@ export class NoteRepository { return rows.map((r) => this.hydrate(r)); } + /** + * Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate + * tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote + * follow-ups) where listTrashed() is wasteful. + */ + countTrashed(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`) + .get() as { c: number }; + return row.c; + } + /** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */ delete(id: string): void { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 622b049..6262ec0 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -364,6 +364,40 @@ describe('NoteRepository.listTrashed', () => { }); }); +describe('NoteRepository.countTrashed', () => { + let db: Database.Database; + let repo: NoteRepository; + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('returns 0 when no trash', () => { + repo.create({ rawText: 'active' }); + expect(repo.countTrashed()).toBe(0); + }); + + it('counts only trashed notes', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.create({ rawText: 'b (active)' }); + const c = repo.create({ rawText: 'c' }).id; + repo.trash(a, '2026-05-01T00:00:00.000Z'); + repo.trash(c, '2026-05-01T01:00:00.000Z'); + expect(repo.countTrashed()).toBe(2); + }); + + it('returns count beyond listTrashed limit (no 200 cap drift)', () => { + // listTrashed limit cap is 200; countTrashed must reflect actual count. + for (let i = 0; i < 10; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`); + } + expect(repo.countTrashed()).toBe(10); + expect(repo.listTrashed({ limit: 5 })).toHaveLength(5); + }); +}); + describe('Active queries exclude deleted notes', () => { let db: Database.Database; let repo: NoteRepository;