fix(sessions): optimize N+1 queries and fix search 500 on non-CJK input (#230)

Replace per-session SQL queries in listSessionSummaries/searchSessionSummaries
with a single bulk load via loadAllSessions() + in-memory map traversal,
eliminating N+1 round-trips. Fix search 500 error for pure numbers,
English letters, and other FTS5-incompatible input by extending the
catch fallback beyond CJK-only to all FTS query failures.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-26 10:44:51 +08:00
committed by GitHub
parent 2053da1c10
commit 8db644496e
2 changed files with 113 additions and 96 deletions
+25 -27
View File
@@ -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()
})
})