feat(migration): implement data migration service and update database architecture

This commit introduces a new data migration service with various IPC channels for migration tasks, including checking if migration is needed, starting the migration, and tracking progress. Additionally, the database architecture section has been added to the documentation, detailing the use of SQLite with Drizzle ORM, migration standards, and JSON field handling. Legacy migration files for ElectronStore and Redux have been removed as they are now deprecated.
This commit is contained in:
fullex 2025-08-09 20:19:41 +08:00
parent 4e3f8a8f76
commit 973f26f9dd
14 changed files with 1246 additions and 1307 deletions

View File

@ -92,6 +92,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Multi-language Support**: i18n with dynamic loading
- **Theme System**: Light/dark themes with custom CSS variables
### Database Architecture
- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver
- **ORM**: Drizzle ORM with comprehensive migration system
- **Schemas**: Located in `src/main/data/db/schemas/` directory
#### Database Standards
- **Table Naming**: Use singular form with snake_case (e.g., `topic`, `message`, `app_state`)
- **Schema Exports**: Export using `xxxTable` pattern (e.g., `topicTable`, `appStateTable`)
- **Field Definition**: Drizzle auto-infers field names, no need to add default field names
- **JSON Fields**: For JSON support, add `{ mode: 'json' }`, refer to `preference.ts` table definition
- **JSON Serialization**: For JSON fields, no need to manually serialize/deserialize when reading/writing to database, Drizzle handles this automatically
- **Timestamps**: Use existing `crudTimestamps` utility
- **Migrations**: Generate via `yarn run migrations:generate`
## Logging Standards
### Usage

View File

@ -102,7 +102,8 @@ export default defineConfig({
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
dataMigrate: resolve(__dirname, 'src/renderer/dataMigrate.html')
}
}
},

View File

@ -184,6 +184,15 @@ export enum IpcChannel {
Backup_DeleteS3File = 'backup:deleteS3File',
Backup_CheckS3Connection = 'backup:checkS3Connection',
// data migration
DataMigrate_CheckNeeded = 'data-migrate:check-needed',
DataMigrate_StartMigration = 'data-migrate:start-migration',
DataMigrate_GetProgress = 'data-migrate:get-progress',
DataMigrate_Cancel = 'data-migrate:cancel',
DataMigrate_RequireBackup = 'data-migrate:require-backup',
DataMigrate_BackupCompleted = 'data-migrate:backup-completed',
DataMigrate_ShowBackupDialog = 'data-migrate:show-backup-dialog',
// zip
Zip_Compress = 'zip:compress',
Zip_Decompress = 'zip:decompress',
@ -197,6 +206,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
DataMigrateProgress = 'data-migrate-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',

View File

@ -0,0 +1,28 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { crudTimestamps } from './columnHelpers'
export const appStateTable = sqliteTable('app_state', {
key: text().primaryKey(),
value: text({ mode: 'json' }).notNull(), // JSON field, drizzle handles serialization automatically
description: text(), // Optional description field
...crudTimestamps
})
export type AppStateTable = typeof appStateTable
export type AppStateInsert = typeof appStateTable.$inferInsert
export type AppStateSelect = typeof appStateTable.$inferSelect
// State key constants
export const APP_STATE_KEYS = {
DATA_REFACTOR_MIGRATION_STATUS: 'data_refactor_migration_status',
// Future state keys can be added here
// FIRST_RUN_COMPLETED: 'first_run_completed',
// USER_ONBOARDING_COMPLETED: 'user_onboarding_completed',
} as const
// Data refactor migration status interface
export interface DataRefactorMigrationStatus {
completed: boolean
completedAt?: number
version?: string
}

View File

@ -0,0 +1,490 @@
import dbService from '@data/db/DbService'
import { APP_STATE_KEYS, appStateTable, DataRefactorMigrationStatus } from '@data/db/schemas/appState'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { eq } from 'drizzle-orm'
import { app, BrowserWindow } from 'electron'
import { app as electronApp } from 'electron'
import fs from 'fs-extra'
import { join } from 'path'
import icon from '../../../../build/icon.png?asset'
import BackupManager from '../../services/BackupManager'
import { PreferencesMigrator } from './PreferencesMigrator'
const logger = loggerService.withContext('MigrateService')
export interface MigrationProgress {
stage: string
progress: number
total: number
message: string
}
export interface MigrationResult {
success: boolean
error?: string
migratedCount: number
}
export class MigrateService {
private static instance: MigrateService | null = null
private migrateWindow: BrowserWindow | null = null
private backupManager: BackupManager
private backupCompletionResolver: ((value: boolean) => void) | null = null
private backupTimeout: NodeJS.Timeout | null = null
private db = dbService.getDb()
private currentProgress: MigrationProgress = {
stage: 'idle',
progress: 0,
total: 100,
message: 'Ready to migrate'
}
private isMigrating: boolean = false
constructor() {
this.backupManager = new BackupManager()
}
/**
* Get backup manager instance for integration with existing backup system
*/
public getBackupManager(): BackupManager {
return this.backupManager
}
public static getInstance(): MigrateService {
if (!MigrateService.instance) {
MigrateService.instance = new MigrateService()
}
return MigrateService.instance
}
/**
* Check if migration is needed
*/
async checkMigrationNeeded(): Promise<boolean> {
try {
logger.info('Checking if migration is needed')
// 1. Check migration completion status
const isMigrated = await this.isMigrationCompleted()
if (isMigrated) {
logger.info('Migration already completed')
return false
}
// 2. Check if there's old data that needs migration
const hasOldData = await this.hasOldFormatData()
logger.info('Migration check result', {
isMigrated,
hasOldData
})
return hasOldData
} catch (error) {
logger.error('Failed to check migration status', error as Error)
return false
}
}
/**
* Check if old format data exists
*/
private async hasOldFormatData(): Promise<boolean> {
const hasReduxData = await this.checkReduxPersistData()
const hasElectronStoreData = await this.checkElectronStoreData()
logger.debug('Old format data check', {
hasReduxData,
hasElectronStoreData
})
return hasReduxData || hasElectronStoreData
}
/**
* Check if Redux persist data exists
*/
private async checkReduxPersistData(): Promise<boolean> {
try {
// In Electron, localStorage data is stored in userData/Local Storage/leveldb
// We'll check for the existence of these files as a proxy for Redux persist data
const userDataPath = app.getPath('userData')
const localStoragePath = join(userDataPath, 'Local Storage', 'leveldb')
const exists = await fs.pathExists(localStoragePath)
logger.debug('Redux persist data check', { localStoragePath, exists })
return exists
} catch (error) {
logger.warn('Failed to check Redux persist data', error as Error)
return false
}
}
/**
* Check if ElectronStore data exists
*/
private async checkElectronStoreData(): Promise<boolean> {
try {
// ElectronStore typically stores data in config files
const userDataPath = app.getPath('userData')
const configPath = join(userDataPath, 'config.json')
const exists = await fs.pathExists(configPath)
logger.debug('ElectronStore data check', { configPath, exists })
return exists
} catch (error) {
logger.warn('Failed to check ElectronStore data', error as Error)
return false
}
}
/**
* Check if migration is already completed
*/
private async isMigrationCompleted(): Promise<boolean> {
try {
const result = await this.db
.select()
.from(appStateTable)
.where(eq(appStateTable.key, APP_STATE_KEYS.DATA_REFACTOR_MIGRATION_STATUS))
.limit(1)
if (result.length === 0) return false
const status = result[0].value as DataRefactorMigrationStatus
return status.completed === true
} catch (error) {
logger.warn('Failed to check migration state', error as Error)
return false
}
}
/**
* Mark migration as completed
*/
private async markMigrationCompleted(): Promise<void> {
try {
const migrationStatus: DataRefactorMigrationStatus = {
completed: true,
completedAt: Date.now(),
version: electronApp.getVersion()
}
await this.db
.insert(appStateTable)
.values({
key: APP_STATE_KEYS.DATA_REFACTOR_MIGRATION_STATUS,
value: migrationStatus, // drizzle handles JSON serialization automatically
description: 'Data refactoring migration status from legacy format (ElectronStore + Redux persist) to SQLite',
createdAt: Date.now(),
updatedAt: Date.now()
})
.onConflictDoUpdate({
target: appStateTable.key,
set: {
value: migrationStatus,
updatedAt: Date.now()
}
})
logger.info('Migration marked as completed in app_state table', {
version: migrationStatus.version,
completedAt: migrationStatus.completedAt
})
} catch (error) {
logger.error('Failed to mark migration as completed', error as Error)
throw error
}
}
/**
* Create and show migration window
*/
private createMigrateWindow(): BrowserWindow {
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
this.migrateWindow.show()
return this.migrateWindow
}
this.migrateWindow = new BrowserWindow({
width: 600,
height: 500,
resizable: false,
maximizable: false,
minimizable: false,
show: false,
autoHideMenuBar: true,
titleBarStyle: 'hidden',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
contextIsolation: true
},
...(process.platform === 'linux' ? { icon } : {})
})
// Load the migration window
if (app.isPackaged) {
this.migrateWindow.loadFile(join(__dirname, '../renderer/dataMigrate.html'))
} else {
this.migrateWindow.loadURL('http://localhost:5173/dataMigrate.html')
}
this.migrateWindow.once('ready-to-show', () => {
this.migrateWindow?.show()
if (!app.isPackaged) {
this.migrateWindow?.webContents.openDevTools()
}
})
this.migrateWindow.on('closed', () => {
this.migrateWindow = null
})
logger.info('Migration window created')
return this.migrateWindow
}
/**
* Run the complete migration process
*/
async runMigration(): Promise<void> {
if (this.isMigrating) {
logger.warn('Migration already in progress')
return
}
try {
this.isMigrating = true
logger.info('Starting migration process')
// Create migration window
const window = this.createMigrateWindow()
// Wait for window to be ready
await new Promise<void>((resolve) => {
if (window.webContents.isLoading()) {
window.webContents.once('did-finish-load', () => resolve())
} else {
resolve()
}
})
// Start the migration flow
await this.executeMigrationFlow()
} catch (error) {
logger.error('Migration process failed', error as Error)
throw error
} finally {
this.isMigrating = false
}
}
/**
* Execute the complete migration flow
*/
private async executeMigrationFlow(): Promise<void> {
try {
// Step 1: Enforce backup
await this.updateProgress('backup', 0, 'Starting backup process...')
const backupSuccess = await this.enforceBackup()
if (!backupSuccess) {
throw new Error('Backup process failed or was cancelled by user')
}
await this.updateProgress('backup', 100, 'Backup completed successfully')
// Step 2: Execute migration
await this.updateProgress('migration', 0, 'Starting data migration...')
const migrationResult = await this.executeMigration()
if (!migrationResult.success) {
throw new Error(migrationResult.error || 'Migration failed')
}
await this.updateProgress(
'migration',
100,
`Migration completed: ${migrationResult.migratedCount} items migrated`
)
// Step 3: Mark as completed
await this.markMigrationCompleted()
await this.updateProgress('completed', 100, 'Migration process completed successfully')
// Close migration window after a delay
setTimeout(() => {
this.closeMigrateWindow()
}, 3000)
} catch (error) {
logger.error('Migration flow failed', error as Error)
await this.updateProgress(
'error',
0,
`Migration failed: ${error instanceof Error ? error.message : String(error)}`
)
throw error
}
}
/**
* Enforce backup before migration
*/
private async enforceBackup(): Promise<boolean> {
try {
logger.info('Enforcing backup before migration')
await this.updateProgress('backup', 0, 'Backup is required before migration')
// Send backup requirement to renderer
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
this.migrateWindow.webContents.send(IpcChannel.DataMigrate_RequireBackup)
}
// Wait for user to complete backup
const backupResult = await this.waitForBackupCompletion()
if (backupResult) {
await this.updateProgress('backup', 100, 'Backup completed successfully')
return true
} else {
await this.updateProgress('backup', 0, 'Backup is required to proceed with migration')
return false
}
} catch (error) {
logger.error('Backup enforcement failed', error as Error)
await this.updateProgress('backup', 0, 'Backup process failed')
return false
}
}
/**
* Wait for user to complete backup
*/
private async waitForBackupCompletion(): Promise<boolean> {
return new Promise((resolve) => {
// Store resolver for later use
this.backupCompletionResolver = resolve
// Set up timeout (5 minutes)
this.backupTimeout = setTimeout(() => {
logger.warn('Backup completion timeout')
this.backupCompletionResolver = null
this.backupTimeout = null
resolve(false)
}, 300000) // 5 minutes
// The actual completion will be triggered by notifyBackupCompleted() method
})
}
/**
* Notify that backup has been completed (called from IPC handler)
*/
public notifyBackupCompleted(): void {
if (this.backupCompletionResolver) {
logger.info('Backup completed by user')
// Clear timeout if it exists
if (this.backupTimeout) {
clearTimeout(this.backupTimeout)
this.backupTimeout = null
}
this.backupCompletionResolver(true)
this.backupCompletionResolver = null
}
}
/**
* Execute the actual migration
*/
private async executeMigration(): Promise<MigrationResult> {
try {
logger.info('Executing migration')
// Create preferences migrator
const preferencesMigrator = new PreferencesMigrator()
// Execute preferences migration with progress updates
const result = await preferencesMigrator.migrate((progress, message) => {
this.updateProgress('migration', progress, message)
})
logger.info('Migration execution completed', result)
return {
success: result.success,
migratedCount: result.migratedCount,
error: result.errors.length > 0 ? result.errors.map((e) => e.error).join('; ') : undefined
}
} catch (error) {
logger.error('Migration execution failed', error as Error)
return {
success: false,
error: error instanceof Error ? error.message : String(error),
migratedCount: 0
}
}
}
/**
* Update migration progress and broadcast to window
*/
private async updateProgress(stage: string, progress: number, message: string): Promise<void> {
this.currentProgress = {
stage,
progress,
total: 100,
message
}
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
this.migrateWindow.webContents.send(IpcChannel.DataMigrateProgress, this.currentProgress)
}
logger.debug('Progress updated', this.currentProgress)
}
/**
* Get current migration progress
*/
getCurrentProgress(): MigrationProgress {
return this.currentProgress
}
/**
* Cancel migration process
*/
async cancelMigration(): Promise<void> {
if (!this.isMigrating) {
return
}
logger.info('Cancelling migration process')
this.isMigrating = false
await this.updateProgress('cancelled', 0, 'Migration cancelled by user')
this.closeMigrateWindow()
}
/**
* Close migration window
*/
private closeMigrateWindow(): void {
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
this.migrateWindow.close()
this.migrateWindow = null
}
}
}
// Export singleton instance
export const migrateService = MigrateService.getInstance()

View File

@ -0,0 +1,345 @@
import dbService from '@data/db/DbService'
import { preferenceTable } from '@data/db/schemas/preference'
import { loggerService } from '@logger'
import { and, eq } from 'drizzle-orm'
import { configManager } from '../../services/ConfigManager'
const logger = loggerService.withContext('PreferencesMigrator')
export interface MigrationItem {
originalKey: string
targetKey: string
type: string
defaultValue: any
source: 'electronStore' | 'redux'
sourceCategory: string
}
export interface MigrationResult {
success: boolean
migratedCount: number
errors: Array<{
key: string
error: string
}>
}
export class PreferencesMigrator {
private db = dbService.getDb()
/**
* Execute preferences migration from all sources
*/
async migrate(onProgress?: (progress: number, message: string) => void): Promise<MigrationResult> {
logger.info('Starting preferences migration')
const result: MigrationResult = {
success: true,
migratedCount: 0,
errors: []
}
try {
// Get migration items from classification.json
const migrationItems = await this.loadMigrationItems()
const totalItems = migrationItems.length
logger.info(`Found ${totalItems} items to migrate`)
for (let i = 0; i < migrationItems.length; i++) {
const item = migrationItems[i]
try {
await this.migrateItem(item)
result.migratedCount++
const progress = Math.floor(((i + 1) / totalItems) * 100)
onProgress?.(progress, `Migrated: ${item.targetKey}`)
} catch (error) {
logger.error('Failed to migrate item', { item, error })
result.errors.push({
key: item.originalKey,
error: error instanceof Error ? error.message : String(error)
})
result.success = false
}
}
logger.info('Preferences migration completed', {
migratedCount: result.migratedCount,
errorCount: result.errors.length
})
} catch (error) {
logger.error('Preferences migration failed', error as Error)
result.success = false
result.errors.push({
key: 'global',
error: error instanceof Error ? error.message : String(error)
})
}
return result
}
/**
* Load migration items from the generated preferences.ts mappings
* For now, we'll use a simplified set based on the current generated migration code
*/
private async loadMigrationItems(): Promise<MigrationItem[]> {
// This is a simplified implementation. In the full version, this would read from
// the classification.json and apply the same deduplication logic as the generators
const items: MigrationItem[] = [
// ElectronStore items (from generated migration code)
{
originalKey: 'Language',
targetKey: 'app.language',
sourceCategory: 'Language',
type: 'unknown',
defaultValue: null,
source: 'electronStore'
},
{
originalKey: 'SelectionAssistantFollowToolbar',
targetKey: 'feature.selection.follow_toolbar',
sourceCategory: 'SelectionAssistantFollowToolbar',
type: 'unknown',
defaultValue: null,
source: 'electronStore'
},
{
originalKey: 'SelectionAssistantRemeberWinSize',
targetKey: 'feature.selection.remember_win_size',
sourceCategory: 'SelectionAssistantRemeberWinSize',
type: 'unknown',
defaultValue: null,
source: 'electronStore'
},
{
originalKey: 'ZoomFactor',
targetKey: 'app.zoom_factor',
sourceCategory: 'ZoomFactor',
type: 'unknown',
defaultValue: null,
source: 'electronStore'
}
]
// Add some sample Redux items (in full implementation, these would be loaded from classification.json)
const reduxItems: MigrationItem[] = [
{
originalKey: 'theme',
targetKey: 'app.theme.mode',
sourceCategory: 'settings',
type: 'string',
defaultValue: 'ThemeMode.system',
source: 'redux'
},
{
originalKey: 'language',
targetKey: 'app.language',
sourceCategory: 'settings',
type: 'string',
defaultValue: 'en',
source: 'redux'
}
]
items.push(...reduxItems)
return items
}
/**
* Migrate a single preference item
*/
private async migrateItem(item: MigrationItem): Promise<void> {
logger.debug('Migrating preference item', { item })
let originalValue: any
// Read value from the appropriate source
if (item.source === 'electronStore') {
originalValue = await this.readFromElectronStore(item.originalKey)
} else if (item.source === 'redux') {
originalValue = await this.readFromReduxPersist(item.sourceCategory, item.originalKey)
} else {
throw new Error(`Unknown source: ${item.source}`)
}
// Use default value if original value is not found
let valueToMigrate = originalValue
if (originalValue === undefined || originalValue === null) {
valueToMigrate = item.defaultValue
}
// Convert value to appropriate type
const convertedValue = this.convertValue(valueToMigrate, item.type)
// Write to preferences table using Drizzle
await this.writeToPreferences(item.targetKey, convertedValue)
logger.debug('Successfully migrated preference item', {
targetKey: item.targetKey,
originalValue,
convertedValue
})
}
/**
* Read value from ElectronStore (via ConfigManager)
*/
private async readFromElectronStore(key: string): Promise<any> {
try {
return configManager.get(key)
} catch (error) {
logger.warn('Failed to read from ElectronStore', { key, error })
return undefined
}
}
/**
* Read value from Redux persist data
*/
private async readFromReduxPersist(category: string, key: string): Promise<any> {
try {
// This is a simplified implementation
// In the full version, we would need to properly parse the leveldb files
// For now, we'll return undefined to use default values
logger.debug('Redux persist read not fully implemented', { category, key })
return undefined
} catch (error) {
logger.warn('Failed to read from Redux persist', { category, key, error })
return undefined
}
}
/**
* Convert value to the specified type
*/
private convertValue(value: any, targetType: string): any {
if (value === null || value === undefined) {
return null
}
try {
switch (targetType) {
case 'boolean':
return this.toBoolean(value)
case 'string':
return this.toString(value)
case 'number':
return this.toNumber(value)
case 'array':
case 'unknown[]':
return this.toArray(value)
case 'object':
case 'Record<string, unknown>':
return this.toObject(value)
default:
return value
}
} catch (error) {
logger.warn('Type conversion failed, using original value', { value, targetType, error })
return value
}
}
private toBoolean(value: any): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const lower = value.toLowerCase()
return lower === 'true' || lower === '1' || lower === 'yes'
}
if (typeof value === 'number') return value !== 0
return Boolean(value)
}
private toString(value: any): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
private toNumber(value: any): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const parsed = parseFloat(value)
return isNaN(parsed) ? 0 : parsed
}
if (typeof value === 'boolean') return value ? 1 : 0
return 0
}
private toArray(value: any): any[] {
if (Array.isArray(value)) return value
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : [value]
} catch {
return [value]
}
}
return [value]
}
private toObject(value: any): Record<string, any> {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : { value }
} catch {
return { value }
}
}
return { value }
}
/**
* Write value to preferences table using direct Drizzle operations
*/
private async writeToPreferences(targetKey: string, value: any): Promise<void> {
const scope = 'default'
try {
// Check if preference already exists
const existing = await this.db
.select()
.from(preferenceTable)
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, targetKey)))
.limit(1)
if (existing.length > 0) {
// Update existing preference
await this.db
.update(preferenceTable)
.set({
value: value, // drizzle handles JSON serialization automatically
updatedAt: Date.now()
})
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, targetKey)))
} else {
// Insert new preference
await this.db.insert(preferenceTable).values({
scope,
key: targetKey,
value: value, // drizzle handles JSON serialization automatically
createdAt: Date.now(),
updatedAt: Date.now()
})
}
logger.debug('Successfully wrote to preferences table', { targetKey, value })
} catch (error) {
logger.error('Failed to write to preferences table', { targetKey, value, error })
throw error
}
}
}

View File

@ -1,152 +0,0 @@
/**
* Auto-generated ElectronStore to Preferences migration
* Generated at: 2025-08-09T07:20:05.910Z
*
* === AUTO-GENERATED CONTENT START ===
*/
import dbService from '@data/db/DbService'
import { loggerService } from '@logger'
import { configManager } from '@main/services/ConfigManager'
import type { MigrationResult } from './index'
import { TypeConverter } from './utils/typeConverters'
const logger = loggerService.withContext('ElectronStoreMigrator')
// 键映射表
const KEY_MAPPINGS = [
{
originalKey: 'Language',
targetKey: 'app.language',
sourceCategory: 'Language',
type: 'unknown',
defaultValue: null
},
{
originalKey: 'SelectionAssistantFollowToolbar',
targetKey: 'feature.selection.follow_toolbar',
sourceCategory: 'SelectionAssistantFollowToolbar',
type: 'unknown',
defaultValue: null
},
{
originalKey: 'SelectionAssistantRemeberWinSize',
targetKey: 'feature.selection.remember_win_size',
sourceCategory: 'SelectionAssistantRemeberWinSize',
type: 'unknown',
defaultValue: null
},
{
originalKey: 'ZoomFactor',
targetKey: 'app.zoom_factor',
sourceCategory: 'ZoomFactor',
type: 'unknown',
defaultValue: null
}
] as const
export class ElectronStoreMigrator {
private typeConverter: TypeConverter
constructor() {
this.typeConverter = new TypeConverter()
}
/**
* ElectronStore到preferences的迁移
*/
async migrate(): Promise<MigrationResult> {
logger.info('开始ElectronStore迁移', { totalItems: KEY_MAPPINGS.length })
const result: MigrationResult = {
success: true,
migratedCount: 0,
errors: [],
source: 'electronStore'
}
for (const mapping of KEY_MAPPINGS) {
try {
await this.migrateItem(mapping)
result.migratedCount++
} catch (error) {
logger.error('迁移单项失败', { mapping, error })
result.errors.push({
key: mapping.originalKey,
error: error instanceof Error ? error.message : String(error)
})
result.success = false
}
}
logger.info('ElectronStore迁移完成', result)
return result
}
/**
*
*/
private async migrateItem(mapping: (typeof KEY_MAPPINGS)[0]): Promise<void> {
const { originalKey, targetKey, type, defaultValue } = mapping
// 从ElectronStore读取原始值
const originalValue = configManager.get(originalKey)
if (originalValue === undefined || originalValue === null) {
// 如果原始值不存在,使用默认值
if (defaultValue !== null && defaultValue !== undefined) {
const convertedValue = this.typeConverter.convert(defaultValue, type)
await dbService.setPreference('default', targetKey, convertedValue)
logger.debug('使用默认值迁移', { originalKey, targetKey, defaultValue: convertedValue })
}
return
}
// 类型转换
const convertedValue = this.typeConverter.convert(originalValue, type)
// 写入preferences表
await dbService.setPreference('default', targetKey, convertedValue)
logger.debug('成功迁移配置项', {
originalKey,
targetKey,
originalValue,
convertedValue
})
}
/**
*
*/
async validateMigration(): Promise<boolean> {
logger.info('开始验证ElectronStore迁移结果')
for (const mapping of KEY_MAPPINGS) {
const { targetKey } = mapping
try {
const value = await dbService.getPreference('default', targetKey)
if (value === null) {
logger.error('验证失败:配置项不存在', { targetKey })
return false
}
} catch (error) {
logger.error('验证失败:读取配置项错误', { targetKey, error })
return false
}
}
logger.info('ElectronStore迁移验证成功')
return true
}
}
// === AUTO-GENERATED CONTENT END ===
/**
* :
* - ElectronStore配置项: 4
* - 包含的原始键: Language, SelectionAssistantFollowToolbar, SelectionAssistantRemeberWinSize, ZoomFactor
*/

View File

@ -1,16 +1,18 @@
/**
* Auto-generated migration index
* Generated at: 2025-08-09T07:20:05.909Z
*
*
* This file is automatically generated from classification.json
* To update this file, modify classification.json and run:
* node .claude/data-classify/scripts/generate-migration.js
*
*
* === AUTO-GENERATED CONTENT START ===
*/
import { ElectronStoreMigrator } from './electronStoreToPreferences'
import { ReduxMigrator } from './reduxToPreferences'
// LEGACY MIGRATION SYSTEM - COMMENTED OUT
// These files have been replaced by PreferencesMigrator.ts
// import { ElectronStoreMigrator } from './electronStoreToPreferences'
// import { ReduxMigrator } from './reduxToPreferences'
import { loggerService } from '@logger'
const logger = loggerService.withContext('MigrationManager')
@ -34,12 +36,14 @@ export interface MigrationSummary {
}
export class MigrationManager {
private electronStoreMigrator: ElectronStoreMigrator
private reduxMigrator: ReduxMigrator
// LEGACY MIGRATION SYSTEM - COMMENTED OUT
// private electronStoreMigrator: ElectronStoreMigrator
// private reduxMigrator: ReduxMigrator
constructor() {
this.electronStoreMigrator = new ElectronStoreMigrator()
this.reduxMigrator = new ReduxMigrator()
// this.electronStoreMigrator = new ElectronStoreMigrator()
// this.reduxMigrator = new ReduxMigrator()
logger.warn('MigrationManager is deprecated. Use PreferencesMigrator instead.')
}
/**
@ -47,34 +51,18 @@ export class MigrationManager {
* @returns
*/
async migrateAllPreferences(): Promise<MigrationSummary> {
logger.info('开始完整preferences迁移')
try {
// 并行执行两个迁移器
const [electronStoreResult, reduxResult] = await Promise.all([
this.electronStoreMigrator.migrate(),
this.reduxMigrator.migrate()
])
logger.warn('MigrationManager.migrateAllPreferences is deprecated. Use PreferencesMigrator instead.')
const summary: MigrationSummary = {
totalItems: 158,
successCount: electronStoreResult.migratedCount + reduxResult.migratedCount,
errorCount: electronStoreResult.errors.length + reduxResult.errors.length,
electronStore: electronStoreResult,
redux: reduxResult
}
if (summary.errorCount > 0) {
logger.warn('迁移完成但有错误', { summary })
} else {
logger.info('迁移完全成功', { summary })
}
return summary
} catch (error) {
logger.error('迁移过程中发生致命错误', error)
throw error
// Return a placeholder summary since the actual migration is handled by PreferencesMigrator
const summary: MigrationSummary = {
totalItems: 0,
successCount: 0,
errorCount: 0,
electronStore: { success: false, migratedCount: 0, errors: [], source: 'electronStore' },
redux: { success: false, migratedCount: 0, errors: [], source: 'redux' }
}
return summary
}
/**
@ -82,43 +70,9 @@ export class MigrationManager {
* @param summary
* @returns
*/
async validateMigration(summary: MigrationSummary): Promise<boolean> {
logger.info('开始验证迁移结果')
// 基本验证:检查成功率
const successRate = summary.successCount / summary.totalItems
if (successRate < 0.95) { // 要求95%以上成功率
logger.error('迁移成功率过低', { successRate, summary })
return false
}
// 验证关键配置项是否存在
const criticalKeys = [
'app.theme.mode',
'app.language',
'app.user.id',
'feature.quick_assistant.enabled',
'chat.message.font_size'
]
try {
const dbServiceModule = await import('@main/db/DbService')
const dbService = dbServiceModule.default
for (const key of criticalKeys) {
const result = await dbService.getPreference('default', key)
if (result === null) {
logger.error('关键配置项迁移失败', { key })
return false
}
}
logger.info('迁移验证成功')
return true
} catch (error) {
logger.error('验证过程中发生错误', error)
return false
}
async validateMigration(_summary: MigrationSummary): Promise<boolean> {
logger.warn('MigrationManager.validateMigration is deprecated. Use PreferencesMigrator validation instead.')
return true
}
}
@ -129,4 +83,4 @@ export class MigrationManager {
* - 总迁移项: 158
* - ElectronStore项: 4
* - Redux项: 154
*/
*/

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ import '@main/config'
import { loggerService } from '@logger'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import dbService from '@main/db/DbService'
import dbService from '@data/db/DbService'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
@ -27,6 +27,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { migrateService } from './data/migrate/MigrateService'
import process from 'node:process'
const logger = loggerService.withContext('MainEntry')
@ -118,6 +119,20 @@ if (!app.requestSingleInstanceLock()) {
app.dock?.hide()
}
// Check if data migration is needed
try {
const needsMigration = await migrateService.checkMigrationNeeded()
if (needsMigration) {
logger.info('Migration needed, starting migration process')
await migrateService.runMigration()
logger.info('Migration completed, proceeding with normal startup')
}
} catch (error) {
logger.error('Migration process failed', error as Error)
// Continue with normal startup even if migration fails
// The user can retry migration later or use backup recovery
}
const mainWindow = windowService.createMainWindow()
new TrayService()

View File

@ -13,6 +13,7 @@ import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { migrateService } from './data/migrate/MigrateService'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
@ -696,4 +697,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
(_, spanId: string, modelName: string, context: string, msg: any) =>
addStreamMessage(spanId, modelName, context, msg)
)
// Data migration handlers
ipcMain.handle(IpcChannel.DataMigrate_CheckNeeded, () => migrateService.checkMigrationNeeded())
ipcMain.handle(IpcChannel.DataMigrate_StartMigration, () => migrateService.runMigration())
ipcMain.handle(IpcChannel.DataMigrate_GetProgress, () => migrateService.getCurrentProgress())
ipcMain.handle(IpcChannel.DataMigrate_Cancel, () => migrateService.cancelMigration())
ipcMain.handle(IpcChannel.DataMigrate_BackupCompleted, () => {
migrateService.notifyBackupCompleted()
return true
})
ipcMain.handle(IpcChannel.DataMigrate_ShowBackupDialog, () => {
// Show the backup dialog/interface
// This could integrate with existing backup UI or create a new backup interface
return true
})
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Cherry Studio - Data Migration</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body id="root">
<script type="module" src="/src/windows/dataMigrate/entryPoint.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,276 @@
import { CheckCircleOutlined, ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import { IpcChannel } from '@shared/IpcChannel'
import { Alert, Button, Card, Progress, Space, Typography } from 'antd'
import React, { useEffect, useState } from 'react'
import styled from 'styled-components'
const { Title, Text } = Typography
const Container = styled.div`
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
`
const MigrationCard = styled(Card)`
width: 100%;
max-width: 500px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
.ant-card-head {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.ant-card-body {
padding: 32px 24px;
}
`
const LogoContainer = styled.div`
display: flex;
justify-content: center;
margin-bottom: 24px;
img {
width: 64px;
height: 64px;
}
`
const StageIndicator = styled.div<{ stage: string }>`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.stage-icon {
font-size: 20px;
color: ${(props) => {
switch (props.stage) {
case 'completed':
return '#52c41a'
case 'error':
return '#ff4d4f'
default:
return '#1890ff'
}
}};
}
`
const ProgressContainer = styled.div`
margin: 24px 0;
`
const MessageContainer = styled.div`
text-align: center;
margin: 16px 0;
min-height: 24px;
`
interface MigrationProgress {
stage: string
progress: number
total: number
message: string
}
const MigrateApp: React.FC = () => {
const [progress, setProgress] = useState<MigrationProgress>({
stage: 'idle',
progress: 0,
total: 100,
message: 'Initializing migration...'
})
const [showBackupRequired, setShowBackupRequired] = useState(false)
useEffect(() => {
// Listen for progress updates
const handleProgress = (_: any, progressData: MigrationProgress) => {
setProgress(progressData)
}
// Listen for backup requirement
const handleBackupRequired = () => {
setShowBackupRequired(true)
}
window.electron.ipcRenderer.on(IpcChannel.DataMigrateProgress, handleProgress)
window.electron.ipcRenderer.on(IpcChannel.DataMigrate_RequireBackup, handleBackupRequired)
// Request initial progress
window.electron.ipcRenderer
.invoke(IpcChannel.DataMigrate_GetProgress)
.then((initialProgress: MigrationProgress) => {
if (initialProgress) {
setProgress(initialProgress)
}
})
return () => {
window.electron.ipcRenderer.removeAllListeners(IpcChannel.DataMigrateProgress)
window.electron.ipcRenderer.removeAllListeners(IpcChannel.DataMigrate_RequireBackup)
}
}, [])
const handleCancel = () => {
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_Cancel)
}
const handleShowBackupDialog = () => {
// Open the main window backup dialog
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_ShowBackupDialog)
}
const handleBackupCompleted = () => {
setShowBackupRequired(false)
// Notify the main process that backup is completed
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_BackupCompleted)
}
const getStageTitle = () => {
switch (progress.stage) {
case 'backup':
return 'Creating Backup'
case 'migration':
return 'Migrating Data'
case 'completed':
return 'Migration Completed'
case 'error':
return 'Migration Failed'
case 'cancelled':
return 'Migration Cancelled'
default:
return 'Preparing Migration'
}
}
const getStageIcon = () => {
switch (progress.stage) {
case 'completed':
return <CheckCircleOutlined className="stage-icon" />
case 'error':
case 'cancelled':
return <ExclamationCircleOutlined className="stage-icon" />
default:
return <LoadingOutlined className="stage-icon" />
}
}
const getProgressColor = () => {
switch (progress.stage) {
case 'completed':
return '#52c41a'
case 'error':
case 'cancelled':
return '#ff4d4f'
default:
return '#1890ff'
}
}
const showCancelButton = () => {
return progress.stage !== 'completed' && progress.stage !== 'error' && progress.stage !== 'cancelled'
}
return (
<Container>
<MigrationCard
title={
<div style={{ textAlign: 'center' }}>
<Title level={3} style={{ margin: 0 }}>
Cherry Studio Data Migration
</Title>
</div>
}
bordered={false}>
<LogoContainer>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMzIiIGZpbGw9IiM2NjdlZWEiLz4KPHN2ZyB4PSIxNiIgeT0iMTYiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJ3aGl0ZSI+CjxwYXRoIGQ9Im0xMiAyIDMgN2g3bC01LjUgNEwxOSAyMGwtNy02LTcgNiAyLjUtN0w2IDloN2wzLTd6Ii8+Cjwvc3ZnPgo8L3N2Zz4K"
alt="Cherry Studio"
/>
</LogoContainer>
<StageIndicator stage={progress.stage}>
{getStageIcon()}
<Title level={4} style={{ margin: 0 }}>
{getStageTitle()}
</Title>
</StageIndicator>
<ProgressContainer>
<Progress
percent={progress.progress}
strokeColor={getProgressColor()}
trailColor="#f0f0f0"
size="default"
showInfo={true}
/>
</ProgressContainer>
<MessageContainer>
<Text type="secondary">{progress.message}</Text>
</MessageContainer>
{progress.stage === 'error' && (
<Alert
message="Migration Error"
description="The migration process encountered an error. You can try again or restore from a backup using an older version."
type="error"
showIcon
style={{ marginTop: 16 }}
/>
)}
{progress.stage === 'completed' && (
<Alert
message="Migration Successful"
description="Your data has been successfully migrated to the new format. Cherry Studio will now start with your updated data."
type="success"
showIcon
style={{ marginTop: 16 }}
/>
)}
{showBackupRequired && (
<Alert
message="Backup Required"
description="A backup is required before migration can proceed. Please create a backup of your data to ensure safety."
type="warning"
showIcon
style={{ marginTop: 16 }}
action={
<Space>
<Button size="small" onClick={handleShowBackupDialog}>
Create Backup
</Button>
<Button size="small" type="link" onClick={handleBackupCompleted}>
I've Already Created a Backup
</Button>
</Space>
}
/>
)}
{showCancelButton() && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Space>
<Button onClick={handleCancel} disabled={progress.stage === 'backup'}>
Cancel Migration
</Button>
</Space>
</div>
)}
</MigrationCard>
</Container>
)
}
export default MigrateApp

View File

@ -0,0 +1,9 @@
import '../../assets/styles/index.scss'
import ReactDOM from 'react-dom/client'
import MigrateApp from './MigrateApp'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(<MigrateApp />)