Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4e7536086 | |||
|
|
39b8d1e728 | ||
|
|
e32223d28c | ||
|
|
81fbacb21e | ||
|
|
ff1a015226 | ||
|
|
b4c2d85b26 | ||
|
|
7541d3c9e4 | ||
|
|
18deee5900 | ||
|
|
76c23457ee | ||
|
|
88ce78d860 | ||
|
|
07e61bc9e1 |
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
File diff suppressed because it is too large
Load Diff
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1507,9 +1507,9 @@ app.on('activate', () => {
|
||||
|
||||
---
|
||||
|
||||
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (🌱 raw — v0.2.8 후보, **load-bearing invariant 재검토**)
|
||||
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (✅ promoted v0.2.10 Cut C — invariant 폐기)
|
||||
|
||||
**진행 상태:** 🌱 raw — 메모리 정책 `raw_text 불변` 재논의 트리거. v0.2.8 brainstorm 시 invariant 변경 여부 결정.
|
||||
**진행 상태:** ✅ promoted v0.2.10 Cut C — `raw_text 불변` invariant 폐기, `note_revisions` 테이블로 변경 이력 보존. m006 마이그레이션 + `updateRawText`/`listRevisions`/`restoreRevision` repo API + RevisionHistoryModal UI. AI 재실행 input = current latest raw_text (옵션 B).
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 마이그레이션 (m005)
|
||||
## 3. Schema 마이그레이션 (m006)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.
|
||||
|
||||
```sql
|
||||
CREATE TABLE note_revisions (
|
||||
@@ -171,7 +173,7 @@ interface NoteRevision {
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m005 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
|
||||
| m006 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
|
||||
| `updateRawText` | notes.raw_text 갱신 + 새 revision INSERT atomic |
|
||||
| `listRevisions` | DESC 순 + edited_by 정확 |
|
||||
| `restoreRevision` | 옛 raw_text 가 새 revision 으로 INSERT + notes.raw_text 갱신 |
|
||||
@@ -179,7 +181,7 @@ interface NoteRevision {
|
||||
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
|
||||
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |
|
||||
|
||||
**목표**: 단위 490 → 약 505 (+15), typecheck 0.
|
||||
**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.10",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.9",
|
||||
"version": "0.2.10",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -4,8 +4,9 @@ import * as m002 from './m002_due_date.js';
|
||||
import * as m003 from './m003_soft_delete.js';
|
||||
import * as m004 from './m004_status.js';
|
||||
import * as m005 from './m005_ai_disabled.js';
|
||||
import * as m006 from './m006_revisions.js';
|
||||
|
||||
const migrations = [m001, m002, m003, m004, m005];
|
||||
const migrations = [m001, m002, m003, m004, m005, m006];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
23
src/main/db/migrations/m006_revisions.ts
Normal file
23
src/main/db/migrations/m006_revisions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// v6: note_revisions 테이블 + 기존 notes 의 raw_text 를 edited_by='capture' revision 으로 backfill.
|
||||
// FK ON DELETE CASCADE — notes 영구 삭제 시 revision 도 함께 삭제.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 6;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE note_revisions (
|
||||
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL,
|
||||
edited_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (edited_by IN ('user','capture')),
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
|
||||
|
||||
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
SELECT id, raw_text, created_at, 'capture' FROM notes;
|
||||
`);
|
||||
}
|
||||
@@ -269,6 +269,29 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
await deps.health.runOnce();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
|
||||
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
|
||||
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
|
||||
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
|
||||
if (typeof newText !== 'string' || newText.trim().length === 0) {
|
||||
return { ok: false as const, reason: 'empty' as const };
|
||||
}
|
||||
deps.repo.updateRawText(id, newText);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
|
||||
|
||||
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
|
||||
try {
|
||||
deps.repo.restoreRevision(id, revId);
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -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 {
|
||||
@@ -22,7 +22,10 @@ export interface NewMediaRow {
|
||||
|
||||
export interface ImportNoteInput {
|
||||
/** Proposed id from the export file. May be replaced if it collides with
|
||||
* an existing row whose `raw_text` differs (raw_text invariant guard). */
|
||||
* an existing row whose `raw_text` differs — fork-on-conflict so a single
|
||||
* id never resolves to two distinct historical baselines (v0.2.10 Cut C
|
||||
* changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
|
||||
* baseline distinction is now preserved per-id, edit history per-note). */
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
@@ -61,6 +64,10 @@ export class NoteRepository {
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, now, now);
|
||||
this.db
|
||||
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`)
|
||||
.run(id, input.rawText, now);
|
||||
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
|
||||
if (aiStatus === 'pending') {
|
||||
this.db
|
||||
@@ -465,6 +472,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
|
||||
@@ -614,11 +684,17 @@ export class NoteRepository {
|
||||
|
||||
/**
|
||||
* Import a note from an external source (F5 export tree).
|
||||
* Conflict policy:
|
||||
* Conflict policy (fork-on-id-collision):
|
||||
* - id missing in DB → INSERT (status: 'inserted')
|
||||
* - id present + raw_text identical → no-op (status: 'skipped')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7
|
||||
* to preserve the raw_text-immutable invariant (status: 'forked')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
|
||||
* never points at two different baselines (status: 'forked'). v0.2.10 Cut C
|
||||
* relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
|
||||
* but per-id baseline distinction is still required for sync determinism.
|
||||
*
|
||||
* v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
|
||||
* 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
|
||||
* edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
|
||||
*
|
||||
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
|
||||
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
|
||||
@@ -669,6 +745,12 @@ export class NoteRepository {
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(finalId, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
import { pushTagUndo } from './TagUndoToast.js';
|
||||
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
|
||||
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
@@ -118,6 +119,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
|
||||
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
const [draftRaw, setDraftRaw] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const possibleTargets: NoteStatus[] = (
|
||||
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
|
||||
@@ -150,6 +154,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}
|
||||
|
||||
async function saveRaw() {
|
||||
const next = draftRaw;
|
||||
if (next.trim().length === 0) return;
|
||||
const r = await inboxApi.updateRawText(note.id, next);
|
||||
if (!r.ok) return;
|
||||
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
setEditingRaw(false);
|
||||
}
|
||||
|
||||
async function removeTag(tagName: string) {
|
||||
const removed = local.tags.find((t) => t.name === tagName);
|
||||
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
@@ -371,9 +386,32 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{editingRaw ? (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label="원문 편집"
|
||||
value={draftRaw}
|
||||
onChange={(e) => setDraftRaw(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>취소</button>
|
||||
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}>이력</button>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>편집</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -487,6 +525,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showRevisions && (
|
||||
<RevisionHistoryModal
|
||||
noteId={local.id}
|
||||
onClose={() => setShowRevisions(false)}
|
||||
onRestored={(newRawText) => {
|
||||
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
src/renderer/inbox/components/RevisionHistoryModal.tsx
Normal file
95
src/renderer/inbox/components/RevisionHistoryModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { NoteRevision } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
onClose: () => void;
|
||||
/** 회수 성공 후 부모 (NoteCard) 가 local rawText 를 갱신하도록 통지. */
|
||||
onRestored: (newRawText: string) => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 520,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function editedByLabel(by: 'user' | 'capture'): string {
|
||||
return by === 'capture' ? '캡처' : '사용자';
|
||||
}
|
||||
|
||||
export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): React.ReactElement {
|
||||
const [revs, setRevs] = useState<NoteRevision[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await inboxApi.listRevisions(noteId);
|
||||
if (!cancelled) setRevs(r);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [noteId]);
|
||||
|
||||
async function onRestore(rev: NoteRevision) {
|
||||
if (!window.confirm('이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.')) return;
|
||||
const r = await inboxApi.restoreRevision(noteId, rev.revId);
|
||||
if (!r.ok) {
|
||||
setError(r.reason ?? '복원 실패');
|
||||
return;
|
||||
}
|
||||
onRestored(rev.rawText);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>이력 ({revs.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{loading && <div style={{ marginTop: 10, fontSize: 12, color: '#888' }}>불러오는 중…</div>}
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{!loading && revs.map((rev) => (
|
||||
<div key={rev.revId} style={rowStyle}>
|
||||
<div style={{ fontSize: 11, color: '#888', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{formatDate(rev.editedAt)} · {editedByLabel(rev.editedBy)}</span>
|
||||
<button
|
||||
onClick={() => { void onRestore(rev); }}
|
||||
aria-label="회수"
|
||||
style={{ background: 'none', border: '1px solid #0a4b80', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 3 }}
|
||||
>
|
||||
회수
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{ margin: '6px 0 0 0', whiteSpace: 'pre-wrap', fontSize: 12, color: '#444', background: '#fafafa', padding: 6, borderRadius: 3 }}>
|
||||
{rev.rawText}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,13 +4,14 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify } = vi.hoisted(() => ({
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({
|
||||
mockOpenMedia: vi.fn(async () => ({ ok: true })),
|
||||
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
mockClassify: vi.fn(async () => ({
|
||||
recommended: 'archived' as const,
|
||||
rationale: 'stub'
|
||||
}))
|
||||
})),
|
||||
mockUpdateRawText: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
@@ -24,7 +25,10 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify
|
||||
classifyStatus: mockClassify,
|
||||
updateRawText: mockUpdateRawText,
|
||||
listRevisions: vi.fn(async () => []),
|
||||
restoreRevision: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -154,3 +158,31 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — raw_text editing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
|
||||
const onUpdated = vi.fn();
|
||||
render(<NoteCard note={{ ...baseNote, rawText: 'old' }} onUpdated={onUpdated} mode="inbox" />);
|
||||
// 원문 펼침
|
||||
fireEvent.click(screen.getByRole('button', { name: /원문/ }));
|
||||
// 편집 진입
|
||||
fireEvent.click(screen.getByRole('button', { name: '편집' }));
|
||||
const ta = screen.getByRole('textbox', { name: /원문 편집/ }) as HTMLTextAreaElement;
|
||||
fireEvent.change(ta, { target: { value: 'new' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '저장' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRawText).toHaveBeenCalledWith('n1', 'new');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
});
|
||||
const last = onUpdated.mock.calls.at(-1)![0] as { rawText: string };
|
||||
expect(last.rawText).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1054,3 +1054,23 @@ describe('NoteRepository.countByAiStatus', () => {
|
||||
expect(repo.countByAiStatus('done')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — note_revisions', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => {
|
||||
const { id } = repo.create({ rawText: 'hello' });
|
||||
const rows = db
|
||||
.prepare(`SELECT raw_text, edited_by FROM note_revisions WHERE note_id=?`)
|
||||
.all(id) as Array<{ raw_text: string; edited_by: string }>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' });
|
||||
});
|
||||
});
|
||||
|
||||
171
tests/unit/NoteRevisions.test.ts
Normal file
171
tests/unit/NoteRevisions.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importNote — capture revision 생성 (final review 보강)', () => {
|
||||
it('insert path: imported note 가 capture revision (createdAt = edited_at) 을 함께 갖는다', () => {
|
||||
const r = repo.importNote({
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
rawText: 'imported text',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
aiTitle: 't',
|
||||
aiSummary: 's',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-04-02T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: []
|
||||
});
|
||||
expect(r.status).toBe('inserted');
|
||||
|
||||
const revs = repo.listRevisions(r.id);
|
||||
expect(revs).toHaveLength(1);
|
||||
expect(revs[0]!.rawText).toBe('imported text');
|
||||
expect(revs[0]!.editedBy).toBe('capture');
|
||||
expect(revs[0]!.editedAt).toBe('2026-04-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('fork path: id 충돌 시 fresh uuidv7 + 새 capture revision (옛 노트 revision 보존)', () => {
|
||||
// 기존 노트 (capture 'v1' revision 자동 생성됨)
|
||||
const existing = repo.create({ rawText: 'v1' });
|
||||
// 동일 id 로 다른 raw_text 를 import → fork
|
||||
const r = repo.importNote({
|
||||
id: existing.id,
|
||||
rawText: 'imported v2',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
aiTitle: null,
|
||||
aiSummary: null,
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: null,
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: []
|
||||
});
|
||||
expect(r.status).toBe('forked');
|
||||
expect(r.id).not.toBe(existing.id);
|
||||
|
||||
// forked 노트에 capture revision
|
||||
const forkRevs = repo.listRevisions(r.id);
|
||||
expect(forkRevs).toHaveLength(1);
|
||||
expect(forkRevs[0]!.rawText).toBe('imported v2');
|
||||
expect(forkRevs[0]!.editedBy).toBe('capture');
|
||||
|
||||
// 기존 노트의 revision 은 그대로 보존
|
||||
const existingRevs = repo.listRevisions(existing.id);
|
||||
expect(existingRevs).toHaveLength(1);
|
||||
expect(existingRevs[0]!.rawText).toBe('v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
64
tests/unit/RevisionHistoryModal.test.tsx
Normal file
64
tests/unit/RevisionHistoryModal.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockListRevisions, mockRestoreRevision } = vi.hoisted(() => ({
|
||||
mockListRevisions: vi.fn(),
|
||||
mockRestoreRevision: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listRevisions: mockListRevisions,
|
||||
restoreRevision: mockRestoreRevision
|
||||
}
|
||||
}));
|
||||
|
||||
import { RevisionHistoryModal } from '../../src/renderer/inbox/components/RevisionHistoryModal';
|
||||
|
||||
describe('RevisionHistoryModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListRevisions.mockResolvedValue([
|
||||
{ revId: 3, noteId: 'a', rawText: 'v3', editedAt: '2026-05-11T00:00:00Z', editedBy: 'user' },
|
||||
{ revId: 2, noteId: 'a', rawText: 'v2', editedAt: '2026-05-10T00:00:00Z', editedBy: 'user' },
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: '2026-05-01T00:00:00Z', editedBy: 'capture' }
|
||||
]);
|
||||
mockRestoreRevision.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('open 시 listRevisions 호출 + 목록 표시 (capture/user 라벨)', async () => {
|
||||
render(<RevisionHistoryModal noteId="a" onClose={() => {}} onRestored={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v3')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/캡처/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/사용자/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('회수 클릭 → confirm OK → restoreRevision + onRestored 호출 + onClose', async () => {
|
||||
const onRestored = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<RevisionHistoryModal noteId="a" onClose={onClose} onRestored={onRestored} />);
|
||||
await waitFor(() => screen.getByText('v1'));
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: /회수/ });
|
||||
// last button = oldest (v1)
|
||||
const lastButton = buttons[buttons.length - 1];
|
||||
if (lastButton === undefined) throw new Error('no 회수 button');
|
||||
fireEvent.click(lastButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreRevision).toHaveBeenCalledWith('a', 1);
|
||||
});
|
||||
expect(onRestored).toHaveBeenCalledWith('v1');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
99
tests/unit/inboxApi-revisions.test.ts
Normal file
99
tests/unit/inboxApi-revisions.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: { handle: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import electron from 'electron';
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
||||
const repo = {
|
||||
updateRawText: vi.fn(),
|
||||
listRevisions: vi.fn(() => []),
|
||||
restoreRevision: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0),
|
||||
countByAiStatus: vi.fn(() => 0),
|
||||
countTrashed: vi.fn(() => 0),
|
||||
countFailed: vi.fn(() => 0),
|
||||
listTrashed: vi.fn(() => []),
|
||||
setStatus: vi.fn(),
|
||||
requeueDisabled: vi.fn(() => 0),
|
||||
getAllPendingJobs: vi.fn(() => []),
|
||||
getPendingCount: vi.fn(() => 0),
|
||||
countToday: vi.fn(() => 0)
|
||||
} as unknown as InboxIpcDeps['repo'];
|
||||
return {
|
||||
repo,
|
||||
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
|
||||
capture: {} as InboxIpcDeps['capture'],
|
||||
health: {} as InboxIpcDeps['health'],
|
||||
intent: {} as InboxIpcDeps['intent'],
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as InboxIpcDeps['settings'],
|
||||
providerHolder: {} as InboxIpcDeps['providerHolder'],
|
||||
paths: { profileDir: '/tmp' },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxApi revisions IPC', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
});
|
||||
|
||||
it('inbox:update-raw-text — repo.updateRawText 호출 + ok:true', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:update-raw-text');
|
||||
const r = await h({}, 'note-1', 'new text');
|
||||
expect(deps.repo.updateRawText).toHaveBeenCalledWith('note-1', 'new text');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('inbox:update-raw-text — 빈 문자열 reject', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:update-raw-text');
|
||||
const r = await h({}, 'note-1', ' ');
|
||||
expect(deps.repo.updateRawText).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: false, reason: 'empty' });
|
||||
});
|
||||
|
||||
it('inbox:list-revisions — repo.listRevisions 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.listRevisions as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
|
||||
]);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:list-revisions');
|
||||
const r = await h({}, 'a');
|
||||
expect(r).toEqual([
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('inbox:restore-revision — repo throw 시 ok:false', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.restoreRevision as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error('revision 99 not found for note a');
|
||||
});
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:restore-revision');
|
||||
const r = await h({}, 'a', 99);
|
||||
expect(r).toEqual({ ok: false, reason: 'revision 99 not found for note a' });
|
||||
});
|
||||
});
|
||||
95
tests/unit/m006-migration.test.ts
Normal file
95
tests/unit/m006-migration.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { up } from '../../src/main/db/migrations/m006_revisions.js';
|
||||
|
||||
describe('m006 migration — note_revisions table', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
raw_text TEXT NOT NULL,
|
||||
ai_title TEXT,
|
||||
ai_summary TEXT,
|
||||
ai_status TEXT NOT NULL
|
||||
CHECK (ai_status IN ('pending','done','failed','disabled')),
|
||||
ai_error TEXT,
|
||||
ai_provider TEXT,
|
||||
ai_generated_at TEXT,
|
||||
title_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
summary_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
user_intent TEXT,
|
||||
intent_prompted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
last_recalled_at TEXT,
|
||||
recall_dismissed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
status_changed_at TEXT,
|
||||
move_reason TEXT
|
||||
);
|
||||
INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES ('a', 'first text', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'),
|
||||
('b', 'second text', 'done', '2026-05-02T00:00:00Z', '2026-05-02T00:00:00Z');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('creates note_revisions table with required columns', () => {
|
||||
up(db);
|
||||
const cols = db.prepare(`PRAGMA table_info(note_revisions)`).all() as Array<{ name: string }>;
|
||||
const names = cols.map((c) => c.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(['rev_id', 'note_id', 'raw_text', 'edited_at', 'edited_by'])
|
||||
);
|
||||
});
|
||||
|
||||
it('creates idx_note_revisions_note_id index', () => {
|
||||
up(db);
|
||||
const idx = db.prepare(`PRAGMA index_list(note_revisions)`).all() as Array<{ name: string }>;
|
||||
expect(idx.map((i) => i.name)).toContain('idx_note_revisions_note_id');
|
||||
});
|
||||
|
||||
it('cascades on note delete (FK ON DELETE CASCADE)', () => {
|
||||
up(db);
|
||||
db.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES ('a', 'manual rev', '2026-05-03T00:00:00Z', 'user')`
|
||||
).run();
|
||||
db.prepare(`DELETE FROM notes WHERE id=?`).run('a');
|
||||
const rows = db.prepare(`SELECT * FROM note_revisions WHERE note_id=?`).all('a');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("backfills existing notes as edited_by='capture' revisions", () => {
|
||||
up(db);
|
||||
const rows = db
|
||||
.prepare(`SELECT note_id, raw_text, edited_at, edited_by FROM note_revisions ORDER BY note_id`)
|
||||
.all() as Array<{ note_id: string; raw_text: string; edited_at: string; edited_by: string }>;
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({
|
||||
note_id: 'a',
|
||||
raw_text: 'first text',
|
||||
edited_at: '2026-05-01T00:00:00Z',
|
||||
edited_by: 'capture'
|
||||
});
|
||||
expect(rows[1]).toEqual({
|
||||
note_id: 'b',
|
||||
raw_text: 'second text',
|
||||
edited_at: '2026-05-02T00:00:00Z',
|
||||
edited_by: 'capture'
|
||||
});
|
||||
});
|
||||
|
||||
it('exports version=6', async () => {
|
||||
const mod = await import('../../src/main/db/migrations/m006_revisions.js');
|
||||
expect(mod.version).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('user_version reaches latest (5)', () => {
|
||||
it('user_version reaches latest (6)', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(row.user_version).toBe(5);
|
||||
expect(row.user_version).toBe(6);
|
||||
db.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user