mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(desktop): enforce data directory compatibility
This commit is contained in:
1 parent
84b42a68f7
commit
ad8181d6f4
7 files changed
+264
-15
No files matched your search
@@ -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',
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
Reference in new issue
Block a user