fix(trash): add repo.countTrashed() — fix UI 200-cap mismatch (review 회차 1)

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>
This commit is contained in:
altair823
2026-05-01 22:45:11 +09:00
parent 2ac4d648c1
commit 87b6d71628
3 changed files with 49 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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;