diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 5a540e8..8b09003 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -5,8 +5,9 @@ import * as m003 from './m003_soft_delete.js'; 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'; -const migrations = [m001, m002, m003, m004, m005, m006]; +const migrations = [m001, m002, m003, m004, m005, m006, m007]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m007_fts.ts b/src/main/db/migrations/m007_fts.ts new file mode 100644 index 0000000..1a1e518 --- /dev/null +++ b/src/main/db/migrations/m007_fts.ts @@ -0,0 +1,48 @@ +// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill. +// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를 +// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path. +import type Database from 'better-sqlite3'; + +export const version = 7; + +export function up(db: Database.Database): void { + db.exec(` + CREATE VIRTUAL TABLE notes_fts USING fts5( + note_id UNINDEXED, + raw_text, + ai_title, + ai_summary, + tags, + tokenize='unicode61' + ); + + CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), ''); + END; + + CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN + DELETE FROM notes_fts WHERE note_id = OLD.id; + END; + + CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN + UPDATE notes_fts + SET raw_text = NEW.raw_text, + ai_title = COALESCE(NEW.ai_title, ''), + ai_summary = COALESCE(NEW.ai_summary, '') + WHERE note_id = NEW.id; + END; + + INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags) + SELECT + n.id, + n.raw_text, + COALESCE(n.ai_title, ''), + COALESCE(n.ai_summary, ''), + COALESCE((SELECT GROUP_CONCAT(t.name, ' ') + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = n.id), '') + FROM notes n + WHERE n.status != 'trashed'; + `); +} diff --git a/tests/unit/m007-migration.test.ts b/tests/unit/m007-migration.test.ts new file mode 100644 index 0000000..4a37651 --- /dev/null +++ b/tests/unit/m007-migration.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { up } from '../../src/main/db/migrations/m007_fts.js'; + +describe('m007 migration — notes_fts virtual table + triggers', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + db.exec(` + CREATE TABLE notes ( + id TEXT PRIMARY KEY, raw_text TEXT NOT NULL, + ai_title TEXT, ai_summary TEXT, + ai_status TEXT NOT NULL CHECK (ai_status IN ('pending','done','failed','disabled')), + ai_error TEXT, ai_provider TEXT, ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0, + summary_edited_by_user INTEGER NOT NULL DEFAULT 0, + user_intent TEXT, intent_prompted_at TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + due_date TEXT, due_date_edited_by_user INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, last_recalled_at TEXT, recall_dismissed_at TEXT, + status TEXT NOT NULL DEFAULT 'active', status_changed_at TEXT, move_reason TEXT + ); + CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE); + CREATE TABLE note_tags ( + note_id TEXT NOT NULL, tag_id INTEGER NOT NULL, source TEXT NOT NULL, + PRIMARY KEY(note_id, tag_id), + FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status) + VALUES + ('a', '오늘 회의 정리', '회의록', '월요일 회의', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', 'active'), + ('b', '예전 메모', '예전 제목', '예전 요약', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'completed'), + ('c', '버려진 메모', '버린 제목', '버린 요약', 'done', '2026-03-01T00:00:00Z', '2026-03-01T00:00:00Z', 'trashed'); + INSERT INTO tags (id, name) VALUES (1, '기획'), (2, '회의'); + INSERT INTO note_tags (note_id, tag_id, source) VALUES ('a', 1, 'ai'), ('a', 2, 'user'); + `); + }); + + afterEach(() => { db.close(); }); + + it('creates notes_fts virtual table with FTS5 columns', () => { + up(db); + const rows = db.prepare(`SELECT sql FROM sqlite_master WHERE name='notes_fts'`).all() as Array<{ sql: string }>; + expect(rows).toHaveLength(1); + expect(rows[0]!.sql.toLowerCase()).toContain('using fts5'); + }); + + it('backfills active/completed notes; excludes trashed', () => { + up(db); + const rows = db + .prepare(`SELECT note_id, ai_title, tags FROM notes_fts ORDER BY note_id`) + .all() as Array<{ note_id: string; ai_title: string; tags: string }>; + expect(rows.map((r) => r.note_id)).toEqual(['a', 'b']); + const a = rows.find((r) => r.note_id === 'a')!; + expect(a.ai_title).toBe('회의록'); + expect(a.tags.split(' ').sort()).toEqual(['기획', '회의']); + const b = rows.find((r) => r.note_id === 'b')!; + expect(b.tags).toBe(''); + }); + + it('AFTER INSERT trigger syncs new note', () => { + up(db); + db.prepare(`INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status) + VALUES ('d', '새 메모', '새 제목', '새 요약', 'pending', '2026-05-09T00:00:00Z', '2026-05-09T00:00:00Z', 'active')`).run(); + const r = db.prepare(`SELECT raw_text, ai_title FROM notes_fts WHERE note_id=?`).get('d') as { raw_text: string; ai_title: string }; + expect(r.raw_text).toBe('새 메모'); + expect(r.ai_title).toBe('새 제목'); + }); + + it('AFTER UPDATE trigger syncs raw_text + ai_title + ai_summary', () => { + up(db); + db.prepare(`UPDATE notes SET raw_text=?, ai_title=?, ai_summary=?, updated_at=? WHERE id=?`) + .run('수정한 본문', '수정 제목', '수정 요약', '2026-05-10T00:00:00Z', 'a'); + const r = db.prepare(`SELECT raw_text, ai_title, ai_summary FROM notes_fts WHERE note_id=?`).get('a') as { + raw_text: string; ai_title: string; ai_summary: string; + }; + expect(r.raw_text).toBe('수정한 본문'); + expect(r.ai_title).toBe('수정 제목'); + expect(r.ai_summary).toBe('수정 요약'); + }); + + it('AFTER DELETE trigger removes FTS row', () => { + up(db); + db.prepare(`DELETE FROM notes WHERE id=?`).run('a'); + const r = db.prepare(`SELECT * FROM notes_fts WHERE note_id=?`).all('a'); + expect(r).toHaveLength(0); + }); + + it('exports version=7', async () => { + const mod = await import('../../src/main/db/migrations/m007_fts.js'); + expect(mod.version).toBe(7); + }); +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index e5d3ca3..bc6da16 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => { db.close(); }); - it('user_version reaches latest (6)', () => { + it('user_version reaches latest (7)', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; - expect(row.user_version).toBe(6); + expect(row.user_version).toBe(7); db.close(); });