#!/usr/bin/env node /** * sync-company-to-graph-db.js — 把一批公司在 graph.sqlite 里的全部行,从一个库合并进另一个库 * * 背景: 2026-08-28 把本地 mycompany / innovator-fde-trial 同步到生产时手搓过一次这个逻辑, * 踩了两个坑,见 docs/done/production-deploy.md「跨环境同步公司数据」一节。这个脚本是那次 * 手搓版本的正式化,以后再有「把某几家公司搬到另一个 graph.sqlite」的需求,直接用它, * 不要重新手写 SELECT * 的合并逻辑。 * * 两个坑,这个脚本已经绕开: * 1. 列序不能信。本地库和生产库的表结构字段名一样,但物理列序可能不同——同一张表, * 有的库是老版本 CREATE TABLE 之后靠 ALTER TABLE 一路加列,有的库是某次重建/清空后 * 吃到了当时最新的 CREATE TABLE 字面量,两边列的物理顺序会对不上。SELECT * 位置对齐 * 的 INSERT 会静默把值写进错误的列(NOT NULL 约束能接住一部分,其余的没有任何报错)。 * 这里一律按列名显式对齐(用 PRAGMA table_info 在运行时取目标库的列名)。 * 2. graph_runs.parent_node_run_id ⇄ graph_run_nodes.run_id 是循环外键。先插 graph_runs * (parent_node_run_id 强制置 NULL),再插 graph_run_nodes,最后回填 parent_node_run_id。 * 回填那条 UPDATE 的关联子查询**必须**给内外表都加别名——用裸表名的话 SQLite 会把子查询 * 里的列解析错关联对象,静默回填成 NULL,不报错、不报变更数异常(changes 照样是「成功」)。 * * 用法: * node scripts/sync-company-to-graph-db.js \ * --source data/graph.sqlite \ * --target /path/to/other/graph.sqlite \ * --companies mycompany,innovator-fde-trial \ * [--dry-run] * * --dry-run 整个合并跑在一个事务里,最后主动抛出回滚,不留任何行;控制台照常打印各表行数。 * * 表顺序即依赖顺序(父表先于子表插入): * system_agents(公司私有的) → company_agents → org_graph_nodes → org_graph_edges * → work_graphs → work_graph_nodes → work_graph_edges → graph_runs → graph_run_nodes * → graph_run_loop_records → graph_files → graph_edge_contracts → goals → tasks * * 不做的事: 不搬 data/companies/{cid}/ 下的工作文件(memory / conversations / workspace)—— * 那部分是普通文件,直接 rsync 就行,不需要脚本。这个脚本只管 graph.sqlite 里的行。 */ const Database = require('better-sqlite3'); function parseArgs(argv) { const out = { dryRun: argv.includes('--dry-run') }; for (const flag of ['--source', '--target', '--companies']) { const i = argv.indexOf(flag); if (i !== -1) out[flag.slice(2)] = argv[i + 1]; } return out; } const { source, target, companies, dryRun } = parseArgs(process.argv.slice(2)); if (!source || !target || !companies) { console.error( 'usage: node scripts/sync-company-to-graph-db.js --source --target --companies id1,id2 [--dry-run]' ); process.exit(1); } const COMPANIES = companies.split(',').map((s) => s.trim()).filter(Boolean); const ph = COMPANIES.map(() => '?').join(','); const db = new Database(target); db.pragma('foreign_keys = ON'); db.exec(`ATTACH DATABASE '${source}' AS src`); // 列名一律从目标库运行时读取(PRAGMA table_info),不写死顺序——见文件头坑 1。 const targetCols = (table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name); const log = (...a) => console.log(dryRun ? '[dry-run]' : '[sync]', ...a); function insert(table, mode, whereSql, colOverrides = {}) { const cols = targetCols(table); const selectExprs = cols.map((c) => (c in colOverrides ? `${colOverrides[c]} AS ${c}` : c)); const sql = `INSERT ${mode} INTO ${table} (${cols.join(', ')}) SELECT ${selectExprs.join(', ')} FROM src.${table} ${whereSql}`; const info = db.prepare(sql).run(...COMPANIES); log(`${table}: ${info.changes} rows`); } const tx = db.transaction(() => { insert('system_agents', 'OR REPLACE', `WHERE owner_company_id IN (${ph})`); insert('company_agents', 'OR IGNORE', `WHERE company_id IN (${ph})`); insert('org_graph_nodes', 'OR IGNORE', `WHERE company_id IN (${ph})`); insert('org_graph_edges', 'OR IGNORE', `WHERE company_id IN (${ph})`); insert('work_graphs', 'OR IGNORE', `WHERE company_id IN (${ph})`); insert('work_graph_nodes', 'OR IGNORE', `WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))`); insert('work_graph_edges', 'OR IGNORE', `WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))`); // 坑 2:先插 graph_runs,parent_node_run_id 强制 NULL,等 graph_run_nodes 落地后再回填。 insert('graph_runs', 'OR IGNORE', `WHERE company_id IN (${ph})`, { parent_node_run_id: 'NULL' }); insert('graph_run_nodes', 'OR IGNORE', `WHERE run_id IN (SELECT id FROM src.graph_runs WHERE company_id IN (${ph}))`); // 回填必须给内外表都加别名,否则子查询里的列会解析错关联对象,静默写成 NULL(坑 2 的后半)。 const backfill = db.prepare(` UPDATE graph_runs AS tgt SET parent_node_run_id = ( SELECT s.parent_node_run_id FROM src.graph_runs AS s WHERE s.id = tgt.id ) WHERE tgt.company_id IN (${ph}) `).run(...COMPANIES); log(`graph_runs.parent_node_run_id backfilled: ${backfill.changes} rows touched`); insert('graph_run_loop_records', 'OR IGNORE', `WHERE run_id IN (SELECT id FROM src.graph_runs WHERE company_id IN (${ph}))`); insert('graph_files', 'OR IGNORE', `WHERE company_id IN (${ph})`); insert('graph_edge_contracts', 'OR IGNORE', `WHERE edge_layer = 'org' AND edge_id IN (SELECT CAST(id AS TEXT) FROM src.org_graph_edges WHERE company_id IN (${ph}))`); insert('graph_edge_contracts', 'OR IGNORE', `WHERE edge_layer = 'work' AND edge_id IN ( SELECT id FROM src.work_graph_edges WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph})) )`); insert('goals', 'OR IGNORE', `WHERE company_id IN (${ph})`); insert('tasks', 'OR IGNORE', `WHERE company_id IN (${ph})`); // FK 校验放在事务内部:同一连接能看见本事务未提交的写入,dry-run 也能校验到真实结果。 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('sync complete'); } catch (e) { if (e.message === '__DRY_RUN_ROLLBACK__') { log('rolled back (dry run) — no rows persisted, foreign_key_check passed'); console.log('dry run complete, no changes persisted'); } else { console.error(e.message); failed = true; } } finally { db.exec('DETACH DATABASE src'); db.close(); } if (failed) process.exit(1);