fix: recover legacy session_usage migration (#345)

Quote SQL defaults when rebuilding legacy usage tables and recover rows left in session_usage_old by failed migrations.
This commit is contained in:
Zhicheng Han
2026-04-30 11:17:20 +02:00
committed by GitHub
parent cd14bb1963
commit e82674039c
2 changed files with 236 additions and 27 deletions
+117 -27
View File
@@ -178,6 +178,105 @@ export const GC_SESSION_PROFILES_SCHEMA: Record<string, string> = {
import { ensureTable, getDb } from '../index'
function quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`
}
function sqlLiteral(value: string | number): string {
if (typeof value === 'number') return String(value)
return `'${value.replace(/'/g, "''")}'`
}
function usageSchemaDefinitionSql(): string {
return Object.entries(USAGE_SCHEMA)
.map(([col, def]) => `${quoteIdentifier(col)} ${def}`)
.join(', ')
}
function sqliteTableExists(db: NonNullable<ReturnType<typeof getDb>>, tableName: string): boolean {
return Boolean(db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(tableName))
}
function sqliteTableColumns(db: NonNullable<ReturnType<typeof getDb>>, tableName: string): Set<string> {
const rows = db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all() as Array<{ name: string }>
return new Set(rows.map(row => row.name))
}
function legacyUsageValueSql(
sourceAlias: string,
oldCols: Set<string>,
col: string,
defaults: Record<string, string | number>,
): string {
const sourceColumn = (sourceCol: string) => `${quoteIdentifier(sourceAlias)}.${quoteIdentifier(sourceCol)}`
if (col === 'created_at' && oldCols.has('updated_at')) {
return `COALESCE(${sourceColumn('updated_at')}, ${sqlLiteral(defaults.created_at)})`
}
if (oldCols.has(col)) {
return `COALESCE(${sourceColumn(col)}, ${sqlLiteral(defaults[col] ?? 0)})`
}
return sqlLiteral(defaults[col] ?? 0)
}
function insertUsageRowsFromLegacyTable(
db: NonNullable<ReturnType<typeof getDb>>,
oldTableName: string,
oldCols: Set<string>,
skipExistingSessionIds = false,
): void {
const defaults: Record<string, string | number> = {
session_id: '',
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
created_at: Date.now(),
model: '',
profile: 'default',
}
const sourceAlias = 'old_usage'
const sourceColumn = (col: string) => `${quoteIdentifier(sourceAlias)}.${quoteIdentifier(col)}`
const insertValues: string[] = []
const selectValues: string[] = []
for (const col of Object.keys(USAGE_SCHEMA)) {
if (col === 'id') continue
insertValues.push(quoteIdentifier(col))
selectValues.push(legacyUsageValueSql(sourceAlias, oldCols, col, defaults))
}
const skipExistingWhere = skipExistingSessionIds && oldCols.has('session_id')
? ` WHERE NOT EXISTS (SELECT 1 FROM ${quoteIdentifier(USAGE_TABLE)} WHERE ${quoteIdentifier(USAGE_TABLE)}.${quoteIdentifier('session_id')} = ${sourceColumn('session_id')})`
: ''
db.exec(
`INSERT INTO ${quoteIdentifier(USAGE_TABLE)} (${insertValues.join(', ')}) ` +
`SELECT ${selectValues.join(', ')} FROM ${quoteIdentifier(oldTableName)} AS ${quoteIdentifier(sourceAlias)}` +
skipExistingWhere,
)
}
function recoverInterruptedUsageMigration(db: NonNullable<ReturnType<typeof getDb>>): void {
const oldUsageTable = `${USAGE_TABLE}_old`
if (!sqliteTableExists(db, oldUsageTable)) return
const oldCols = sqliteTableColumns(db, oldUsageTable)
db.exec('BEGIN')
try {
insertUsageRowsFromLegacyTable(db, oldUsageTable, oldCols, true)
db.exec(`DROP TABLE ${quoteIdentifier(oldUsageTable)}`)
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
/**
* Initialize all Hermes SQLite tables with proper schemas.
* This function creates tables and adds missing columns if schemas change.
@@ -188,38 +287,29 @@ export function initAllHermesTables(): void {
if (!db) return
// Usage store - with special migration logic
const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(USAGE_TABLE)
const cols = (tableExists
? db.prepare(`PRAGMA table_info("${USAGE_TABLE}")`).all() as Array<{ name: string; pk: number }>
: [])
const tableExists = sqliteTableExists(db, USAGE_TABLE)
const cols = tableExists
? db.prepare(`PRAGMA table_info(${quoteIdentifier(USAGE_TABLE)})`).all() as Array<{ name: string; pk: number }>
: []
const hasId = cols.some(c => c.name === 'id')
if (!hasId && tableExists) {
// Migration: if session_id is still PRIMARY KEY (no separate id column), recreate table
const oldCols = new Set(cols.map(c => c.name))
const insertCols = ['session_id', 'input_tokens', 'output_tokens']
const selectCols = [...insertCols]
if (oldCols.has('cache_read_tokens')) { insertCols.push('cache_read_tokens'); selectCols.push('cache_read_tokens') }
if (oldCols.has('cache_write_tokens')) { insertCols.push('cache_write_tokens'); selectCols.push('cache_write_tokens') }
if (oldCols.has('reasoning_tokens')) { insertCols.push('reasoning_tokens'); selectCols.push('reasoning_tokens') }
if (oldCols.has('created_at')) { insertCols.push('created_at'); selectCols.push('created_at') }
if (oldCols.has('model')) { insertCols.push('model'); selectCols.push('model') }
const defaults = {
cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0,
created_at: Date.now(), model: '', profile: 'default',
const oldUsageTable = `${USAGE_TABLE}_old`
db.exec('BEGIN')
try {
db.exec(`ALTER TABLE ${quoteIdentifier(USAGE_TABLE)} RENAME TO ${quoteIdentifier(oldUsageTable)}`)
db.exec(`CREATE TABLE ${quoteIdentifier(USAGE_TABLE)} (${usageSchemaDefinitionSql()})`)
insertUsageRowsFromLegacyTable(db, oldUsageTable, oldCols)
db.exec(`DROP TABLE ${quoteIdentifier(oldUsageTable)}`)
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
const insertValues = insertCols.map(c => c)
const selectValues = selectCols.map(c => c)
// Columns in new schema but not in old table — use defaults
for (const [col] of Object.entries(USAGE_SCHEMA)) {
if (!oldCols.has(col) && col !== 'id') {
insertValues.push(col)
selectValues.push(String(defaults[col as keyof typeof defaults] ?? 0))
}
}
db.exec(`ALTER TABLE "${USAGE_TABLE}" RENAME TO "${USAGE_TABLE}_old"`)
db.exec(`CREATE TABLE "${USAGE_TABLE}" (${Object.entries(USAGE_SCHEMA).map(([col, def]) => `"${col}" ${def}`).join(', ')})`)
db.exec(`INSERT INTO "${USAGE_TABLE}" (${insertValues.join(', ')}) SELECT ${selectValues.join(', ')} FROM "${USAGE_TABLE}_old"`)
db.exec(`DROP TABLE "${USAGE_TABLE}_old"`)
} else if (hasId) {
recoverInterruptedUsageMigration(db)
}
ensureTable(USAGE_TABLE, USAGE_SCHEMA)
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`
}
function ensureTableForTest(db: any, tableName: string, schema: Record<string, string>): void {
const colDefs = Object.entries(schema)
.map(([col, def]) => `${quoteIdentifier(col)} ${def}`)
.join(', ')
db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(tableName)} (${colDefs})`)
const rows = db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all() as Array<{ name: string }>
const existingCols = new Set(rows.map(row => row.name))
for (const [col, def] of Object.entries(schema)) {
if (!existingCols.has(col)) {
db.exec(`ALTER TABLE ${quoteIdentifier(tableName)} ADD COLUMN ${quoteIdentifier(col)} ${def}`)
}
}
}
describe('Hermes schema migrations', () => {
let db: any = null
beforeEach(async () => {
vi.resetModules()
const { DatabaseSync } = await import('node:sqlite')
db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
ensureTable: (tableName: string, schema: Record<string, string>) => ensureTableForTest(db, tableName, schema),
}))
})
afterEach(() => {
db?.close()
db = null
vi.doUnmock('../../packages/server/src/db/index')
vi.resetModules()
})
it('migrates legacy session_usage rows with SQL-safe defaults', async () => {
const updatedAt = Date.UTC(2026, 3, 29)
db.exec(`CREATE TABLE "session_usage" (
"session_id" TEXT PRIMARY KEY,
"input_tokens" INTEGER NOT NULL DEFAULT 0,
"output_tokens" INTEGER NOT NULL DEFAULT 0,
"updated_at" INTEGER NOT NULL
)`)
db.prepare(
`INSERT INTO "session_usage" (session_id, input_tokens, output_tokens, updated_at) VALUES (?, ?, ?, ?)`,
).run('legacy-session', 123, 45, updatedAt)
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
expect(() => initAllHermesTables()).not.toThrow()
const row = db.prepare(
`SELECT session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, model, profile, created_at
FROM "session_usage"`,
).get() as any
expect(row).toMatchObject({
session_id: 'legacy-session',
input_tokens: 123,
output_tokens: 45,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: updatedAt,
})
expect(db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='session_usage_old'`).get()).toBeUndefined()
})
it('recovers rows left in session_usage_old by a failed previous migration', async () => {
const updatedAt = Date.UTC(2026, 3, 30)
db.exec(`CREATE TABLE "session_usage" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"session_id" TEXT NOT NULL,
"input_tokens" INTEGER NOT NULL DEFAULT 0,
"output_tokens" INTEGER NOT NULL DEFAULT 0,
"cache_read_tokens" INTEGER NOT NULL DEFAULT 0,
"cache_write_tokens" INTEGER NOT NULL DEFAULT 0,
"reasoning_tokens" INTEGER NOT NULL DEFAULT 0,
"model" TEXT NOT NULL DEFAULT '',
"profile" TEXT NOT NULL DEFAULT 'default',
"created_at" INTEGER NOT NULL
)`)
db.exec(`CREATE TABLE "session_usage_old" (
"session_id" TEXT PRIMARY KEY,
"input_tokens" INTEGER NOT NULL DEFAULT 0,
"output_tokens" INTEGER NOT NULL DEFAULT 0,
"updated_at" INTEGER NOT NULL
)`)
db.prepare(
`INSERT INTO "session_usage_old" (session_id, input_tokens, output_tokens, updated_at) VALUES (?, ?, ?, ?)`,
).run('stranded-session', 200, 80, updatedAt)
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
expect(() => initAllHermesTables()).not.toThrow()
const row = db.prepare(
`SELECT session_id, input_tokens, output_tokens, model, profile, created_at FROM "session_usage"`,
).get() as any
expect(row).toMatchObject({
session_id: 'stranded-session',
input_tokens: 200,
output_tokens: 80,
model: '',
profile: 'default',
created_at: updatedAt,
})
expect(db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='session_usage_old'`).get()).toBeUndefined()
})
})