fix(data): migrate legacy chat database columns before reads

This commit is contained in:
digua committed 2026-06-03 00:46:34 +08:00
1 parent bb4986d703
commit 42837278fa
4 files changed
+187 -2

No files matched your search

+2 -2
View File
@@ -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
@@ -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()
})
@@ -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.
@@ -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)')
},
},
]
}