Commit 2fc5039feeb37d3fe0be844bb164ca16d2cd25a5

Authored by yibo
1 parent ec5a805c

docs+tool: 记录跨环境 graph.sqlite 合并的两个坑,补可复用同步脚本

2026-08-28 把本地 mycompany/innovator-fde-trial 同步到生产时手搓合并脚本踩过两个坑:
本地库和生产库同名字段的物理列序可能不同(SELECT * 位置对齐会静默写错列),以及
graph_runs/graph_run_nodes 循环外键的回填 UPDATE 必须给关联子查询加表别名。

把手搓版本整理成 scripts/sync-company-to-graph-db.js(按列名对齐 + 支持 --dry-run),
并在 graph-db.js migrate() 和 production-deploy.md 里记下这两个坑,供下次同步复用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpkWPa2CmuWJFJZksqvxWB
docs/done/production-deploy.md
... ... @@ -60,6 +60,35 @@ docker run -d --name lemonboss \
60 60  
61 61 **当前模型配置**(2026-08-27 起):智谱 GLM-5.3-Flash,`ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic`,`ANTHROPIC_MODEL=glm-5.3-flash`(此前是 DeepSeek `deepseek-v4-flash` + `https://api.deepseek.com/anthropic`)。
62 62  
  63 +## 跨环境同步公司数据(graph.sqlite)
  64 +
  65 +2026-08-28 把本地 `mycompany` / `innovator-fde-trial` 两家公司同步到生产时踩出来的经验。
  66 +「公司数据」分两半,**必须分开处理**:
  67 +
  68 +1. **工作文件**:`data/companies/{cid}/`(memory / conversations / workspace 等),普通文件,直接
  69 + `rsync -az` 到 `/opt/lemonboss/data/companies/{cid}/`,传完 `chown hexyun:hexyun`(root 传的文件
  70 + 默认 root:root)。
  71 +2. **注册信息**:「这家公司雇了谁、org/work graph 长什么样、goal/task/run 记录」全部在
  72 + `data/graph.sqlite` 里,不在文件系统里——只搬文件,网页上完全看不到这家公司。
  73 +
  74 +第 2 部分**不要手写 SELECT * 去搬**,用 `scripts/sync-company-to-graph-db.js`(先 `--dry-run` 看
  75 +一遍行数,确认无误再去掉这个 flag 跑真的)。这个脚本已经绕开两个手搓时踩过的坑:
  76 +
  77 +- **列序不能信**:本地库和生产库的表字段名一样,但物理列序可能不同——一个是老库一路
  78 + `ALTER TABLE` 攒出来的顺序,一个是某次重建/清空后吃到的当时最新 `CREATE TABLE` 字面量顺序。
  79 + `SELECT *` 位置对齐的 `INSERT` 会把值静默写进错误的列,`NOT NULL` 列能报错拦一部分,可空/
  80 + 同类型的列错了不会有任何提示。详见 `server/services/graph-db.js` 的 `migrate()` 函数头注释。
  81 +- **`graph_runs` ⇄ `graph_run_nodes` 是循环外键**(`graph_runs.parent_node_run_id` 指
  82 + `graph_run_nodes(id)`,`graph_run_nodes.run_id` 指 `graph_runs(id)`),批量搬迁需要先插
  83 + `graph_runs`(`parent_node_run_id` 置空)、再插 `graph_run_nodes`、最后回填。**回填那条
  84 + `UPDATE` 的关联子查询必须给内外表都加别名**——两边都用裸表名的话,SQLite 会把子查询里的列
  85 + 解析到错误的关联对象,静默回填成 `NULL`,`changes` 还显示"成功",不会报错。
  86 +
  87 +操作顺序:先在生产上备份(`tar` 打包 `companies/` 目录 + 用 `db.backup()` 存一份
  88 +`graph.sqlite.bak-<ts>`),再 rsync 文件,再跑同步脚本(对着生产的 `/app/data/graph.sqlite`,
  89 +可以通过 `docker exec lemonboss node scripts/sync-company-to-graph-db.js ...` 或者先
  90 +`docker cp` 脚本进去跑),最后 `docker restart lemonboss` 让心跳/graph-seed 重新装载。
  91 +
63 92 ## 其他线索
64 93  
65 94 - `/opt/lemonboss/.env` 里的 `OPEN_API_KEYS` 是 `/api/open/*` 外部调用的鉴权 key,本文档不记录明文值。**注意**:`.env` 文件本身对 `ANTHROPIC_*` 这三个变量是摆设(见上),但 `OPEN_API_KEYS` 等其他没有被 `docker run -e` 固化的变量,还是照常从 `.env` 里读取生效的——只有被固化过的那几个例外。
... ...
scripts/sync-company-to-graph-db.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * sync-company-to-graph-db.js — 把一批公司在 graph.sqlite 里的全部行,从一个库合并进另一个库
  4 + *
  5 + * 背景: 2026-08-28 把本地 mycompany / innovator-fde-trial 同步到生产时手搓过一次这个逻辑,
  6 + * 踩了两个坑,见 docs/done/production-deploy.md「跨环境同步公司数据」一节。这个脚本是那次
  7 + * 手搓版本的正式化,以后再有「把某几家公司搬到另一个 graph.sqlite」的需求,直接用它,
  8 + * 不要重新手写 SELECT * 的合并逻辑。
  9 + *
  10 + * 两个坑,这个脚本已经绕开:
  11 + * 1. 列序不能信。本地库和生产库的表结构字段名一样,但物理列序可能不同——同一张表,
  12 + * 有的库是老版本 CREATE TABLE 之后靠 ALTER TABLE 一路加列,有的库是某次重建/清空后
  13 + * 吃到了当时最新的 CREATE TABLE 字面量,两边列的物理顺序会对不上。SELECT * 位置对齐
  14 + * 的 INSERT 会静默把值写进错误的列(NOT NULL 约束能接住一部分,其余的没有任何报错)。
  15 + * 这里一律按列名显式对齐(用 PRAGMA table_info 在运行时取目标库的列名)。
  16 + * 2. graph_runs.parent_node_run_id ⇄ graph_run_nodes.run_id 是循环外键。先插 graph_runs
  17 + * (parent_node_run_id 强制置 NULL),再插 graph_run_nodes,最后回填 parent_node_run_id。
  18 + * 回填那条 UPDATE 的关联子查询**必须**给内外表都加别名——用裸表名的话 SQLite 会把子查询
  19 + * 里的列解析错关联对象,静默回填成 NULL,不报错、不报变更数异常(changes 照样是「成功」)。
  20 + *
  21 + * 用法:
  22 + * node scripts/sync-company-to-graph-db.js \
  23 + * --source data/graph.sqlite \
  24 + * --target /path/to/other/graph.sqlite \
  25 + * --companies mycompany,innovator-fde-trial \
  26 + * [--dry-run]
  27 + *
  28 + * --dry-run 整个合并跑在一个事务里,最后主动抛出回滚,不留任何行;控制台照常打印各表行数。
  29 + *
  30 + * 表顺序即依赖顺序(父表先于子表插入):
  31 + * system_agents(公司私有的) → company_agents → org_graph_nodes → org_graph_edges
  32 + * → work_graphs → work_graph_nodes → work_graph_edges → graph_runs → graph_run_nodes
  33 + * → graph_run_loop_records → graph_files → graph_edge_contracts → goals → tasks
  34 + *
  35 + * 不做的事: 不搬 data/companies/{cid}/ 下的工作文件(memory / conversations / workspace)——
  36 + * 那部分是普通文件,直接 rsync 就行,不需要脚本。这个脚本只管 graph.sqlite 里的行。
  37 + */
  38 +
  39 +const Database = require('better-sqlite3');
  40 +
  41 +function parseArgs(argv) {
  42 + const out = { dryRun: argv.includes('--dry-run') };
  43 + for (const flag of ['--source', '--target', '--companies']) {
  44 + const i = argv.indexOf(flag);
  45 + if (i !== -1) out[flag.slice(2)] = argv[i + 1];
  46 + }
  47 + return out;
  48 +}
  49 +
  50 +const { source, target, companies, dryRun } = parseArgs(process.argv.slice(2));
  51 +if (!source || !target || !companies) {
  52 + console.error(
  53 + 'usage: node scripts/sync-company-to-graph-db.js --source <db> --target <db> --companies id1,id2 [--dry-run]'
  54 + );
  55 + process.exit(1);
  56 +}
  57 +const COMPANIES = companies.split(',').map((s) => s.trim()).filter(Boolean);
  58 +const ph = COMPANIES.map(() => '?').join(',');
  59 +
  60 +const db = new Database(target);
  61 +db.pragma('foreign_keys = ON');
  62 +db.exec(`ATTACH DATABASE '${source}' AS src`);
  63 +
  64 +// 列名一律从目标库运行时读取(PRAGMA table_info),不写死顺序——见文件头坑 1。
  65 +const targetCols = (table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
  66 +
  67 +const log = (...a) => console.log(dryRun ? '[dry-run]' : '[sync]', ...a);
  68 +
  69 +function insert(table, mode, whereSql, colOverrides = {}) {
  70 + const cols = targetCols(table);
  71 + const selectExprs = cols.map((c) => (c in colOverrides ? `${colOverrides[c]} AS ${c}` : c));
  72 + const sql = `INSERT ${mode} INTO ${table} (${cols.join(', ')}) SELECT ${selectExprs.join(', ')} FROM src.${table} ${whereSql}`;
  73 + const info = db.prepare(sql).run(...COMPANIES);
  74 + log(`${table}: ${info.changes} rows`);
  75 +}
  76 +
  77 +const tx = db.transaction(() => {
  78 + insert('system_agents', 'OR REPLACE', `WHERE owner_company_id IN (${ph})`);
  79 + insert('company_agents', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  80 + insert('org_graph_nodes', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  81 + insert('org_graph_edges', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  82 + insert('work_graphs', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  83 + insert('work_graph_nodes', 'OR IGNORE',
  84 + `WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))`);
  85 + insert('work_graph_edges', 'OR IGNORE',
  86 + `WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))`);
  87 +
  88 + // 坑 2:先插 graph_runs,parent_node_run_id 强制 NULL,等 graph_run_nodes 落地后再回填。
  89 + insert('graph_runs', 'OR IGNORE', `WHERE company_id IN (${ph})`, { parent_node_run_id: 'NULL' });
  90 + insert('graph_run_nodes', 'OR IGNORE',
  91 + `WHERE run_id IN (SELECT id FROM src.graph_runs WHERE company_id IN (${ph}))`);
  92 +
  93 + // 回填必须给内外表都加别名,否则子查询里的列会解析错关联对象,静默写成 NULL(坑 2 的后半)。
  94 + const backfill = db.prepare(`
  95 + UPDATE graph_runs AS tgt SET parent_node_run_id = (
  96 + SELECT s.parent_node_run_id FROM src.graph_runs AS s WHERE s.id = tgt.id
  97 + )
  98 + WHERE tgt.company_id IN (${ph})
  99 + `).run(...COMPANIES);
  100 + log(`graph_runs.parent_node_run_id backfilled: ${backfill.changes} rows touched`);
  101 +
  102 + insert('graph_run_loop_records', 'OR IGNORE',
  103 + `WHERE run_id IN (SELECT id FROM src.graph_runs WHERE company_id IN (${ph}))`);
  104 + insert('graph_files', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  105 + insert('graph_edge_contracts', 'OR IGNORE',
  106 + `WHERE edge_layer = 'org' AND edge_id IN (SELECT CAST(id AS TEXT) FROM src.org_graph_edges WHERE company_id IN (${ph}))`);
  107 + insert('graph_edge_contracts', 'OR IGNORE',
  108 + `WHERE edge_layer = 'work' AND edge_id IN (
  109 + SELECT id FROM src.work_graph_edges
  110 + WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))
  111 + )`);
  112 + insert('goals', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  113 + insert('tasks', 'OR IGNORE', `WHERE company_id IN (${ph})`);
  114 +
  115 + // FK 校验放在事务内部:同一连接能看见本事务未提交的写入,dry-run 也能校验到真实结果。
  116 + const fkErrors = db.pragma('foreign_key_check');
  117 + if (fkErrors.length) {
  118 + throw new Error('foreign_key_check failed: ' + JSON.stringify(fkErrors));
  119 + }
  120 +
  121 + if (dryRun) throw new Error('__DRY_RUN_ROLLBACK__');
  122 +});
  123 +
  124 +let failed = false;
  125 +try {
  126 + tx();
  127 + console.log('sync complete');
  128 +} catch (e) {
  129 + if (e.message === '__DRY_RUN_ROLLBACK__') {
  130 + log('rolled back (dry run) — no rows persisted, foreign_key_check passed');
  131 + console.log('dry run complete, no changes persisted');
  132 + } else {
  133 + console.error(e.message);
  134 + failed = true;
  135 + }
  136 +} finally {
  137 + db.exec('DETACH DATABASE src');
  138 + db.close();
  139 +}
  140 +if (failed) process.exit(1);
... ...
server/services/graph-db.js
... ... @@ -54,6 +54,19 @@ function hasColumn(database, table, column) {
54 54  
55 55 /**
56 56 * 存量库迁移。新建库走上面的 CREATE TABLE 就已经是目标形态,这里全部是 no-op。
  57 + *
  58 + * ⚠️ 「目标形态」只保证列名和默认值一致,**不保证物理列序跟老库一样**:新建库拿到的是
  59 + * CREATE TABLE 字面量里写的顺序,老库(一路 ALTER TABLE 攒起来的)拿到的是历次 ALTER
  60 + * 的追加顺序,两者不一定相同——只有 system_agents.origin 那一列刻意「放最后」对齐过
  61 + * (见下面 CREATE TABLE 里的注释),org_graph_nodes 的 status / reports_to_node_id 等
  62 + * 没有这个保证。2026-08-28 往生产同步公司数据时实测过:本地库(一直没被重建,列序是
  63 + * ALTER 攒出来的)和生产库(发生过重置,吃到的是新版字面量)对同一张 org_graph_nodes
  64 + * 表列序完全不同。
  65 + *
  66 + * 后果:任何跨库操作(备份还原、环境间同步、手写迁移脚本)一律不能用 `SELECT *` 做
  67 + * 位置对齐的搬运——按列名对齐,或者干脆用 scripts/sync-company-to-graph-db.js(已经
  68 + * 在跑时读 PRAGMA table_info 解决了这个问题)。NOT NULL 列能接住一部分错位,可空/同类型
  69 + * 列错位了不会有任何报错,数据会静默写歪。
57 70 */
58 71 function migrate(database) {
59 72 // 1. org_graph_nodes 追加两列
... ...