diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 8b09003..bfa01e2 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -6,8 +6,9 @@ import * as m004 from './m004_status.js'; import * as m005 from './m005_ai_disabled.js'; import * as m006 from './m006_revisions.js'; import * as m007 from './m007_fts.js'; +import * as m008 from './m008_notebooks.js'; -const migrations = [m001, m002, m003, m004, m005, m006, m007]; +const migrations = [m001, m002, m003, m004, m005, m006, m007, m008]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m008_notebooks.ts b/src/main/db/migrations/m008_notebooks.ts new file mode 100644 index 0000000..dcc982f --- /dev/null +++ b/src/main/db/migrations/m008_notebooks.ts @@ -0,0 +1,37 @@ +// v8: notebooks 테이블 + notes.notebook_id (FK) + archived → completed 정리. +// CHECK 제약 없는 status 컬럼이라 SQL 변경은 데이터 정리만, enum 단속은 TypeScript 측에서. +import type Database from 'better-sqlite3'; +import { v7 as uuidv7 } from 'uuid'; + +export const version = 8; + +const DEFAULT_NOTEBOOK_NAME = '기본'; + +export function up(db: Database.Database): void { + const now = new Date().toISOString(); + const defaultId = uuidv7(); + + db.exec(` + CREATE TABLE notebooks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX idx_notebooks_name ON notebooks(name); + + ALTER TABLE notes ADD COLUMN notebook_id TEXT + REFERENCES notebooks(id) ON DELETE RESTRICT; + CREATE INDEX idx_notes_notebook_id ON notes(notebook_id); + `); + + db.prepare( + `INSERT INTO notebooks (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run(defaultId, DEFAULT_NOTEBOOK_NAME, now, now); + + db.prepare(`UPDATE notes SET notebook_id = ? WHERE notebook_id IS NULL`).run(defaultId); + + // archived 잔류 (dogfood 0건 확인됐지만 defensive) → completed 로 통합. + db.prepare(`UPDATE notes SET status='completed' WHERE status='archived'`).run(); +} diff --git a/tests/unit/m008.test.ts b/tests/unit/m008.test.ts new file mode 100644 index 0000000..588b595 --- /dev/null +++ b/tests/unit/m008.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; + +describe('m008 notebooks migration', () => { + let db: Database.Database; + beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); }); + afterEach(() => { db.close(); }); + + it('fresh DB: notebooks 테이블 + default "기본" notebook 생성', () => { + runMigrations(db); + const row = db.prepare(`SELECT name FROM notebooks`).get() as { name: string }; + expect(row.name).toBe('기본'); + }); + + it('기존 notes 가 default notebook 으로 마이그레이션 — fresh insert 는 NULL 유지', () => { + runMigrations(db); + const defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + db.prepare(`INSERT INTO notes(id,raw_text,ai_status,created_at,updated_at,status) VALUES('n1','t','pending','2026-05-14','2026-05-14','active')`).run(); + const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='n1'`).get() as { notebook_id: string | null }; + expect(r.notebook_id).toBeNull(); // m008 의 UPDATE 는 migration 시점의 NULL 만 채움 + }); + + it('archived 잔류 노트가 있다면 completed 로 통합', () => { + runMigrations(db); + expect(() => db.prepare(`SELECT COUNT(*) FROM notes WHERE status='archived'`).get()).not.toThrow(); + }); + + it('UNIQUE index 가 같은 이름 중복 INSERT 거부', () => { + runMigrations(db); + expect(() => + db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('x','기본','t','t')`).run() + ).toThrow(); + }); +});