fix(sync,rag): keep restored rows, reconcile after tombstone pruning, reject partial knowledge docs, surface RAG indexing failures

This commit is contained in:
Yun Chan 2026-09-28 02:16:14 +09:00
parent bbaf1e0a99
commit 3a46437f28
13 changed files with 1630 additions and 448 deletions

View file

@ -0,0 +1,281 @@
// 레드팀 r2-0: 원격 삭제(tombstone) 반영의 복원·보존 기간 경계, 지식 문서 청크 완전성.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history, ragChunks, ragDocuments } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { completeKnowledgeChunks } from '../../../src/main/services/sync/sync-adapters'
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
import {
TOMBSTONE_RETENTION_MS,
earliestDeletedAt,
isRestoredAfter,
remoteTimeAfter,
tombstoneWindowExpired,
} from '../../../src/main/services/sync/tombstone-policy'
import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port'
const USER = '11111111-1111-4111-8111-111111111111'
const DAY = 24 * 60 * 60 * 1000
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
let clock: number
const offlineEmbedder: EmbeddingPort = {
model: 'test-embed',
ensureModel: () => Promise.reject(new Error('no embedding server in tests')),
embed: () => Promise.reject(new Error('no embedding server in tests')),
}
function makeEngine(): SyncEngine {
return new SyncEngine({ remote, userId: USER, now: () => clock })
}
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 })
getCustomInstructionService().initialize()
remote = new FakeSyncRemote(USER)
clock = Date.parse('2026-09-27T00:00:00.000Z')
engine = makeEngine()
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
resetRAGServiceForTests()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
})
function historyRow(id: string, text: string): Record<string, unknown> {
return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' }
}
function localHistoryIds(): string[] {
return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort()
}
describe('tombstone 뒤 계정 보관본 복원', () => {
it('삭제를 이미 반영한 데스크톱: 복원된 행을 받고, 이후 pull의 겹쳐 읽기가 다시 지우지 않는다', async () => {
const x = crypto.randomUUID()
remote.mobileInsert('history', historyRow(x, 'keep me'))
await engine.runFullSync()
expect(localHistoryIds()).toEqual([x])
remote.mobileDelete('history', x)
await engine.pull()
expect(localHistoryIds()).toEqual([])
// restore_account_portability: 같은 id로 다시 넣고, 0037 이후 서버가 updated_at을 새로 찍는다.
remote.mobileInsert('history', historyRow(x, 'keep me'))
await engine.pull()
expect(localHistoryIds()).toEqual([x])
// tombstone 커서는 여전히 T1 근처라 다음 pull은 같은 tombstone을 다시 읽는다.
await engine.pull()
await engine.pull()
expect(localHistoryIds()).toEqual([x])
})
it('삭제와 복원 사이 오프라인이던 데스크톱: 첫 pull에서 복원된 행을 지우지 않는다', async () => {
const x = crypto.randomUUID()
remote.mobileInsert('history', historyRow(x, 'v1'))
await engine.runFullSync()
remote.mobileDelete('history', x)
remote.mobileInsert('history', historyRow(x, 'restored'))
const result = await engine.pull()
expect(result.deleted).toBe(0)
expect(localHistoryIds()).toEqual([x])
expect(testDb.db.select().from(history).where(eq(history.id, x)).get()?.originalText).toBe('restored')
})
it('runFullSync(push 전 tombstone + pull 안의 tombstone)도 복원된 행을 지우지 않는다', async () => {
const x = crypto.randomUUID()
remote.mobileInsert('history', historyRow(x, 'v1'))
await engine.runFullSync()
remote.mobileDelete('history', x)
remote.mobileInsert('history', historyRow(x, 'restored'))
await engine.runFullSync()
await engine.runFullSync()
expect(localHistoryIds()).toEqual([x])
})
it('복원되지 않은 삭제는 그대로 반영하고, 로컬 대기 변경보다 우선한다', async () => {
const kept = crypto.randomUUID()
const gone = crypto.randomUUID()
remote.mobileInsert('history', historyRow(kept, 'k'))
remote.mobileInsert('history', historyRow(gone, 'g'))
await engine.runFullSync()
enqueueChange('history', gone, 'upsert')
remote.mobileDelete('history', gone)
remote.mobileDelete('history', kept)
remote.mobileInsert('history', historyRow(kept, 'k restored'))
await engine.pull()
expect(localHistoryIds()).toEqual([kept])
expect(pendingOps('history').has(gone)).toBe(false)
})
})
describe('tombstone 보존 기간(180일) 초과', () => {
function pruneAllTombstones(): void {
remote.rows('sync_tombstones').splice(0)
}
it('보존 기간보다 오래 쉰 기기는 서버에 없는 로컬 행을 지워 전체 대조한다', async () => {
const stale = crypto.randomUUID()
const alive = crypto.randomUUID()
remote.mobileInsert('history', historyRow(stale, 'deleted on phone long ago'))
remote.mobileInsert('history', historyRow(alive, 'still here'))
await engine.runFullSync()
// 다음 tombstone 커서가 생기도록 다른 행 하나를 지워 둔다
const other = crypto.randomUUID()
remote.mobileInsert('history', historyRow(other, 'other'))
await engine.pull()
remote.mobileDelete('history', other)
await engine.pull()
remote.mobileDelete('history', stale)
pruneAllTombstones()
clock += TOMBSTONE_RETENTION_MS + DAY
const result = await engine.pull()
expect(result.errors).toEqual([])
expect(localHistoryIds()).toEqual([alive])
expect(result.deleted).toBe(1)
// 편집해도 지운 행이 서버에 되살아나지 않는다(로컬에 없다)
expect(remote.find('history', stale)).toBeUndefined()
})
it('아직 올리지 않은 로컬 변경이 있는 행은 전체 대조가 지우지 않는다', async () => {
const seed = crypto.randomUUID()
remote.mobileInsert('history', historyRow(seed, 'seed'))
await engine.runFullSync()
remote.mobileDelete('history', seed)
await engine.pull()
const local = crypto.randomUUID()
const at = clock
testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run()
enqueueChange('history', local, 'upsert')
pruneAllTombstones()
clock += TOMBSTONE_RETENTION_MS + DAY
await engine.pull()
expect(localHistoryIds()).toEqual([local])
})
it('보존 기간 안이면 전체 대조를 하지 않는다(서버에 없는 행을 함부로 지우지 않는다)', async () => {
const seed = crypto.randomUUID()
remote.mobileInsert('history', historyRow(seed, 'seed'))
await engine.runFullSync()
remote.mobileDelete('history', seed)
await engine.pull()
const local = crypto.randomUUID()
testDb.db.insert(history).values({ id: local, originalText: 'x', duration: 1, createdAt: clock, updatedAt: clock }).run()
clock += 30 * DAY
await engine.pull()
expect(localHistoryIds()).toEqual([local])
})
it('한 번 대조하면 다음 pull부터는 다시 대조하지 않는다', async () => {
const seed = crypto.randomUUID()
remote.mobileInsert('history', historyRow(seed, 'seed'))
await engine.runFullSync()
remote.mobileDelete('history', seed)
await engine.pull()
clock += TOMBSTONE_RETENTION_MS + DAY
await engine.pull()
const fresh = crypto.randomUUID()
testDb.db.insert(history).values({ id: fresh, originalText: 'y', duration: 1, createdAt: clock, updatedAt: clock }).run()
clock += DAY
await engine.pull()
expect(localHistoryIds()).toEqual([fresh])
})
})
describe('tombstone 정책 (순수 함수)', () => {
it('서버 시각 비교는 마이크로초까지 본다', () => {
expect(remoteTimeAfter('2026-09-27T00:00:00.000200+00:00', '2026-09-27T00:00:00.000100+00:00')).toBe(true)
expect(remoteTimeAfter('2026-09-27T00:00:01Z', '2026-09-27T00:00:02Z')).toBe(false)
expect(remoteTimeAfter('bad', '2026-09-27T00:00:02Z')).toBe(false)
})
it('복원 판정과 가장 이른 삭제 시각', () => {
const ref = { rowId: 'a', deletedAt: '2026-09-27T00:00:05Z' }
expect(isRestoredAfter(ref, '2026-09-27T00:00:06Z')).toBe(true)
expect(isRestoredAfter(ref, '2026-09-27T00:00:04Z')).toBe(false)
expect(isRestoredAfter({ rowId: 'a', deletedAt: null }, '2026-09-27T00:00:06Z')).toBe(false)
expect(earliestDeletedAt([ref, { rowId: 'b', deletedAt: '2026-09-27T00:00:01Z' }, { rowId: 'c', deletedAt: null }])).toBe(
'2026-09-27T00:00:01Z'
)
})
it('보존 기간 창', () => {
const now = Date.parse('2026-09-27T00:00:00Z')
expect(tombstoneWindowExpired(null, now)).toBe(false)
expect(tombstoneWindowExpired(now - 30 * DAY, now)).toBe(false)
expect(tombstoneWindowExpired(now - 179 * DAY, now)).toBe(true)
})
})
describe('지식 문서 청크 완전성', () => {
it('문서 행의 chunk_count보다 적은 청크만 올라와 있으면 미루고, 다 올라오면 전부 저장한다', async () => {
const id = crypto.randomUUID()
remote.mobileInsert('knowledge_documents', { id, title: 'Big', file_name: 'big.txt', file_type: 'txt', chunk_count: 3 })
remote.rows('knowledge_chunks').push(
{ id: crypto.randomUUID(), document_id: id, chunk_index: 0, content: 'c0' },
{ id: crypto.randomUUID(), document_id: id, chunk_index: 1, content: 'c1' }
)
await engine.runFullSync()
expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()).toBeUndefined()
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: 2, content: 'c2' })
await engine.pull()
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
expect(doc?.chunkCount).toBe(3)
const chunks = testDb.db.select().from(ragChunks).where(eq(ragChunks.documentId, id)).all()
expect(chunks.sort((a, b) => a.chunkIndex - b.chunkIndex).map((c) => c.content)).toEqual(['c0', 'c1', 'c2'])
})
it('completeKnowledgeChunks: 개수·연속 index를 확인한다', () => {
const rows = [
{ chunk_index: 1, content: 'b' },
{ chunk_index: 0, content: 'a' },
]
expect(completeKnowledgeChunks(rows, 2)).toEqual(['a', 'b'])
expect(completeKnowledgeChunks(rows, 3)).toBeNull()
expect(completeKnowledgeChunks(rows, null)).toEqual(['a', 'b'])
expect(completeKnowledgeChunks([{ chunk_index: 0, content: 'a' }, { chunk_index: 2, content: 'c' }], null)).toBeNull()
expect(completeKnowledgeChunks([{ chunk_index: 0, content: 'a' }, { chunk_index: 0, content: 'a' }], 2)).toBeNull()
expect(completeKnowledgeChunks([], 0)).toBeNull()
expect(completeKnowledgeChunks([{ chunk_index: 0, content: null }], 1)).toBeNull()
})
})