feat(trash): NoteRepository.permanentDelete/emptyTrash/listTrashed (#4 v0.2.3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-01 20:47:05 +09:00
parent bf49b8351e
commit 11703b976e
2 changed files with 111 additions and 0 deletions

View File

@@ -238,6 +238,34 @@ export class NoteRepository {
.run(now, id);
}
permanentDelete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
emptyTrash(): { noteIds: string[] } {
const noteIds: string[] = [];
const tx = this.db.transaction(() => {
const rows = this.db
.prepare('SELECT id FROM notes WHERE deleted_at IS NOT NULL')
.all() as Array<{ id: string }>;
for (const r of rows) {
this.db.prepare('DELETE FROM notes WHERE id=?').run(r.id);
noteIds.push(r.id);
}
});
tx();
return { noteIds };
}
listTrashed(opts: { limit: number }): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
.all(limit) as any[];
return rows.map((r) => this.hydrate(r));
}
/** @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

@@ -280,3 +280,86 @@ describe('NoteRepository.restore', () => {
expect(repo.findById(id)!.deletedAt).toBeNull();
});
});
describe('NoteRepository.permanentDelete', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('removes notes row + cascades note_tags / pending_jobs', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.permanentDelete(id);
expect(repo.findById(id)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
});
describe('NoteRepository.emptyTrash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hard-deletes all trashed notes and returns their ids', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
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');
const r = repo.emptyTrash();
expect(r.noteIds.sort()).toEqual([a, c].sort());
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).not.toBeNull();
expect(repo.findById(c)).toBeNull();
});
it('returns empty array when trash is empty', () => {
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
});
});
describe('NoteRepository.listTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns trashed notes ordered by deleted_at DESC', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T02:00:00.000Z');
repo.trash(b, '2026-05-01T01:00:00.000Z');
const r = repo.listTrashed({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([c, b, a]);
});
it('excludes active notes', () => {
repo.create({ rawText: 'active' });
const r = repo.listTrashed({ limit: 50 });
expect(r).toEqual([]);
});
it('respects limit', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
}
const r = repo.listTrashed({ limit: 3 });
expect(r).toHaveLength(3);
});
});