Files
metona-ai-desktop/electron/services/database.service.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

720 lines
33 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' },
// 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.tsSecurityScanHook)与 ipc/agent.ts(用户消息检测)消费
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
// v0.7.3 P1-5: 记忆固化节流(consolidation-policy 消费)
{ key: 'memory.consolidationEnabled', value: true, category: 'memory' },
{ key: 'memory.consolidationMinChars', value: 200, category: 'memory' },
{ key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' },
// v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试)
{ key: 'mcp.autoReconnect', value: true, category: 'mcp' },
// 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。首次升级到版本化机制后,
* 版本号相同的库将跳过整个探测式迁移批次。
* v0.7.4 P4-4: 1 → 2 —— 迁移 9messages_fts trigram)纳入版本化,
* 失败中断批次不盖章 → 下次启动重试(根治"失败被吞 + 永久跳过")。
* v0.8.0 P2-1: 2 → 3 —— 迁移 10sessions.deleted_at 回收站软删除列)。
*/
static readonly SCHEMA_VERSION = 3;
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(`
-- ===== 会话表 =====
-- v0.8.0 P2-1: deleted_at 回收站软删除(NULL = 正常;时间戳 = 已删除待清理)
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,
deleted_at INTEGER,
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);
-- v0.8.0 P2-1: idx_sessions_deleted 由迁移 10 统一创建(createTables 的静态
-- CREATE INDEX 在"表存在但缺 deleted_at 列"的遗留库上会 no-such-column 失败)
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
-- v0.7.4 P4-4: tokenizer 升级为 trigram —— 支持任意 ≥3 字符子串匹配
-- (中文非连续子串搜索根治;英文整词/短语仍命中)。实测本 SQLite 构建
-- 不支持 'unicode61 trigram' 多 tokenizer 组合(tokenizer constructor 报错),
-- 故用 trigram 单 tokenizer2 字中文由 searchMessages 的 LIKE 回退兜底。
-- 存量库由迁移 9 检测重建。
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid,
tokenize='trigram'
);
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=====
//
// 既有模式是"幂等探测式迁移":每次启动都跑全套探测 SQLtable_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 会保存空字符串
}
// v0.8.0 P2-1 迁移 10: sessions.deleted_at 回收站软删除列 + 索引
// 存在性守卫:极简遗留库(如 v2 盖章的早期测试库)可能没有 sessions 表,
// 迁移必须可重试且不因缺表中断整个批次
{
const hasSessions = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
if (hasSessions) {
tryAddColumn('sessions', 'deleted_at', 'INTEGER');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_deleted ON sessions(deleted_at);');
}
}
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
// 建表 SQL,不含 'trigram' 即需重建。幂等:重建后 SQL 含 trigram,下次跳过。
try {
const ftsSqlRow = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string } | undefined;
const ftsSql = ftsSqlRow?.sql ?? '';
if (!ftsSql.includes('trigram')) {
log.info('[DB] Migration: rebuilding messages_fts with trigram tokenizer');
// FTS5 外内容表重建:DROP 虚表(触发器保留),重建后由下方 rebuild 回填
db.exec(`
DROP TABLE IF EXISTS messages_fts;
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid,
tokenize='trigram'
);
INSERT INTO messages_fts(messages_fts) VALUES ('rebuild');
`);
log.info('[DB] Migration: messages_fts rebuilt with trigram tokenizer');
}
} catch (error) {
// v0.7.4 P4-4: 迁移 9 失败必须中断批次(抛错 → 不盖章 user_version=2 →
// 下次启动重试)。旧实现 catch 吞错 + 盖章 1 → 中文子串搜索永久失效且无日志。
const msg = toErrorMessage(error);
log.error(`[DB] Migration 9 (messages_fts trigram) failed: ${msg}`);
throw error;
}
});
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');
}
}