From 39b8d1e728321f772d2e1dcf7c4b48ae1970be83 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 20:59:37 +0900 Subject: [PATCH] =?UTF-8?q?fix(v0210):=20importNote=20=EA=B0=80=20capture?= =?UTF-8?q?=20revision=20=EC=9D=84=20=ED=95=A8=EA=BB=98=20INSERT=20(final?= =?UTF-8?q?=20review=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit final code review 발견: F5 import 후 first user edit 시 import 시점 본문이 note_revisions 에 없어 history 에서 사라지는 회귀. importNote transaction 안 INSERT 추가 (createdAt = edited_at). 부수 작업: ImportNoteInput / importNote 의 "raw_text invariant guard" 주석을 v0.2.10 의 'fork-on-id-collision (sync determinism)' 정확한 의미로 갱신. 테스트 +2 — insert path / fork path 모두 capture revision 검증. --- src/main/repository/NoteRepository.ts | 23 ++++++++-- tests/unit/NoteRevisions.test.ts | 61 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 411550a..3567a45 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -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; @@ -681,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 갱신. @@ -736,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` diff --git a/tests/unit/NoteRevisions.test.ts b/tests/unit/NoteRevisions.test.ts index f9acd2e..44c3bae 100644 --- a/tests/unit/NoteRevisions.test.ts +++ b/tests/unit/NoteRevisions.test.ts @@ -107,4 +107,65 @@ describe('NoteRepository — note_revisions', () => { 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'); + }); + }); });