48 KiB
v0.2.10 Cut C — raw_text 수정 + revision history Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: F20 — 사용자가 notes.raw_text 자유 편집 + 옛 버전 모두 보존 + 회수 가능. load-bearing invariant 변경 (raw_text 불변 → raw_text 가변 + revision 보존).
Architecture: 새 테이블 note_revisions 추가 (m006), 모든 raw_text 변경 시 새 revision row INSERT. notes.raw_text 는 latest 값 그대로 유지 (FTS5/AiWorker source 불변). 기존 노트는 m006 backfill 시 edited_by='capture' revision 생성. UI = NoteCard 의 "원문 보기" 영역에 "편집"/"이력" 버튼 추가, 이력은 modal.
Tech Stack: SQLite (better-sqlite3 12.9, AUTOINCREMENT + FK ON DELETE CASCADE), Electron IPC, React 19 + zustand 5, vitest 4 + RTL.
선행 문서:
docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md— 본 plan 의 source specdocs/superpowers/strategy/v028plus-roadmap.md— Cut C 위치docs/superpowers/specs/2026-04-25-dogfood-feedback.md— F20
File Structure
Create:
src/main/db/migrations/m006_revisions.ts—note_revisions테이블 + index + 기존 notes backfill (edited_by='capture')src/renderer/inbox/components/RevisionHistoryModal.tsx— 이력 modal (rev 목록 + 회수 confirm)tests/unit/m006-migration.test.ts— 5 tests (cols / index / FK cascade / backfill / version=6)tests/unit/NoteRevisions.test.ts— repo 테스트 (insert/update/list/restore)tests/unit/RevisionHistoryModal.test.tsx— modal 테스트
Modify:
src/main/db/migrations/index.ts— m006 import + 배열 추가src/main/repository/NoteRepository.ts—create에 첫 revision INSERT, 신규updateRawText/listRevisions/restoreRevisionsrc/main/ipc/inboxApi.ts— 3 새 IPC handlersrc/preload/index.ts— 3 새 bridge 함수src/shared/types.ts—NoteRevision인터페이스 +InboxApi3 메서드 시그니처src/renderer/inbox/components/NoteCard.tsx— 원문 영역에 편집 textarea + "이력" 버튼 + modal togglesrc/renderer/inbox/api.ts— re-export 만 (변경 없음, types 가 늘어 자동 노출)tests/unit/NoteRepository.test.ts—create가 'capture' revision INSERT 함을 검증하는 test 1건 추가tests/unit/AiWorker.test.ts— updateRawText 후 AiWorker.findById 가 latest raw_text 사용함을 회귀 검증tests/unit/NoteCard.test.tsx— 편집 textarea save → IPC 호출 검증package.json— version0.2.9→0.2.10docs/superpowers/specs/2026-04-25-dogfood-feedback.md— F20 promoted 마킹 + Cut C 라벨
단위 목표
548 (v0.2.9) → 약 565 (+17), typecheck 0.
Task 1: m006 migration — note_revisions 테이블
Files:
- Create:
src/main/db/migrations/m006_revisions.ts - Create:
tests/unit/m006-migration.test.ts - Modify:
src/main/db/migrations/index.ts
note_revisions 는 notes.id 를 FK + ON DELETE CASCADE 로 참조 (영구 삭제 시 revision 도 삭제). (note_id, edited_at DESC) index 로 listRevisions 빠르게. backfill = 기존 모든 notes 의 raw_text 를 edited_by='capture' 로 INSERT (created_at 을 edited_at 으로).
- Step 1: failing test 작성
tests/unit/m006-migration.test.ts:
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');
// m005 baseline (notes 테이블 — ai_status enum 'disabled' 포함)
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);
});
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/m006-migration.test.ts
Expected: FAIL — m006_revisions.js module not found.
- Step 3: m006 migration 구현
src/main/db/migrations/m006_revisions.ts (전체 내용):
// 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;
`);
}
- Step 4: index.ts 갱신
src/main/db/migrations/index.ts 의 import + 배열에 m006 추가:
import * as m001 from './m001_initial.js';
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, m006];
- Step 5: test PASS 확인
Run: npx vitest run tests/unit/m006-migration.test.ts
Expected: PASS — 5/5 tests.
- Step 6: typecheck
Run: npm run typecheck
Expected: 0 errors.
- Step 7: commit
git add src/main/db/migrations/m006_revisions.ts \
src/main/db/migrations/index.ts \
tests/unit/m006-migration.test.ts
git commit -m "feat(v0210): m006 migration — note_revisions 테이블 + capture backfill"
Task 2: NoteRepository.create — 첫 revision INSERT (edited_by='capture')
Files:
- Modify:
src/main/repository/NoteRepository.ts - Modify:
tests/unit/NoteRepository.test.ts
create 가 새 노트를 INSERT 하면서 동일 transaction 안에서 note_revisions 에 edited_by='capture' row 를 함께 INSERT. 새 노트의 첫 revision 은 항상 capture.
- Step 1: failing test 작성
tests/unit/NoteRepository.test.ts 의 적절한 describe 안에 추가:
it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => {
const repo = new NoteRepository(db);
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' });
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/NoteRepository.test.ts -t "create() 가 첫 revision"
Expected: FAIL — note_revisions empty.
- Step 3: NoteRepository.create 갱신
src/main/repository/NoteRepository.ts 의 create 메서드 transaction 본문에 INSERT 추가:
create(input: CreateNoteInput): { id: string } {
const id = uuidv7();
const now = new Date().toISOString();
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
const tx = this.db.transaction(() => {
this.db
.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);
if (aiStatus === 'pending') {
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
}
});
tx();
return { id };
}
- Step 4: test PASS 확인
Run: npx vitest run tests/unit/NoteRepository.test.ts
Expected: 모든 NoteRepository test PASS (기존 + 신규 1).
- Step 5: commit
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(v0210): NoteRepository.create 가 capture revision 을 함께 INSERT"
Task 3: NoteRepository.updateRawText — raw_text 갱신 + 새 revision INSERT
Files:
- Modify:
src/main/repository/NoteRepository.ts - Create:
tests/unit/NoteRevisions.test.ts
새 메서드. notes.raw_text 와 notes.updated_at 갱신 + note_revisions 에 edited_by='user' 새 row INSERT — 단일 transaction.
- Step 1: failing test 파일 생성
tests/unit/NoteRevisions.test.ts:
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[0].edited_by).toBe('capture');
expect(revs[0].raw_text).toBe('v1');
expect(revs[1].edited_by).toBe('user');
expect(revs[1].raw_text).toBe('v2');
expect(revs[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']);
});
});
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/NoteRevisions.test.ts
Expected: FAIL — repo.updateRawText is not a function.
- Step 3: updateRawText 메서드 추가
src/main/repository/NoteRepository.ts 에 추가 (다른 update 메서드 근처 — 예: setStatus 위):
/**
* 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();
}
- Step 4: test PASS 확인
Run: npx vitest run tests/unit/NoteRevisions.test.ts
Expected: PASS — 2/2 tests.
- Step 5: commit
git add src/main/repository/NoteRepository.ts tests/unit/NoteRevisions.test.ts
git commit -m "feat(v0210): NoteRepository.updateRawText — raw_text 갱신 + user revision INSERT"
Task 4: NoteRepository.listRevisions — DESC 순서 + edited_by 정확
Files:
- Modify:
src/main/repository/NoteRepository.ts - Modify:
tests/unit/NoteRevisions.test.ts
조회 메서드. note_revisions 를 edited_at DESC, rev_id DESC 로 반환. 결과 = NoteRevision[] 형태로 hydrate.
- Step 1: failing test 추가
tests/unit/NoteRevisions.test.ts 의 describe 블록 안에 추가:
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[0].rawText).toBe('v3');
expect(revs[0].editedBy).toBe('user');
expect(revs[1].rawText).toBe('v2');
expect(revs[1].editedBy).toBe('user');
expect(revs[2].rawText).toBe('v1');
expect(revs[2].editedBy).toBe('capture');
// hydrate 정확성 — revId/noteId/editedAt 모두 채워짐
expect(typeof revs[0].revId).toBe('number');
expect(revs[0].noteId).toBe(id);
expect(revs[0].editedAt).toBe('2026-05-11T00:00:00.000Z');
});
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/NoteRevisions.test.ts -t "listRevisions"
Expected: FAIL — repo.listRevisions is not a function.
- Step 3: listRevisions 메서드 추가 (+ NoteRevision import)
src/main/repository/NoteRepository.ts 상단 import 에 NoteRevision 추가 (Task 6 에서 정의 — 미리 import 만 하고 Task 6 에서 type 정의 시 동작):
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
다음 메서드를 updateRawText 아래에 추가:
/**
* 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
}));
}
- Step 4: test FAIL 다시 확인 (이번엔 NoteRevision 타입 미정의로 typecheck 에러)
Run: npx vitest run tests/unit/NoteRevisions.test.ts -t "listRevisions"
Expected: FAIL — TypeScript / module 에러 (NoteRevision 타입 export 없음). Task 6 에서 처리.
NOTE — vitest 가 type 검사를 ts 모듈 import 시점까지만 수행하므로 실제로는 runtime PASS 가능. typecheck 단계에서 문제 잡힘.
Run: npm run typecheck
Expected: error TS2305 — '"@shared/types"' has no exported member 'NoteRevision'.
- Step 5: skip — Task 6 에서 NoteRevision 정의 추가 후 일괄 PASS 예상
본 task 는 commit 하지 않고 Task 5 까지 누적 후 한 번에 commit. 이유: NoteRevision type 정의 (Task 6) 가 나오기 전까지 typecheck red — 중간 commit 시 빌드 깨짐.
진행만 하고 끝.
Task 5: NoteRepository.restoreRevision — 옛 raw_text 를 새 revision 으로 복원
Files:
- Modify:
src/main/repository/NoteRepository.ts - Modify:
tests/unit/NoteRevisions.test.ts
옛 revision 의 raw_text 를 latest 로 복원. 구현 = 해당 raw_text 를 updateRawText 로 호출 (chain 끊지 않고 새 user revision 으로 INSERT). 없으면 throw.
- Step 1: failing tests 추가
tests/unit/NoteRevisions.test.ts describe 블록에 추가:
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'));
// 첫 revision (v1) 의 rev_id 조회
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[0].rawText).toBe('v1');
expect(after[0].editedBy).toBe('user');
expect(after[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/);
});
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/NoteRevisions.test.ts -t "restoreRevision"
Expected: FAIL — repo.restoreRevision is not a function.
- Step 3: restoreRevision 메서드 추가
src/main/repository/NoteRepository.ts 의 listRevisions 아래:
/**
* 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);
}
- Step 4: 모든 NoteRevisions.test PASS 확인 (단, NoteRevision 타입은 Task 6 까지 미정의)
Run: npx vitest run tests/unit/NoteRevisions.test.ts
Expected: tests PASS or FAIL based on NoteRevision type. If type-only — runtime PASS. typecheck 별도 — Task 6 까지 red 예상.
- Step 5: commit (Task 3-5 일괄)
이 시점에서 NoteRepository 의 3 메서드 (updateRawText / listRevisions / restoreRevision) 가 모두 구현됨. NoteRevision type 은 Task 6 에서. 본 commit 은 type 미정의로 typecheck red. → Task 6 까지 보류.
대신 staged 상태로만 두지 말고 untracked-but-not-committed 로 보존. 명령:
# 아직 commit 하지 않음 — Task 6 와 함께 ATOMIC commit 예정
echo "Task 5 implementation complete; type definition pending in Task 6"
Task 6: shared types — NoteRevision + InboxApi 3 메서드
Files:
- Modify:
src/shared/types.ts
타입 정의가 main(repo)/preload/renderer 모두 한 모듈에서 흐름. NoteRevision interface + InboxApi 메서드 3개 추가.
- Step 1: NoteRevision + InboxApi 메서드 시그니처 추가
src/shared/types.ts 의 NoteTag interface 아래에 추가:
// 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';
}
InboxApi interface 의 마지막 (getDisabledCount 다음) 에 추가:
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
updateRawText(noteId: string, newText: string): Promise<{ ok: true }>;
listRevisions(noteId: string): Promise<NoteRevision[]>;
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
- Step 2: typecheck PASS 확인
Run: npm run typecheck
Expected: 0 errors. (Task 3-5 의 NoteRepository import + Task 4 의 listRevisions return 타입 모두 정상.)
- Step 3: 단위 테스트 일괄 PASS 확인
Run: npx vitest run tests/unit/NoteRevisions.test.ts tests/unit/NoteRepository.test.ts tests/unit/m006-migration.test.ts
Expected: 모두 PASS.
- Step 4: commit (Task 3-6 일괄)
git add src/main/repository/NoteRepository.ts \
src/shared/types.ts \
tests/unit/NoteRevisions.test.ts
git commit -m "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 보존)"
Task 7: IPC handlers — set / list / restore
Files:
- Modify:
src/main/ipc/inboxApi.ts - Create:
tests/unit/inboxApi-revisions.test.ts
3개 ipc handler 추가. argument validation = 빈 문자열/공백 차단 (updateRawText 는 trim 후 length===0 이면 reject — 빈 raw_text 는 의미 없음). restoreRevision 은 repo throw 시 { ok: false }.
- Step 1: failing test 작성
tests/unit/inboxApi-revisions.test.ts:
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' });
});
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/inboxApi-revisions.test.ts
Expected: FAIL — handler 미등록.
- Step 3: 3 handler 등록
src/main/ipc/inboxApi.ts 의 마지막 handler (inbox:saveOllamaSettings) 다음 / 함수 닫는 } 직전에 추가:
// 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 };
}
});
- Step 4: test PASS 확인
Run: npx vitest run tests/unit/inboxApi-revisions.test.ts
Expected: PASS — 4/4.
- Step 5: commit
git add src/main/ipc/inboxApi.ts tests/unit/inboxApi-revisions.test.ts
git commit -m "feat(v0210): inbox:{update-raw-text,list-revisions,restore-revision} IPC"
Task 8: preload bridge
Files:
- Modify:
src/preload/index.ts
Renderer 가 window.inkling.inbox.{updateRawText,listRevisions,restoreRevision} 로 호출.
- Step 1: 3 bridge 함수 추가
src/preload/index.ts 의 getDisabledCount 다음에 추가 (객체 닫기 } 직전):
// 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),
- Step 2: typecheck
Run: npm run typecheck
Expected: 0 errors. (Task 6 의 InboxApi 시그니처와 일치.)
- Step 3: commit
git add src/preload/index.ts
git commit -m "feat(v0210): preload bridge — updateRawText/listRevisions/restoreRevision"
Task 9: NoteCard — 원문 영역 편집 UI
Files:
- Modify:
src/renderer/inbox/components/NoteCard.tsx - Modify:
tests/unit/NoteCard.test.tsx
기존 rawOpen 펼침 안에서 textarea 편집 진입 + 저장/취소 버튼. 저장 시 inboxApi.updateRawText 호출 → local state 갱신 + onUpdated.
- Step 1: failing test 작성 —
tests/unit/NoteCard.test.tsx의 적절한 describe 안
(기존 테스트 파일 패턴 따름. test mocking 은 기존 NoteCard.test.tsx 가 이미 vi.mock('../../src/renderer/inbox/api.js', ...) 로 inboxApi 를 mock 하므로 그 패턴 그대로.)
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
const note = makeNote({ rawText: 'old' });
const onUpdated = vi.fn();
render(<NoteCard note={note} onUpdated={onUpdated} />);
// 원문 펼침
await userEvent.click(screen.getByRole('button', { name: /원문/ }));
// 편집 진입
await userEvent.click(screen.getByRole('button', { name: '편집' }));
const ta = screen.getByRole('textbox', { name: /원문 편집/ });
await userEvent.clear(ta);
await userEvent.type(ta, 'new');
await userEvent.click(screen.getByRole('button', { name: '저장' }));
expect(inboxApi.updateRawText).toHaveBeenCalledWith(note.id, 'new');
await waitFor(() => {
expect(onUpdated).toHaveBeenCalled();
});
const last = onUpdated.mock.calls.at(-1)![0];
expect(last.rawText).toBe('new');
});
NOTE — makeNote factory 는 기존 NoteCard.test.tsx 에 있다고 가정. 없으면 test 안에서 inline 정의.
- Step 2: api 모듈 mock 에 updateRawText 추가 (test setup)
tests/unit/NoteCard.test.tsx 상단 vi.mock(...) 블록에 updateRawText 가 함수로 등록되어 있는지 확인. 없으면 추가:
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// 기존 메서드 ...
updateRawText: vi.fn(),
listRevisions: vi.fn(() => Promise.resolve([])),
restoreRevision: vi.fn(() => Promise.resolve({ ok: true }))
}
}));
(기존 mock 의 메서드 목록을 보고 추가 항목만. 다른 테스트 깨뜨리지 않도록 vi.fn() 으로 안전.)
- Step 3: test FAIL 확인
Run: npx vitest run tests/unit/NoteCard.test.tsx -t "원문 편집"
Expected: FAIL — "편집" 버튼 미존재.
- Step 4: NoteCard 의 원문 영역에 편집 UI 추가
src/renderer/inbox/components/NoteCard.tsx 의 useState block (line 119 근처) 에 추가:
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
(showRevisions 는 Task 10 에서 사용.)
raw save handler 추가 (saveDueDate 근처):
async function saveRaw() {
const next = draftRaw;
if (next.trim().length === 0) return; // 빈 텍스트 reject — IPC 도 reject
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);
}
기존 rawOpen && (<pre>...) 블록 (line 374 근처) 을 다음으로 교체:
{rawOpen && (
<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>
)}
(showRevisions modal 토글은 Task 10 에서 추가.)
- Step 5: test PASS 확인
Run: npx vitest run tests/unit/NoteCard.test.tsx -t "원문 편집"
Expected: PASS.
- Step 6: 회귀 — 기존 NoteCard 테스트 모두 PASS
Run: npx vitest run tests/unit/NoteCard.test.tsx
Expected: 모든 기존 + 신규 1 PASS.
- Step 7: commit
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v0210): NoteCard 원문 영역 편집 UI (textarea + 저장/취소 + updateRawText)"
Task 10: RevisionHistoryModal — 이력 modal + 회수
Files:
- Create:
src/renderer/inbox/components/RevisionHistoryModal.tsx - Create:
tests/unit/RevisionHistoryModal.test.tsx - Modify:
src/renderer/inbox/components/NoteCard.tsx(modal mount)
modal pattern 은 기존 MoveStatusModal.tsx 참고. open 시 inboxApi.listRevisions 호출 → 목록 표시 → "회수" 클릭 시 window.confirm → inboxApi.restoreRevision → onRestored(rawText) callback. confirm 거부/실패 시 modal 유지.
- Step 1: failing test 작성
tests/unit/RevisionHistoryModal.test.tsx:
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
listRevisions: vi.fn(),
restoreRevision: vi.fn()
}
}));
import { inboxApi } from '../../src/renderer/inbox/api.js';
import { RevisionHistoryModal } from '../../src/renderer/inbox/components/RevisionHistoryModal.js';
describe('RevisionHistoryModal', () => {
beforeEach(() => {
(inboxApi.listRevisions as ReturnType<typeof vi.fn>).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' }
]);
(inboxApi.restoreRevision as ReturnType<typeof vi.fn>).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(); // capture 라벨
expect(screen.getAllByText(/사용자/).length).toBeGreaterThanOrEqual(1);
});
it('회수 클릭 → confirm OK → restoreRevision + onRestored 호출', 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: /회수/ });
await userEvent.click(buttons[buttons.length - 1]); // v1 의 회수 버튼
await waitFor(() => {
expect(inboxApi.restoreRevision).toHaveBeenCalledWith('a', 1);
});
expect(onRestored).toHaveBeenCalledWith('v1');
expect(onClose).toHaveBeenCalled();
confirmSpy.mockRestore();
});
});
- Step 2: test FAIL 확인
Run: npx vitest run tests/unit/RevisionHistoryModal.test.tsx
Expected: FAIL — module not found.
- Step 3: RevisionHistoryModal 컴포넌트 구현
src/renderer/inbox/components/RevisionHistoryModal.tsx:
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); }}
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>
);
}
- Step 4: NoteCard 에 modal mount
src/renderer/inbox/components/NoteCard.tsx 의 import 에 추가:
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
MoveStatusModal mount 영역 다음에 추가:
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}
onClose={() => setShowRevisions(false)}
onRestored={(newRawText) => {
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
}}
/>
)}
- Step 5: test PASS 확인
Run: npx vitest run tests/unit/RevisionHistoryModal.test.tsx
Expected: PASS — 2/2.
- Step 6: 회귀 — NoteCard test 모두 PASS
Run: npx vitest run tests/unit/NoteCard.test.tsx
Expected: 기존 + Task 9 신규 모두 PASS.
- Step 7: commit
git add src/renderer/inbox/components/RevisionHistoryModal.tsx \
src/renderer/inbox/components/NoteCard.tsx \
tests/unit/RevisionHistoryModal.test.tsx
git commit -m "feat(v0210): RevisionHistoryModal — 이력 목록 + 회수 confirm + chain 보존"
Task 11: AiWorker source 회귀 — findById 가 latest raw_text 반환
Files:
- Modify:
tests/unit/NoteRevisions.test.ts
AiWorker 의 raw_text source = repo.findById(id).rawText (src/main/ai/AiWorker.ts:124,234). updateRawText 후 findById 가 새 값을 반환하면 worker 도 자동으로 latest 사용. 본 회귀는 그 invariant 만 검증.
- Step 1: 회귀 test 추가
tests/unit/NoteRevisions.test.ts 의 최상위 describe 안에 추가 (updateRawText describe 와 같은 레벨):
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');
});
});
- Step 2: test PASS 확인
Run: npx vitest run tests/unit/NoteRevisions.test.ts -t "AiWorker source"
Expected: PASS — Task 3 의 updateRawText 가 notes.raw_text 갱신 보장.
- Step 3: commit
git add tests/unit/NoteRevisions.test.ts
git commit -m "test(v0210): findById 가 latest raw_text 반환 회귀 (AiWorker source 보존)"
Task 12: dogfood-feedback.md F20 promoted + version bump + release notes
Files:
- Modify:
docs/superpowers/specs/2026-04-25-dogfood-feedback.md - Modify:
package.json
dogfood spec 의 F20 항목을 promoted (Cut C) 로 마킹. version 0.2.9 → 0.2.10.
- Step 1: dogfood-feedback.md 의 F20 섹션 갱신
해당 섹션 헤더 옆에 (promoted v0.2.10 Cut C) 추가. 본문 마지막에 short note:
**상태**: ✅ promoted v0.2.10 Cut C — m006 + updateRawText/listRevisions/restoreRevision + RevisionHistoryModal.
- Step 2: package.json version bump
package.json 의 "version": "0.2.9" → "version": "0.2.10".
- Step 3: 단위 + typecheck full run
Run: npm run typecheck && npm test -- --run
Expected: typecheck 0 errors, 단위 약 565 PASS.
- Step 4: e2e smoke
Run: npm run test:e2e
Expected: 1/1 PASS (smoke + OnboardingWizard dismiss step). 본 cut 은 e2e 시나리오 미추가.
- Step 5: commit
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
git commit -m "chore(release): v0.2.10 — Cut C (raw_text 가변 + revision history)"
Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검)
- Spec coverage: design spec §3 (m006) / §4 (3 메서드) / §5 (UI) / §6 (AI 정책 무변) / §8 (IPC + types) / §9 (테스트 17) / §11 (메모리 정책 갱신 — 본 plan 의 task 가 아니라 별도 controller 작업)
- Type 일관성:
NoteRevision(Task 6) ↔listRevisions반환 (Task 4) ↔ test 기대값 (Task 4/5/10) 모두 camelCase + 'user'/'capture' literal 일치 - 단위 카운트: 5 (m006) + 1 (create) + 2 (updateRawText) + 1 (listRevisions) + 2 (restoreRevision) + 4 (IPC) + 1 (NoteCard 편집) + 2 (RevisionHistoryModal) = 18 + 회귀 1 (Task 11) = 약 19 신규. 단위 548 + 19 ≈ 567. spec 목표 565 근사.
- 메모리 정책 갱신은 plan 외: 본 plan 은 implementation 만. memory
project_inkling_status.md의raw_text 불변정책 갱신은 Cut C 머지 후 controller 가 담당 (TodoWrite item 8).
Risk
- m006 production 적용: 본인 v0.2.9 사용자 DB 기준 노트 수 < 1000 — backfill INSERT SELECT 빠름. 큰 DB 환경 timing 미검증 (Cut B m005 와 동일 risk pattern).
- revision 무한 누적: 메모 1개당 100+ revision 시 DB bloat. 본 cut 은 unlimited. 향후 cut 에서 cap 정책 (예: 최근 50개) 검토.
- NoteCard test mock 갱신 누락: 다른 test 파일 (예: store.test.ts 등) 이 inboxApi 를 mock 할 때 updateRawText/listRevisions/restoreRevision 미정의 → vi.fn() 으로 자동 채워질 수 있으나 strict mock 시 깨짐. 회귀 PASS 가 안전망.
- 편집 textarea autoFocus 누락: UX 미세함. 본 cut 미반영 — dogfood 후 추가 가능.
- 이력 modal pagination 미구현: 100+ revision 시 modal 길어짐. YAGNI — dogfood 후 cap 정책과 함께.