feat(db): migration v2 — due_date columns + pre-migration snapshot

ALTER TABLE notes adds due_date TEXT + due_date_edited_by_user INTEGER.
openDb takes <dbFile>.pre-v<N>.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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 11:05:44 +09:00
parent cfd34c352b
commit 0bb6c12bbb
8 changed files with 129 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@@ -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[],

View File

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

View File

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

View File

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

View File

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