Files
metona-ai-desktop/electron/services/database.service.ts
T
thzxx 7e8b4882a0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m38s
CI / 产物编译验证 (push) Successful in 10m15s
CI / 全量测试 (Electron ABI) (push) Failing after 5m27s
feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
P0 安全与工程基线(止血):
- .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401)
- 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web),
  pre-commit 与 CI 门禁恢复拦截能力
- 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、
  agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型
- 补装 v0.4.1 声明但未安装的 node-html-parser 依赖

P1 逻辑缺陷修复(跨模块边界):
- ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离,
  abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染)
- SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入
  ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区)
  ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件)
- main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起)

P2 工程强化:
- CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁;
  GITEA_NPM_AUTH secret 注入说明
- 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons
  (业务代码变更不再使 vendor 缓存失效)
- database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建)

P3 功能补全:
- DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance)
- FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 +
  存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索
  (短语转义防 FTS 运算符注入,按会话聚合展示 snippet)
- 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮

文档一致性大扫除:
- README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、
  删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、
  项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、
  新增 GITEA_NPM_AUTH 配置说明、测试数 207
- 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、
  磁盘文件 2 个现状注记、ipc/*.ts 路径)
- eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型

测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例)
验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
2026-08-21 21:07:01 +08:00

563 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Database Service — SQLite 数据库管理
*
* 使用 better-sqlite3(同步、高性能、主进程专用)。
* 负责数据库初始化、Schema 迁移、连接管理。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
* @see standard/开发规范.md — 禁止自写数据库层,使用 better-sqlite3
*/
import Database from 'better-sqlite3';
import { join } from 'path';
import { app } from 'electron';
import { existsSync, mkdirSync } from 'fs';
import log from 'electron-log';
/**
* L-8 修复: 提取 toErrorMessage 工具函数,消除 5 处重复的 error instanceof Error 三元表达式
*/
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */
export interface ConfigDefaultEntry {
key: string;
value: unknown;
category: string;
}
/**
* 全部配置默认值(唯一维护点)
*
* 注意:新增/修改配置默认值时只需改这里,全局配置层(SEED_DEFAULTS 判定"未配置"的依据)
* 会自动同步,避免双源漂移。
*/
export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
// LLM 配置(无硬编码值,用户必须手动配置)
{ key: 'llm.provider', value: '', category: 'llm' },
{ key: 'llm.model', value: '', category: 'llm' },
{ key: 'llm.apiKey', value: '', category: 'llm' },
{ key: 'llm.baseURL', value: '', category: 'llm' },
{ key: 'llm.temperature', value: 0, category: 'llm' },
{ key: 'llm.maxTokens', value: 63488, category: 'llm' },
// P1: Provider 故障转移配置
{ key: 'llm.fallbackProvider', value: '', category: 'llm' },
{ key: 'llm.fallbackModel', value: '', category: 'llm' },
{ key: 'llm.fallbackApiKey', value: '', category: 'llm' },
{ key: 'llm.fallbackBaseURL', value: '', category: 'llm' },
// Agent 配置
{ key: 'agent.maxIterations', value: 20, category: 'agent' },
{ key: 'agent.totalTimeoutMs', value: 600000, category: 'agent' },
{ key: 'agent.enableThinking', value: true, category: 'agent' },
{ key: 'agent.thinkingEffort', value: 'high', category: 'agent' },
{ key: 'agent.enableReflection', value: false, category: 'agent' },
// C-10 修复: 补充缺失的 agent 配置默认值
// @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s)
{ key: 'agent.confirmationTimeoutMs', value: 120000, category: 'agent' },
{ key: 'agent.toolExecutionTimeoutMs', value: 120000, category: 'agent' },
// 安全配置
{ key: 'security.requireWriteConfirmation', value: true, category: 'security' },
{ key: 'security.maxFileWriteSizeKB', value: 1024, category: 'security' },
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
// UI 配置
{ key: 'ui.theme', value: 'auto', category: 'ui' },
{ key: 'ui.animationMode', value: 'auto', category: 'ui' },
{ key: 'ui.fontSize', value: 'medium', category: 'ui' },
// 日志配置
{ key: 'logging.level', value: 'info', category: 'logging' },
{ key: 'logging.auditEnabled', value: true, category: 'logging' },
{ key: 'logging.traceEnabled', value: true, category: 'logging' },
// Ollama 配置
{ key: 'ollama.numCtx', value: null, category: 'ollama' },
// Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示)
{ key: 'deepseek.contextWindow', value: 1000000, category: 'deepseek' },
{ key: 'agnes.contextWindow', value: 1000000, category: 'agnes' },
{ key: 'mimo.contextWindow', value: 1000000, category: 'mimo' },
// P3: OpenAI / Anthropic Provider
{ key: 'openai.contextWindow', value: 128000, category: 'openai' },
{ key: 'anthropic.contextWindow', value: 200000, category: 'anthropic' },
// Onboarding
{ key: 'onboarding.completed', value: false, category: 'general' },
];
export class DatabaseService {
private db: Database.Database | null = null;
private dbPath: string;
constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
const metonaDir = join(baseDir, '.metona');
// 确保 .metona 目录存在
if (!existsSync(metonaDir)) {
mkdirSync(metonaDir, { recursive: true });
}
this.dbPath = join(metonaDir, 'agent.db');
}
/**
* 初始化数据库(创建表结构)
*/
initialize(): void {
if (this.db) {
log.warn('Database already initialized');
return;
}
log.info(`Initializing database: ${this.dbPath}`);
this.db = new Database(this.dbPath);
// 启用 WAL 模式(更好的并发性能)
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
this.createTables();
this.runMigrations();
this.seedDefaults();
log.info('Database initialized successfully');
}
/**
* 获取数据库实例
*/
getDB(): Database.Database {
if (!this.db) {
throw new Error('Database not initialized. Call initialize() first.');
}
return this.db;
}
/**
* 关闭数据库
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
log.info('Database closed');
}
}
/**
* 创建表结构
*/
private createTables(): void {
const db = this.db!;
db.exec(`
-- ===== 会话表 =====
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '新会话',
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
message_count INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
metadata TEXT DEFAULT '{}'
);
-- ===== 消息表 =====
-- C-6 修复: content 允许 NULL — assistant 消息仅有 tool_calls 时 content 必须为 null
-- @see project_memory.md — Assistant messages with tool_calls must set content to null
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- ===== 配置表 =====
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ===== 审计日志表 =====
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
iteration INTEGER,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT NOT NULL,
details TEXT,
outcome TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
prev_hash TEXT,
current_hash TEXT
);
-- ===== MCP 服务配置表 =====
-- v0.5.0: 建表 CHECK 直接含 streamable-http(与迁移 6 的重建后 schema 对齐,
-- 新库不再依赖迁移 6 立即重建一次表)
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT,
args TEXT,
url TEXT,
headers TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_connected INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ===== 情节记忆表 =====
CREATE TABLE IF NOT EXISTS episodic_memories (
id TEXT PRIMARY KEY,
session_id TEXT,
content TEXT NOT NULL,
summary TEXT,
source TEXT NOT NULL,
importance REAL DEFAULT 0.5,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
expires_at INTEGER
);
-- ===== 语义记忆表 =====
CREATE TABLE IF NOT EXISTS semantic_memories (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
category TEXT,
confidence REAL DEFAULT 0.8,
source_session TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
access_count INTEGER DEFAULT 0
);
-- ===== 工作记忆表 =====
CREATE TABLE IF NOT EXISTS working_memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
UNIQUE(session_id, task_id, key)
);
-- ===== v0.2.0: 任务表 =====
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')),
priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')),
parent_id TEXT,
assigned_to TEXT,
order_idx INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
completed_at INTEGER,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE
);
-- ===== P2: 会话摘要表(分层上下文——超长会话早期消息压缩为摘要,LLM 只加载摘要 + 近期原文) =====
CREATE TABLE IF NOT EXISTS session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ===== 索引 =====
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned DESC, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_logs(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_config_category ON app_config(category);
CREATE INDEX IF NOT EXISTS idx_semantic_key ON semantic_memories(key);
CREATE INDEX IF NOT EXISTS idx_semantic_category ON semantic_memories(category);
CREATE INDEX IF NOT EXISTS idx_episodic_session ON episodic_memories(session_id);
CREATE INDEX IF NOT EXISTS idx_episodic_importance ON episodic_memories(importance DESC);
CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
-- ===== v0.5.0: 消息全文搜索(FTS5 外内容表模式,content 列索引) =====
-- 会话内容搜索通过 sessions:searchContent IPC 使用 MATCH 查询;
-- 触发器保持索引与 messages 表实时同步(INSERT/UPDATE/DELETE
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid
);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
`);
// 审计日志防篡改触发器(INSERT-ONLY
db.exec(`
CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit_logs
BEGIN
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Modification is not allowed.');
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_logs
BEGIN
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
END;
`);
log.info('Database tables created');
}
/**
* 运行数据库迁移
*/
private runMigrations(): void {
const db = this.db!;
// L-6 修复: 提取 tryAddColumn 辅助方法,消除 4 处重复的 try/catch 模式
// L-8 修复: 使用 toErrorMessage 替代重复的 error instanceof Error 三元表达式
const tryAddColumn = (table: string, column: string, type: string) => {
try {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
log.info(`[DB] Migration: added ${column} column to ${table}`);
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = toErrorMessage(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
};
// #33 修复: 整个迁移批次包裹在事务中,保证原子性
// 若某个 migration 部分失败(如 ALTER TABLE 成功,CREATE INDEX 失败),
// 事务回滚,数据库不会处于部分变更的不一致状态,下次启动可安全重试。
// better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。
const runAllMigrations = db.transaction(() => {
// 迁移 1: messages 表添加 attachments 列
tryAddColumn('messages', 'attachments', 'TEXT');
// 迁移 2: audit_logs 表添加 iteration 列
tryAddColumn('audit_logs', 'iteration', 'INTEGER');
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
tryAddColumn('audit_logs', 'prev_hash', 'TEXT');
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
tryAddColumn('audit_logs', 'current_hash', 'TEXT');
// P2: 记忆 TF 缓存列(存储 tokenize 结果,避免每次检索重复分词)
tryAddColumn('episodic_memories', 'tf_cache', 'TEXT');
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
tryAddColumn('working_memories', 'tf_cache', 'TEXT');
// v0.4.1 迁移 6: 重建 mcp_servers 表,transport CHECK 约束放宽以支持 'streamable-http'
// 旧约束 CHECK(transport IN ('stdio','sse')) 会拒绝新传输方式写入
try {
const schemaRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'mcp_servers'")
.get() as { sql: string } | undefined;
// 检测现有 CHECK 约束是否已包含 streamable-http(新表跳过重建)
if (schemaRow && schemaRow.sql && !schemaRow.sql.includes('streamable-http')) {
log.info(
'[DB] Migration: rebuilding mcp_servers table to support streamable-http transport',
);
const rebuildMcpServers = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS mcp_servers_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT,
args TEXT,
url TEXT,
headers TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_connected INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
INSERT INTO mcp_servers_new (id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at)
SELECT id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at
FROM mcp_servers;
DROP TABLE mcp_servers;
ALTER TABLE mcp_servers_new RENAME TO mcp_servers;
`);
});
rebuildMcpServers();
log.info(
'[DB] Migration: mcp_servers table rebuilt successfully (transport now supports streamable-http)',
);
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 6 (mcp_servers transport CHECK) skipped: ${msg}`);
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
}
// v0.5.0 迁移 7: 存量库 FTS 索引回填
// messages_fts 虚表由 createTables 创建(IF NOT EXISTS),但升级到 v0.5.0 的存量库
// 已有消息不会自动进入索引(触发器只覆盖新写入)。行数不一致时执行 rebuild 全量回填。
try {
const ftsCount = db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as {
c: number;
};
const msgCount = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number };
if (ftsCount.c !== msgCount.c) {
log.info(
`[DB] Migration: rebuilding messages_fts index (fts=${ftsCount.c}, messages=${msgCount.c})`,
);
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
log.info('[DB] Migration: messages_fts index rebuilt successfully');
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 7 (messages_fts rebuild) skipped: ${msg}`);
// 非致命 — 索引回填失败仅影响全文搜索结果完整性,不影响消息读写;
// 新写入的消息仍通过触发器正常进入索引
}
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// SQLite 不支持 ALTER COLUMN,需要重建表
try {
// 检测 content 列是否有 NOT NULL 约束
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{
name: string;
notnull: number;
}>;
const contentCol = columns.find((c) => c.name === 'content');
if (contentCol && contentCol.notnull === 1) {
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
// #33 修复: 重建表的多步骤包裹在嵌套事务中,部分失败时回滚
// 避免 CREATE messages_new 成功但 DROP/RENAME 失败导致数据丢失或 schema 不一致
const rebuildMessages = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS messages_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at
FROM messages;
DROP TABLE messages;
ALTER TABLE messages_new RENAME TO messages;
`);
// 重建索引
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
// v0.5.0: DROP TABLE messages 连带删除了 FTS 触发器,此处必须重建
db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
`);
});
rebuildMessages();
// v0.5.0: 表重建后 rowid 全部重排,FTS 索引指向的旧 rowid 已失效 — 无条件全量回填
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
}
} catch (error) {
// L-8 修复: 使用 toErrorMessage 替代重复的三元表达式
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`);
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
}
});
runAllMigrations();
}
/**
* 插入默认配置(P1-13: 从 CONFIG_DEFAULTS 单一来源派生)
*/
private seedDefaults(): void {
const db = this.db!;
const insert = db.prepare(`
INSERT OR IGNORE INTO app_config (key, value, category) VALUES (?, ?, ?)
`);
const insertMany = db.transaction((items: ConfigDefaultEntry[]) => {
for (const item of items) {
insert.run(item.key, JSON.stringify(item.value), item.category);
}
});
insertMany(CONFIG_DEFAULTS);
log.info('Default config seeded');
}
}