Commit 2fc5039feeb37d3fe0be844bb164ca16d2cd25a5
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
Showing
3 changed files
with
182 additions
and
0 deletions
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 追加两列 | ... | ... |