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:
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user