feat(expiry): NoteRepository.trashBatch atomic (#5 v0.2.3)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user