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:
246
src/main/repository/NoteRepository.ts
Normal file
246
src/main/repository/NoteRepository.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user