feat(trash): telemetry 4 new kinds (trash/restore/permanent_delete/empty_trash) (#4 v0.2.3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-01 21:05:06 +09:00
parent 78c10e8817
commit 284bfcbdd1
4 changed files with 82 additions and 4 deletions

View File

@@ -18,7 +18,11 @@ export interface TelemetryServiceOptions {
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } };
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } };
export class TelemetryService {
constructor(

View File

@@ -20,10 +20,22 @@ const AiFailedPayload = z.object({
attempts: z.number().int().nonnegative()
}).strict();
const NoteIdPayload = z.object({
noteId: z.string().min(1)
}).strict();
const EmptyTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict()
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict()
]);
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;

View File

@@ -146,7 +146,7 @@ describe('TelemetryService.readAllRecent', () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(3);
expect(events.map((e) => e.payload.noteId)).toEqual(['a', 'b', 'b']);
expect(events.map((e) => (e.payload as { noteId: string }).noteId)).toEqual(['a', 'b', 'b']);
});
it('skips malformed lines (silent — invariant)', async () => {
@@ -157,7 +157,7 @@ describe('TelemetryService.readAllRecent', () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(1);
expect(events[0]!.payload.noteId).toBe('a');
expect((events[0]!.payload as { noteId: string }).noteId).toBe('a');
});
it('returns [] when dir missing', async () => {

View File

@@ -87,3 +87,65 @@ describe('validateEvent — privacy invariant', () => {
})).toThrow();
});
});
describe('validateEvent — trash family (v0.2.3 #4)', () => {
it('accepts trash event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('trash');
});
it('accepts restore event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'restore',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('restore');
});
it('accepts permanent_delete event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'permanent_delete',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('permanent_delete');
});
it('accepts empty_trash event with count', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 7 }
});
expect(e.kind).toBe('empty_trash');
});
it('rejects trash payload with rawText leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1', rawText: 'leak' }
})).toThrow();
});
it('rejects empty_trash with negative count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: -1 }
})).toThrow();
});
it('rejects empty_trash with non-integer count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 1.5 }
})).toThrow();
});
});