|
| 1 | +/** |
| 2 | + * 世界主权 IPC handlers(sovereignty:*,阶段 D 信任问题) |
| 3 | + * World sovereignty IPC handlers (export / restore) |
| 4 | + * |
| 5 | + * @ai-context: 「世界之书」的落盘与恢复。导出=知识图谱摘要 |
| 6 | + * (memoryQueries.queryKnowledgeGraph,摘要级)+ 世界快照(world_snapshots |
| 7 | + * 单行 latest)+ 入籍记录(imports)+ 十张学习表完整行(恢复层), |
| 8 | + * 复用 worldExport 纯函数组装——bundle 不含任何 AI 密钥/网关配置。 |
| 9 | + * 恢复=validateWorldImport Result 校验(版本/结构/节点≤5000/表白名单/ |
| 10 | + * 总行数)→ 按白名单顺序重排 → 整体事务内循环 importTable |
| 11 | + * (INSERT OR REPLACE 幂等,better-sqlite3 嵌套事务自动降级 savepoint, |
| 12 | + * 任一表失败整体回滚)→ FTS 索引重建让搜索立即覆盖恢复内容。 |
| 13 | + * |
| 14 | + * @ai-context: Export bundles the graph summary, world snapshot, settling |
| 15 | + * records and ten whitelisted tables (never any secrets). Restore validates |
| 16 | + * first, then imports in one transaction (idempotent INSERT OR REPLACE), |
| 17 | + * then rebuilds the FTS index. |
| 18 | + */ |
| 19 | +import { dialog } from 'electron'; |
| 20 | +import { writeFile, readFile } from 'fs/promises'; |
| 21 | +import { safeHandle } from './ipcUtils.js'; |
| 22 | +import { logger } from './logger.js'; |
| 23 | +import { getConnection } from './db/sqliteService.js'; |
| 24 | +import { importTable } from './db/migration.js'; |
| 25 | +import { queryKnowledgeGraph } from './mcp/memoryQueries.js'; |
| 26 | +import { collectIndexableData, rebuildIndex } from './db/fts5Search.js'; |
| 27 | +import { |
| 28 | + buildWorldExport, |
| 29 | + validateWorldImport, |
| 30 | + WORLD_TABLE_WHITELIST, |
| 31 | + type WorldTableBundle, |
| 32 | +} from '../src/features/sovereignty/lib/worldExport.js'; |
| 33 | + |
| 34 | +/** 快照单行 id(与 useWorldSnapshotSync 的 SNAPSHOT_ID 对齐) / Snapshot row id */ |
| 35 | +const SNAPSHOT_ID = 'latest'; |
| 36 | +/** 入籍记录导出上限(叙述层防超大响应;恢复层 tables.imports 不受限) */ |
| 37 | +const MAX_RECORDS = 5000; |
| 38 | + |
| 39 | +/** 恢复层表白名单(父表在前,满足外键约束;与 worldExport 白名单同源) */ |
| 40 | +const EXPORT_TABLES = WORLD_TABLE_WHITELIST; |
| 41 | + |
| 42 | +/** 读取单表全量行(表名来自编译期白名单,非用户输入) / Read all rows of a whitelisted table */ |
| 43 | +function readTableRows(db: ReturnType<typeof getConnection>, table: string): Array<Record<string, unknown>> { |
| 44 | + return db.prepare(`SELECT * FROM "${table}"`).all() as Array<Record<string, unknown>>; |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * 注册世界主权 IPC handlers(app ready 后调用一次) |
| 49 | + */ |
| 50 | +export function registerSovereigntyHandlers(): void { |
| 51 | + /** |
| 52 | + * sovereignty:export-world — 导出世界之书到 JSON 文件。 |
| 53 | + * 保存对话框由主进程弹出;导出内容不含任何密钥/配置。 |
| 54 | + */ |
| 55 | + safeHandle('sovereignty:export-world', async () => { |
| 56 | + try { |
| 57 | + const db = getConnection(); |
| 58 | + |
| 59 | + // 叙述层:图谱摘要(摘要级:概念前 60 字 + 档位 + 关联数) |
| 60 | + const graph = queryKnowledgeGraph(db, 100); |
| 61 | + // 叙述层:世界快照(world_snapshots 单行 latest 的 payload 对象) |
| 62 | + const snapshotRow = db.prepare( |
| 63 | + `SELECT id, payload FROM world_snapshots WHERE id = ?`, |
| 64 | + ).get(SNAPSHOT_ID) as { payload: string } | undefined; |
| 65 | + // 叙述层:入籍记录(倒序,上限防超大) |
| 66 | + const records = db.prepare( |
| 67 | + `SELECT id, source, raw_name, concept_count, settled_at |
| 68 | + FROM imports ORDER BY settled_at DESC LIMIT ?`, |
| 69 | + ).all(MAX_RECORDS) as Array<Record<string, unknown>>; |
| 70 | + // 恢复层:十张白名单表完整行(外键顺序) |
| 71 | + const tables: WorldTableBundle[] = EXPORT_TABLES.map((table) => ({ |
| 72 | + table, |
| 73 | + rows: readTableRows(db, table), |
| 74 | + })); |
| 75 | + |
| 76 | + const bundle = buildWorldExport(graph, snapshotRow ?? null, records, { |
| 77 | + exportedAt: new Date().toISOString(), |
| 78 | + tables, |
| 79 | + }); |
| 80 | + |
| 81 | + const defaultName = `entropy-world-${new Date().toISOString().slice(0, 10)}.json`; |
| 82 | + const result = await dialog.showSaveDialog({ |
| 83 | + title: '导出我的世界', |
| 84 | + defaultPath: defaultName, |
| 85 | + filters: [ |
| 86 | + { name: '世界之书', extensions: ['json'] }, |
| 87 | + { name: '所有文件', extensions: ['*'] }, |
| 88 | + ], |
| 89 | + }); |
| 90 | + if (result.canceled || !result.filePath) { |
| 91 | + return { success: false, canceled: true, path: null }; |
| 92 | + } |
| 93 | + |
| 94 | + await writeFile(result.filePath, JSON.stringify(bundle, null, 2), 'utf-8'); |
| 95 | + const rowCount = tables.reduce((s, t) => s + t.rows.length, 0); |
| 96 | + const nodeCount = Array.isArray(bundle.graph.nodes) ? bundle.graph.nodes.length : 0; |
| 97 | + logger.info(`[Sovereignty] World exported to ${result.filePath} (${rowCount} rows, ${nodeCount} nodes)`); |
| 98 | + return { success: true, canceled: false, path: result.filePath }; |
| 99 | + } catch (err) { |
| 100 | + const msg = err instanceof Error ? err.message : String(err); |
| 101 | + logger.error('[Sovereignty] Export failed', err); |
| 102 | + return { success: false, canceled: false, path: null, error: `世界导出失败:${msg}` }; |
| 103 | + } |
| 104 | + }); |
| 105 | + |
| 106 | + /** |
| 107 | + * sovereignty:import-world — 从 JSON 文件恢复世界。 |
| 108 | + * 校验先行(Result 模式),通过后整体事务幂等导入。 |
| 109 | + */ |
| 110 | + safeHandle('sovereignty:import-world', async () => { |
| 111 | + const result = await dialog.showOpenDialog({ |
| 112 | + title: '恢复我的世界', |
| 113 | + filters: [ |
| 114 | + { name: '世界之书', extensions: ['json'] }, |
| 115 | + { name: '所有文件', extensions: ['*'] }, |
| 116 | + ], |
| 117 | + properties: ['openFile'], |
| 118 | + }); |
| 119 | + if (result.canceled || result.filePaths.length === 0) { |
| 120 | + return { success: false, canceled: true }; |
| 121 | + } |
| 122 | + |
| 123 | + try { |
| 124 | + const text = await readFile(result.filePaths[0], 'utf-8'); |
| 125 | + let parsed: unknown; |
| 126 | + try { |
| 127 | + parsed = JSON.parse(text); |
| 128 | + } catch { |
| 129 | + return { success: false, error: '文件不是有效的 JSON,无法恢复' }; |
| 130 | + } |
| 131 | + |
| 132 | + const check = validateWorldImport(parsed); |
| 133 | + if (!check.ok) { |
| 134 | + logger.warn(`[Sovereignty] Import rejected: ${check.error}`); |
| 135 | + return { success: false, error: check.error }; |
| 136 | + } |
| 137 | + |
| 138 | + // 按白名单顺序重排(防御手改文件打乱顺序破坏外键约束) |
| 139 | + const ordered = [...check.bundle.tables].sort( |
| 140 | + (a, b) => EXPORT_TABLES.indexOf(a.table) - EXPORT_TABLES.indexOf(b.table), |
| 141 | + ); |
| 142 | + |
| 143 | + const db = getConnection(); |
| 144 | + let rowsImported = 0; |
| 145 | + // 整体事务:importTable 内部嵌套事务自动降级 savepoint,任一表失败整体回滚 |
| 146 | + const txn = db.transaction((tables: WorldTableBundle[]) => { |
| 147 | + for (const t of tables) { |
| 148 | + rowsImported += importTable(t.table, t.rows); |
| 149 | + } |
| 150 | + }); |
| 151 | + txn(ordered); |
| 152 | + |
| 153 | + // FTS 索引重建:让搜索立即覆盖恢复的笔记/卡片 |
| 154 | + try { |
| 155 | + rebuildIndex(collectIndexableData(db)); |
| 156 | + } catch (ftsErr) { |
| 157 | + logger.warn(`[Sovereignty] FTS rebuild after restore failed: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`); |
| 158 | + } |
| 159 | + |
| 160 | + logger.info(`[Sovereignty] World restored from ${result.filePaths[0]} (${rowsImported} rows)`); |
| 161 | + return { |
| 162 | + success: true, |
| 163 | + rowsImported, |
| 164 | + tables: ordered.map((t) => ({ table: t.table, rows: t.rows.length })), |
| 165 | + }; |
| 166 | + } catch (err) { |
| 167 | + const msg = err instanceof Error ? err.message : String(err); |
| 168 | + logger.error('[Sovereignty] Import failed', err); |
| 169 | + return { success: false, error: `世界恢复失败:${msg}` }; |
| 170 | + } |
| 171 | + }); |
| 172 | +} |
0 commit comments