From fec80361ddcdba9e160542125fecfde3ba3de3fd Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 00:01:03 +0900 Subject: [PATCH] feat(expiry): NoteRepository.trashBatch atomic (#5 v0.2.3) --- src/main/repository/NoteRepository.ts | 26 ++++++++++++++++ tests/unit/NoteRepository.test.ts | 45 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 4884139..37bb549 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -241,6 +241,32 @@ export class NoteRepository { tx(); } + /** + * Atomically transition a batch of notes from active โ†’ trash. + * Returns the number of notes that actually transitioned (i.e. were active + * before the call). Already-trashed and unknown ids are silent skips โ€” + * counting them would inflate `expired_batch_trash` telemetry. + * + * Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup + * invariant (ยง9.2 of #4 spec). + */ + trashBatch(ids: string[], deletedAt: string): { trashedCount: number } { + if (ids.length === 0) return { trashedCount: 0 }; + let trashedCount = 0; + const tx = this.db.transaction((batch: string[]) => { + for (const id of batch) { + const row = this.db + .prepare(`SELECT deleted_at FROM notes WHERE id = ?`) + .get(id) as { deleted_at: string | null } | undefined; + if (!row || row.deleted_at !== null) continue; + this.trash(id, deletedAt); + trashedCount += 1; + } + }); + tx(ids); + return { trashedCount }; + } + restore(id: string): void { const now = new Date().toISOString(); this.db diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 9720181..ba9d753 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -529,3 +529,48 @@ describe('NoteRepository.findExpiredCandidates', () => { expect(r.map((n) => n.id)).toEqual([past]); }); }); + +describe('NoteRepository.trashBatch', () => { + let db: Database.Database; + let repo: NoteRepository; + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('atomically trashes all valid ids and returns trashedCount', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(3); + expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c)) + .toMatchObject({ c: 0 }); + }); + + it('returns trashedCount=0 for empty array (no-op)', () => { + const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(0); + }); + + it('skips ids that are already trashed (idempotent โ€” count = 0 transitions)', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.trash(a, '2026-04-30T00:00:00.000Z'); + const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(0); + expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z'); + }); + + it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + repo.trash(b, '2026-04-30T00:00:00.000Z'); + const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z'); + expect(r.trashedCount).toBe(1); + expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + }); +});