diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 7f76045..f6959bd 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -51,6 +51,29 @@ export interface ImportNoteResult { status: ImportNoteStatus; } +export interface UpsertFromSyncInput { + id: string; + rawText: string; + createdAt: string; + updatedAt: string; + aiTitle: string | null; + aiSummary: string | null; + titleEditedByUser: boolean; + summaryEditedByUser: boolean; + aiProvider: string | null; + aiGeneratedAt: string | null; + userIntent: string | null; + intentPromptedAt: string | null; + tags: { name: string; source: 'ai' | 'user' }[]; + status: NoteStatus; + statusChangedAt: string | null; + moveReason: string | null; + dueDate: string | null; + dueDateEditedByUser: boolean; +} + +export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped'; + const KEBAB_CASE_RE = /^[a-z0-9-]+$/; export class NoteRepository { @@ -863,6 +886,143 @@ export class NoteRepository { return { id: finalId, status }; } + /** + * v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은 + * sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가). + * + * 3 분기: + * - id 없음 → INSERT (capture revision + tags FTS sync) + * - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신 + * - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision), + * local 이 더 최신이면 skip + * + * tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용. + * raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용. + */ + upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } { + const existing = this.db + .prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`) + .get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined; + + if (!existing) { + // INSERT path + const tx = this.db.transaction(() => { + this.db + .prepare( + `INSERT INTO notes + (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, + user_intent, intent_prompted_at, + created_at, updated_at, + due_date, due_date_edited_by_user, + status, status_changed_at, move_reason) + VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.id, + input.rawText, + input.aiTitle, + input.aiSummary, + input.aiProvider, + input.aiGeneratedAt, + input.titleEditedByUser ? 1 : 0, + input.summaryEditedByUser ? 1 : 0, + input.userIntent, + input.intentPromptedAt, + input.createdAt, + input.updatedAt, + input.dueDate, + input.dueDateEditedByUser ? 1 : 0, + input.status, + input.statusChangedAt, + input.moveReason + ); + this.db + .prepare( + `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'capture')` + ) + .run(input.id, 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` + ); + const linkAi = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + const linkUser = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of input.tags) { + const row = getOrInsertTag.get(t.name) as { id: number }; + if (t.source === 'ai') linkAi.run(input.id, row.id); + else linkUser.run(input.id, row.id); + } + this.rebuildFtsTagsForNote(input.id); + } + }); + tx(); + return { id: input.id, status: 'inserted' }; + } + + if (input.updatedAt <= existing.updated_at) { + return { id: input.id, status: 'skipped' }; + } + + if (existing.raw_text !== input.rawText) { + this.updateRawText(input.id, input.rawText, new Date(input.updatedAt)); + } + + const tx = this.db.transaction(() => { + this.db + .prepare( + `UPDATE notes + SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END, + ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END, + ai_provider = ?, + ai_generated_at = ?, + due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END, + status = ?, + status_changed_at = ?, + move_reason = ?, + updated_at = ? + WHERE id = ?` + ) + .run( + input.aiTitle, + input.aiSummary, + input.aiProvider, + input.aiGeneratedAt, + input.dueDate, + input.status, + input.statusChangedAt, + input.moveReason, + input.updatedAt, + input.id + ); + this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id); + 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` + ); + const linkAi = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + const linkUser = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of input.tags) { + const row = getOrInsertTag.get(t.name) as { id: number }; + if (t.source === 'ai') linkAi.run(input.id, row.id); + else linkUser.run(input.id, row.id); + } + } + this.rebuildFtsTagsForNote(input.id); + }); + tx(); + return { id: input.id, status: 'updated' }; + } + getPendingCount(): number { const row = this.db .prepare( diff --git a/tests/unit/NoteRepository.upsertFromSync.test.ts b/tests/unit/NoteRepository.upsertFromSync.test.ts new file mode 100644 index 0000000..4500dad --- /dev/null +++ b/tests/unit/NoteRepository.upsertFromSync.test.ts @@ -0,0 +1,98 @@ +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'; + +const baseInput = { + id: '00000000-0000-0000-0000-000000000001', + rawText: 'sync 본문', + createdAt: '2026-05-09T00:00:00Z', + updatedAt: '2026-05-10T00:00:00Z', + aiTitle: 'sync 제목', + aiSummary: 'sync 요약', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'p', + aiGeneratedAt: '2026-05-10T00:00:00Z', + userIntent: null, + intentPromptedAt: null, + tags: [{ name: '동기', source: 'user' as const }], + status: 'active' as const, + statusChangedAt: null, + moveReason: null, + dueDate: null, + dueDateEditedByUser: false +}; + +describe('NoteRepository.upsertFromSync', () => { + 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(); }); + + it('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => { + const r = repo.upsertFromSync(baseInput); + expect(r.status).toBe('inserted'); + expect(r.id).toBe(baseInput.id); + const note = repo.findById(baseInput.id); + expect(note?.rawText).toBe('sync 본문'); + expect(note?.aiTitle).toBe('sync 제목'); + const revs = repo.listRevisions(baseInput.id); + expect(revs).toHaveLength(1); + expect(revs[0]!.editedBy).toBe('capture'); + const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string }; + expect(fts.tags).toBe('동기'); + }); + + it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => { + const created = repo.create({ rawText: 'sync 본문' }); + repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id }); + expect(r.status).toBe('updated'); + const note = repo.findById(created.id); + expect(note?.aiTitle).toBe('sync 제목'); + expect(note?.tags.map((t) => t.name)).toEqual(['동기']); + const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string }; + expect(fts.tags).toBe('동기'); + }); + + it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => { + const created = repo.create({ rawText: 'sync 본문' }); + repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' }); + expect(r.status).toBe('skipped'); + const note = repo.findById(created.id); + expect(note?.aiTitle).toBe('신선한 제목'); + }); + + it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => { + const created = repo.create({ rawText: 'old text' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' }); + expect(r.status).toBe('updated'); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('new sync text'); + const revs = repo.listRevisions(created.id); + expect(revs).toHaveLength(2); // capture (old) + user (new) + expect(revs[0]!.editedBy).toBe('user'); + expect(revs[0]!.rawText).toBe('new sync text'); + }); + + it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => { + const created = repo.create({ rawText: 'local fresh' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' }); + expect(r.status).toBe('skipped'); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('local fresh'); + }); +});