P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
655 lines
29 KiB
TypeScript
655 lines
29 KiB
TypeScript
/**
|
||
* 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' },
|
||
// F-8 接通: temperature/maxTokens 此前为死配置(引擎硬编码),现由 main.ts 注入引擎
|
||
{ key: 'llm.temperature', value: 0, category: 'llm' },
|
||
{ key: 'llm.maxTokens', value: 63488, category: 'llm' },
|
||
// v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭,
|
||
// 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断)
|
||
{ key: 'llm.multimodalEnabled', value: false, 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' },
|
||
|
||
// 安全配置
|
||
// F-8 清理: 移除死配置 security.requireWriteConfirmation / security.maxFileWriteSizeKB
|
||
// (无任何消费者 —— 确认策略由工具定义的 requiresPermission/riskLevel 驱动,
|
||
// 文件大小上限由 file-guard.ts 的 MAX_FILE_SIZE_BYTES 常量控制)
|
||
// F-8 接通: promptInjectionDefense 由 main.ts(SecurityScanHook)与 ipc/agent.ts(用户消息检测)消费
|
||
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
|
||
|
||
// UI 配置
|
||
// F-8 清理: 移除死配置 ui.fontSize / ui.animationMode(无消费者;主题走 localStorage)
|
||
{ key: 'ui.theme', value: 'auto', category: 'ui' },
|
||
|
||
// 日志配置
|
||
// F-8 接通: auditEnabled/traceEnabled 由 main.ts 构建 hooks 与 SessionRecorder 时消费
|
||
{ 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;
|
||
|
||
/**
|
||
* v0.6.4 P3-3: 当前 schema 版本号(PRAGMA user_version 目标值)。
|
||
* 每次在 runMigrations 中新增一个迁移时 +1。首次升级到版本化机制后,
|
||
* 版本号相同的库将跳过整个探测式迁移批次。
|
||
*/
|
||
static readonly SCHEMA_VERSION = 1;
|
||
|
||
|
||
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 只加载摘要 + 近期原文) =====
|
||
-- F-3 修复: 补 FOREIGN KEY ON DELETE CASCADE —— 此前无级联,删除会话后摘要残留,
|
||
-- 随使用无限累积(messages/tasks 均有级联,唯独此表遗漏)
|
||
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),
|
||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- ===== 索引 =====
|
||
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!;
|
||
|
||
// ===== v0.6.4 P3-3: schema 版本化(PRAGMA user_version)=====
|
||
//
|
||
// 既有模式是"幂等探测式迁移":每次启动都跑全套探测 SQL(table_info、
|
||
// foreign_key_list、sqlite_master 匹配等)。在当前体量下可用,但存在两个
|
||
// 越来越脆的问题:(1) 启动耗时随迁移数量线性增长;(2) 探测语句之间存在
|
||
// 隐式次序耦合(如迁移 5 的无条件 FTS rebuild 依赖虚拟表已建)。
|
||
//
|
||
// 版本化策略(保留兼容,不破坏任何存量库):
|
||
// - SCHEMA_VERSION 每新增一个迁移 +1;
|
||
// - 存量库首次启动 user_version=0 < SCHEMA_VERSION → 完整跑一遍幂等批次
|
||
// (各迁移本身安全),成功后盖章版本号;
|
||
// - 已盖章的库 → 直接跳过整个探测批次的执行;
|
||
// - 回滚到旧版应用不会降级数据(旧代码不读 user_version,仍走幂等路径)。
|
||
const currentVersion =
|
||
typeof db.pragma('user_version', { simple: true }) === 'number'
|
||
? (db.pragma('user_version', { simple: true }) as number)
|
||
: 0;
|
||
|
||
if (currentVersion >= DatabaseService.SCHEMA_VERSION) {
|
||
log.info(
|
||
`[DB] Schema up to date (user_version=${currentVersion}, target=${DatabaseService.SCHEMA_VERSION}) — skipping migration probe batch`,
|
||
);
|
||
return;
|
||
}
|
||
log.info(
|
||
`[DB] Running schema migrations (user_version ${currentVersion} → ${DatabaseService.SCHEMA_VERSION})`,
|
||
);
|
||
|
||
// 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}`);
|
||
// 非致命 — 索引回填失败仅影响全文搜索结果完整性,不影响消息读写;
|
||
// 新写入的消息仍通过触发器正常进入索引
|
||
}
|
||
|
||
// F-3 迁移 8: 重建 session_summaries 表,补 FOREIGN KEY ON DELETE CASCADE
|
||
// 此前该表无级联删除 —— 删除会话(sessions:delete / data:clearSessions)后
|
||
// 摘要记录永久残留,随使用无限累积。SQLite 不支持 ALTER ADD CONSTRAINT,需重建表。
|
||
// 幂等:PRAGMA foreign_key_list 检测已有级联则跳过。
|
||
try {
|
||
const fkRows = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
|
||
table: string;
|
||
on_delete: string;
|
||
}>;
|
||
const hasCascade = fkRows.some(
|
||
(fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE',
|
||
);
|
||
if (!hasCascade) {
|
||
log.info('[DB] Migration: rebuilding session_summaries table to add ON DELETE CASCADE');
|
||
const rebuildSummaries = db.transaction(() => {
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS session_summaries_new (
|
||
session_id TEXT PRIMARY KEY,
|
||
summary TEXT NOT NULL,
|
||
summarized_until_rowid INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||
);
|
||
|
||
INSERT INTO session_summaries_new (session_id, summary, summarized_until_rowid, updated_at)
|
||
SELECT session_id, summary, summarized_until_rowid, updated_at
|
||
FROM session_summaries;
|
||
|
||
DROP TABLE session_summaries;
|
||
ALTER TABLE session_summaries_new RENAME TO session_summaries;
|
||
`);
|
||
});
|
||
rebuildSummaries();
|
||
log.info(
|
||
'[DB] Migration: session_summaries table rebuilt successfully (ON DELETE CASCADE added)',
|
||
);
|
||
}
|
||
} catch (error) {
|
||
const msg = toErrorMessage(error);
|
||
log.warn(`[DB] Migration 8 (session_summaries CASCADE) 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();
|
||
|
||
// v0.6.4 P3-3: 迁移成功后盖章 user_version —— 后续启动走快速路径,
|
||
// 不再每次执行全套 PRAGMA 探测 SQL。
|
||
db.pragma(`user_version = ${DatabaseService.SCHEMA_VERSION}`);
|
||
}
|
||
|
||
/**
|
||
* 插入默认配置(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');
|
||
}
|
||
}
|