feat(v0210): m006 migration — note_revisions 테이블 + capture backfill
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
23
src/main/db/migrations/m006_revisions.ts
Normal file
23
src/main/db/migrations/m006_revisions.ts
Normal 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;
|
||||
`);
|
||||
}
|
||||
95
tests/unit/m006-migration.test.ts
Normal file
95
tests/unit/m006-migration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user