- M1: getTopUsedTags 의 LIMIT-then-filter 의미 docstring 명시 - M2: AI+user source 통합 테스트 강화 — 카운트 차이로 정렬 검증 (toContain 만으론 약함) updateUserAiFields 는 tags REPLACE 방식 (DELETE+reinsert) 이므로 fallback 패턴 사용: 3개 노트 각 1태그, AI/user 혼합으로 design=2 > meeting=1 검증 - N1: SQL "COUNT(*) c" → "COUNT(*) AS c" (countFailed 패턴과 일관) - N2: kebab-case regex 모듈 상수 KEBAB_CASE_RE 로 hoist skip: N3 (test 헬퍼 — verbosity 경미), N4 (it 블록 분리 — 코드베이스 패턴 유지) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
617 lines
21 KiB
TypeScript
617 lines
21 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';
|
|
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 #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
|
|
};
|
|
}
|
|
}
|