source-kind-drop-migration.test.js
5.44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/**
* 28-followups §3.1 —— `system_agents.source_kind` 删列的**存量库路径**。
*
* 单独钉这一条的理由与 tasks-executor-migration 一样:新建库那条路由单测天然覆盖
* (建表块里已经没有这一列),「存量库 + 重启」这条必须另外安排人走一遍。
*
* ── 要守的三件事 ──────────────────────────────────────────────────────
* ① **顺序**:先把 source_kind 的信息回填进 origin,再删列。反过来做,
* 「origin 列出现之前写下的行」永远回不到正确的 origin —— 那正是那条回填存在的理由。
* 这一条最容易在重构里被弄反,而且弄反之后**不报错**:库能开、测试能过,
* 只有那几行 origin 悄悄是错的。
* ② **幂等**:删过之后第二次开库不能抛(`no such column: source_kind`)。
* ③ **位置**:删列必须跑在任何写入之前。migrate() 由 getDb() 在建表之后立刻调用,
* 正是那个位置;挪到别处的话,带着 source_kind 的 INSERT 会先撞 NOT NULL。
*
* ── 为什么 legacy_path 不一起删 ────────────────────────────────────────
* 它**还没死**:新行里它恒等于 system/agents/{id}(冗余),但存量行里存着批次 4 之前的
* 公司侧路径,而 graph-seed-service 判「悬空行 / 孤儿行」靠的就是它指向的目录还在不在。
* 实查生产库(2026-08-20)76 行里有 2 行仍是老路径。退出条件写在 graph-db.js 的建表注释里。
*/
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
import fs from 'fs';
import os from 'os';
import path from 'path';
const require_ = createRequire(import.meta.url);
/** 用独立 DB 文件跑一遍 getDb()(写法照抄 tasks-executor-migration.test.js) */
function freshDb(dbPath) {
process.env.GRAPH_DB_PATH = dbPath;
for (const m of ['../../services/graph-db', '../../services/work-graph-db']) {
delete require_.cache[require_.resolve(m)];
}
const graphDb = require_('../../services/graph-db');
graphDb.getDb();
return graphDb;
}
const cols = (db) => db.pragma('table_info(system_agents)').map((c) => c.name);
describe('system_agents.source_kind 删列', () => {
it('存量库重启:先回填 origin 再删列,且不炸', () => {
const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sk-drop-')), 'graph.sqlite');
const graphDb = freshDb(dbPath);
expect(cols(graphDb.getDb())).not.toContain('source_kind'); // 新建库本来就没有
graphDb.getDb().close();
// 退回删列之前的形态:把列加回来,并造两行「origin 列出现之前」的记录 ——
// 它们的 origin 是加列时的默认值 'builtin',真相只存在 source_kind 里。
const Database = require_('better-sqlite3');
const raw = new Database(dbPath);
raw.exec(`ALTER TABLE system_agents ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'global_agent'`);
const now = new Date().toISOString();
const ins = raw.prepare(`INSERT INTO system_agents
(id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at, origin, source_kind)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 'builtin', ?)`);
ins.run('legacy-hired', 'legacy-hired', 'company', 'co-x', 'system/agents/legacy-hired', now, now, 'company_hired');
ins.run('legacy-global', 'legacy-global', 'public', null, 'system/agents/legacy-global', now, now, 'global_agent');
expect(raw.pragma('table_info(system_agents)').map((c) => c.name)).toContain('source_kind');
raw.close();
// 重开进程 = 服务重启
const graphDb2 = freshDb(dbPath);
const db2 = graphDb2.getDb();
expect(cols(db2)).not.toContain('source_kind'); // ② 列没了
// ① 顺序:company_hired 那行的 origin 必须已经被回填成 created。
// 先删后填的话这里会停在 'builtin' —— 而且不会有任何报错。
const get = (id) => db2.prepare('SELECT origin FROM system_agents WHERE id = ?').get(id);
expect(get('legacy-hired').origin).toBe('created');
expect(get('legacy-global').origin).toBe('builtin'); // 对照:不该被一起改写
db2.close();
// ③ 幂等:再重启一次,不抛,结果不变
const graphDb3 = freshDb(dbPath);
expect(cols(graphDb3.getDb())).not.toContain('source_kind');
expect(graphDb3.getDb().prepare('SELECT origin FROM system_agents WHERE id = ?').get('legacy-hired').origin).toBe('created');
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
delete process.env.GRAPH_DB_PATH;
});
it('legacy_path 仍在,且删它的退出条件写在了建表注释里(别顺手一起删)', () => {
const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sk-drop2-')), 'graph.sqlite');
const graphDb = freshDb(dbPath);
expect(cols(graphDb.getDb())).toContain('legacy_path');
graphDb.getDb().close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
delete process.env.GRAPH_DB_PATH;
// 退出条件必须留在源码里可被读到 —— 否则下一个人只会看到一列「没人读的冗余字段」
const src = fs.readFileSync(path.join(__dirname, '../../services/graph-db.js'), 'utf-8');
expect(src).toContain("legacy_path <> 'system/agents/' || id");
});
});