diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index cbbfc00..5a540e8 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -4,8 +4,9 @@ import * as m002 from './m002_due_date.js'; 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'; -const migrations = [m001, m002, m003, m004, m005]; +const migrations = [m001, m002, m003, m004, m005, m006]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m006_revisions.ts b/src/main/db/migrations/m006_revisions.ts new file mode 100644 index 0000000..121e2eb --- /dev/null +++ b/src/main/db/migrations/m006_revisions.ts @@ -0,0 +1,23 @@ +// v6: note_revisions 테이블 + 기존 notes 의 raw_text 를 edited_by='capture' revision 으로 backfill. +// FK ON DELETE CASCADE — notes 영구 삭제 시 revision 도 함께 삭제. +import type Database from 'better-sqlite3'; + +export const version = 6; + +export function up(db: Database.Database): void { + db.exec(` + CREATE TABLE note_revisions ( + rev_id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id TEXT NOT NULL, + raw_text TEXT NOT NULL, + edited_at TEXT NOT NULL, + edited_by TEXT NOT NULL DEFAULT 'user' + CHECK (edited_by IN ('user','capture')), + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE + ); + CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC); + + INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + SELECT id, raw_text, created_at, 'capture' FROM notes; + `); +} diff --git a/tests/unit/m006-migration.test.ts b/tests/unit/m006-migration.test.ts new file mode 100644 index 0000000..f27464e --- /dev/null +++ b/tests/unit/m006-migration.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { up } from '../../src/main/db/migrations/m006_revisions.js'; + +describe('m006 migration — note_revisions table', () => { + 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 + ); + INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES ('a', 'first text', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'), + ('b', 'second text', 'done', '2026-05-02T00:00:00Z', '2026-05-02T00:00:00Z'); + `); + }); + + afterEach(() => { db.close(); }); + + it('creates note_revisions table with required columns', () => { + up(db); + const cols = db.prepare(`PRAGMA table_info(note_revisions)`).all() as Array<{ name: string }>; + const names = cols.map((c) => c.name); + expect(names).toEqual( + expect.arrayContaining(['rev_id', 'note_id', 'raw_text', 'edited_at', 'edited_by']) + ); + }); + + it('creates idx_note_revisions_note_id index', () => { + up(db); + const idx = db.prepare(`PRAGMA index_list(note_revisions)`).all() as Array<{ name: string }>; + expect(idx.map((i) => i.name)).toContain('idx_note_revisions_note_id'); + }); + + it('cascades on note delete (FK ON DELETE CASCADE)', () => { + up(db); + db.prepare( + `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES ('a', 'manual rev', '2026-05-03T00:00:00Z', 'user')` + ).run(); + db.prepare(`DELETE FROM notes WHERE id=?`).run('a'); + const rows = db.prepare(`SELECT * FROM note_revisions WHERE note_id=?`).all('a'); + expect(rows).toHaveLength(0); + }); + + it("backfills existing notes as edited_by='capture' revisions", () => { + up(db); + const rows = db + .prepare(`SELECT note_id, raw_text, edited_at, edited_by FROM note_revisions ORDER BY note_id`) + .all() as Array<{ note_id: string; raw_text: string; edited_at: string; edited_by: string }>; + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ + note_id: 'a', + raw_text: 'first text', + edited_at: '2026-05-01T00:00:00Z', + edited_by: 'capture' + }); + expect(rows[1]).toEqual({ + note_id: 'b', + raw_text: 'second text', + edited_at: '2026-05-02T00:00:00Z', + edited_by: 'capture' + }); + }); + + it('exports version=6', async () => { + const mod = await import('../../src/main/db/migrations/m006_revisions.js'); + expect(mod.version).toBe(6); + }); +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index 7936510..e5d3ca3 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 (5)', () => { + it('user_version reaches latest (6)', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; - expect(row.user_version).toBe(5); + expect(row.user_version).toBe(6); db.close(); });