From b94e68238c79de2765db3fefe94ac966e246f67a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 13:13:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(recall):=20telemetryEvents=20=E2=80=94=20r?= =?UTF-8?q?ecall=5Fshown/opened/dismissed/snoozed=20zod=20schemas=20(#6=20?= =?UTF-8?q?v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecallShownPayload { noteId, ageDays: int>=0 } .strict() - recall_opened/dismissed/snoozed → NoteIdPayload 재사용 - TelemetryEventSchema union 15 → 19 - 단위 +3 cases (recall_shown valid, extra field 거부, opened/dismissed/snoozed valid) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/telemetryEvents.ts | 11 ++++++++++- tests/unit/telemetryEvents.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 66471e7..67d194f 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -59,6 +59,11 @@ const TagVocabMissPayload = z.object({ vocabSize: z.number().int().nonnegative() }).strict(); +const RecallShownPayload = z.object({ + noteId: z.string().min(1), + ageDays: 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(), @@ -74,7 +79,11 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 5266346..b6e4dd4 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -306,3 +306,30 @@ describe('validateEvent — tag vocab', () => { })).toThrow(); }); }); + +describe('validateEvent — recall', () => { + it('accepts recall_shown event', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'recall_shown', + payload: { noteId: 'n1', ageDays: 14 } + }); + expect(e.kind).toBe('recall_shown'); + }); + + it('rejects recall_shown with extra field (privacy)', () => { + expect(() => validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'recall_shown', + payload: { noteId: 'n1', ageDays: 14, content: 'leak' } + })).toThrow(); + }); + + it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => { + for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) { + const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } }); + expect(e.kind).toBe(kind); + } + }); +}); +