Files
inkling/src/main/repository/NoteRepository.ts
altair823 87b6d71628 fix(trash): add repo.countTrashed() — fix UI 200-cap mismatch (review 회차 1)
PR #14 회차 1 review actionable — `inbox:trashCount` 와 `emptyTrash` dialog
가 `listTrashed({limit:200})` 로 카운트를 도출하면서 (a) hot path 에서 N rows
+ tags/media JOIN hydrate 비효율 (b) trash > 200 시 dialog message 가
실제 SQL DELETE 동작과 mismatch ('200개 영구 삭제합니다' 표시 vs 500개
실제 삭제) 발생.

NoteRepository.countTrashed() — `SELECT COUNT(*) FROM notes WHERE deleted_at
IS NOT NULL` 단일 쿼리. hydrate 없이 정확한 카운트만 반환. 두 IPC 핸들러를
이 메서드 호출로 교체.

테스트: 3 신규 단위 테스트 (0 trash / 부분 trash / 200 cap 초과 범위)
292 → 295 (+3). typecheck 0 errors.

deferrable (v0.2.4 backlog 그대로): AiWorker race guard 강화, restore self-guard,
limit 200 매직 넘버 상수화.

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

469 lines
16 KiB
TypeScript

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
};
}
}