From ad8181d6f4cc1e8f0cfd18bdd6db4b21feaeaa33 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 7 Jun 2026 21:55:47 +0800 Subject: [PATCH] feat(desktop): enforce data directory compatibility --- apps/desktop/main/database/core.ts | 23 ++++- apps/desktop/main/database/migrations.test.ts | 83 +++++++++++++++++++ apps/desktop/main/database/migrations.ts | 50 +++++++++-- apps/desktop/main/index.ts | 23 ++++- apps/desktop/main/internal-api.ts | 7 +- apps/desktop/main/runtime-compat.test.ts | 54 ++++++++++++ apps/desktop/main/runtime-compat.ts | 39 +++++++++ 7 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/main/database/migrations.test.ts create mode 100644 apps/desktop/main/runtime-compat.test.ts create mode 100644 apps/desktop/main/runtime-compat.ts diff --git a/apps/desktop/main/database/core.ts b/apps/desktop/main/database/core.ts index 85480fa0..c385fa26 100644 --- a/apps/desktop/main/database/core.ts +++ b/apps/desktop/main/database/core.ts @@ -13,6 +13,7 @@ import { renameSession as coreRenameSession, } from '@openchatlab/core' import { BetterSqliteAdapter, writeParseResultToDb } from '@openchatlab/node-runtime' +import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat' import type { ParseResult } from '../../../../src/types/base' import { migrateDatabase, needsMigration, CURRENT_SCHEMA_VERSION } from './migrations' import { getPathProvider } from '../path-context' @@ -85,7 +86,11 @@ export function openDatabase(sessionId: string, readonly = true): Database.Datab * @param sessionId 会话ID * @param forceRepair 是否强制修复(即使版本号已是最新也重新执行迁移脚本) */ -export function openDatabaseWithMigration(sessionId: string, forceRepair = false): Database.Database | null { +export function openDatabaseWithMigration( + sessionId: string, + forceRepair = false, + runtime?: RuntimeIdentity +): Database.Database | null { const dbPath = getDbPath(sessionId) if (!fs.existsSync(dbPath)) { return null @@ -95,7 +100,7 @@ export function openDatabaseWithMigration(sessionId: string, forceRepair = false db.pragma('journal_mode = WAL') // 执行迁移 - migrateDatabase(db, forceRepair) + migrateDatabase(db, forceRepair, { pathProvider: getPathProvider(), runtime }) return db } @@ -287,6 +292,18 @@ export function migrateAllDatabases(): { migratedCount: number failures: MigrationFailure[] error?: string +} +export function migrateAllDatabases(runtime: RuntimeIdentity): { + success: boolean + migratedCount: number + failures: MigrationFailure[] + error?: string +} +export function migrateAllDatabases(runtime?: RuntimeIdentity): { + success: boolean + migratedCount: number + failures: MigrationFailure[] + error?: string } { const { sessionIds, forceRepairIds } = checkMigrationNeeded() const forceRepairSet = new Set(forceRepairIds) @@ -301,7 +318,7 @@ export function migrateAllDatabases(): { for (const sessionId of sessionIds) { try { const needsForceRepair = forceRepairSet.has(sessionId) - const db = openDatabaseWithMigration(sessionId, needsForceRepair) + const db = openDatabaseWithMigration(sessionId, needsForceRepair, runtime) if (db) { db.close() migratedCount++ diff --git a/apps/desktop/main/database/migrations.test.ts b/apps/desktop/main/database/migrations.test.ts new file mode 100644 index 00000000..982c99c1 --- /dev/null +++ b/apps/desktop/main/database/migrations.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import type { PathProvider } from '@openchatlab/core' +import { readDataDirCompatibilityMeta } from '@openchatlab/node-runtime/src/data-dir-compat' +import { migrateDatabase } from './migrations' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-desktop-migration-')) +} + +function makePathProvider(root: string): PathProvider { + return { + getSystemDir: () => path.join(root, 'system'), + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getAiDataDir: () => path.join(root, 'system', 'ai'), + getSettingsDir: () => path.join(root, 'system', 'settings'), + getCacheDir: () => path.join(root, 'system', 'cache'), + getTempDir: () => path.join(root, 'system', 'temp'), + getLogsDir: () => path.join(root, 'system', 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('migrateDatabase writes data directory compatibility meta after segment schema migration', () => { + const root = makeTempDir() + const dbPath = path.join(root, 'data', 'databases', 'desktop-legacy.db') + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + + const db = new Database(dbPath) + db.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Desktop Legacy Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + `) + + try { + assert.equal( + migrateDatabase(db, false, { + pathProvider: makePathProvider(root), + runtime: { version: '0.25.1', kind: 'desktop' }, + }), + true + ) + } finally { + db.close() + } + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) + assert.deepEqual(meta?.updatedBy, { + runtime: 'desktop', + module: 'chat-db-migration', + version: '0.25.1', + }) +}) diff --git a/apps/desktop/main/database/migrations.ts b/apps/desktop/main/database/migrations.ts index 327db46b..d21a2eba 100644 --- a/apps/desktop/main/database/migrations.ts +++ b/apps/desktop/main/database/migrations.ts @@ -6,11 +6,21 @@ */ import type Database from 'better-sqlite3' -import { CURRENT_SCHEMA_VERSION, runMigrations, needsMigration as coreNeedsMigration } from '@openchatlab/core' -import type { DatabaseAdapter } from '@openchatlab/core' -import { BetterSqliteAdapter, getChatDbMigrations } from '@openchatlab/node-runtime' +import { + CURRENT_SCHEMA_VERSION, + getSchemaVersion, + runMigrations, + needsMigration as coreNeedsMigration, +} from '@openchatlab/core' +import type { DatabaseAdapter, PathProvider } from '@openchatlab/core' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime/src/better-sqlite3-adapter' +import { + CHAT_DB_COMPATIBILITY_RAISES, + getChatDbMigrations, +} from '@openchatlab/node-runtime/src/migrations/chat-db-migrations' +import { raiseDataDirMinRuntimeVersion, type RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat' import { t } from '../i18n' -import { tokenizeForFts } from '@openchatlab/node-runtime' +import { tokenizeForFts } from '@openchatlab/node-runtime/src/nlp/fts-tokenizer' export { CURRENT_SCHEMA_VERSION } @@ -20,6 +30,11 @@ export interface MigrationInfo { userMessage: string } +export interface DesktopMigrationOptions { + pathProvider?: PathProvider + runtime?: RuntimeIdentity +} + const i18nKeys: Array<{ descriptionKey: string; userMessageKey: string }> = [ { descriptionKey: 'database.migrationV1Desc', userMessageKey: 'database.migrationV1Message' }, { descriptionKey: 'database.migrationV2Desc', userMessageKey: 'database.migrationV2Message' }, @@ -49,7 +64,11 @@ function checkDatabaseIntegrity(db: DatabaseAdapter): { valid: boolean; error?: * Wraps better-sqlite3 Database in a DatabaseAdapter and delegates to core runMigrations * with shared migration definitions from @openchatlab/node-runtime. */ -export function migrateDatabase(db: Database.Database, forceRepair = false): boolean { +export function migrateDatabase( + db: Database.Database, + forceRepair = false, + options: DesktopMigrationOptions = {} +): boolean { const adapter = new BetterSqliteAdapter(db) const integrity = checkDatabaseIntegrity(adapter) @@ -57,8 +76,27 @@ export function migrateDatabase(db: Database.Database, forceRepair = false): boo throw new Error(integrity.error) } + const beforeVersion = getSchemaVersion(adapter) const migrations = getChatDbMigrations({ tokenizeForFts }) - return runMigrations(adapter, migrations, forceRepair) + const migrated = runMigrations(adapter, migrations, forceRepair) + if (!migrated || !options.pathProvider || !options.runtime) return migrated + + const afterVersion = getSchemaVersion(adapter) + for (const compatibilityRaise of CHAT_DB_COMPATIBILITY_RAISES) { + if (beforeVersion >= compatibilityRaise.migrationVersion || afterVersion < compatibilityRaise.migrationVersion) { + continue + } + + raiseDataDirMinRuntimeVersion(options.pathProvider, { + minRuntimeVersion: compatibilityRaise.minRuntimeVersion, + dataCompatibilityVersion: compatibilityRaise.dataCompatibilityVersion, + reason: compatibilityRaise.reason, + runtime: options.runtime, + module: compatibilityRaise.module, + }) + } + + return migrated } export function needsMigration(db: Database.Database): boolean { diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 1e6701f6..236295da 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -22,6 +22,8 @@ import { import { migrateAllDatabases, checkMigrationNeeded } from './database/core' import { initLocale } from './i18n' import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' +import { assertDesktopDataDirCompatible } from './runtime-compat' +import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat' import { applyCurrentTitleBarOverlay, getTitleBarOverlayOptions, @@ -114,6 +116,21 @@ class MainProcess { // 确保应用目录存在 ensureAppDirs() + let runtime: RuntimeIdentity + try { + runtime = assertDesktopDataDirCompatible(getPathProvider(), app.getVersion()) + } catch (error) { + console.error('[Main] Data directory compatibility check failed:', error) + dialog.showErrorBox( + 'ChatLab Data Directory Incompatible', + `ChatLab cannot open this data directory with the current desktop version.\n\n${ + error instanceof Error ? error.message : String(error) + }` + ) + app.quit() + return + } + // 执行配置数据迁移(Migration Runner,Electron 和 CLI 共享) await new MigrationRunner(ALL_MIGRATIONS, { dataDir: getSystemDataDir(), @@ -129,7 +146,7 @@ class MainProcess { await initLocale() // 执行数据库 schema 迁移(确保所有数据库在 Worker 查询前已是最新 schema) - this.migrateDatabasesIfNeeded() + this.migrateDatabasesIfNeeded(runtime) initProxy() // 初始化代理配置 @@ -187,11 +204,11 @@ class MainProcess { } // 执行数据库 schema 迁移(静默迁移) - migrateDatabasesIfNeeded() { + migrateDatabasesIfNeeded(runtime: RuntimeIdentity) { try { const { count } = checkMigrationNeeded() if (count > 0) { - const result = migrateAllDatabases() + const result = migrateAllDatabases(runtime) if (!result.success) { console.error('[Main] Database schema migration failed:', result.error) } diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index ff042cba..e1832349 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -35,6 +35,7 @@ import { getManager as getAIChatManager } from './ai/chats' import { getManager as getAssistantManager } from './ai/assistant/manager' import { getManager as getSkillManager } from './ai/skills/manager' import { createElectronRunAgentStream } from './ai/agent-stream-runner' +import { assertDesktopDataDirCompatible } from './runtime-compat' export interface InternalEndpoint { baseUrl: string @@ -76,8 +77,10 @@ export async function startInternalServer(pathProvider: PathProvider): Promise path.join(userDataDir, '..', 'system'), + getUserDataDir: () => userDataDir, + getDatabaseDir: () => path.join(userDataDir, 'databases'), + getAiDataDir: () => path.join(userDataDir, '..', 'system', 'ai'), + getSettingsDir: () => path.join(userDataDir, '..', 'system', 'settings'), + getCacheDir: () => path.join(userDataDir, '..', 'system', 'cache'), + getTempDir: () => path.join(userDataDir, '..', 'system', 'temp'), + getLogsDir: () => path.join(userDataDir, '..', 'system', 'logs'), + getDownloadsDir: () => path.join(userDataDir, '..', 'downloads'), + } +} + +test('assertDesktopDataDirCompatible formats a startup error for older desktop runtimes', () => { + const userDataDir = makeTempDir() + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '999.0.0', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '999.0.0' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) + + assert.throws( + () => assertDesktopDataDirCompatible(makePathProvider(userDataDir), '0.25.0'), + (error) => { + assert.ok(error instanceof Error) + assert.match(error.message, /requires ChatLab 999\.0\.0 or newer/) + assert.match(error.message, /Current desktop version: 0\.25\.0/) + assert.match(error.message, new RegExp(userDataDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.match(error.message, /upgrade ChatLab desktop/) + return true + } + ) +}) diff --git a/apps/desktop/main/runtime-compat.ts b/apps/desktop/main/runtime-compat.ts new file mode 100644 index 00000000..954b0943 --- /dev/null +++ b/apps/desktop/main/runtime-compat.ts @@ -0,0 +1,39 @@ +import type { PathProvider } from '@openchatlab/core' +import { + assertDataDirCompatible, + DataDirCompatibilityError, + type RuntimeIdentity, +} from '@openchatlab/node-runtime/src/data-dir-compat' + +export function createDesktopRuntimeIdentity(version: string): RuntimeIdentity { + return { version, kind: 'desktop' } +} + +export function assertDesktopDataDirCompatible(pathProvider: PathProvider, version: string): RuntimeIdentity { + const runtime = createDesktopRuntimeIdentity(version) + + try { + assertDataDirCompatible(pathProvider, runtime) + } catch (error) { + if ( + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.minRuntimeVersion + ) { + throw new Error(formatDesktopDataDirCompatibilityError(error, runtime), { cause: error }) + } + + throw error + } + + return runtime +} + +function formatDesktopDataDirCompatibilityError(error: DataDirCompatibilityError, runtime: RuntimeIdentity): string { + return [ + `ChatLab data directory requires ChatLab ${error.minRuntimeVersion} or newer.`, + `Current desktop version: ${runtime.version}.`, + `Data directory: ${error.userDataDir}.`, + 'Please upgrade ChatLab desktop before opening this data directory.', + ].join('\n') +}