fix(v0210): importNote 가 capture revision 을 함께 INSERT (final review fix)

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 검증.
This commit is contained in:
altair823
2026-05-09 20:59:37 +09:00
parent e32223d28c
commit 39b8d1e728
2 changed files with 80 additions and 4 deletions

View File

@@ -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`

View File

@@ -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');
});
});
});