diff --git a/scripts/delete-company-from-graph-db.js b/scripts/delete-company-from-graph-db.js new file mode 100644 index 0000000..2539aff --- /dev/null +++ b/scripts/delete-company-from-graph-db.js @@ -0,0 +1,114 @@ +#!/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);