feat(trash): ImportService deletedAt preservation + skip-merge policy (#4 v0.2.3)

This commit is contained in:
altair823
2026-05-01 21:23:23 +09:00
parent 468ea90d6c
commit a5f23b925e
4 changed files with 97 additions and 3 deletions

View File

@@ -28,6 +28,7 @@ export interface ImportNoteInput {
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
deletedAt?: string | null;
}
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
@@ -294,6 +295,17 @@ export class NoteRepository {
let status: ImportNoteStatus = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
// skip — 단, source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존)
if (input.deletedAt) {
const destRow = this.db
.prepare('SELECT deleted_at FROM notes WHERE id=?')
.get(input.id) as { deleted_at: string | null } | undefined;
if (destRow && destRow.deleted_at === null) {
this.db
.prepare('UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?')
.run(input.deletedAt, input.deletedAt, input.id);
}
}
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
@@ -305,8 +317,8 @@ export class NoteRepository {
`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)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
finalId,
@@ -319,6 +331,7 @@ export class NoteRepository {
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.deletedAt ?? null,
input.createdAt,
input.updatedAt
);

View File

@@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput {
aiGeneratedAt: parsed.aiGeneratedAt,
userIntent: parsed.userIntent,
intentPromptedAt: parsed.intentPromptedAt,
tags: parsed.tags
tags: parsed.tags,
deletedAt: parsed.deletedAt
};
}

View File

@@ -33,6 +33,7 @@ export interface ParsedNote {
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
deletedAt: string | null; // 신규 v0.2.3 #4
tags: ParsedNoteTag[];
images: ParsedNoteImage[];
exportVersion: number;
@@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote {
aiGeneratedAt: get('ai_generated_at'),
userIntent: get('user_intent'),
intentPromptedAt: get('intent_prompted_at'),
deletedAt: get('deleted_at'),
tags: fm.tags,
images: fm.images,
exportVersion

View File

@@ -233,3 +233,81 @@ describe('ImportService', () => {
expect(dbNote!.media[0]!.bytes).toBe(7);
});
});
describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
const r = repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('skipped');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: null
});
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('id-new insert: source deletedAt 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const r = repo.importNote({
id: 'fresh-id', rawText: 'fresh',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('inserted');
expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'original' });
const r = repo.importNote({
id, rawText: 'different',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(id);
expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});