From 797d97c3924d0243f4f4b51880d6ca3948696bbf Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 12:06:45 +0900 Subject: [PATCH] feat(repo): NoteRepository with intent, edited flags, AI overwrite guard Task 7 of the slice plan. Implements the full repository surface backing every IPC inbox/capture path: create (UUID v7 + atomic notes + pending_jobs insert), insertMedia, findById/list, updateAiResult (CASE WHEN guard against title/summary overwrite when *_edited_by_user flips), markAiFailed (truncates ai_error to 500 chars + clears pending job), updateUserAiFields (sets edited flags as a side effect, replaces user-source tags), setIntent + dismissIntent (intent_prompted_at uses COALESCE so the first stamp wins), delete, getPendingCount, getAllPendingJobs, incrementJobAttempt, and a private hydrate that joins notes with note_tags + media. Plan deviation: list/list-with-cursor query gets a secondary "id DESC" tiebreaker. Two notes created in the same millisecond shared created_at and reordered nondeterministically; UUID v7 sorts monotonically with creation order, so id DESC restores "newest first" within ties. Verification: `npx vitest run tests/unit/NoteRepository.test.ts` 12 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 246 ++++++++++++++++++++++++++ tests/unit/NoteRepository.test.ts | 129 ++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/main/repository/NoteRepository.ts create mode 100644 tests/unit/NoteRepository.test.ts diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts new file mode 100644 index 0000000..39aff00 --- /dev/null +++ b/src/main/repository/NoteRepository.ts @@ -0,0 +1,246 @@ +import type Database from 'better-sqlite3'; +import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; +import type { Note, NoteMedia, NoteTag } from '@shared/types'; + +export interface CreateNoteInput { rawText: string; } + +export interface NewMediaRow { + noteId: string; + kind: 'image'; + relPath: string; + mime: string; + bytes: number; +} + +export class NoteRepository { + constructor(private db: Database.Database) {} + + create(input: CreateNoteInput): { id: string } { + const id = uuidv7(); + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + this.db + .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, ?, 'pending', ?, ?)`) + .run(id, input.rawText, now, now); + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, now); + }); + tx(); + return { id }; + } + + insertMedia(rows: NewMediaRow[]): void { + if (rows.length === 0) return; + const now = new Date().toISOString(); + const stmt = this.db.prepare( + `INSERT INTO media (id, note_id, kind, rel_path, mime, bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const tx = this.db.transaction(() => { + for (const r of rows) { + stmt.run(uuidv4(), r.noteId, r.kind, r.relPath, r.mime, r.bytes, now); + } + }); + tx(); + } + + findById(id: string): Note | null { + const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any; + if (!row) return null; + return this.hydrate(row); + } + + list(opts: { limit: number; cursor?: string }): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit)); + const rows = opts.cursor + ? (this.db + .prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`) + .all(opts.cursor, limit) as any[]) + : (this.db + .prepare(`SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ?`) + .all(limit) as any[]); + return rows.map((r) => this.hydrate(r)); + } + + updateAiResult( + id: string, + result: { title: string; summary: string; tags: string[]; provider: string } + ): void { + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + this.db + .prepare( + `UPDATE notes + SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END, + ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END, + ai_status = 'done', + ai_provider = ?, + ai_generated_at = ?, + ai_error = NULL, + updated_at = ? + WHERE id = ?` + ) + .run(result.title, result.summary, result.provider, now, now, id); + this.db.prepare(`DELETE FROM note_tags WHERE note_id=? AND source='ai'`).run(id); + const getOrInsertTag = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const linkTag = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + for (const t of result.tags) { + const tagRow = getOrInsertTag.get(t) as { id: number }; + linkTag.run(id, tagRow.id); + } + this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + }); + tx(); + } + + markAiFailed(id: string, error: string): void { + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + this.db + .prepare(`UPDATE notes SET ai_status='failed', ai_error=?, updated_at=? WHERE id=?`) + .run(error.slice(0, 500), now, id); + this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + }); + tx(); + } + + updateUserAiFields( + id: string, + fields: { title?: string; summary?: string; tags?: string[] } + ): void { + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + const updates: string[] = []; + const params: unknown[] = []; + if (fields.title !== undefined) { + updates.push('ai_title=?'); + updates.push('title_edited_by_user=1'); + params.push(fields.title); + } + if (fields.summary !== undefined) { + updates.push('ai_summary=?'); + updates.push('summary_edited_by_user=1'); + params.push(fields.summary); + } + if (updates.length > 0) { + updates.push('updated_at=?'); + params.push(now); + params.push(id); + this.db.prepare(`UPDATE notes SET ${updates.join(', ')} WHERE id=?`).run(...params); + } + if (fields.tags !== undefined) { + this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(id); + const getOrInsert = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const link = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of fields.tags) { + const row = getOrInsert.get(t) as { id: number }; + link.run(id, row.id); + } + } + }); + tx(); + } + + setIntent(id: string, text: string): void { + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE notes + SET user_intent = ?, + intent_prompted_at = COALESCE(intent_prompted_at, ?), + updated_at = ? + WHERE id = ?` + ) + .run(text.slice(0, 200), now, now, id); + } + + dismissIntent(id: string): void { + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE notes + SET intent_prompted_at = COALESCE(intent_prompted_at, ?), + updated_at = ? + WHERE id = ?` + ) + .run(now, now, id); + } + + delete(id: string): void { + this.db.prepare('DELETE FROM notes WHERE id=?').run(id); + } + + getPendingCount(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`) + .get() as { c: number }; + return row.c; + } + + getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { + const rows = this.db + .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) + .all() as any[]; + return rows.map((r) => ({ + noteId: r.note_id, + attempts: r.attempts, + nextRunAt: r.next_run_at + })); + } + + incrementJobAttempt(noteId: string, nextRunAt: string, lastError: string): void { + this.db + .prepare( + `UPDATE pending_jobs + SET attempts = attempts + 1, + next_run_at = ?, + last_error = ? + WHERE note_id = ?` + ) + .run(nextRunAt, lastError.slice(0, 500), noteId); + } + + private hydrate(row: any): Note { + const tags = this.db + .prepare( + `SELECT t.name, nt.source + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = ? ORDER BY t.name` + ) + .all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>; + const media = this.db + .prepare( + `SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?` + ) + .all(row.id) as NoteMedia[]; + return { + id: row.id, + rawText: row.raw_text, + aiTitle: row.ai_title, + aiSummary: row.ai_summary, + aiStatus: row.ai_status, + aiError: row.ai_error, + aiProvider: row.ai_provider, + aiGeneratedAt: row.ai_generated_at, + titleEditedByUser: row.title_edited_by_user === 1, + summaryEditedByUser: row.summary_edited_by_user === 1, + userIntent: row.user_intent, + intentPromptedAt: row.intent_prompted_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + tags: tags as NoteTag[], + media + }; + } +} diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts new file mode 100644 index 0000000..71a1466 --- /dev/null +++ b/tests/unit/NoteRepository.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; + +function freshDb() { + const db = new Database(':memory:'); + runMigrations(db); + return db; +} + +describe('NoteRepository', () => { + let db: Database.Database; + let repo: NoteRepository; + beforeEach(() => { db = freshDb(); repo = new NoteRepository(db); }); + + it('create stores raw_text, defaults edited flags to 0, intent fields NULL, enqueues pending job', () => { + const { id } = repo.create({ rawText: '회의 메모' }); + const note = repo.findById(id)!; + expect(note.rawText).toBe('회의 메모'); + expect(note.titleEditedByUser).toBe(false); + expect(note.summaryEditedByUser).toBe(false); + expect(note.userIntent).toBeNull(); + expect(note.intentPromptedAt).toBeNull(); + expect(note.aiStatus).toBe('pending'); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeDefined(); + }); + + it('updateAiResult does not overwrite user-edited title/summary', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p' }); + repo.updateUserAiFields(id, { title: '내 제목', summary: '내 요약\n둘\n셋' }); + repo.updateAiResult(id, { title: 'AI 제목 2', summary: 'x\ny\nz', tags: [], provider: 'p' }); + const note = repo.findById(id)!; + expect(note.aiTitle).toBe('내 제목'); + expect(note.aiSummary).toBe('내 요약\n둘\n셋'); + expect(note.titleEditedByUser).toBe(true); + expect(note.summaryEditedByUser).toBe(true); + }); + + it('updateAiResult marks done, replaces ai tags, removes pending job', () => { + const { id } = repo.create({ rawText: '원문' }); + repo.updateAiResult(id, { + title: '제목', summary: '1줄\n2줄\n3줄', + tags: ['api-timeout', 'meeting'], provider: 'local-ollama/gemma4:e4b' + }); + const note = repo.findById(id)!; + expect(note.aiStatus).toBe('done'); + expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']); + expect(note.tags.every((t) => t.source === 'ai')).toBe(true); + expect(db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id)).toBeUndefined(); + }); + + it('markAiFailed truncates and clears pending job', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.markAiFailed(id, 'E'.repeat(600)); + const note = repo.findById(id)!; + expect(note.aiStatus).toBe('failed'); + expect(note.aiError?.length).toBe(500); + }); + + it('updateUserAiFields replaces user-sourced tags', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 'ai', summary: 'a\nb\nc', tags: ['ai-tag'], provider: 'p' }); + repo.updateUserAiFields(id, { tags: ['user-tag'] }); + const note = repo.findById(id)!; + expect(note.tags).toEqual([{ name: 'user-tag', source: 'user' }]); + }); + + it('setIntent stores user_intent, sets intent_prompted_at first time, preserves on subsequent', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.setIntent(id, '내일의 나에게'); + const a = repo.findById(id)!; + expect(a.userIntent).toBe('내일의 나에게'); + expect(a.intentPromptedAt).not.toBeNull(); + const firstStamp = a.intentPromptedAt!; + repo.setIntent(id, '수정'); + const b = repo.findById(id)!; + expect(b.userIntent).toBe('수정'); + expect(b.intentPromptedAt).toBe(firstStamp); + }); + + it('dismissIntent stamps intent_prompted_at without setting user_intent', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.dismissIntent(id); + const note = repo.findById(id)!; + expect(note.userIntent).toBeNull(); + expect(note.intentPromptedAt).not.toBeNull(); + }); + + it('setIntent truncates to 200 chars', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.setIntent(id, 'X'.repeat(300)); + expect(repo.findById(id)!.userIntent?.length).toBe(200); + }); + + it('delete cascades note_tags, media, pending_jobs', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.insertMedia([{ noteId: id, kind: 'image', relPath: 'm/x.png', mime: 'image/png', bytes: 10 }]); + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['z'], provider: 'p' }); + repo.delete(id); + expect(repo.findById(id)).toBeNull(); + expect(db.prepare('SELECT COUNT(*) AS c FROM media').get()).toEqual({ c: 0 }); + expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags').get()).toEqual({ c: 0 }); + }); + + it('list returns notes in descending created_at', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + expect(repo.list({ limit: 10 }).map((n) => n.id)).toEqual([b, a]); + }); + + it('getPendingCount counts pending notes', () => { + repo.create({ rawText: 'a' }); + const { id } = repo.create({ rawText: 'b' }); + expect(repo.getPendingCount()).toBe(2); + repo.markAiFailed(id, 'err'); + expect(repo.getPendingCount()).toBe(1); + }); + + it('incrementJobAttempt bumps attempts and stores last_error', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.incrementJobAttempt(id, new Date().toISOString(), 'boom'); + const row = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id) as any; + expect(row.attempts).toBe(1); + expect(row.last_error).toBe('boom'); + }); +});