P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
/**
|
||
* 数据库 schema 版本化测试(v0.6.4 P3-3)
|
||
*
|
||
* 契约:
|
||
* 1. 全新库 initialize() 后 PRAGMA user_version 盖章为 DatabaseService.SCHEMA_VERSION;
|
||
* 2. 已盖章的库再次 initialize(新实例、同文件)→ 走快速路径,探测批次跳过,
|
||
* 且版本号保持不变;
|
||
* 3. user_version 与数据共存:盖章不会丢失/改变已有数据。
|
||
*
|
||
* 运行要求:better-sqlite3 需为 Electron ABI 构建(test:electron 模式),
|
||
* 系统 Node 下自动跳过。
|
||
*/
|
||
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
|
||
let dbAvailable = true;
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const probe = require('better-sqlite3');
|
||
const p = new probe(':memory:');
|
||
p.close();
|
||
} catch {
|
||
dbAvailable = false;
|
||
}
|
||
|
||
import { DatabaseService } from '../database.service';
|
||
|
||
/** 预置 legacy 库:先建 .metona 目录再开库(与 DatabaseService 目录布局一致) */
|
||
function openLegacyDb(dir: string): any {
|
||
const metonaDir = join(dir, '.metona');
|
||
mkdirSync(metonaDir, { recursive: true });
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const raw = require('better-sqlite3');
|
||
return new raw(join(metonaDir, 'agent.db'));
|
||
}
|
||
|
||
describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化', () => {
|
||
let dir: string;
|
||
let svc1: DatabaseService;
|
||
let svc2: DatabaseService;
|
||
|
||
beforeAll(async () => {
|
||
dir = mkdtempSync(join(tmpdir(), 'metona-dbmig-'));
|
||
const mod = await import('../database.service');
|
||
const Svc = mod.DatabaseService;
|
||
svc1 = new Svc(dir);
|
||
svc1.initialize();
|
||
svc2 = new Svc(dir); // 模拟第二次启动
|
||
svc2.initialize();
|
||
});
|
||
|
||
afterAll(() => {
|
||
try {
|
||
svc2?.close();
|
||
svc1?.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('initialize 后 user_version 已盖章为 SCHEMA_VERSION', () => {
|
||
const version = svc2.getDB().pragma('user_version', { simple: true }) as number;
|
||
// v0.7.4 P4-4: SCHEMA_VERSION 1 → 2(迁移 9 trigram 纳入版本化)
|
||
expect(version).toBe(DatabaseService.SCHEMA_VERSION);
|
||
});
|
||
|
||
it('盖章库的探测批次被跳过(重复 initialize 不报错且配置 seed 保持存在)', () => {
|
||
// 核心断言在日志侧难以捕获 —— 这里验证幂等性的可观察结果:
|
||
// 种子默认值仍然存在(INSERT OR IGNORE),表结构与首次一致
|
||
const row = svc2.getDB().prepare(`SELECT COUNT(*) AS n FROM app_config`).get() as { n: number };
|
||
expect(row.n).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('DatabaseService — 全量表结构与索引', () => {
|
||
let dir: string;
|
||
let svc: DatabaseService;
|
||
let db: any;
|
||
|
||
beforeAll(async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
dir = mkdtempSync(join(tmpdir(), 'metona-migtables-'));
|
||
svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
db = svc.getDB();
|
||
});
|
||
|
||
afterAll(() => {
|
||
try {
|
||
svc?.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('messages 表包含 FTS 所需的全部列(content 可 NULL + attachments + reasoning_content)', () => {
|
||
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||
name: string;
|
||
notnull: number;
|
||
}>;
|
||
const names = cols.map((c) => c.name);
|
||
for (const expected of [
|
||
'id',
|
||
'session_id',
|
||
'role',
|
||
'content',
|
||
'reasoning_content',
|
||
'tool_calls',
|
||
'tool_result',
|
||
'attachments',
|
||
'iteration',
|
||
'created_at',
|
||
]) {
|
||
expect(names).toContain(expected);
|
||
}
|
||
const contentCol = cols.find((c) => c.name === 'content');
|
||
expect(contentCol!.notnull).toBe(0); // C-6: content 允许 NULL
|
||
});
|
||
|
||
it('messages_fts 虚表使用 trigram tokenizer(迁移 9 目标 schema)', () => {
|
||
const sqlRow = db
|
||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||
.get() as { sql: string };
|
||
expect(sqlRow.sql).toContain('trigram');
|
||
});
|
||
|
||
it('FTS 触发器存活:insert/delete/update 三个触发器', () => {
|
||
const triggers = db
|
||
.prepare(
|
||
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='messages' ORDER BY name`,
|
||
)
|
||
.all() as Array<{ name: string }>;
|
||
const names = triggers.map((t) => t.name);
|
||
expect(names).toContain('messages_fts_insert');
|
||
expect(names).toContain('messages_fts_delete');
|
||
expect(names).toContain('messages_fts_update');
|
||
});
|
||
|
||
it('FTS 实时同步:插入消息后索引可见、删除后索引同步', () => {
|
||
db.prepare(
|
||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('mig_s1', 's', 1, 1)`,
|
||
).run();
|
||
db.prepare(
|
||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('mig_m1', 'mig_s1', 'user', 'migftshit 内容', 1)`,
|
||
).run();
|
||
const hit = db
|
||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
|
||
.get() as { c: number };
|
||
expect(hit.c).toBe(1);
|
||
db.prepare(`DELETE FROM messages WHERE id = 'mig_m1'`).run();
|
||
const miss = db
|
||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
|
||
.get() as { c: number };
|
||
expect(miss.c).toBe(0);
|
||
});
|
||
|
||
it('audit_logs 的 INSERT-ONLY 触发器存活(audit_no_update / audit_no_delete)', () => {
|
||
const triggers = db
|
||
.prepare(
|
||
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='audit_logs' ORDER BY name`,
|
||
)
|
||
.all() as Array<{ name: string }>;
|
||
const names = triggers.map((t) => t.name);
|
||
expect(names).toContain('audit_no_update');
|
||
expect(names).toContain('audit_no_delete');
|
||
// 更新被拒绝
|
||
db.prepare(
|
||
`INSERT INTO audit_logs (session_id, event_type, actor, target, created_at) VALUES ('s', 'error', 'agent', 't', 1)`,
|
||
).run();
|
||
expect(() =>
|
||
db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE event_type = 'error'`).run(),
|
||
).toThrow();
|
||
});
|
||
|
||
it('episodic/semantic/working 记忆表均含 tf_cache 列(P2-12)', () => {
|
||
for (const table of ['episodic_memories', 'semantic_memories', 'working_memories']) {
|
||
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||
expect(cols.map((c) => c.name)).toContain('tf_cache');
|
||
}
|
||
});
|
||
|
||
it('session_summaries 外键为 ON DELETE CASCADE(迁移 8)', () => {
|
||
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
|
||
table: string;
|
||
on_delete: string;
|
||
}>;
|
||
expect(fks.some((fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE')).toBe(true);
|
||
});
|
||
|
||
it('mcp_servers transport CHECK 含 streamable-http(迁移 6)', () => {
|
||
const sqlRow = db
|
||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_servers'`)
|
||
.get() as { sql: string };
|
||
expect(sqlRow.sql).toContain('streamable-http');
|
||
});
|
||
|
||
it('CONFIG_DEFAULTS 全部 seed 入库(每个默认 key 一行)', async () => {
|
||
const { CONFIG_DEFAULTS } = await import('../database.service');
|
||
const total = db.prepare('SELECT COUNT(*) AS c FROM app_config').get() as { c: number };
|
||
expect(total.c).toBeGreaterThanOrEqual(CONFIG_DEFAULTS.length);
|
||
// 抽查默认 key 存在
|
||
const keys = db.prepare('SELECT key FROM app_config').all() as Array<{ key: string }>;
|
||
const keySet = new Set(keys.map((k) => k.key));
|
||
for (const d of CONFIG_DEFAULTS) {
|
||
expect(keySet.has(d.key)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('索引覆盖常用查询路径(idx_messages_session 复合索引存在)', () => {
|
||
const idx = db.prepare(`PRAGMA index_list(messages)`).all() as Array<{ name: string }>;
|
||
expect(idx.map((i) => i.name)).toContain('idx_messages_session');
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_version 0/1/2)', () => {
|
||
it('user_version=0 的旧库:初始化后完整迁移并盖章为 SCHEMA_VERSION', async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv0-'));
|
||
// 预置一个"旧版"库:只有消息表 + 非 trigram FTS(模拟迁移 9 之前)
|
||
const legacy = openLegacyDb(dir);
|
||
legacy.exec(`
|
||
CREATE TABLE messages (
|
||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||
);
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||
`);
|
||
legacy.pragma('user_version = 0');
|
||
legacy.close();
|
||
|
||
const svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
const db = svc.getDB();
|
||
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
|
||
// 迁移 9:FTS 重建为 trigram
|
||
const ftsSql = db
|
||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||
.get() as { sql: string };
|
||
expect(ftsSql.sql).toContain('trigram');
|
||
// 迁移 5:messages content 允许 NULL
|
||
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||
notnull: number;
|
||
name: string;
|
||
}>;
|
||
const contentCol = cols.find((c) => c.name === 'content');
|
||
expect(contentCol!.notnull).toBe(0);
|
||
svc.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('user_version=1 的库(迁移 9 之前盖章):重新初始化补齐 trigram', async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv1-'));
|
||
const legacy = openLegacyDb(dir);
|
||
legacy.exec(`
|
||
CREATE TABLE messages (
|
||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||
);
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||
`);
|
||
legacy.pragma('user_version = 1'); // 旧版本盖章但缺迁移 9
|
||
legacy.close();
|
||
|
||
const svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
const db = svc.getDB();
|
||
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
|
||
const ftsSql = db
|
||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||
.get() as { sql: string };
|
||
expect(ftsSql.sql).toContain('trigram');
|
||
svc.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('user_version=2 的库:快速路径跳过迁移,版本号保持不变', async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
|
||
const legacy = openLegacyDb(dir);
|
||
legacy.exec(`
|
||
CREATE TABLE messages (
|
||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
content TEXT, created_at INTEGER NOT NULL
|
||
);
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram');
|
||
`);
|
||
legacy.pragma('user_version = 2');
|
||
legacy.close();
|
||
|
||
const svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
const db = svc.getDB();
|
||
expect(db.pragma('user_version', { simple: true })).toBe(2);
|
||
// 快速路径不重建 FTS(保持原有 trigram 表)
|
||
const ftsSql = db
|
||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||
.get() as { sql: string };
|
||
expect(ftsSql.sql).toContain('trigram');
|
||
svc.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('迁移 9 幂等:重复 initialize 不重复重建(SQL 始终为 trigram 单份定义)', async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
const dir = mkdtempSync(join(tmpdir(), 'metona-migidem-'));
|
||
const svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
svc.close();
|
||
const svc2 = new DatabaseService(dir);
|
||
svc2.initialize();
|
||
const db = svc2.getDB();
|
||
const ftsRows = db
|
||
.prepare(`SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||
.get() as { c: number };
|
||
expect(ftsRows.c).toBe(1);
|
||
const sqlRow = db
|
||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||
.get() as { sql: string };
|
||
expect(sqlRow.sql).toContain('trigram');
|
||
svc2.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('迁移 7 FTS 回填:已有消息在 initialize 后进入索引(rebuild 语义)', async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
const dir = mkdtempSync(join(tmpdir(), 'metona-migbackfill-'));
|
||
const legacy = openLegacyDb(dir);
|
||
// 旧库:messages 有数据但 messages_fts 空(无回填)
|
||
legacy.exec(`
|
||
CREATE TABLE messages (
|
||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0,
|
||
pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}'
|
||
);
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||
`);
|
||
legacy.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s1', 1, 1)`).run();
|
||
legacy
|
||
.prepare(
|
||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('m1', 's1', 'user', 'backfillkeyword 数据', 1)`,
|
||
)
|
||
.run();
|
||
legacy.close();
|
||
|
||
const svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
const db = svc.getDB();
|
||
// 迁移 9 重建 + rebuild 后,存量消息应可被搜索
|
||
const hit = db
|
||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'backfillkeyw'`)
|
||
.get() as { c: number };
|
||
expect(hit.c).toBe(1);
|
||
svc.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('DatabaseService — 种子配置与连接语义', () => {
|
||
let dir: string;
|
||
let svc: DatabaseService;
|
||
|
||
beforeAll(async () => {
|
||
const { DatabaseService } = await import('../database.service');
|
||
dir = mkdtempSync(join(tmpdir(), 'metona-migseed-'));
|
||
svc = new DatabaseService(dir);
|
||
svc.initialize();
|
||
});
|
||
|
||
afterAll(() => {
|
||
try {
|
||
svc?.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('敏感默认 key 以明文空串 seed(新用户未配置),category 正确', () => {
|
||
const row = svc
|
||
.getDB()
|
||
.prepare(`SELECT value, category FROM app_config WHERE key = 'llm.apiKey'`)
|
||
.get() as { value: string; category: string };
|
||
expect(JSON.parse(row.value)).toBe('');
|
||
expect(row.category).toBe('llm');
|
||
});
|
||
|
||
it('数字/布尔默认值 seed 为 JSON 类型', () => {
|
||
const row = svc
|
||
.getDB()
|
||
.prepare(`SELECT value FROM app_config WHERE key = 'agent.maxIterations'`)
|
||
.get() as { value: string };
|
||
expect(JSON.parse(row.value)).toBe(20);
|
||
});
|
||
|
||
it('WAL 模式已开启(journal_mode=wal)', () => {
|
||
const mode = svc.getDB().pragma('journal_mode', { simple: true }) as string;
|
||
expect(mode).toBe('wal');
|
||
});
|
||
});
|