- 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>
124 lines
5.3 KiB
TypeScript
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
|
|
};
|
|
}
|
|
}
|