diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index aa766a6..411550a 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,6 +1,6 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; -import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; +import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; export interface CreateNoteInput { @@ -469,6 +469,69 @@ export class NoteRepository { .run(now, id); } + /** + * v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에 + * edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성). + * + * 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨. + */ + updateRawText(id: string, newText: string, now: Date = new Date()): void { + const ts = now.toISOString(); + const tx = this.db.transaction(() => { + this.db + .prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`) + .run(newText, ts, id); + this.db + .prepare( + `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'user')` + ) + .run(id, newText, ts); + }); + tx(); + } + + /** + * v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환. + * NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak. + */ + listRevisions(id: string): NoteRevision[] { + const rows = this.db + .prepare( + `SELECT rev_id, note_id, raw_text, edited_at, edited_by + FROM note_revisions + WHERE note_id = ? + ORDER BY edited_at DESC, rev_id DESC` + ) + .all(id) as Array<{ + rev_id: number; + note_id: string; + raw_text: string; + edited_at: string; + edited_by: 'user' | 'capture'; + }>; + return rows.map((r) => ({ + revId: r.rev_id, + noteId: r.note_id, + rawText: r.raw_text, + editedAt: r.edited_at, + editedBy: r.edited_by + })); + } + + /** + * v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고 + * 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이 + * 아니면 throw — restore 대상 잘못 매칭 방지. + */ + restoreRevision(id: string, revId: number, now: Date = new Date()): void { + const rev = this.db + .prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`) + .get(revId, id) as { raw_text: string } | undefined; + if (!rev) throw new Error(`revision ${revId} not found for note ${id}`); + this.updateRawText(id, rev.raw_text, now); + } + /** * v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed). * status + status_changed_at + move_reason + updated_at 갱신 + deleted_at diff --git a/src/preload/index.ts b/src/preload/index.ts index 33b6d42..0606fbe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -81,6 +81,10 @@ const api: InklingApi = { // v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count. enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'), getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'), + // v0.2.10 Cut C — raw_text 가변 + revision 보존. + updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText), + listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId), + restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index fb45da3..82784b9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -21,6 +21,16 @@ export interface NoteTag { source: 'ai' | 'user'; } +// v0.2.10 Cut C — note_revisions 테이블 row. +// 'capture' = 최초 캡처 시점, 'user' = 사용자가 raw_text 정정한 시점. +export interface NoteRevision { + revId: number; + noteId: string; + rawText: string; + editedAt: string; + editedBy: 'user' | 'capture'; +} + export interface Note { id: string; rawText: string; @@ -156,6 +166,10 @@ export interface InboxApi { // v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시). enqueueDisabled(): Promise<{ count: number }>; getDisabledCount(): Promise; + // v0.2.10 Cut C — raw_text 가변 + revision 보존. + updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>; + listRevisions(noteId: string): Promise; + restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>; } export interface InklingApi { diff --git a/tests/unit/NoteRevisions.test.ts b/tests/unit/NoteRevisions.test.ts new file mode 100644 index 0000000..f9acd2e --- /dev/null +++ b/tests/unit/NoteRevisions.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository — note_revisions', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + afterEach(() => { db.close(); }); + + describe('updateRawText', () => { + it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => { + const { id } = repo.create({ rawText: 'v1' }); + const t = new Date('2026-05-10T00:00:00Z'); + repo.updateRawText(id, 'v2', t); + + const note = db.prepare(`SELECT raw_text, updated_at FROM notes WHERE id=?`).get(id) as { + raw_text: string; + updated_at: string; + }; + expect(note.raw_text).toBe('v2'); + expect(note.updated_at).toBe('2026-05-10T00:00:00.000Z'); + + const revs = db + .prepare(`SELECT raw_text, edited_by, edited_at FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`) + .all(id) as Array<{ raw_text: string; edited_by: string; edited_at: string }>; + expect(revs).toHaveLength(2); // capture + user + expect(revs.at(0)!.edited_by).toBe('capture'); + expect(revs.at(0)!.raw_text).toBe('v1'); + expect(revs.at(1)!.edited_by).toBe('user'); + expect(revs.at(1)!.raw_text).toBe('v2'); + expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z'); + }); + + it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => { + const { id } = repo.create({ rawText: 'v1' }); + repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z')); + repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z')); + const revs = db + .prepare(`SELECT raw_text FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`) + .all(id) as Array<{ raw_text: string }>; + expect(revs.map((r) => r.raw_text)).toEqual(['v1', 'v2', 'v3']); + }); + }); + + describe('listRevisions', () => { + it('DESC 순서 + edited_by + camelCase hydrate', () => { + const { id } = repo.create({ rawText: 'v1' }); + repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z')); + repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z')); + + const revs = repo.listRevisions(id); + expect(revs).toHaveLength(3); + expect(revs.at(0)!.rawText).toBe('v3'); + expect(revs.at(0)!.editedBy).toBe('user'); + expect(revs.at(1)!.rawText).toBe('v2'); + expect(revs.at(1)!.editedBy).toBe('user'); + expect(revs.at(2)!.rawText).toBe('v1'); + expect(revs.at(2)!.editedBy).toBe('capture'); + expect(typeof revs.at(0)!.revId).toBe('number'); + expect(revs.at(0)!.noteId).toBe(id); + expect(revs.at(0)!.editedAt).toBe('2026-05-11T00:00:00.000Z'); + }); + }); + + describe('restoreRevision', () => { + it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => { + const { id } = repo.create({ rawText: 'v1' }); + repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z')); + repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z')); + + const revs = repo.listRevisions(id); + const v1 = revs.find((r) => r.rawText === 'v1'); + expect(v1).toBeDefined(); + + repo.restoreRevision(id, v1!.revId, new Date('2026-05-12T00:00:00Z')); + + const note = db.prepare(`SELECT raw_text FROM notes WHERE id=?`).get(id) as { raw_text: string }; + expect(note.raw_text).toBe('v1'); + + const after = repo.listRevisions(id); + expect(after).toHaveLength(4); // v1(capture) + v2 + v3 + v1 restored (user) + expect(after.at(0)!.rawText).toBe('v1'); + expect(after.at(0)!.editedBy).toBe('user'); + expect(after.at(0)!.editedAt).toBe('2026-05-12T00:00:00.000Z'); + }); + + it('존재하지 않는 revId 는 throw', () => { + const { id } = repo.create({ rawText: 'v1' }); + expect(() => repo.restoreRevision(id, 999_999, new Date())).toThrow(/not found/); + }); + }); + + describe('AiWorker source 회귀', () => { + it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => { + const { id } = repo.create({ rawText: 'v1' }); + repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z')); + const note = repo.findById(id); + expect(note?.rawText).toBe('v2 corrected'); + }); + }); +});