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:
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
11
src/main/db/migrations/m002_due_date.ts
Normal file
11
src/main/db/migrations/m002_due_date.ts
Normal 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));
|
||||
`);
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
28
tests/unit/migrations.due_date.test.ts
Normal file
28
tests/unit/migrations.due_date.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user