import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { Note, NoteMedia, NoteTag } from '@shared/types'; import { todayInKstString } from '../util/kstDate.js'; 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; } const KEBAB_CASE_RE = /^[a-z0-9-]+$/; 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(); } findFailedIds(): string[] { const rows = this.db .prepare( `SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC` ) .all() as Array<{ id: string }>; return rows.map((r) => r.id); } countFailed(): number { const row = this.db .prepare( `SELECT COUNT(*) AS c FROM notes WHERE ai_status='failed' AND deleted_at IS NULL` ) .get() as { c: number }; return row.c; } /** * 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset 하고 pending_jobs 재투입. * 단일 transaction. v0.2.3 #2 retryAllFailed. * * INSERT OR IGNORE 로 race 안전 (이미 pending_jobs row 존재 시 skip). */ retryAllFailed(now: string): { ids: string[] } { const ids: string[] = []; const tx = this.db.transaction(() => { const rows = this.db .prepare(`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`) .all() as Array<{ id: string }>; if (rows.length === 0) return; const reset = this.db.prepare( `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` ); const insert = this.db.prepare( `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` ); for (const r of rows) { reset.run(now, r.id); insert.run(r.id, now); ids.push(r.id); } }); tx(); return { ids }; } /** * pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음. * v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로). */ setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void { this.db .prepare( `UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?` ) .run(nextRunAt, lastError.slice(0, 500), noteId); } /** * v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선. * - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전) * - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트 * - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today) * KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가. */ findRecallCandidate(): Note | null { const row = this.db .prepare( `SELECT * FROM notes WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day')) AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day')) AND ai_status = 'done' AND deleted_at IS NULL AND (due_date IS NULL OR due_date >= date('now','+9 hours')) ORDER BY created_at ASC LIMIT 1` ) .get() as Record | undefined; return row ? this.hydrate(row) : null; } /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */ markRecallOpened(id: string, now: string): void { this.db .prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`) .run(now, now, id); } /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */ dismissRecall(id: string, now: string): void { this.db .prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`) .run(now, now, id); } /** * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). * deleted_at IS NULL 만 (휴지통 노트 태그 제외). * * Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨. * 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case * 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음. */ getTopUsedTags(limit = 20): string[] { const rows = this.db .prepare( `SELECT t.name, COUNT(*) AS c FROM tags t JOIN note_tags nt ON nt.tag_id = t.id JOIN notes n ON n.id = nt.note_id WHERE n.deleted_at IS NULL GROUP BY t.id ORDER BY c DESC, t.id ASC LIMIT ?` ) .all(limit) as Array<{ name: string; c: number }>; return rows .map((r) => r.name) .filter((n) => KEBAB_CASE_RE.test(n)); } /** * v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장. * tags.name COLLATE NOCASE 라 case-insensitive lookup. */ getTagIdByName(name: string): number | null { const row = this.db .prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`) .get(name) as { id: number } | undefined; return row ? row.id : null; } 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(); } /** * Atomically transition a batch of notes from active → trash. * Returns the number of notes that actually transitioned (i.e. were active * before the call). Already-trashed and unknown ids are silent skips — * counting them would inflate `expired_batch_trash` telemetry. * * Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup * invariant (§9.2 of #4 spec). */ trashBatch(ids: string[], deletedAt: string): { trashedCount: number } { if (ids.length === 0) return { trashedCount: 0 }; let trashedCount = 0; const tx = this.db.transaction((batch: string[]) => { for (const id of batch) { const row = this.db .prepare(`SELECT deleted_at FROM notes WHERE id = ?`) .get(id) as { deleted_at: string | null } | undefined; if (!row || row.deleted_at !== null) continue; this.trash(id, deletedAt); trashedCount += 1; } }); tx(ids); return { trashedCount }; } 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; } /** * Notes whose due_date is strictly before today (KST calendar) and that are * still active (not trashed) and AI-processed. Includes both AI-extracted and * user-edited due_date (v0.2.3 #5 spec §1 Q1=B). * * Caller may inject `now` for testability; defaults to `new Date()`. */ findExpiredCandidates(now: Date = new Date()): Note[] { const today = todayInKstString(now); const rows = this.db .prepare( `SELECT * FROM notes WHERE due_date IS NOT NULL AND due_date < ? AND deleted_at IS NULL AND ai_status = 'done' ORDER BY created_at DESC, id DESC` ) .all(today) as any[]; return rows.map((r) => this.hydrate(r)); } 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 }; } }