/** * 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); } /** * v0.8.1: 已废除的配置键(单一来源 —— 迁移 12 与全局配置层清理共用)。 * 分 Provider contextWindow 与 ollama.numCtx 由全局 llm.contextWindow 取代。 */ export const DEPRECATED_CONFIG_KEYS = [ 'ollama.numCtx', 'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow', 'openai.contextWindow', 'anthropic.contextWindow', ] as const; /** 配置默认值条目(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' }, // v0.8.0 FEAT-1 / v0.8.1: 最大输出上限 —— 唯一合法的输出上限配置(全局,跨 Provider) { key: 'llm.maxTokens', value: 63488, category: 'llm' }, // v0.8.1: 上下文长度 —— 唯一合法的上下文窗口配置(全局,跨 Provider/模型; // 驱动引擎压缩预算、前端占用指示,Ollama 场景同时作为 num_ctx 下发)。 // 分 Provider 的 deepseek/agnes/mimo/openai/anthropic.contextWindow 与 ollama.numCtx // 已废除(迁移 12 清理遗留键)。 { key: 'llm.contextWindow', value: 131072, 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' }, // 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.8.1 P1-1: 本地向量记忆嵌入模型(Ollama embedding 模型名,空 = 向量检索关闭, // 仅 TF-IDF)。唯一合法来源是设置面板 —— 代码中不存在默认模型名。 { key: 'memory.embeddingModel', value: '', 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' }, // v0.8.1: Ollama numCtx 独立配置已废除(与「上下文长度」合并为 llm.contextWindow); // Provider 上下文窗口分键配置已废除 —— 遗留键由迁移 12 从存量库清理。 // 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 —— 迁移 9(messages_fts trigram)纳入版本化, * 失败中断批次不盖章 → 下次启动重试(根治"失败被吞 + 永久跳过")。 * v0.8.0 P2-1: 2 → 3 —— 迁移 10(sessions.deleted_at 回收站软删除列)。 * v0.8.1: 3 → 4 —— 迁移 11(记忆表 embedding 列,本地向量混合检索)+ * 迁移 12(清理已废除的 llm.contextWindow 分 Provider 键与 ollama.numCtx)。 * v0.8.1 review: 4 → 5 —— 迁移 13(working_memories 补 sessions 外键 CASCADE, * 孤儿行清理;根治历史 schema 缺失级联导致的孤儿数据)。 */ static readonly SCHEMA_VERSION = 5; 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 ); -- ===== 工作记忆表 ===== -- v0.8.1 review: 补 sessions 外键(CASCADE)—— 历史建表缺失级联 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), tf_cache TEXT, UNIQUE(session_id, task_id, key), FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); -- ===== 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 单 tokenizer;2 字中文由 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)===== // // 既有模式是"幂等探测式迁移":每次启动都跑全套探测 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 会保存空字符串 } // 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.8.1 P1-1 迁移 11: 记忆表 embedding 列(本地向量混合检索)。 // 存量记忆行为 NULL = 未向量化(检索时回退 TF-IDF 路径),新写入由 // MemoryEmbedder 异步回填;向量维度由用户配置的 embedding 模型决定, // 故用 BLOB 存 Float32Array 而非固定宽度 F32Blob 列。 tryAddColumn('episodic_memories', 'embedding', 'BLOB'); tryAddColumn('semantic_memories', 'embedding', 'BLOB'); // v0.8.1 迁移 12: 清理已废除的上下文窗口配置键(全局 llm.contextWindow 取代)。 // 旧键残留会使设置面板与引擎出现双源语义,启动迁移一次性移除(幂等)。 { const placeholders = DEPRECATED_CONFIG_KEYS.map(() => '?').join(', '); const result = db .prepare(`DELETE FROM app_config WHERE key IN (${placeholders})`) .run(...DEPRECATED_CONFIG_KEYS); if (result.changes > 0) { log.info( `[DB] Migration: removed ${result.changes} deprecated context-window config key(s) (replaced by llm.contextWindow)`, ); } } // v0.8.1 review 迁移 13: working_memories 补 sessions 外键(ON DELETE CASCADE) // 历史缺陷:该表自 v0.2.0 建表起就没有 sessions 外键 —— 会话删除/清空后 // working 行成为孤儿(行为面已由 0.8.1 终态清理接线覆盖,此处根治 schema)。 // 先清理孤儿行(早于 0.8.1 的历史删除遗留),再以 FK 重建。 { const hasWorking = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='working_memories'") .get(); // hasSessions 由迁移 10 块内查询(块作用域),此处独立复查 const hasSessions = !!db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'") .get(); const fkRows = hasWorking ? (db.prepare('PRAGMA foreign_key_list(working_memories)').all() as Array<{ table: string; }>) : []; const hasSessionsFk = fkRows.some((fk) => fk.table === 'sessions'); if (hasWorking && hasSessions && !hasSessionsFk) { log.info('[DB] Migration: rebuilding working_memories with sessions FK (CASCADE)'); const rebuildWorking = db.transaction(() => { db.exec(` DELETE FROM working_memories WHERE session_id NOT IN (SELECT id FROM sessions); CREATE TABLE working_memories_new ( 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), tf_cache TEXT, UNIQUE(session_id, task_id, key), FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); INSERT INTO working_memories_new (id, session_id, task_id, key, value, updated_at, tf_cache) SELECT id, session_id, task_id, key, value, updated_at, tf_cache FROM working_memories; DROP TABLE working_memories; ALTER TABLE working_memories_new RENAME TO working_memories; CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id); `); }); rebuildWorking(); log.info('[DB] Migration: working_memories rebuilt with sessions FK'); } } // 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'); } }