mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-25 05:20:15 +00:00
d0f1e7d1f2
* feat(bridge): refactor compression to use DB history and add structured logging - Extract buildDbHistory() to share message loading between buildCompressedHistory and forceCompressBridgeHistory - forceCompressBridgeHistory now reads from local DB instead of using Python-provided messages, ensuring consistency with api_server path - Pass sessionId to compressor for snapshot-aware compression - Add force_compress flag to bridge chat requests - Add bridgeLogger structured logging for compression lifecycle - Simplify schemas, session-sync, and providers Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix bridge compression history handling --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { unlinkSync, existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync } from 'fs'
|
|
import { resolve } from 'path'
|
|
|
|
// Test database path
|
|
const TEST_DB_DIR = resolve(process.cwd(), 'packages/server/data/test')
|
|
const TEST_DB_PATH = resolve(TEST_DB_DIR, 'test-hermes.db')
|
|
|
|
// Global test database instance
|
|
let testDbInstance: DatabaseSync | null = null
|
|
|
|
// Mock getDb to return our test database
|
|
vi.mock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => testDbInstance,
|
|
getStoragePath: () => TEST_DB_PATH,
|
|
}))
|
|
|
|
// Helper to get the actual database instance
|
|
function getTestDb(): DatabaseSync {
|
|
if (!testDbInstance) {
|
|
throw new Error('Test database not initialized. Call beforeAll() first.')
|
|
}
|
|
return testDbInstance
|
|
}
|
|
|
|
// Helper to check if table exists
|
|
function tableExists(db: DatabaseSync, tableName: string): boolean {
|
|
const result = db.prepare(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
).get(tableName)
|
|
return !!result
|
|
}
|
|
|
|
// Helper to get table columns
|
|
function getTableColumns(db: DatabaseSync, tableName: string): Map<string, string> {
|
|
const columns = db.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{
|
|
name: string
|
|
type: string
|
|
pk: number
|
|
}>
|
|
const columnMap = new Map<string, string>()
|
|
for (const col of columns) {
|
|
columnMap.set(col.name, col.type)
|
|
}
|
|
return columnMap
|
|
}
|
|
|
|
// Helper to get table primary key from SQL
|
|
function getTablePrimaryKey(db: DatabaseSync, tableName: string): string | null {
|
|
const tableInfo = db.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
|
|
).get(tableName) as { sql: string } | undefined
|
|
|
|
const sql = tableInfo?.sql || ''
|
|
|
|
// First, check for composite primary key: PRIMARY KEY (col1, col2)
|
|
const pkMatch = sql.match(/PRIMARY KEY\s*\(([^)]+)\)/i)
|
|
if (pkMatch) {
|
|
return pkMatch[1].replace(/\s+/g, '')
|
|
}
|
|
|
|
// Then, check for inline primary key: col TEXT PRIMARY KEY
|
|
const inlinePkMatch = sql.match(/"(\w+)"\s+\w+\s+PRIMARY KEY/i)
|
|
if (inlinePkMatch) {
|
|
return inlinePkMatch[1]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
describe('Database Schema Synchronization', () => {
|
|
beforeAll(() => {
|
|
// Create test directory
|
|
if (!existsSync(TEST_DB_DIR)) {
|
|
mkdirSync(TEST_DB_DIR, { recursive: true })
|
|
}
|
|
})
|
|
|
|
beforeEach(() => {
|
|
// Clean up any existing test database
|
|
try { unlinkSync(TEST_DB_PATH) } catch {}
|
|
try { unlinkSync(TEST_DB_PATH + '-wal') } catch {}
|
|
try { unlinkSync(TEST_DB_PATH + '-shm') } catch {}
|
|
|
|
// Create new test database
|
|
testDbInstance = new DatabaseSync(TEST_DB_PATH)
|
|
testDbInstance.exec('PRAGMA journal_mode=WAL')
|
|
testDbInstance.exec('PRAGMA synchronous=NORMAL')
|
|
|
|
// Reset modules to ensure fresh imports
|
|
vi.resetModules()
|
|
})
|
|
|
|
afterEach(() => {
|
|
// Close test database
|
|
if (testDbInstance) {
|
|
testDbInstance.close()
|
|
testDbInstance = null
|
|
}
|
|
|
|
// Clean up test database and backup files
|
|
try { unlinkSync(TEST_DB_PATH) } catch {}
|
|
try { unlinkSync(TEST_DB_PATH + '-wal') } catch {}
|
|
try { unlinkSync(TEST_DB_PATH + '-shm') } catch {}
|
|
})
|
|
|
|
describe('Normal initialization - fresh database creation', () => {
|
|
it('creates all tables with correct schemas when database does not exist', async () => {
|
|
const { initAllHermesTables, USAGE_TABLE, USAGE_SCHEMA, SESSIONS_TABLE, SESSIONS_SCHEMA } =
|
|
await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
initAllHermesTables()
|
|
|
|
const db = getTestDb()
|
|
|
|
// Verify USAGE_TABLE was created
|
|
expect(tableExists(db, USAGE_TABLE)).toBe(true)
|
|
|
|
// Verify USAGE_TABLE has correct columns
|
|
const usageCols = getTableColumns(db, USAGE_TABLE)
|
|
expect(usageCols.size).toBe(Object.keys(USAGE_SCHEMA).length)
|
|
expect(usageCols.has('id')).toBe(true)
|
|
expect(usageCols.has('session_id')).toBe(true)
|
|
expect(usageCols.has('input_tokens')).toBe(true)
|
|
|
|
// Verify SESSIONS_TABLE was created
|
|
expect(tableExists(db, SESSIONS_TABLE)).toBe(true)
|
|
|
|
// Verify SESSIONS_TABLE has correct columns
|
|
const sessionsCols = getTableColumns(db, SESSIONS_TABLE)
|
|
expect(sessionsCols.size).toBe(Object.keys(SESSIONS_SCHEMA).length)
|
|
expect(sessionsCols.has('id')).toBe(true)
|
|
expect(sessionsCols.has('profile')).toBe(true)
|
|
expect(sessionsCols.has('source')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Safe additive schema changes', () => {
|
|
it('adds missing safe columns to existing table without rebuilding', async () => {
|
|
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
// Create initial table without some columns
|
|
const db = getTestDb()
|
|
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL)`)
|
|
|
|
// Insert test data
|
|
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run('test-1', Date.now())
|
|
|
|
// Sync with full schema
|
|
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
|
|
|
// Verify safe missing columns now exist
|
|
const cols = getTableColumns(db, USAGE_TABLE)
|
|
expect(cols.has('input_tokens')).toBe(true)
|
|
expect(cols.has('output_tokens')).toBe(true)
|
|
expect(cols.has('cache_read_tokens')).toBe(true)
|
|
expect(cols.has('cache_write_tokens')).toBe(true)
|
|
|
|
// Verify data integrity (should be preserved)
|
|
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('test-1')
|
|
expect(row).toBeTruthy()
|
|
expect(row.session_id).toBe('test-1')
|
|
})
|
|
})
|
|
|
|
describe('Schema sync with single-column primary keys', () => {
|
|
it('creates table with single-column primary key', async () => {
|
|
const { syncTable, GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA } =
|
|
await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, {
|
|
primaryKey: 'id',
|
|
})
|
|
|
|
const db = getTestDb()
|
|
|
|
// Verify table exists
|
|
expect(tableExists(db, GC_ROOM_AGENTS_TABLE)).toBe(true)
|
|
|
|
// Verify single-column primary key
|
|
const pk = getTablePrimaryKey(db, GC_ROOM_AGENTS_TABLE)
|
|
expect(pk).toBe('id')
|
|
|
|
// Verify all columns exist
|
|
const cols = getTableColumns(db, GC_ROOM_AGENTS_TABLE)
|
|
expect(cols.has('id')).toBe(true)
|
|
expect(cols.has('roomId')).toBe(true)
|
|
expect(cols.has('agentId')).toBe(true)
|
|
expect(cols.has('profile')).toBe(true)
|
|
expect(cols.has('name')).toBe(true)
|
|
|
|
// Verify primary key constraint works (unique id required)
|
|
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run('agent-1', 'room-1', 'agent-1', 'default', 'Agent 1', '', 0)
|
|
|
|
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run('agent-2', 'room-1', 'agent-2', 'default', 'Agent 2', '', 0)
|
|
|
|
// Verify both rows exist
|
|
const rows = db.prepare(`SELECT COUNT(*) as count FROM "${GC_ROOM_AGENTS_TABLE}"`).get() as { count: number }
|
|
expect(rows.count).toBe(2)
|
|
|
|
// Verify duplicate primary key is rejected
|
|
expect(() => {
|
|
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run('agent-1', 'room-1', 'agent-1', 'default', 'Agent 1 Duplicate', '', 0)
|
|
}).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('Destructive schema changes are not applied automatically', () => {
|
|
it('does not rebuild table when primary key differs', async () => {
|
|
const { syncTable, GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA } =
|
|
await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
const db = getTestDb()
|
|
|
|
// Create table with roomId as primary key and all necessary columns
|
|
db.exec(`CREATE TABLE "${GC_ROOM_MEMBERS_TABLE}" (roomId TEXT PRIMARY KEY, userId TEXT, userName TEXT, description TEXT DEFAULT '', joinedAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL)`)
|
|
|
|
// Insert test data
|
|
db.prepare(`INSERT INTO "${GC_ROOM_MEMBERS_TABLE}" (roomId, userId, userName, description, joinedAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run('room-1', 'user-1', 'User 1', '', Date.now(), Date.now())
|
|
|
|
// Sync with id-based primary key schema
|
|
syncTable(GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA, {
|
|
primaryKey: 'id',
|
|
})
|
|
|
|
// Verify existing primary key was left untouched
|
|
const tableCols = db.prepare(`PRAGMA table_info("${GC_ROOM_MEMBERS_TABLE}")`).all() as Array<{ name: string; pk: number }>
|
|
expect(tableCols.find(c => c.name === 'roomId')?.pk).toBe(1)
|
|
|
|
// Verify data was preserved
|
|
const row = db.prepare(`SELECT * FROM "${GC_ROOM_MEMBERS_TABLE}" WHERE roomId = ? AND userId = ?`).get('room-1', 'user-1')
|
|
expect(row).toBeTruthy()
|
|
expect(row.roomId).toBe('room-1')
|
|
expect(row.userId).toBe('user-1')
|
|
})
|
|
|
|
it('does not rebuild table when column types differ', async () => {
|
|
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
const db = getTestDb()
|
|
|
|
// Create table with wrong column type (INTEGER instead of TEXT for session_id)
|
|
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, created_at INTEGER NOT NULL)`)
|
|
|
|
// Insert test data
|
|
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run(12345, Date.now())
|
|
|
|
// Sync with correct schema
|
|
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
|
|
|
// Verify column type was left untouched
|
|
const cols = getTableColumns(db, USAGE_TABLE)
|
|
expect(cols.get('session_id')).toBe('INTEGER')
|
|
|
|
// Verify data was preserved
|
|
const rows = db.prepare(`SELECT COUNT(*) as count FROM "${USAGE_TABLE}"`).get() as { count: number }
|
|
expect(rows.count).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Index synchronization', () => {
|
|
it('creates specified indexes on table', async () => {
|
|
const { syncTable, MESSAGES_TABLE, MESSAGES_SCHEMA } =
|
|
await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
syncTable(MESSAGES_TABLE, MESSAGES_SCHEMA, {
|
|
indexes: {
|
|
idx_messages_session_id: 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)',
|
|
},
|
|
})
|
|
|
|
const db = getTestDb()
|
|
|
|
// Verify index was created
|
|
const indexes = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_messages_session_id')
|
|
expect(indexes).toBeTruthy()
|
|
})
|
|
|
|
it('does not alter indexes on existing tables', async () => {
|
|
const { syncTable, MESSAGES_TABLE, MESSAGES_SCHEMA } =
|
|
await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
const db = getTestDb()
|
|
|
|
// Create table and an extra index
|
|
db.exec(`CREATE TABLE "${MESSAGES_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, content TEXT)`)
|
|
db.exec(`CREATE INDEX idx_extra ON "${MESSAGES_TABLE}"(content)`)
|
|
|
|
// Sync without the extra index
|
|
syncTable(MESSAGES_TABLE, MESSAGES_SCHEMA, {
|
|
indexes: {
|
|
idx_messages_session_id: 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)',
|
|
},
|
|
})
|
|
|
|
// Verify extra index remains
|
|
const extraIndex = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_extra')
|
|
expect(extraIndex).toBeTruthy()
|
|
|
|
// Verify expected index was not added to an existing table
|
|
const correctIndex = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_messages_session_id')
|
|
expect(correctIndex).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
describe('Data preservation during schema sync', () => {
|
|
it('preserves data when adding safe columns', async () => {
|
|
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
const db = getTestDb()
|
|
|
|
// Create minimal table
|
|
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL)`)
|
|
|
|
// Insert test data (only columns that exist)
|
|
const sessionId = 'test-session-123'
|
|
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run(sessionId, Date.now())
|
|
|
|
// Sync with full schema (should add safe columns only)
|
|
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
|
|
|
// Verify data is still there
|
|
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get(sessionId)
|
|
expect(row).toBeTruthy()
|
|
expect(row.session_id).toBe(sessionId)
|
|
|
|
const cols = getTableColumns(db, USAGE_TABLE)
|
|
expect(cols.has('input_tokens')).toBe(true)
|
|
})
|
|
|
|
it('preserves data and existing table definition when primary key is missing', async () => {
|
|
const { syncTable, GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA } =
|
|
await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
const db = getTestDb()
|
|
|
|
// Create table without id primary key but with all columns
|
|
db.exec(`CREATE TABLE "${GC_ROOM_AGENTS_TABLE}" (id TEXT NOT NULL, roomId TEXT NOT NULL, agentId TEXT NOT NULL, profile TEXT NOT NULL, name TEXT NOT NULL, description TEXT DEFAULT '', invited INTEGER DEFAULT 0)`)
|
|
|
|
// Insert test data (only columns that exist)
|
|
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run('agent-1', 'room-1', 'agent-1', 'default', 'Test Agent', '', 0)
|
|
|
|
// Sync with id primary key expectation; should not rebuild existing table
|
|
syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, {
|
|
primaryKey: 'id',
|
|
})
|
|
|
|
expect(getTablePrimaryKey(db, GC_ROOM_AGENTS_TABLE)).toBe(null)
|
|
|
|
// Verify data was preserved
|
|
const row = db.prepare(`SELECT * FROM "${GC_ROOM_AGENTS_TABLE}" WHERE id = ?`)
|
|
.get('agent-1')
|
|
expect(row).toBeTruthy()
|
|
expect(row.id).toBe('agent-1')
|
|
expect(row.roomId).toBe('room-1')
|
|
expect(row.agentId).toBe('agent-1')
|
|
expect(row.name).toBe('Test Agent')
|
|
})
|
|
})
|
|
|
|
describe('Column preservation', () => {
|
|
it('keeps extra columns on existing table', async () => {
|
|
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
// Create table with extra columns
|
|
const db = getTestDb()
|
|
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL, extra_col TEXT, another_extra INTEGER)`)
|
|
|
|
// Insert test data (only for columns that exist)
|
|
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at, extra_col, another_extra) VALUES (?, ?, ?, ?)`)
|
|
.run('test-1', Date.now(), 'value', 123)
|
|
|
|
// Sync with schema (should keep extra columns)
|
|
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
|
|
|
// Verify extra columns are preserved
|
|
const cols = getTableColumns(db, USAGE_TABLE)
|
|
expect(cols.has('extra_col')).toBe(true)
|
|
expect(cols.has('another_extra')).toBe(true)
|
|
|
|
// Verify data is still there
|
|
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('test-1')
|
|
expect(row).toBeTruthy()
|
|
expect(row.session_id).toBe('test-1')
|
|
})
|
|
})
|
|
})
|