Files
inkling/src/main/repository/NotebookRepository.ts
th-kim0823 eca91a1e7c feat(notebook): m009 sort_order 컬럼 + reorder 메서드 + IPC notebook:reorder
- m009 마이그레이션: notebooks.sort_order INTEGER 컬럼 추가, 기존 rows created_at 순으로 backfill
- NotebookRepository.list ORDER BY sort_order ASC, name ASC 로 변경
- NotebookRepository.create 신규 노트북 sort_order = max+1 자동 할당
- NotebookRepository.reorder(id, direction) — swap transaction 으로 atomic 순서 변경
- IPC notebook:reorder 핸들러 등록, preload/shared types pass-through
- 테스트 45개 추가 (m009, reorder 케이스 4, list ORDER BY, IPC 핸들러 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:06:44 +09:00

124 lines
5.3 KiB
TypeScript

import type Database from 'better-sqlite3';
import { v7 as uuidv7 } from 'uuid';
import type { Notebook } from '@shared/types';
export class NotebookRepository {
constructor(private db: Database.Database) {}
list(): Notebook[] {
const rows = this.db.prepare(
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
(SELECT COUNT(*) FROM notes n
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
FROM notebooks nb
ORDER BY nb.sort_order ASC, nb.name ASC`
).all() as Array<Record<string, unknown>>;
return rows.map((r) => this.hydrate(r));
}
findById(id: string): Notebook | null {
const r = this.db.prepare(
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
(SELECT COUNT(*) FROM notes n
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
FROM notebooks nb WHERE nb.id = ?`
).get(id) as Record<string, unknown> | undefined;
return r ? this.hydrate(r) : null;
}
/** name 은 COLLATE NOCASE UNIQUE — case-insensitive 중복 거부. */
create(input: { name: string; color?: string | null }): Notebook {
const id = uuidv7();
const now = new Date().toISOString();
const maxRow = this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM notebooks`).get() as { m: number };
const sortOrder = maxRow.m + 1;
this.db.prepare(
`INSERT INTO notebooks(id, name, color, created_at, updated_at, sort_order) VALUES(?,?,?,?,?,?)`
).run(id, input.name, input.color ?? null, now, now, sortOrder);
return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 };
}
reorder(id: string, direction: 'up' | 'down'): { ok: boolean } {
const cur = this.db.prepare(`SELECT sort_order FROM notebooks WHERE id = ?`).get(id) as { sort_order: number } | undefined;
if (!cur) return { ok: false };
const neighbor = direction === 'up'
? this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order < ? ORDER BY sort_order DESC LIMIT 1`).get(cur.sort_order)
: this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order > ? ORDER BY sort_order ASC LIMIT 1`).get(cur.sort_order);
if (!neighbor) return { ok: false }; // 이미 끝
const n = neighbor as { id: string; sort_order: number };
const now = new Date().toISOString();
const tx = this.db.transaction(() => {
this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(n.sort_order, now, id);
this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(cur.sort_order, now, n.id);
});
tx();
return { ok: true };
}
rename(id: string, name: string): void {
const now = new Date().toISOString();
this.db.prepare(`UPDATE notebooks SET name=?, updated_at=? WHERE id=?`).run(name, now, id);
}
setColor(id: string, color: string | null): void {
const now = new Date().toISOString();
this.db.prepare(`UPDATE notebooks SET color=?, updated_at=? WHERE id=?`).run(color, now, id);
}
/** FK RESTRICT 가 메모 잔류 시 throw — ok:false 로 변환. */
delete(id: string): { ok: true } | { ok: false; reason: 'has_notes' | 'not_found' } {
const exists = this.db.prepare(`SELECT 1 FROM notebooks WHERE id=?`).get(id);
if (!exists) return { ok: false, reason: 'not_found' };
try {
this.db.prepare(`DELETE FROM notebooks WHERE id=?`).run(id);
return { ok: true };
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('FOREIGN KEY')) return { ok: false, reason: 'has_notes' };
throw e;
}
}
/** name 으로 notebook 조회 (COLLATE NOCASE — case-insensitive). */
findByName(name: string): Notebook | null {
const r = this.db.prepare(
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
(SELECT COUNT(*) FROM notes n
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
FROM notebooks nb WHERE nb.name = ? COLLATE NOCASE`
).get(name) as Record<string, unknown> | undefined;
return r ? this.hydrate(r) : null;
}
/**
* v0.4 Task 11 — 가장 오래된 (created_at ASC LIMIT 1) notebook = default.
* m008 마이그레이션이 기존 노트를 자동으로 이 notebook 에 할당.
*/
getDefault(): Notebook | null {
const r = this.db.prepare(
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
(SELECT COUNT(*) FROM notes n
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
FROM notebooks nb ORDER BY nb.created_at ASC LIMIT 1`
).get() as Record<string, unknown> | undefined;
return r ? this.hydrate(r) : null;
}
/** notes.notebook_id 갱신만 (status 등은 보존). */
moveNote(noteId: string, notebookId: string): void {
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
.run(notebookId, new Date().toISOString(), noteId);
}
private hydrate(r: Record<string, unknown>): Notebook {
return {
id: r.id as string,
name: r.name as string,
color: (r.color as string | null) ?? null,
createdAt: r.created_at as string,
updatedAt: r.updated_at as string,
noteCount: r.note_count as number
};
}
}