import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js'; import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js'; export interface CreateNoteInput { rawText: string; /** * v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip. * 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat). */ aiStatus?: AiStatus; } 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 — fork-on-conflict so a single * id never resolves to two distinct historical baselines (v0.2.10 Cut C * changed `raw_text 불변` policy → `raw_text 가변` + revision history; the * baseline distinction is now preserved per-id, edit history per-note). */ 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 interface UpsertFromSyncInput { 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' }[]; status: NoteStatus; statusChangedAt: string | null; moveReason: string | null; dueDate: string | null; dueDateEditedByUser: boolean; } export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped'; const KEBAB_CASE_RE = /^[a-z0-9-]+$/; export class NoteRepository { constructor(private db: Database.Database) {} create(input: CreateNoteInput, now: Date = new Date()): { id: string } { const id = uuidv7(); const ts = now.toISOString(); const aiStatus: AiStatus = input.aiStatus ?? 'pending'; const tx = this.db.transaction(() => { this.db .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`) .run(id, input.rawText, aiStatus, ts, ts); this.db .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'capture')`) .run(id, input.rawText, ts); // pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함. if (aiStatus === 'pending') { this.db .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`) .run(id, ts); } }); 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 Record; 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 Record[]) : (this.db .prepare( `SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ?` ) .all(limit) as Record[]); 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 Record[]; 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); this.rebuildFtsTagsForNote(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 }; } /** * v0.3.9 — 단일 failed 노트 재시도. retryAllFailed 의 per-row 로직 동일. * NoteCard 의 per-note "재시도" 버튼 path. failed 가 아닌 status 면 no-op. */ retryOneFailed(id: string, now: string): { ok: boolean } { const row = this.db .prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`) .get(id) as { ai_status: string } | undefined; if (!row || row.ai_status !== 'failed') return { ok: false }; const tx = this.db.transaction(() => { this.db .prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`) .run(now, id); this.db .prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`) .run(id, now); }); tx(); return { ok: true }; } /** * v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제. * raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path. * pending 외 status 면 no-op. */ cancelPending(id: string, now: string): { ok: boolean } { const row = this.db .prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`) .get(id) as { ai_status: string } | undefined; if (!row || row.ai_status !== 'pending') return { ok: false }; const tx = this.db.transaction(() => { this.db .prepare(`UPDATE notes SET ai_status='disabled', ai_error=NULL, updated_at=? WHERE id=?`) .run(now, id); this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); }); tx(); return { ok: true }; } /** * v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고 * pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리" * 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성). * * INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip). * 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용). */ requeueDisabled(now: Date = new Date()): number { const tx = this.db.transaction(() => { const ts = now.toISOString(); const targets = this.db .prepare(`SELECT id FROM notes WHERE ai_status='disabled'`) .all() as Array<{ id: string }>; for (const { id } of targets) { this.db .prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`) .run(ts, id); this.db .prepare( `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` ) .run(id, ts); } return targets.length; }); return tx(); } /** * v0.2.9 Cut B Task 16 — ai_status 별 row count. * 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트). * deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는 * "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가. */ countByAiStatus(status: AiStatus): number { const row = this.db .prepare( `SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL` ) .get(status) as { c: number }; return row.c; } /** * 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); } this.rebuildFtsTagsForNote(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); } /** * v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에 * edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성). * * 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨. */ updateRawText(id: string, newText: string, now: Date = new Date()): void { const ts = now.toISOString(); const tx = this.db.transaction(() => { this.db .prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`) .run(newText, ts, id); this.db .prepare( `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'user')` ) .run(id, newText, ts); }); tx(); } /** * v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환. * NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak. */ listRevisions(id: string): NoteRevision[] { const rows = this.db .prepare( `SELECT rev_id, note_id, raw_text, edited_at, edited_by FROM note_revisions WHERE note_id = ? ORDER BY edited_at DESC, rev_id DESC` ) .all(id) as Array<{ rev_id: number; note_id: string; raw_text: string; edited_at: string; edited_by: 'user' | 'capture'; }>; return rows.map((r) => ({ revId: r.rev_id, noteId: r.note_id, rawText: r.raw_text, editedAt: r.edited_at, editedBy: r.edited_by })); } /** * v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고 * 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이 * 아니면 throw — restore 대상 잘못 매칭 방지. */ restoreRevision(id: string, revId: number, now: Date = new Date()): void { const rev = this.db .prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`) .get(revId, id) as { raw_text: string } | undefined; if (!rev) throw new Error(`revision ${revId} not found for note ${id}`); this.updateRawText(id, rev.raw_text, now); } /** * v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed). * status + status_changed_at + move_reason + updated_at 갱신 + deleted_at * backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL). * * 단일 transaction. 호출자가 `now` 주입 가능 (테스트성). */ setStatus( id: string, status: NoteStatus, reason: string | null, now: Date = new Date() ): void { const tx = this.db.transaction(() => { const ts = now.toISOString(); this.db .prepare( `UPDATE notes SET status = ?, move_reason = ?, status_changed_at = ?, updated_at = ? WHERE id = ?` ) .run(status, reason, ts, ts, id); // backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화. if (status === 'trashed') { this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id); } else { this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id); } }); tx(); } /** * v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용. * tags/media hydrate 없음 (cheap path, listByStatus 와 별도). */ countByStatus(status: NoteStatus): number { const row = this.db .prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`) .get(status) as { c: number }; return row.c; } /** * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). */ listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] { const limit = Math.max(1, Math.min(200, opts.limit ?? 200)); const rows = this.db .prepare( `SELECT * FROM notes WHERE status = ? ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC LIMIT ?` ) .all(status, limit) as Record[]; return rows.map((r) => this.hydrate(r)); } /** * v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외. * 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize. */ search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] { const sanitized = sanitizeFtsQuery(query); if (sanitized.length === 0) return []; const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`; const sql = ` SELECT n.* FROM notes n JOIN notes_fts f ON n.id = f.note_id WHERE notes_fts MATCH ? ${statusClause} ORDER BY rank LIMIT ? `; const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit]; const rows = this.db.prepare(sql).all(...args) as Record[]; return rows.map((r) => this.hydrate(r)); } /** * v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트 * (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) / * dueProgress(passed/pending KST today 기준). */ reviewAggregate(period: ReviewPeriod, now: Date = new Date()): { totalCount: number; recentNotes: Note[]; tagCounts: Array<{ tag: string; count: number }>; dueProgress: { total: number; passed: number; pending: number }; } { const cutoff = computeCutoff(period, now); const todayIso = kstTodayIso(now); const totalCount = (this.db .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`) .get(cutoff) as { c: number }).c; const recentRows = this.db .prepare( `SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed' ORDER BY created_at DESC, id DESC LIMIT 50` ) .all(cutoff) as Record[]; const recentNotes = recentRows.map((r) => this.hydrate(r)); const tagCounts = this.db .prepare( `SELECT t.name AS tag, COUNT(*) AS count FROM note_tags nt JOIN notes n ON n.id = nt.note_id JOIN tags t ON t.id = nt.tag_id WHERE n.created_at >= ? AND n.status != 'trashed' GROUP BY t.id ORDER BY count DESC, t.name ASC` ) .all(cutoff) as Array<{ tag: string; count: number }>; const dueRow = this.db .prepare( `SELECT COUNT(*) AS total, SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed, SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending FROM notes WHERE created_at >= ? AND status != 'trashed' AND due_date IS NOT NULL` ) .get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null }; const dueProgress = { total: dueRow.total, passed: dueRow.passed ?? 0, pending: dueRow.pending ?? 0 }; return { totalCount, recentNotes, tagCounts, dueProgress }; } /** * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 + * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입). */ restoreNote(id: string): void { const tx = this.db.transaction(() => { const before = this.db .prepare(`SELECT ai_status FROM notes WHERE id = ?`) .get(id) as { ai_status: string } | undefined; // setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신. this.setStatus(id, 'active', null); const now = new Date().toISOString(); // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 if (before?.ai_status === 'failed') { this.db .prepare( `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` ) .run(now, id); this.db .prepare( `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` ) .run(id, now); } else if (before?.ai_status === 'pending') { // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent) this.db .prepare( `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` ) .run(id, now); } // done 노트는 재처리 안 함 (이미 결과 있음) }); tx(); } 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 Record[]; 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 (fork-on-id-collision): * - 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 so the same id * never points at two different baselines (status: 'forked'). v0.2.10 Cut C * relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`, * but per-id baseline distinction is still required for sync determinism. * * v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에 * 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user * edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견). * * v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId) * 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는 * notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서 * 매칭 안 되는 회귀 (final review 발견). * * 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 ); this.db .prepare( `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'capture')` ) .run(finalId, input.rawText, input.createdAt); 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); } // v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path). this.rebuildFtsTagsForNote(finalId); } }); tx(); return { id: finalId, status }; } /** * v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은 * sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가). * * 3 분기: * - id 없음 → INSERT (capture revision + tags FTS sync) * - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신 * - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision), * local 이 더 최신이면 skip * * tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용. * raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용. */ upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } { const existing = this.db .prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`) .get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined; if (!existing) { // INSERT path 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, created_at, updated_at, due_date, due_date_edited_by_user, status, status_changed_at, move_reason) VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( input.id, input.rawText, input.aiTitle, input.aiSummary, input.aiProvider, input.aiGeneratedAt, input.titleEditedByUser ? 1 : 0, input.summaryEditedByUser ? 1 : 0, input.userIntent, input.intentPromptedAt, input.createdAt, input.updatedAt, input.dueDate, input.dueDateEditedByUser ? 1 : 0, input.status, input.statusChangedAt, input.moveReason ); this.db .prepare( `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'capture')` ) .run(input.id, input.rawText, input.createdAt); 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(input.id, row.id); else linkUser.run(input.id, row.id); } this.rebuildFtsTagsForNote(input.id); } }); tx(); return { id: input.id, status: 'inserted' }; } if (input.updatedAt <= existing.updated_at) { return { id: input.id, status: 'skipped' }; } if (existing.raw_text !== input.rawText) { this.updateRawText(input.id, input.rawText, new Date(input.updatedAt)); } 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_provider = ?, ai_generated_at = ?, due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END, status = ?, status_changed_at = ?, move_reason = ?, updated_at = ? WHERE id = ?` ) .run( input.aiTitle, input.aiSummary, input.aiProvider, input.aiGeneratedAt, input.dueDate, input.status, input.statusChangedAt, input.moveReason, input.updatedAt, input.id ); this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id); 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(input.id, row.id); else linkUser.run(input.id, row.id); } } this.rebuildFtsTagsForNote(input.id); }); tx(); return { id: input.id, status: 'updated' }; } 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 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 = kstTodayIso(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 Record[]; 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 Record[]; return rows.map((r) => ({ noteId: r.note_id as string, attempts: r.attempts as number, nextRunAt: r.next_run_at as string })); } 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); } /** * v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성. * 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출. */ private rebuildFtsTagsForNote(noteId: string): void { const row = this.db .prepare( `SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv FROM note_tags nt JOIN tags t ON t.id = nt.tag_id WHERE nt.note_id = ?` ) .get(noteId) as { csv: string }; this.db .prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`) .run(row.csv, noteId); } private hydrate(row: Record): 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 string) 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 string) as NoteMedia[]; return { id: row.id as string, rawText: row.raw_text as string, aiTitle: row.ai_title as string | null, aiSummary: row.ai_summary as string | null, aiStatus: row.ai_status as AiStatus, aiError: row.ai_error as string | null, aiProvider: row.ai_provider as string | null, aiGeneratedAt: row.ai_generated_at as string | null, titleEditedByUser: (row.title_edited_by_user as number) === 1, summaryEditedByUser: (row.summary_edited_by_user as number) === 1, userIntent: row.user_intent as string | null, intentPromptedAt: row.intent_prompted_at as string | null, dueDate: (row.due_date as string | null) ?? null, dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1, deletedAt: (row.deleted_at as string | null) ?? null, lastRecalledAt: (row.last_recalled_at as string | null) ?? null, recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null, status: ((row.status as NoteStatus | undefined) ?? 'active'), statusChangedAt: (row.status_changed_at as string | null) ?? null, moveReason: (row.move_reason as string | null) ?? null, createdAt: row.created_at as string, updatedAt: row.updated_at as string, tags: tags as NoteTag[], media }; } }