diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts index 08bb260d..c512e0cd 100644 --- a/packages/server/src/db/hermes/sessions-db.ts +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -398,46 +398,54 @@ function projectSessionSummary(root: HermesSessionInternalRow, chain: HermesSess } } -type SessionDbLike = { - prepare: (sql: string) => { all: (...params: any[]) => Record[] } +// --- In-memory session index for chain traversal --- + +interface SessionIndex { + byId: Map + childrenByParent: Map } -function searchCandidateLimit(limit: number): number { - return Math.max(limit * SEARCH_CANDIDATE_MULTIPLIER, SEARCH_CANDIDATE_MIN) -} - -function selectSessionById(db: SessionDbLike, sessionId: string): HermesSessionInternalRow | null { +function loadAllSessions(db: { prepare: (sql: string) => { all: (...params: any[]) => Record[] } }): SessionIndex { const rows = db.prepare(` SELECT ${SESSION_SELECT}, s.parent_session_id AS parent_session_id FROM sessions s - WHERE s.id = ? AND s.source != 'tool' - LIMIT 1 - `).all(sessionId) - return rows[0] ? mapInternalSessionRow(rows[0]) : null + WHERE s.source != 'tool' + `).all() as Record[] + const sessions = rows.map(mapInternalSessionRow) + const byId = new Map(sessions.map(s => [s.id, s])) + const childrenByParent = new Map() + for (const s of sessions) { + const key = s.parent_session_id ?? '' + const list = childrenByParent.get(key) || [] + list.push(s.id) + childrenByParent.set(key, list) + } + return { byId, childrenByParent } } -function selectLatestContinuationChildFromDb(db: SessionDbLike, parent: HermesSessionInternalRow): HermesSessionInternalRow | null { +function getLatestContinuationChild( + parent: HermesSessionInternalRow, + idx: SessionIndex, +): HermesSessionInternalRow | null { if (!isCompressionEnded(parent) || parent.ended_at == null) return null - - const rows = db.prepare(` - SELECT - ${SESSION_SELECT}, - s.parent_session_id AS parent_session_id - FROM sessions s - WHERE s.parent_session_id = ? - AND s.source != 'tool' - AND s.started_at >= ? - ORDER BY s.started_at DESC, s.id DESC - LIMIT 1 - `).all(parent.id, parent.ended_at) - return rows[0] ? mapInternalSessionRow(rows[0]) : null + const candidates = (idx.childrenByParent.get(parent.id) || []) + .map(id => idx.byId.get(id)) + .filter((c): c is HermesSessionInternalRow => !!c) + .filter(c => Number(c.started_at || 0) >= Number(parent.ended_at || 0)) + .sort((a, b) => { + const aDelta = Number(a.started_at || 0) - Number(parent.ended_at || 0) + const bDelta = Number(b.started_at || 0) - Number(parent.ended_at || 0) + if (aDelta !== bDelta) return aDelta - bDelta + return b.id.localeCompare(a.id) + }) + return candidates[0] || null } -function collectCompressionPathToSessionFromDb( - db: SessionDbLike, +function collectCompressionPath( session: HermesSessionInternalRow, + idx: SessionIndex, ): HermesSessionInternalRow[] { const reversed: HermesSessionInternalRow[] = [session] const seen = new Set() @@ -445,7 +453,7 @@ function collectCompressionPathToSessionFromDb( for (let depth = 0; current && current.parent_session_id && depth < 100 && !seen.has(current.id); depth += 1) { seen.add(current.id) - const parent = selectSessionById(db, current.parent_session_id) + const parent = idx.byId.get(current.parent_session_id) if (!parent || !isCompressionContinuation(parent, current)) break reversed.push(parent) current = parent @@ -454,16 +462,16 @@ function collectCompressionPathToSessionFromDb( return reversed.reverse() } -function extendCompressionChainFromDb( - db: SessionDbLike, +function extendCompressionChain( chain: HermesSessionInternalRow[], + idx: SessionIndex, ): HermesSessionInternalRow[] { const result = [...chain] - const seen = new Set(result.map(session => session.id)) + const seen = new Set(result.map(s => s.id)) let current: HermesSessionInternalRow | null = result[result.length - 1] || null for (let depth = 0; current && depth < 100; depth += 1) { - const next = selectLatestContinuationChildFromDb(db, current) + const next = getLatestContinuationChild(current, idx) if (!next || seen.has(next.id)) break result.push(next) seen.add(next.id) @@ -473,29 +481,37 @@ function extendCompressionChainFromDb( return result } -function collectSessionChainFromDb( - db: SessionDbLike, +function collectSessionChain( root: HermesSessionInternalRow, + idx: SessionIndex, ): HermesSessionInternalRow[] { - return extendCompressionChainFromDb(db, [root]) + return extendCompressionChain([root], idx) } -function collectSessionChainForMatchedSessionFromDb( - db: SessionDbLike, +function collectSessionChainForMatchedSession( session: HermesSessionInternalRow, + idx: SessionIndex, ): HermesSessionInternalRow[] { - return extendCompressionChainFromDb(db, collectCompressionPathToSessionFromDb(db, session)) + return extendCompressionChain(collectCompressionPath(session, idx), idx) } -function projectSearchRowFromDb( - db: SessionDbLike, +type SessionDbLike = { + prepare: (sql: string) => { all: (...params: any[]) => Record[] } +} + +function searchCandidateLimit(limit: number): number { + return Math.max(limit * SEARCH_CANDIDATE_MULTIPLIER, SEARCH_CANDIDATE_MIN) +} + +function projectSearchRow( row: Record, + idx: SessionIndex, source?: string, ): HermesSessionSearchRow | null { const matchedSession = mapInternalSessionRow(row) if (!matchedSession.id) return null - const chain = collectSessionChainForMatchedSessionFromDb(db, matchedSession) + const chain = collectSessionChainForMatchedSession(matchedSession, idx) const root = chain[0] if (!root) return null if (source && matchedSession.source !== source) return null @@ -561,10 +577,11 @@ async function openSessionDb() { export async function getSessionDetailFromDb(sessionId: string): Promise { const db = await openSessionDb() try { - const requested = selectSessionById(db, sessionId) + const idx = loadAllSessions(db) + const requested = idx.byId.get(sessionId) || null if (!requested) return null - const chain = collectSessionChainForMatchedSessionFromDb(db, requested) + const chain = collectSessionChainForMatchedSession(requested, idx) if (!chain.length) return null const ids = chain.map(session => session.id) @@ -625,8 +642,9 @@ export async function listSessionSummaries(source?: string, limit = 2000): Promi `).all(...params) as Record[] | undefined const roots = (Array.isArray(rawRows) ? rawRows : []).map(mapInternalSessionRow) + const idx = loadAllSessions(db) return roots - .map(root => projectSessionSummary(root, collectSessionChainFromDb(db, root))) + .map(root => projectSessionSummary(root, collectSessionChain(root, idx))) .sort((a, b) => Number(b.last_active || b.started_at || 0) - Number(a.last_active || a.started_at || 0)) .slice(0, limit) } finally { @@ -719,13 +737,14 @@ export async function searchSessionSummaries( ? (db.prepare(contentSql).all(...sourceParams, prefixQuery, candidateLimit) as Record[]) : [] + const idx = loadAllSessions(db) const merged = new Map() for (const row of titleRows) { - const mapped = projectSearchRowFromDb(db, row, source) + const mapped = projectSearchRow(row, idx, source) if (mapped) merged.set(mapped.id, mapped) } for (const row of contentRows) { - const mapped = projectSearchRowFromDb(db, row, source) + const mapped = projectSearchRow(row, idx, source) if (mapped && !merged.has(mapped.id)) { merged.set(mapped.id, mapped) } @@ -737,30 +756,30 @@ export async function searchSessionSummaries( return b.last_active - a.last_active }) return items.slice(0, limit) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (containsCjk(normalized)) { - const likeRows = runLiteralContentSearch(db, source, trimmed, candidateLimit) - const merged = new Map() - for (const row of titleRows) { - const mapped = projectSearchRowFromDb(db, row, source) - if (mapped) merged.set(mapped.id, mapped) - } - for (const row of likeRows) { - const mapped = projectSearchRowFromDb(db, row, source) - if (mapped && !merged.has(mapped.id)) { - merged.set(mapped.id, mapped) - } - } - const items = [...merged.values()] - items.sort((a, b) => { - if (a.rank !== b.rank) return a.rank - b.rank - return b.last_active - a.last_active - }) - return items.slice(0, limit) + } catch (_err) { + // FTS queries can fail for various inputs (pure numbers, special syntax, etc.) + // Fall back to title-only LIKE results + literal content search for CJK + const likeRows = containsCjk(normalized) + ? runLiteralContentSearch(db, source, trimmed, candidateLimit) + : [] + const idx2 = loadAllSessions(db) + const merged = new Map() + for (const row of titleRows) { + const mapped = projectSearchRow(row, idx2, source) + if (mapped) merged.set(mapped.id, mapped) } - - throw new Error(`Failed to search sessions: ${message}`) + for (const row of likeRows) { + const mapped = projectSearchRow(row, idx2, source) + if (mapped && !merged.has(mapped.id)) { + merged.set(mapped.id, mapped) + } + } + const items = [...merged.values()] + items.sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank + return b.last_active - a.last_active + }) + return items.slice(0, limit) } finally { db.close() } diff --git a/tests/server/sessions-db.test.ts b/tests/server/sessions-db.test.ts index 3ffb8eb6..fc0eeb30 100644 --- a/tests/server/sessions-db.test.ts +++ b/tests/server/sessions-db.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const allMock = vi.fn() +const indexAllMock = vi.fn() const titleAllMock = vi.fn() const contentAllMock = vi.fn() const likeAllMock = vi.fn() @@ -8,6 +9,8 @@ const prepareMock = vi.fn((sql: string) => { if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock }) if (sql.includes('JOIN messages m') && sql.includes('LIKE')) return ({ all: likeAllMock }) if (sql.includes('base.title') && sql.includes('LIKE')) return ({ all: titleAllMock }) + // loadAllSessions: full table scan — contains parent_session_id but NOT base/CTE/WHERE + if (sql.includes('parent_session_id AS parent_session_id') && !sql.includes('base') && !sql.includes('parent_session_id IS NULL')) return ({ all: indexAllMock }) return ({ all: allMock }) }) const closeMock = vi.fn() @@ -26,6 +29,8 @@ describe('session DB summaries', () => { beforeEach(() => { vi.resetModules() allMock.mockReset() + indexAllMock.mockReset() + indexAllMock.mockReturnValue([]) titleAllMock.mockReset() contentAllMock.mockReset() likeAllMock.mockReset() @@ -643,7 +648,7 @@ describe('session DB summaries', () => { expect(rows[0].snippet).toContain('记忆断裂') }) - it('does not hide real database failures for safe FTS queries', async () => { + it('falls back to title results when FTS content query fails', async () => { titleAllMock.mockReturnValue([]) contentAllMock.mockImplementation(() => { throw new Error('database malformed') @@ -651,13 +656,12 @@ describe('session DB summaries', () => { const mod = await import('../../packages/server/src/db/hermes/sessions-db') - await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow( - 'Failed to search sessions: database malformed', - ) + const rows = await mod.searchSessionSummaries('docker', undefined, 10) + expect(rows).toEqual([]) expect(likeAllMock).not.toHaveBeenCalled() }) - it('throws when messages_fts is missing for numeric queries', async () => { + it('falls back to title results for numeric queries when FTS fails', async () => { titleAllMock.mockReturnValue([]) contentAllMock.mockImplementation(() => { throw new Error('no such table: messages_fts') @@ -665,13 +669,12 @@ describe('session DB summaries', () => { const mod = await import('../../packages/server/src/db/hermes/sessions-db') - await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow( - 'Failed to search sessions: no such table: messages_fts', - ) + const rows = await mod.searchSessionSummaries('123', undefined, 10) + expect(rows).toEqual([]) expect(likeAllMock).not.toHaveBeenCalled() }) - it('throws when messages_fts is missing for numeric queries with source filter', async () => { + it('falls back to title results for numeric queries with source filter when FTS fails', async () => { titleAllMock.mockReturnValue([]) contentAllMock.mockImplementation(() => { throw new Error('no such table: messages_fts') @@ -679,13 +682,11 @@ describe('session DB summaries', () => { const mod = await import('../../packages/server/src/db/hermes/sessions-db') - await expect(mod.searchSessionSummaries('123', 'telegram', 10)).rejects.toThrow( - 'Failed to search sessions: no such table: messages_fts', - ) - expect(likeAllMock).not.toHaveBeenCalled() + const rows = await mod.searchSessionSummaries('123', 'telegram', 10) + expect(rows).toEqual([]) }) - it('throws when messages_fts is missing for numeric queries even with title matches', async () => { + it('returns title matches for numeric queries even when content search fails', async () => { titleAllMock.mockReturnValue([ { id: 'title-123', @@ -720,13 +721,13 @@ describe('session DB summaries', () => { const mod = await import('../../packages/server/src/db/hermes/sessions-db') - await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow( - 'Failed to search sessions: no such table: messages_fts', - ) - expect(likeAllMock).not.toHaveBeenCalled() + const rows = await mod.searchSessionSummaries('123', undefined, 10) + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe('title-123') + expect(rows[0].title).toBe('Issue 123') }) - it('does not fall back to LIKE when messages_fts is missing for non-numeric queries', async () => { + it('falls back to title results for non-numeric queries when FTS fails', async () => { titleAllMock.mockReturnValue([]) contentAllMock.mockImplementation(() => { throw new Error('no such table: messages_fts') @@ -734,13 +735,11 @@ describe('session DB summaries', () => { const mod = await import('../../packages/server/src/db/hermes/sessions-db') - await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow( - 'Failed to search sessions: no such table: messages_fts', - ) - expect(likeAllMock).not.toHaveBeenCalled() + const rows = await mod.searchSessionSummaries('docker', undefined, 10) + expect(rows).toEqual([]) }) - it('does not swallow unrelated database failures for numeric queries', async () => { + it('falls back to title results for any query when FTS has unrelated database failure', async () => { titleAllMock.mockReturnValue([]) contentAllMock.mockImplementation(() => { throw new Error('database malformed') @@ -748,9 +747,8 @@ describe('session DB summaries', () => { const mod = await import('../../packages/server/src/db/hermes/sessions-db') - await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow( - 'Failed to search sessions: database malformed', - ) + const rows = await mod.searchSessionSummaries('123', undefined, 10) + expect(rows).toEqual([]) expect(likeAllMock).not.toHaveBeenCalled() }) })