From 42837278face891cb0f051315cb14c2e80669f74 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 2 Jun 2026 23:17:16 +0800 Subject: [PATCH] fix(data): migrate legacy chat database columns before reads --- packages/core/src/schema/tables.ts | 4 +- .../node-runtime/src/database-manager.test.ts | 90 +++++++++++++++++++ packages/node-runtime/src/database-manager.ts | 18 ++++ .../src/migrations/chat-db-migrations.ts | 77 ++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 packages/node-runtime/src/database-manager.test.ts diff --git a/packages/core/src/schema/tables.ts b/packages/core/src/schema/tables.ts index b2ff303d..ccf9eb4e 100644 --- a/packages/core/src/schema/tables.ts +++ b/packages/core/src/schema/tables.ts @@ -4,11 +4,11 @@ * 所有 CREATE TABLE / INDEX 语句的单一事实来源。 * 新建数据库时使用完整 Schema,现有数据库通过迁移脚本演进。 * - * 当前 Schema 版本:4 + * 当前 Schema 版本:5 */ /** 当前 Schema 版本(最新迁移的版本号) */ -export const CURRENT_SCHEMA_VERSION = 4 +export const CURRENT_SCHEMA_VERSION = 5 /** * Table DDL only (no indexes). Used by bulk-import workflows that defer diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts new file mode 100644 index 00000000..57328e94 --- /dev/null +++ b/packages/node-runtime/src/database-manager.test.ts @@ -0,0 +1,90 @@ +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 { getSessionInfo } from '@openchatlab/core' +import { DatabaseManager } from './database-manager' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-db-manager-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('open migrates legacy member name columns before readonly queries', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.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 ('Legacy Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + nickname TEXT + ); + INSERT INTO member (platform_id, name, nickname) VALUES ('u1', 'Alice Account', 'Alice Group'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding }) + const db = manager.open('legacy') + assert.ok(db) + + const info = getSessionInfo(db) + assert.equal(info?.name, 'Legacy Chat') + assert.equal(info?.messageCount, 1) + + const columns = db.pragma('table_info(member)') as Array<{ name: string }> + assert.equal( + columns.some((col) => col.name === 'account_name'), + true + ) + const member = db.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?').get('u1') as { + account_name: string | null + group_nickname: string | null + } + assert.equal(member.account_name, 'Alice Account') + assert.equal(member.group_nickname, 'Alice Group') + + manager.closeAll() +}) diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index 2dff68df..4d55b98b 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -42,6 +42,8 @@ export class DatabaseManager { const dbPath = this.getDbPath(sessionId) if (!fs.existsSync(dbPath)) return null + this.migrateIfNeeded(dbPath) + const adapter = openBetterSqliteDatabase(dbPath, { readonly: options?.readonly ?? true, nativeBinding: this.nativeBinding, @@ -50,6 +52,22 @@ export class DatabaseManager { return adapter } + private migrateIfNeeded(dbPath: string): void { + const adapter = openBetterSqliteDatabase(dbPath, { + readonly: false, + nativeBinding: this.nativeBinding, + }) + + try { + if (coreNeedsMigration(adapter, CURRENT_SCHEMA_VERSION)) { + const migrations = getChatDbMigrations(this.migrationDeps) + runMigrations(adapter, migrations) + } + } finally { + adapter.close() + } + } + /** * Open a session DB in read-write mode and auto-run pending migrations. * Falls back to `open(id, { readonly: false })` if migration is unnecessary. diff --git a/packages/node-runtime/src/migrations/chat-db-migrations.ts b/packages/node-runtime/src/migrations/chat-db-migrations.ts index a5226c20..ebd9bbb0 100644 --- a/packages/node-runtime/src/migrations/chat-db-migrations.ts +++ b/packages/node-runtime/src/migrations/chat-db-migrations.ts @@ -21,6 +21,17 @@ export interface MigrationDeps { * @returns Array of migrations compatible with core `runMigrations` */ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { + const hasColumn = (db: DatabaseAdapter, tableName: string, columnName: string): boolean => { + const tableInfo = db.pragma(`table_info(${tableName})`) as Array<{ name: string }> + return tableInfo.some((col) => col.name === columnName) + } + + const addColumnIfMissing = (db: DatabaseAdapter, tableName: string, columnName: string, definition: string): void => { + if (!hasColumn(db, tableName, columnName)) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`) + } + } + return [ { version: 1, @@ -148,5 +159,71 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { } }, }, + { + version: 5, + description: 'Repair legacy member/message columns', + up: (db: DatabaseAdapter) => { + addColumnIfMissing(db, 'meta', 'group_id', 'TEXT') + addColumnIfMissing(db, 'meta', 'group_avatar', 'TEXT') + addColumnIfMissing(db, 'meta', 'owner_id', 'TEXT') + addColumnIfMissing(db, 'meta', 'session_gap_threshold', 'INTEGER') + + const memberHadName = hasColumn(db, 'member', 'name') + const memberHadNickname = hasColumn(db, 'member', 'nickname') + addColumnIfMissing(db, 'member', 'account_name', 'TEXT') + addColumnIfMissing(db, 'member', 'group_nickname', 'TEXT') + addColumnIfMissing(db, 'member', 'aliases', "TEXT DEFAULT '[]'") + addColumnIfMissing(db, 'member', 'avatar', 'TEXT') + addColumnIfMissing(db, 'member', 'roles', "TEXT DEFAULT '[]'") + + if (memberHadName) { + db.exec("UPDATE member SET account_name = COALESCE(NULLIF(account_name, ''), name)") + } + if (memberHadNickname) { + db.exec("UPDATE member SET group_nickname = COALESCE(NULLIF(group_nickname, ''), nickname)") + } + db.exec("UPDATE member SET aliases = COALESCE(aliases, '[]'), roles = COALESCE(roles, '[]')") + + db.exec(` + CREATE TABLE IF NOT EXISTS member_name_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL, + name_type TEXT DEFAULT 'account_name', + name TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER, + FOREIGN KEY(member_id) REFERENCES member(id) + ) + `) + addColumnIfMissing(db, 'member_name_history', 'name_type', "TEXT DEFAULT 'account_name'") + addColumnIfMissing(db, 'message', 'sender_account_name', 'TEXT') + addColumnIfMissing(db, 'message', 'sender_group_nickname', 'TEXT') + addColumnIfMissing(db, 'message', 'reply_to_message_id', 'TEXT DEFAULT NULL') + addColumnIfMissing(db, 'message', 'platform_message_id', 'TEXT DEFAULT NULL') + + db.exec(` + CREATE TABLE IF NOT EXISTS chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ) + `) + + db.exec('CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id)') + db.exec('CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id)') + db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)') + db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)') + }, + }, ] }