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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:06:45 +09:00
parent 114e971518
commit 797d97c392
2 changed files with 375 additions and 0 deletions

View File

@@ -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
};
}
}

View File

@@ -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');
});
});