feat(v0210): NoteRepository revision API + NoteRevision type + InboxApi 시그니처
- updateRawText: raw_text 갱신 + user revision INSERT (atomic) - listRevisions: edited_at DESC 순 hydrate - restoreRevision: 옛 raw_text 를 새 user revision 으로 복원 (chain 보존) - shared/types: NoteRevision + InboxApi 3 메서드 (updateRawText/listRevisions/restoreRevision) - preload: 3 IPC stub 추가 (inbox:update-raw-text / inbox:list-revisions / inbox:restore-revision)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<number>;
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
listRevisions(noteId: string): Promise<NoteRevision[]>;
|
||||
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
110
tests/unit/NoteRevisions.test.ts
Normal file
110
tests/unit/NoteRevisions.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user