feat(desktop): enforce data directory compatibility

This commit is contained in:
digua committed 2026-06-08 10:14:42 +08:00
1 parent 84b42a68f7
commit ad8181d6f4
7 files changed
+264 -15

No files matched your search

+20 -3
View File
@@ -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++
@@ -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',
})
})
+44 -6
View File
@@ -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 {
+20 -3
View File
@@ -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)
}
+4 -3
View File
@@ -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<I
try {
const token = `int_${randomBytes(32).toString('hex')}`
const { app } = await import('electron')
const runtime = assertDesktopDataDirCompatible(pathProvider, app.getVersion())
newDbManager = new DatabaseManager(pathProvider)
newDbManager = new DatabaseManager(pathProvider, { runtime })
const sessionAdapter = createDatabaseManagerAdapter(newDbManager)
const aiDataDir = pathProvider.getAiDataDir()
@@ -90,8 +93,6 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
},
})
const { app } = await import('electron')
const configStorage = createFileConfigStorage(aiDataDir)
const newMergeCache = new MergeSessionCache(pathProvider)
+54
View File
@@ -0,0 +1,54 @@
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 type { PathProvider } from '@openchatlab/core'
import { assertDesktopDataDirCompatible } from './runtime-compat'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-desktop-compat-'))
}
function makePathProvider(userDataDir: string): PathProvider {
return {
getSystemDir: () => 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
}
)
})
+39
View File
@@ -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')
}