mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
feat: Add automatic database migration system for agents service (#10215)
* feat: Add automatic database migration system for agents service - Add migrations tracking schema with version, tag, and timestamp - Implement MigrationService to automatically run pending migrations - Integrate migration check into BaseService initialization - Read migration files from drizzle/ directory and journal.json - Track applied migrations to prevent re-execution - Ensure database is always at latest version on service startup Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * refactor: Improve migration logging and enhance database path configuration * chore: harden migration bootstrap flow --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
This commit is contained in:
parent
669f60273c
commit
219d162e1a
@ -5,6 +5,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import * as schema from './database/schema'
|
||||
import { MigrationService } from './database/MigrationService'
|
||||
import { dbPath } from './drizzle.config'
|
||||
|
||||
const logger = loggerService.withContext('BaseService')
|
||||
@ -66,6 +67,10 @@ export abstract class BaseService {
|
||||
|
||||
BaseService.db = drizzle(BaseService.client, { schema })
|
||||
|
||||
// Run database migrations
|
||||
const migrationService = new MigrationService(BaseService.db, BaseService.client)
|
||||
await migrationService.runMigrations()
|
||||
|
||||
BaseService.isInitialized = true
|
||||
logger.info('Agent database initialized successfully')
|
||||
return
|
||||
|
||||
169
src/main/services/agents/database/MigrationService.ts
Normal file
169
src/main/services/agents/database/MigrationService.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { type Client } from '@libsql/client'
|
||||
import { loggerService } from '@logger'
|
||||
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import * as schema from './schema'
|
||||
import { migrations, type NewMigration } from './schema/migrations.schema'
|
||||
|
||||
const logger = loggerService.withContext('MigrationService')
|
||||
|
||||
interface MigrationJournal {
|
||||
version: string
|
||||
dialect: string
|
||||
entries: Array<{
|
||||
idx: number
|
||||
version: string
|
||||
when: number
|
||||
tag: string
|
||||
breakpoints: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private db: LibSQLDatabase<typeof schema>
|
||||
private client: Client
|
||||
private migrationDir: string
|
||||
|
||||
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
|
||||
this.db = db
|
||||
this.client = client
|
||||
this.migrationDir = path.join(__dirname, 'drizzle')
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
try {
|
||||
logger.info('Starting migration check...')
|
||||
|
||||
// Ensure migrations table exists
|
||||
await this.ensureMigrationsTable()
|
||||
|
||||
// Read migration journal
|
||||
const journal = await this.readMigrationJournal()
|
||||
if (!journal.entries.length) {
|
||||
logger.info('No migrations found in journal')
|
||||
return
|
||||
}
|
||||
|
||||
// Get applied migrations
|
||||
const appliedMigrations = await this.getAppliedMigrations()
|
||||
const appliedVersions = new Set(appliedMigrations.map((m) => Number(m.version)))
|
||||
|
||||
const latestAppliedVersion = appliedMigrations.reduce(
|
||||
(max, migration) => Math.max(max, Number(migration.version)),
|
||||
0
|
||||
)
|
||||
const latestJournalVersion = journal.entries.reduce((max, entry) => Math.max(max, entry.idx), 0)
|
||||
|
||||
logger.info(`Latest applied migration: v${latestAppliedVersion}, latest available: v${latestJournalVersion}`)
|
||||
|
||||
// Find pending migrations (compare journal idx with stored version, which is the same value)
|
||||
const pendingMigrations = journal.entries
|
||||
.filter((entry) => !appliedVersions.has(entry.idx))
|
||||
.sort((a, b) => a.idx - b.idx)
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
logger.info('Database is up to date')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Found ${pendingMigrations.length} pending migrations`)
|
||||
|
||||
// Execute pending migrations
|
||||
for (const migration of pendingMigrations) {
|
||||
await this.executeMigration(migration)
|
||||
}
|
||||
|
||||
logger.info('All migrations completed successfully')
|
||||
} catch (error) {
|
||||
logger.error('Migration failed:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureMigrationsTable(): Promise<void> {
|
||||
try {
|
||||
const tableExists = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
|
||||
|
||||
if (tableExists.rows.length === 0) {
|
||||
logger.info('Migrations table missing, creating...')
|
||||
|
||||
await this.client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
tag TEXT NOT NULL,
|
||||
executed_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
logger.info('Migrations table created successfully')
|
||||
} else {
|
||||
logger.debug('Migrations table already exists')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure migrations table exists:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async readMigrationJournal(): Promise<MigrationJournal> {
|
||||
const journalPath = path.join(this.migrationDir, 'meta', '_journal.json')
|
||||
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
logger.warn('Migration journal not found:', { journalPath })
|
||||
return { version: '7', dialect: 'sqlite', entries: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const journalContent = fs.readFileSync(journalPath, 'utf-8')
|
||||
return JSON.parse(journalContent)
|
||||
} catch (error) {
|
||||
logger.error('Failed to read migration journal:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppliedMigrations(): Promise<schema.Migration[]> {
|
||||
try {
|
||||
return await this.db.select().from(migrations)
|
||||
} catch (error) {
|
||||
// This should not happen since we ensure the table exists in runMigrations()
|
||||
logger.error('Failed to query applied migrations:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async executeMigration(migration: MigrationJournal['entries'][0]): Promise<void> {
|
||||
const sqlFilePath = path.join(this.migrationDir, `${migration.tag}.sql`)
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
throw new Error(`Migration SQL file not found: ${sqlFilePath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Executing migration ${migration.tag}...`)
|
||||
const startTime = Date.now()
|
||||
|
||||
// Read and execute SQL
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, 'utf-8')
|
||||
await this.client.execute(sqlContent)
|
||||
|
||||
// Record migration as applied (store journal idx as version for tracking)
|
||||
const newMigration: NewMigration = {
|
||||
version: migration.idx,
|
||||
tag: migration.tag,
|
||||
executedAt: Date.now()
|
||||
}
|
||||
|
||||
await this.db.insert(migrations).values(newMigration)
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
logger.info(`Migration ${migration.tag} completed in ${executionTime}ms`)
|
||||
} catch (error) {
|
||||
logger.error(`Migration ${migration.tag} failed:`, { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,3 +5,4 @@
|
||||
export * from './agents.schema'
|
||||
export * from './sessions.schema'
|
||||
export * from './messages.schema'
|
||||
export * from './migrations.schema'
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Migration tracking schema
|
||||
*/
|
||||
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const migrations = sqliteTable('migrations', {
|
||||
version: integer('version').primaryKey(),
|
||||
tag: text('tag').notNull(),
|
||||
executedAt: integer('executed_at').notNull()
|
||||
})
|
||||
|
||||
export type Migration = typeof migrations.$inferSelect
|
||||
export type NewMigration = typeof migrations.$inferInsert
|
||||
@ -5,16 +5,23 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isDev } from '@main/constant'
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
import { app } from 'electron'
|
||||
|
||||
export const dbPath = path.join(os.homedir(), '.cherrystudio', 'data', 'agents.db')
|
||||
function getDbPath() {
|
||||
if (isDev) {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'data', 'agents.db')
|
||||
}
|
||||
return path.join(app.getPath('userData'), 'agents.db')
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/main/services/agents/database/schema/index.ts',
|
||||
out: './src/main/services/agents/database/drizzle',
|
||||
dbCredentials: {
|
||||
url: `file:${dbPath}`
|
||||
url: `file:${getDbPath()}`
|
||||
},
|
||||
verbose: true,
|
||||
strict: true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user