Two pieces of upstream promotion came out of this deployment. The sidebar "apiRelay" tab only opened a registration page on a third-party site, so it goes with no replacement. The circuit badge next to Settings was not what it looked like. It opened an ESP32 device manager: add a device, show LAN status, remote connect/disconnect, rename, delete, backed by six REST routes and an mcu_devices table. The purchase button inside that dialog was the promotional part; the rest was real device management. Since the hardware is out of scope here, the entire feature is removed rather than just the link that pointed upstream - UI entry, client API, routes, controller, store, schema, locale strings, and the one GlobalAgentServer method that only existed to answer its status column. Table creation is dropped but no DROP TABLE was added, so an already-provisioned database keeps its rows as harmless orphans instead of losing data on upgrade. DevicesView and the hermes.devices route are untouched: that is LAN device discovery, a separate feature. AGENTS.md now records the removal so the badge does not come back.
463 lines
19 KiB
TypeScript
463 lines
19 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,
|
|
WORKFLOWS_TABLE,
|
|
WORKFLOWS_SCHEMA,
|
|
WORKFLOW_RUNS_TABLE,
|
|
WORKFLOW_RUNS_SCHEMA,
|
|
WORKFLOW_RUN_NODE_SESSIONS_TABLE,
|
|
WORKFLOW_RUN_NODE_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)
|
|
const usageRunIndex = db.prepare(
|
|
`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_session_usage_run'`,
|
|
).get()
|
|
expect(usageRunIndex).toBeTruthy()
|
|
|
|
// 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)
|
|
expect(sessionsCols.has('agent_session_id')).toBe(true)
|
|
expect(sessionsCols.has('agent_native_session_id')).toBe(true)
|
|
|
|
// Verify workflow tables were created
|
|
expect(tableExists(db, WORKFLOWS_TABLE)).toBe(true)
|
|
const workflowCols = getTableColumns(db, WORKFLOWS_TABLE)
|
|
expect(workflowCols.size).toBe(Object.keys(WORKFLOWS_SCHEMA).length)
|
|
expect(workflowCols.has('name')).toBe(true)
|
|
expect(workflowCols.has('profile')).toBe(true)
|
|
expect(workflowCols.has('workspace')).toBe(true)
|
|
expect(workflowCols.has('nodes_json')).toBe(true)
|
|
expect(workflowCols.has('edges_json')).toBe(true)
|
|
expect(workflowCols.has('viewport_json')).toBe(true)
|
|
|
|
expect(tableExists(db, WORKFLOW_RUNS_TABLE)).toBe(true)
|
|
const workflowRunCols = getTableColumns(db, WORKFLOW_RUNS_TABLE)
|
|
expect(workflowRunCols.size).toBe(Object.keys(WORKFLOW_RUNS_SCHEMA).length)
|
|
expect(workflowRunCols.has('workflow_id')).toBe(true)
|
|
expect(workflowRunCols.has('workspace')).toBe(true)
|
|
expect(workflowRunCols.has('start_node_ids_json')).toBe(true)
|
|
expect(workflowRunCols.has('snapshot_nodes_json')).toBe(true)
|
|
expect(workflowRunCols.has('snapshot_edges_json')).toBe(true)
|
|
|
|
expect(tableExists(db, WORKFLOW_RUN_NODE_SESSIONS_TABLE)).toBe(true)
|
|
const workflowRunNodeSessionCols = getTableColumns(db, WORKFLOW_RUN_NODE_SESSIONS_TABLE)
|
|
expect(workflowRunNodeSessionCols.size).toBe(Object.keys(WORKFLOW_RUN_NODE_SESSIONS_SCHEMA).length)
|
|
expect(workflowRunNodeSessionCols.has('run_id')).toBe(true)
|
|
expect(workflowRunNodeSessionCols.has('workflow_id')).toBe(true)
|
|
expect(workflowRunNodeSessionCols.has('node_id')).toBe(true)
|
|
expect(workflowRunNodeSessionCols.has('session_id')).toBe(true)
|
|
expect(workflowRunNodeSessionCols.has('status')).toBe(true)
|
|
expect(workflowRunNodeSessionCols.has('agent')).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)
|
|
expect(cols.has('run_id')).toBe(true)
|
|
expect(cols.has('source')).toBe(true)
|
|
expect(cols.has('agent')).toBe(true)
|
|
expect(cols.has('provider')).toBe(true)
|
|
expect(cols.has('is_estimated')).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')
|
|
})
|
|
|
|
it('adds created_at to legacy session_usage tables missing the column', async () => {
|
|
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
|
|
|
const db = getTestDb()
|
|
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL)`)
|
|
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id) VALUES (?)`).run('legacy-session')
|
|
|
|
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
|
|
|
const cols = getTableColumns(db, USAGE_TABLE)
|
|
expect(cols.has('created_at')).toBe(true)
|
|
|
|
const row = db.prepare(`SELECT session_id, created_at FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('legacy-session')
|
|
expect(row).toMatchObject({ session_id: 'legacy-session', created_at: 0 })
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|