Files
inkling/src/main/repository/NoteRepository.ts
th-kim0823 2e69f598bc chore(release): v0.3.9 — AI 흐름 unblock UI + FTS5 escape
audit edge case 3건:
- pending 노트 "건너뛰기" 버튼 (cancelPending: pending → disabled + jobs DELETE)
- failed 노트 per-note "재시도" 버튼 (retryOneFailed: failed → pending + enqueue)
- FTS5 sanitize regex 확장 (backtick/dash/caret 추가)

동시 편집 race 는 EditableField guard 가 이미 처리 (수정 불필요).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:43:46 +09:00

1203 lines
44 KiB
TypeScript

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<string, unknown>;
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<string, unknown>[])
: (this.db
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(limit) as Record<string, unknown>[]);
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<string, unknown>[];
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<string, unknown> | 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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>): 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
};
}
}