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 interface ImportNoteInput { /** Proposed id from the export file. May be replaced if it collides with * an existing row whose `raw_text` differs (raw_text invariant guard). */ id: string; rawText: string; createdAt: string; updatedAt: string; aiTitle: string | null; aiSummary: string | null; titleEditedByUser: boolean; summaryEditedByUser: boolean; aiProvider: string | null; aiGeneratedAt: string | null; userIntent: string | null; intentPromptedAt: string | null; tags: { name: string; source: 'ai' | 'user' }[]; deletedAt?: string | null; } export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked'; export interface ImportNoteResult { /** Final id used for the row (== input.id for inserted/skipped, fresh uuidv7 for forked). */ id: string; status: ImportNoteStatus; } 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 deleted_at IS NULL AND created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?` ) .all(opts.cursor, limit) as any[]) : (this.db .prepare( `SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ?` ) .all(limit) as any[]); return rows.map((r) => this.hydrate(r)); } listAll(): Note[] { const rows = this.db .prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`) .all() as any[]; return rows.map((r) => this.hydrate(r)); } updateAiResult( id: string, result: { title: string; summary: string; tags: string[]; provider: string; dueDate?: string | null } ): void { const now = new Date().toISOString(); const dueDate = result.dueDate ?? null; 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, due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END, ai_status = 'done', ai_provider = ?, ai_generated_at = ?, ai_error = NULL, updated_at = ? WHERE id = ?` ) .run(result.title, result.summary, dueDate, 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); } setDueDate(id: string, date: string | null): void { const now = new Date().toISOString(); this.db .prepare( `UPDATE notes SET due_date = ?, due_date_edited_by_user = 1, updated_at = ? WHERE id = ?` ) .run(date, now, id); } trash(id: string, deletedAt: string): void { const tx = this.db.transaction(() => { this.db .prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`) .run(deletedAt, deletedAt, id); this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id); }); tx(); } restore(id: string): void { const now = new Date().toISOString(); this.db .prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`) .run(now, id); } permanentDelete(id: string): void { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); } emptyTrash(): { noteIds: string[] } { // Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed) // and avoids per-row prepare overhead. RETURNING is house-style elsewhere // (updateAiResult/updateUserAiFields/getAllPendingJobs). const rows = this.db .prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id') .all() as Array<{ id: string }>; return { noteIds: rows.map((r) => r.id) }; } listTrashed(opts: { limit: number }): Note[] { const limit = Math.max(1, Math.min(200, opts.limit)); const rows = this.db .prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`) .all(limit) as any[]; return rows.map((r) => this.hydrate(r)); } /** * Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate * tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote * follow-ups) where listTrashed() is wasteful. */ countTrashed(): number { const row = this.db .prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`) .get() as { c: number }; return row.c; } /** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */ delete(id: string): void { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); } findRawTextById(id: string): string | null { const row = this.db.prepare('SELECT raw_text FROM notes WHERE id=?').get(id) as | { raw_text: string } | undefined; return row?.raw_text ?? null; } /** * Import a note from an external source (F5 export tree). * Conflict policy: * - id missing in DB → INSERT (status: 'inserted') * - id present + raw_text identical → no-op (status: 'skipped') * - id present + raw_text differs → INSERT under fresh uuidv7 * to preserve the raw_text-immutable invariant (status: 'forked') * * deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선 * (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신. * insert/fork 는 source 의 deletedAt 그대로 보존. */ importNote(input: ImportNoteInput): ImportNoteResult { const existing = this.findRawTextById(input.id); let finalId = input.id; let status: ImportNoteStatus = 'inserted'; if (existing !== null) { if (existing === input.rawText) { // skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존). // trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족. if (input.deletedAt != null) { const destRow = this.db .prepare('SELECT deleted_at FROM notes WHERE id=?') .get(input.id) as { deleted_at: string | null } | undefined; if (destRow && destRow.deleted_at === null) { this.trash(input.id, input.deletedAt); } } return { id: input.id, status: 'skipped' }; } finalId = uuidv7(); status = 'forked'; } const tx = this.db.transaction(() => { this.db .prepare( `INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at, title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at, deleted_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( finalId, input.rawText, input.aiTitle, input.aiSummary, input.aiProvider, input.aiGeneratedAt, input.titleEditedByUser ? 1 : 0, input.summaryEditedByUser ? 1 : 0, input.userIntent, input.intentPromptedAt, input.deletedAt ?? null, input.createdAt, input.updatedAt ); if (input.tags.length > 0) { const getOrInsertTag = this.db.prepare( `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` ); const linkAi = this.db.prepare( `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` ); const linkUser = this.db.prepare( `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` ); for (const t of input.tags) { const row = getOrInsertTag.get(t.name) as { id: number }; if (t.source === 'ai') linkAi.run(finalId, row.id); else linkUser.run(finalId, row.id); } } }); tx(); return { id: finalId, status }; } getPendingCount(): number { const row = this.db .prepare( `SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL` ) .get() as { c: number }; return row.c; } /** * Count notes whose `created_at` falls on the KST calendar date of `now`. * KST = UTC+9. We compute the UTC half-open interval * [KST-midnight today, KST-midnight tomorrow) * and count rows whose UTC ISO `created_at` lies inside. */ countToday(now: Date = new Date()): number { const KST_OFFSET_MS = 9 * 60 * 60 * 1000; const kstNow = new Date(now.getTime() + KST_OFFSET_MS); const kstYear = kstNow.getUTCFullYear(); const kstMonth = kstNow.getUTCMonth(); const kstDate = kstNow.getUTCDate(); const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS; const nextKstMidnightUtc = kstMidnightUtc + 24 * 60 * 60 * 1000; const startIso = new Date(kstMidnightUtc).toISOString(); const endIso = new Date(nextKstMidnightUtc).toISOString(); const row = this.db .prepare( `SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?` ) .get(startIso, endIso) 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, dueDate: row.due_date ?? null, dueDateEditedByUser: row.due_date_edited_by_user === 1, deletedAt: row.deleted_at ?? null, lastRecalledAt: row.last_recalled_at ?? null, recallDismissedAt: row.recall_dismissed_at ?? null, createdAt: row.created_at, updatedAt: row.updated_at, tags: tags as NoteTag[], media }; } }