feat(trash): CaptureService soft-delete + restore/permanent/empty + 4 emits (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,10 @@ import type { MediaStore } from './MediaStore.js';
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
| { kind: 'empty_trash'; payload: { count: number } }
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -54,7 +58,7 @@ export class CaptureService {
|
||||
rawTextLength: input.text.length,
|
||||
hasMedia: input.images.length > 0
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
await this.deps.enqueue(id);
|
||||
this.deps.celebrate(id);
|
||||
@@ -62,7 +66,37 @@ export class CaptureService {
|
||||
}
|
||||
|
||||
async deleteNote(noteId: string): Promise<void> {
|
||||
this.repo.delete(noteId);
|
||||
// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
|
||||
this.repo.trash(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async restoreNote(noteId: string): Promise<void> {
|
||||
this.repo.restore(noteId);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async permanentDeleteNote(noteId: string): Promise<void> {
|
||||
this.repo.permanentDelete(noteId);
|
||||
await this.store.deleteNoteDirectory(noteId);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async emptyTrash(): Promise<{ count: number }> {
|
||||
const { noteIds } = this.repo.emptyTrash();
|
||||
for (const id of noteIds) {
|
||||
try { await this.store.deleteNoteDirectory(id); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
|
||||
}
|
||||
return { count: noteIds.length };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { mkdtempSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -51,11 +51,11 @@ describe('CaptureService', () => {
|
||||
expect(celebrated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleteNote removes db row + media dir', async () => {
|
||||
it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => {
|
||||
const img = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
const { noteId } = await svc.submit({ text: 't', images: [img] });
|
||||
await svc.deleteNote(noteId);
|
||||
expect(repo.findById(noteId)).toBeNull();
|
||||
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,3 +111,100 @@ describe('CaptureService telemetry emit', () => {
|
||||
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService trash flow (v0.2.3 #4)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let events: Array<{ kind: string; payload: any }>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
|
||||
store = new MediaStore(tmp);
|
||||
events = [];
|
||||
});
|
||||
|
||||
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
|
||||
events.length = 0; // clear capture event
|
||||
await svc.deleteNote(noteId);
|
||||
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('trash');
|
||||
expect(events[0]!.payload.noteId).toBe(noteId);
|
||||
// media 디렉터리 보존 확인 (restore 시 필요)
|
||||
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
|
||||
});
|
||||
|
||||
it('restoreNote clears deleted_at and emits restore event', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'hi', images: [] });
|
||||
events.length = 0;
|
||||
await svc.deleteNote(noteId);
|
||||
events.length = 0;
|
||||
await svc.restoreNote(noteId);
|
||||
expect(repo.findById(noteId)!.deletedAt).toBeNull();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('restore');
|
||||
});
|
||||
|
||||
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
|
||||
events.length = 0;
|
||||
await svc.permanentDeleteNote(noteId);
|
||||
expect(repo.findById(noteId)).toBeNull();
|
||||
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('permanent_delete');
|
||||
});
|
||||
|
||||
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
|
||||
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
|
||||
await svc.submit({ text: 'c (active)', images: [] });
|
||||
await svc.deleteNote(a);
|
||||
await svc.deleteNote(b);
|
||||
events.length = 0;
|
||||
const r = await svc.emptyTrash();
|
||||
expect(r.count).toBe(2);
|
||||
expect(repo.findById(a)).toBeNull();
|
||||
expect(repo.findById(b)).toBeNull();
|
||||
expect(existsSync(join(tmp, 'media', a))).toBe(false);
|
||||
expect(existsSync(join(tmp, 'media', b))).toBe(false);
|
||||
const empty = events.find((e) => e.kind === 'empty_trash')!;
|
||||
expect(empty.payload.count).toBe(2);
|
||||
});
|
||||
|
||||
it('emptyTrash returns count=0 when trash empty', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const r = await svc.emptyTrash();
|
||||
expect(r.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user