#!/usr/bin/env node /** * delete-company-from-graph-db.js — 彻底删掉一家公司在 graph.sqlite 里的全部行 * * 跟 scripts/sync-company-to-graph-db.js 是一对:那个脚本负责把公司的行搬进另一个库, * 这个脚本负责把公司的行从一个库里删干净。用于清理僵尸/废弃公司——比如 * `data/companies/{cid}/company.json` 已经不存在(company-service.list() 靠这个文件 * 判断"这是不是一家公司",文件没了这家公司在界面上永远不可见,但 graph.sqlite 里的行、 * `data/companies/{cid}/` 目录下的残留文件不会跟着自动消失)。 * * 只管 graph.sqlite 里的行,不碰 data/companies/{cid}/ 目录——目录是普通文件, * 删之前自己看一眼里面还有没有想留的东西,确认没有再 `rm -rf`。 * * 删除顺序是插入顺序的镜像(先删子表,最后删 system_agents,因为前面所有表都有外键指它): * graph_runs.parent_node_run_id 先置 NULL(打破跟 graph_run_nodes 的循环外键) * → graph_edge_contracts → graph_run_loop_records → graph_run_nodes → graph_runs * → graph_files → work_graph_edges → work_graph_nodes → work_graphs * → org_graph_edges → org_graph_nodes → company_agents → tasks → goals * → system_agents(只删 owner_company_id = 这家公司、且已经没有任何 org/work 节点或雇佣关系 * 引用的行——像 org-coder / org-planner 这类按角色名生成 id 的本体,实测在本仓库的存量 * 数据里被多家公司共用同一行,owner_company_id 只反映最后一次雇它的是谁,见 2026-08-28 * 删 mycompany 时的教训。公共模板 scope='public' 的行本来就不会被这条 WHERE 命中) * * 用法: * node scripts/delete-company-from-graph-db.js --db data/graph.sqlite --company mycompany [--dry-run] * * --dry-run 整个删除跑在一个事务里,最后主动抛出回滚,不留任何改动;控制台照常打印各表行数。 */ const Database = require('better-sqlite3'); function parseArgs(argv) { const out = { dryRun: argv.includes('--dry-run') }; for (const flag of ['--db', '--company']) { const i = argv.indexOf(flag); if (i !== -1) out[flag.slice(2)] = argv[i + 1]; } return out; } const { db: dbPath, company, dryRun } = parseArgs(process.argv.slice(2)); if (!dbPath || !company) { console.error('usage: node scripts/delete-company-from-graph-db.js --db --company [--dry-run]'); process.exit(1); } const db = new Database(dbPath); db.pragma('foreign_keys = ON'); const log = (...a) => console.log(dryRun ? '[dry-run]' : '[delete]', ...a); function del(table, whereSql, params = [company]) { const info = db.prepare(`DELETE FROM ${table} ${whereSql}`).run(...params); log(`${table}: ${info.changes} rows`); } const tx = db.transaction(() => { // 打破 graph_runs ⇄ graph_run_nodes 的循环外键,否则删 graph_run_nodes 时会被 // 还指着它的 graph_runs.parent_node_run_id 拦住。 db.prepare(`UPDATE graph_runs SET parent_node_run_id = NULL WHERE company_id = ?`).run(company); del('graph_edge_contracts', `WHERE edge_layer = 'org' AND edge_id IN (SELECT CAST(id AS TEXT) FROM org_graph_edges WHERE company_id = ?)`); del('graph_edge_contracts', `WHERE edge_layer = 'work' AND edge_id IN ( SELECT id FROM work_graph_edges WHERE work_graph_id IN (SELECT id FROM work_graphs WHERE company_id = ?) )`); del('graph_run_loop_records', `WHERE run_id IN (SELECT id FROM graph_runs WHERE company_id = ?)`); del('graph_run_nodes', `WHERE run_id IN (SELECT id FROM graph_runs WHERE company_id = ?)`); del('graph_runs', `WHERE company_id = ?`); del('graph_files', `WHERE company_id = ?`); del('work_graph_edges', `WHERE work_graph_id IN (SELECT id FROM work_graphs WHERE company_id = ?)`); del('work_graph_nodes', `WHERE work_graph_id IN (SELECT id FROM work_graphs WHERE company_id = ?)`); del('work_graphs', `WHERE company_id = ?`); del('org_graph_edges', `WHERE company_id = ?`); del('org_graph_nodes', `WHERE company_id = ?`); del('company_agents', `WHERE company_id = ?`); del('tasks', `WHERE company_id = ?`); del('goals', `WHERE company_id = ?`); // system_agents 的 id 有时被多家公司共用同一行(角色名撞了就共用,见文件头注释更新)—— // owner_company_id 只反映"最后一次雇它的是谁",不代表没有别的公司还在用同一个 id。 // 只删这家公司名下、且已经没有任何 org/work 节点或雇佣关系再引用的行,避免删出悬空外键。 del('system_agents', ` WHERE owner_company_id = ? AND id NOT IN (SELECT system_agent_id FROM org_graph_nodes) AND id NOT IN (SELECT system_agent_id FROM work_graph_nodes) AND id NOT IN (SELECT system_agent_id FROM company_agents) `); const fkErrors = db.pragma('foreign_key_check'); if (fkErrors.length) { throw new Error('foreign_key_check failed: ' + JSON.stringify(fkErrors)); } if (dryRun) throw new Error('__DRY_RUN_ROLLBACK__'); }); let failed = false; try { tx(); console.log('delete complete'); } catch (e) { if (e.message === '__DRY_RUN_ROLLBACK__') { log('rolled back (dry run) — no rows removed, foreign_key_check passed'); console.log('dry run complete, no changes persisted'); } else { console.error(e.message); failed = true; } } finally { db.close(); } if (failed) process.exit(1);