mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(data): migrate legacy chat database columns before reads
This commit is contained in:
1 parent
bb4986d703
commit
42837278fa
4 files changed
+187
-2
No files matched your search
@@ -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)')
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user