Files
inkling/docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md

48 KiB
Raw Blame History

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 spec
  • docs/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.tsnote_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.tscreate 에 첫 revision INSERT, 신규 updateRawText / listRevisions / restoreRevision
  • src/main/ipc/inboxApi.ts — 3 새 IPC handler
  • src/preload/index.ts — 3 새 bridge 함수
  • src/shared/types.tsNoteRevision 인터페이스 + InboxApi 3 메서드 시그니처
  • src/renderer/inbox/components/NoteCard.tsx — 원문 영역에 편집 textarea + "이력" 버튼 + modal toggle
  • src/renderer/inbox/api.ts — re-export 만 (변경 없음, types 가 늘어 자동 노출)
  • tests/unit/NoteRepository.test.tscreate 가 '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 — version 0.2.90.2.10
  • docs/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_revisionsnotes.id 를 FK + ON DELETE CASCADE 로 참조 (영구 삭제 시 revision 도 삭제). (note_id, edited_at DESC) index 로 listRevisions 빠르게. backfill = 기존 모든 notes 의 raw_text 를 edited_by='capture' 로 INSERT (created_atedited_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_revisionsedited_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.tscreate 메서드 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_textnotes.updated_at 갱신 + note_revisionsedited_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_revisionsedited_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.tslistRevisions 아래:

/**
 * 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.tsNoteTag 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.tsgetDisabledCount 다음에 추가 (객체 닫기 } 직전):

    // 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.confirminboxApi.restoreRevisiononRestored(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.mdraw_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 정책과 함께.