feat(v0210): m006 migration — note_revisions 테이블 + capture backfill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-09 20:32:32 +09:00
parent 88ce78d860
commit 76c23457ee
4 changed files with 122 additions and 3 deletions

View File

@@ -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;

View File

@@ -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;
`);
}

View File

@@ -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);
});
});

View File

@@ -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();
});