2026-05-01 19:48:46 +08:00
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 || ''
2026-05-02 08:58:14 +08:00
// First, check for composite primary key: PRIMARY KEY (col1, col2)
2026-05-01 19:48:46 +08:00
const pkMatch = sql . match ( /PRIMARY KEY\s*\(([^)]+)\)/i )
2026-05-02 08:58:14 +08:00
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
2026-05-01 19:48:46 +08:00
}
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 )
} )
} )
2026-05-14 21:02:59 +08:00
describe ( 'Safe additive schema changes' , ( ) = > {
it ( 'adds missing safe columns to existing table without rebuilding' , async ( ) = > {
2026-05-01 19:48:46 +08:00
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' } )
2026-05-14 21:02:59 +08:00
// Verify safe missing columns now exist
2026-05-01 19:48:46 +08:00
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' )
} )
} )
2026-05-02 08:58:14 +08:00
describe ( 'Schema sync with single-column primary keys' , ( ) = > {
it ( 'creates table with single-column primary key' , async ( ) = > {
2026-05-01 19:48:46 +08:00
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 , {
2026-05-02 08:58:14 +08:00
primaryKey : 'id' ,
2026-05-01 19:48:46 +08:00
} )
const db = getTestDb ( )
// Verify table exists
expect ( tableExists ( db , GC_ROOM_AGENTS_TABLE ) ) . toBe ( true )
2026-05-02 08:58:14 +08:00
// Verify single-column primary key
2026-05-01 19:48:46 +08:00
const pk = getTablePrimaryKey ( db , GC_ROOM_AGENTS_TABLE )
2026-05-02 08:58:14 +08:00
expect ( pk ) . toBe ( 'id' )
2026-05-01 19:48:46 +08:00
// Verify all columns exist
const cols = getTableColumns ( db , GC_ROOM_AGENTS_TABLE )
2026-05-02 08:58:14 +08:00
expect ( cols . has ( 'id' ) ) . toBe ( true )
2026-05-01 19:48:46 +08:00
expect ( cols . has ( 'roomId' ) ) . toBe ( true )
expect ( cols . has ( 'agentId' ) ) . toBe ( true )
expect ( cols . has ( 'profile' ) ) . toBe ( true )
expect ( cols . has ( 'name' ) ) . toBe ( true )
2026-05-02 08:58:14 +08:00
// 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 )
2026-05-01 19:48:46 +08:00
2026-05-02 08:58:14 +08:00
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 )
2026-05-01 19:48:46 +08:00
// 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 ( ( ) = > {
2026-05-02 08:58:14 +08:00
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 )
2026-05-01 19:48:46 +08:00
} ) . toThrow ( )
} )
} )
2026-05-14 21:02:59 +08:00
describe ( 'Destructive schema changes are not applied automatically' , ( ) = > {
it ( 'does not rebuild table when primary key differs' , async ( ) = > {
2026-05-01 19:48:46 +08:00
const { syncTable , GC_ROOM_MEMBERS_TABLE , GC_ROOM_MEMBERS_SCHEMA } =
await import ( '../../packages/server/src/db/hermes/schemas' )
const db = getTestDb ( )
2026-05-02 08:58:14 +08:00
// Create table with roomId as primary key and all necessary columns
2026-05-01 19:48:46 +08:00
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 ( ) )
2026-05-02 08:58:14 +08:00
// Sync with id-based primary key schema
2026-05-01 19:48:46 +08:00
syncTable ( GC_ROOM_MEMBERS_TABLE , GC_ROOM_MEMBERS_SCHEMA , {
2026-05-02 08:58:14 +08:00
primaryKey : 'id' ,
2026-05-01 19:48:46 +08:00
} )
2026-05-14 21:02:59 +08:00
// 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 )
2026-05-01 19:48:46 +08:00
// 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' )
} )
2026-05-14 21:02:59 +08:00
it ( 'does not rebuild table when column types differ' , async ( ) = > {
2026-05-01 19:48:46 +08:00
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' } )
2026-05-14 21:02:59 +08:00
// Verify column type was left untouched
2026-05-01 19:48:46 +08:00
const cols = getTableColumns ( db , USAGE_TABLE )
2026-05-14 21:02:59 +08:00
expect ( cols . get ( 'session_id' ) ) . toBe ( 'INTEGER' )
2026-05-01 19:48:46 +08:00
2026-05-14 21:02:59 +08:00
// Verify data was preserved
2026-05-01 19:48:46 +08:00
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 ( )
} )
2026-05-14 21:02:59 +08:00
it ( 'does not alter indexes on existing tables' , async ( ) = > {
2026-05-01 19:48:46 +08:00
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)' ,
} ,
} )
2026-05-14 21:02:59 +08:00
// Verify extra index remains
2026-05-01 19:48:46 +08:00
const extraIndex = db . prepare ( ` SELECT name FROM sqlite_master WHERE type='index' AND name=? ` ) . get ( 'idx_extra' )
2026-05-14 21:02:59 +08:00
expect ( extraIndex ) . toBeTruthy ( )
2026-05-01 19:48:46 +08:00
2026-05-14 21:02:59 +08:00
// Verify expected index was not added to an existing table
2026-05-01 19:48:46 +08:00
const correctIndex = db . prepare ( ` SELECT name FROM sqlite_master WHERE type='index' AND name=? ` ) . get ( 'idx_messages_session_id' )
2026-05-14 21:02:59 +08:00
expect ( correctIndex ) . toBeFalsy ( )
2026-05-01 19:48:46 +08:00
} )
} )
describe ( 'Data preservation during schema sync' , ( ) = > {
2026-05-14 21:02:59 +08:00
it ( 'preserves data when adding safe columns' , async ( ) = > {
2026-05-01 19:48:46 +08:00
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 ( ) )
2026-05-14 21:02:59 +08:00
// Sync with full schema (should add safe columns only)
2026-05-01 19:48:46 +08:00
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 )
2026-05-14 21:02:59 +08:00
const cols = getTableColumns ( db , USAGE_TABLE )
expect ( cols . has ( 'input_tokens' ) ) . toBe ( true )
2026-05-01 19:48:46 +08:00
} )
2026-05-14 21:02:59 +08:00
it ( 'preserves data and existing table definition when primary key is missing' , async ( ) = > {
2026-05-01 19:48:46 +08:00
const { syncTable , GC_ROOM_AGENTS_TABLE , GC_ROOM_AGENTS_SCHEMA } =
await import ( '../../packages/server/src/db/hermes/schemas' )
const db = getTestDb ( )
2026-05-02 08:58:14 +08:00
// 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) ` )
2026-05-01 19:48:46 +08:00
// Insert test data (only columns that exist)
2026-05-02 08:58:14 +08:00
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 )
2026-05-01 19:48:46 +08:00
2026-05-14 21:02:59 +08:00
// Sync with id primary key expectation; should not rebuild existing table
2026-05-01 19:48:46 +08:00
syncTable ( GC_ROOM_AGENTS_TABLE , GC_ROOM_AGENTS_SCHEMA , {
2026-05-02 08:58:14 +08:00
primaryKey : 'id' ,
2026-05-01 19:48:46 +08:00
} )
2026-05-14 21:02:59 +08:00
expect ( getTablePrimaryKey ( db , GC_ROOM_AGENTS_TABLE ) ) . toBe ( null )
2026-05-01 19:48:46 +08:00
// Verify data was preserved
2026-05-02 08:58:14 +08:00
const row = db . prepare ( ` SELECT * FROM " ${ GC_ROOM_AGENTS_TABLE } " WHERE id = ? ` )
. get ( 'agent-1' )
2026-05-01 19:48:46 +08:00
expect ( row ) . toBeTruthy ( )
2026-05-02 08:58:14 +08:00
expect ( row . id ) . toBe ( 'agent-1' )
2026-05-01 19:48:46 +08:00
expect ( row . roomId ) . toBe ( 'room-1' )
expect ( row . agentId ) . toBe ( 'agent-1' )
expect ( row . name ) . toBe ( 'Test Agent' )
} )
} )
2026-05-14 21:02:59 +08:00
describe ( 'Column preservation' , ( ) = > {
it ( 'keeps extra columns on existing table' , async ( ) = > {
2026-05-01 19:48:46 +08:00
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 )
2026-05-14 21:02:59 +08:00
// Sync with schema (should keep extra columns)
2026-05-01 19:48:46 +08:00
syncTable ( USAGE_TABLE , USAGE_SCHEMA , { primaryKey : 'id' } )
2026-05-14 21:02:59 +08:00
// Verify extra columns are preserved
2026-05-01 19:48:46 +08:00
const cols = getTableColumns ( db , USAGE_TABLE )
2026-05-14 21:02:59 +08:00
expect ( cols . has ( 'extra_col' ) ) . toBe ( true )
expect ( cols . has ( 'another_extra' ) ) . toBe ( true )
2026-05-01 19:48:46 +08:00
// 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' )
} )
} )
} )