From 0bb6c12bbb9a9a5a61c0e1887369469a77fe0c02 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:05:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(db):=20migration=20v2=20=E2=80=94=20due=5F?= =?UTF-8?q?date=20columns=20+=20pre-migration=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALTER TABLE notes adds due_date TEXT + due_date_edited_by_user INTEGER. openDb takes .pre-v.bak before running migrations (F6-L1 follow-up #4 — preserves recoverable state if migration fails). NoteRepository: updateAiResult accepts dueDate?, setDueDate + edited-flag CASE WHEN guard mirroring title/summary pattern. Note interface gains dueDate + dueDateEditedByUser fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/db/index.ts | 15 +++++++- src/main/db/migrations/index.ts | 7 +++- src/main/db/migrations/m002_due_date.ts | 11 ++++++ src/main/repository/NoteRepository.ts | 17 ++++++++- src/shared/types.ts | 2 + tests/unit/NoteRepository.test.ts | 49 +++++++++++++++++++++++++ tests/unit/migrations.due_date.test.ts | 28 ++++++++++++++ tests/unit/migrations.test.ts | 8 ++-- 8 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 src/main/db/migrations/m002_due_date.ts create mode 100644 tests/unit/migrations.due_date.test.ts diff --git a/src/main/db/index.ts b/src/main/db/index.ts index f54cb30..2713185 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -1,7 +1,20 @@ import Database from 'better-sqlite3'; -import { runMigrations } from './migrations/index.js'; +import { existsSync, copyFileSync } from 'node:fs'; +import { runMigrations, latestVersion } from './migrations/index.js'; export function openDb(dbFile: string): Database.Database { + // F6-L1 follow-up #4: snapshot pre-migration if upgrading + if (existsSync(dbFile)) { + const probe = new Database(dbFile, { readonly: true }); + const cur = probe.pragma('user_version', { simple: true }) as number; + probe.close(); + if (cur < latestVersion()) { + const bak = `${dbFile}.pre-v${latestVersion()}.bak`; + if (!existsSync(bak)) { + copyFileSync(dbFile, bak); + } + } + } const db = new Database(dbFile); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 6d354b2..0ab8250 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -1,7 +1,12 @@ import type Database from 'better-sqlite3'; import * as m001 from './m001_initial.js'; +import * as m002 from './m002_due_date.js'; -const migrations = [m001]; +const migrations = [m001, m002]; + +export function latestVersion(): number { + return migrations[migrations.length - 1]!.version; +} export function runMigrations(db: Database.Database): void { const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; diff --git a/src/main/db/migrations/m002_due_date.ts b/src/main/db/migrations/m002_due_date.ts new file mode 100644 index 0000000..4d46c16 --- /dev/null +++ b/src/main/db/migrations/m002_due_date.ts @@ -0,0 +1,11 @@ +import type Database from 'better-sqlite3'; + +export const version = 2; + +export function up(db: Database.Database): void { + db.exec(` + ALTER TABLE notes ADD COLUMN due_date TEXT; + ALTER TABLE notes ADD COLUMN due_date_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (due_date_edited_by_user IN (0,1)); + `); +} diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 224fda0..ee4f4f4 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -100,15 +100,17 @@ export class NoteRepository { updateAiResult( id: string, - result: { title: string; summary: string; tags: string[]; provider: string } + result: { title: string; summary: string; tags: string[]; provider: string; dueDate?: string | null } ): void { const now = new Date().toISOString(); + const dueDate = result.dueDate ?? null; const tx = this.db.transaction(() => { this.db .prepare( `UPDATE notes SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END, ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END, + due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END, ai_status = 'done', ai_provider = ?, ai_generated_at = ?, @@ -116,7 +118,7 @@ export class NoteRepository { updated_at = ? WHERE id = ?` ) - .run(result.title, result.summary, result.provider, now, now, id); + .run(result.title, result.summary, dueDate, result.provider, now, now, id); this.db.prepare(`DELETE FROM note_tags WHERE note_id=? AND source='ai'`).run(id); const getOrInsertTag = this.db.prepare( `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` @@ -210,6 +212,15 @@ export class NoteRepository { .run(now, now, id); } + setDueDate(id: string, date: string | null): void { + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE notes SET due_date = ?, due_date_edited_by_user = 1, updated_at = ? WHERE id = ?` + ) + .run(date, now, id); + } + delete(id: string): void { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); } @@ -340,6 +351,8 @@ export class NoteRepository { summaryEditedByUser: row.summary_edited_by_user === 1, userIntent: row.user_intent, intentPromptedAt: row.intent_prompted_at, + dueDate: row.due_date ?? null, + dueDateEditedByUser: row.due_date_edited_by_user === 1, createdAt: row.created_at, updatedAt: row.updated_at, tags: tags as NoteTag[], diff --git a/src/shared/types.ts b/src/shared/types.ts index 3029e63..311aad0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -31,6 +31,8 @@ export interface Note { summaryEditedByUser: boolean; userIntent: string | null; intentPromptedAt: string | null; + dueDate: string | null; + dueDateEditedByUser: boolean; createdAt: string; updatedAt: string; tags: NoteTag[]; diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 71a1466..0ae09a6 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -126,4 +126,53 @@ describe('NoteRepository', () => { expect(row.attempts).toBe(1); expect(row.last_error).toBe('boom'); }); + + it('hydrate returns dueDate=null + dueDateEditedByUser=false on new note', () => { + const { id } = repo.create({ rawText: 'x' }); + const note = repo.findById(id)!; + expect(note.dueDate).toBeNull(); + expect(note.dueDateEditedByUser).toBe(false); + }); + + it('updateAiResult writes dueDate when edited flag is 0', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' }); + const note = repo.findById(id)!; + expect(note.dueDate).toBe('2026-05-01'); + expect(note.dueDateEditedByUser).toBe(false); + }); + + it('updateAiResult does NOT overwrite dueDate when edited flag is 1', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' }); + repo.setDueDate(id, '2026-05-15'); + repo.updateAiResult(id, { title: 'AI 2', summary: 'd\ne\nf', tags: [], provider: 'p', dueDate: '2026-05-30' }); + const note = repo.findById(id)!; + expect(note.dueDate).toBe('2026-05-15'); + expect(note.dueDateEditedByUser).toBe(true); + }); + + it('setDueDate sets due_date and edited flag', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.setDueDate(id, '2026-06-01'); + const note = repo.findById(id)!; + expect(note.dueDate).toBe('2026-06-01'); + expect(note.dueDateEditedByUser).toBe(true); + }); + + it('setDueDate(null) clears due_date but keeps edited flag', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.setDueDate(id, '2026-06-01'); + repo.setDueDate(id, null); + const note = repo.findById(id)!; + expect(note.dueDate).toBeNull(); + expect(note.dueDateEditedByUser).toBe(true); + }); + + it('updateAiResult without dueDate field treats it as null', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p' }); + const note = repo.findById(id)!; + expect(note.dueDate).toBeNull(); + }); }); diff --git a/tests/unit/migrations.due_date.test.ts b/tests/unit/migrations.due_date.test.ts new file mode 100644 index 0000000..431ec74 --- /dev/null +++ b/tests/unit/migrations.due_date.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations, latestVersion } from '@main/db/migrations/index.js'; + +describe('migrations m002 due_date', () => { + it('latestVersion returns 2', () => { + expect(latestVersion()).toBe(2); + }); + + it('runMigrations on fresh DB advances user_version to 2', () => { + const db = new Database(':memory:'); + runMigrations(db); + const row = db.pragma('user_version', { simple: true }); + expect(row).toBe(2); + }); + + it('due_date column exists with NULL default', () => { + const db = new Database(':memory:'); + runMigrations(db); + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, ?, 'pending', ?, ?)` + ).run('n1', 'x', '2026-04-26T00:00:00Z', '2026-04-26T00:00:00Z'); + const row = db.prepare('SELECT due_date, due_date_edited_by_user FROM notes WHERE id=?').get('n1') as any; + expect(row.due_date).toBeNull(); + expect(row.due_date_edited_by_user).toBe(0); + }); +}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index 95339ac..11d4e51 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -3,11 +3,9 @@ import Database from 'better-sqlite3'; import { runMigrations } from '@main/db/migrations/index.js'; describe('migrations', () => { - it('creates schema at version 1 with intent + edited columns', () => { + it('creates schema with intent + edited columns', () => { const db = new Database(':memory:'); runMigrations(db); - const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; - expect(ver).toBe(1); const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name); expect(cols).toEqual( expect.arrayContaining([ @@ -24,8 +22,10 @@ describe('migrations', () => { it('is idempotent', () => { const db = new Database(':memory:'); runMigrations(db); + const before = (db.prepare('PRAGMA user_version').get() as any).user_version; runMigrations(db); - expect((db.prepare('PRAGMA user_version').get() as any).user_version).toBe(1); + const after = (db.prepare('PRAGMA user_version').get() as any).user_version; + expect(after).toBe(before); db.close(); }); });