@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +51 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +26 -9
- package/src/agent-acl-http.test.ts +8 -0
- package/src/agent-groups-perf.test.ts +23 -0
- package/src/auth.ts +22 -6
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +12 -1
- package/src/db-adapter.ts +64 -5
- package/src/db.ts +7 -1
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-mcp.test.ts +6 -0
- package/src/requirements-migrate.ts +61 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +192 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements.ts +102 -20
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +22 -9
- package/src/server.ts +1 -1
- package/src/side-thread-command-transport.test.ts +12 -2
- package/src/side-thread.ts +4 -0
- package/src/tools.ts +10 -8
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// 任务短号 seq:新建领号(并发不撞)、按网络各自编号、按 #N 查 / 改、删除和归档不回收号、MCP 工具也收 #N。
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-"));
|
|
8
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
9
|
+
|
|
10
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
11
|
+
let base = "";
|
|
12
|
+
let ownerToken = "", otherToken = "", nodeToken = "";
|
|
13
|
+
let ownerNet = "", otherNet = "", ownerId = "", otherId = "";
|
|
14
|
+
|
|
15
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
16
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
|
|
17
|
+
return { status: res.status, body: await res.json() as any };
|
|
18
|
+
}
|
|
19
|
+
const create = (token: string, body: Record<string, unknown>, query = "") => api(token, `/api/requirements${query}`, { method: "POST", body: JSON.stringify(body) });
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
const { register, createNetworkTokenForNode } = await import("./auth.js");
|
|
23
|
+
// 第一个注册的用户是 Hub 管理员、作用域是全部网络;让它先占掉,下面两个是普通用户(各自一个网络)。
|
|
24
|
+
register(`seq_admin_${Date.now()}`, "SeqAdmin123!x", undefined, "seed");
|
|
25
|
+
const o = register(`seq_owner_${Date.now()}`, "SeqOwner123!x", undefined, "seed");
|
|
26
|
+
const x = register(`seq_other_${Date.now()}`, "SeqOther123!x", undefined, "seed");
|
|
27
|
+
ownerToken = o.token!; ownerNet = o.network_id!; ownerId = o.user!.user_id;
|
|
28
|
+
otherToken = x.token!; otherNet = x.network_id!; otherId = x.user!.user_id;
|
|
29
|
+
nodeToken = createNetworkTokenForNode(ownerId, ownerNet, "seq-node").token!;
|
|
30
|
+
const mod: any = await import("./server.js");
|
|
31
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
32
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
33
|
+
}, 30_000);
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
try { server?.stop?.(true); } catch {}
|
|
37
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("requirement short numbers (#N)", () => {
|
|
41
|
+
test("capability is advertised and create returns seq", async () => {
|
|
42
|
+
const list = await api(ownerToken, "/api/requirements");
|
|
43
|
+
expect(list.body.capabilities).toContain("requirement_seq");
|
|
44
|
+
const first = await create(ownerToken, { name: "第一张" });
|
|
45
|
+
expect(first.status).toBe(201);
|
|
46
|
+
expect(first.body.requirement.seq).toBe(1);
|
|
47
|
+
expect(first.body.requirement.id).toMatch(/^req_/);
|
|
48
|
+
const got = await api(ownerToken, `/api/requirements/${first.body.requirement.id}`);
|
|
49
|
+
expect(got.body.requirement.seq).toBe(1);
|
|
50
|
+
expect((await api(ownerToken, "/api/requirements")).body.requirements[0].seq).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("concurrent creates get distinct, gap-free seqs", async () => {
|
|
54
|
+
const before = (await create(ownerToken, { name: "before" })).body.requirement.seq as number;
|
|
55
|
+
const results = await Promise.all(Array.from({ length: 40 }, (_, i) => create(i % 2 ? nodeToken : ownerToken, { name: `并发 ${i}` })));
|
|
56
|
+
expect(results.every(r => r.status === 201)).toBe(true);
|
|
57
|
+
const seqs = results.map(r => r.body.requirement.seq as number).sort((a, b) => a - b);
|
|
58
|
+
expect(new Set(seqs).size).toBe(40);
|
|
59
|
+
expect(seqs).toEqual(Array.from({ length: 40 }, (_, i) => before + 1 + i));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("seq is per network", async () => {
|
|
63
|
+
const a = await create(otherToken, { name: "别的网络第一张" });
|
|
64
|
+
expect(a.body.requirement.seq).toBe(1);
|
|
65
|
+
const b = await create(otherToken, { name: "别的网络第二张" });
|
|
66
|
+
expect(b.body.requirement.seq).toBe(2);
|
|
67
|
+
// 同一个 #1 在两个网络各指各的卡
|
|
68
|
+
expect((await api(ownerToken, "/api/requirements/%231")).body.requirement.name).toBe("第一张");
|
|
69
|
+
expect((await api(otherToken, "/api/requirements/%231")).body.requirement.name).toBe("别的网络第一张");
|
|
70
|
+
// 别的网络的号查不到对方的卡
|
|
71
|
+
expect((await api(otherToken, "/api/requirements/%2310")).status).toBe(404);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("lookup and patch by #N; list filter ?seq=; malformed forms rejected", async () => {
|
|
75
|
+
const c = (await create(ownerToken, { name: "按号查", checklist: [{ id: "a", text: "一" }] })).body.requirement;
|
|
76
|
+
const ref = `%23${c.seq}`;
|
|
77
|
+
const got = await api(ownerToken, `/api/requirements/${ref}`);
|
|
78
|
+
expect(got.status).toBe(200);
|
|
79
|
+
expect(got.body.requirement.id).toBe(c.id);
|
|
80
|
+
const patched = await api(nodeToken, `/api/requirements/${ref}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
|
|
81
|
+
expect(patched.status).toBe(200);
|
|
82
|
+
expect(patched.body.requirement.column).toBe("doing");
|
|
83
|
+
expect(patched.body.requirement.seq).toBe(c.seq); // 改卡不改号
|
|
84
|
+
const ticked = await api(ownerToken, `/api/requirements/${ref}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
85
|
+
expect(ticked.body.requirement.checklist[0].done).toBe(true);
|
|
86
|
+
const listed = await api(ownerToken, `/api/requirements?seq=${c.seq}`);
|
|
87
|
+
expect(listed.body.requirements.map((r: any) => r.id)).toEqual([c.id]);
|
|
88
|
+
expect((await api(ownerToken, "/api/requirements?seq=abc")).status).toBe(400);
|
|
89
|
+
expect((await api(ownerToken, "/api/requirements?seq=0")).status).toBe(400);
|
|
90
|
+
// 旧 id 照常;裸数字、「#」「#0」「#-1」都不是短号,按 id 查 → 404
|
|
91
|
+
expect((await api(ownerToken, `/api/requirements/${c.id}`)).body.requirement.seq).toBe(c.seq);
|
|
92
|
+
for (const bad of [String(c.seq), "%23", "%230", "%23-1", `%23${c.seq}x`]) expect((await api(ownerToken, `/api/requirements/${bad}`)).status).toBe(404);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("deleting or archiving never frees a number", async () => {
|
|
96
|
+
const top = (await create(ownerToken, { name: "最大号" })).body.requirement;
|
|
97
|
+
expect((await api(ownerToken, `/api/requirements/%23${top.seq}`, { method: "DELETE" })).status).toBe(200);
|
|
98
|
+
expect((await api(ownerToken, `/api/requirements/%23${top.seq}`)).status).toBe(404);
|
|
99
|
+
const next = (await create(ownerToken, { name: "删之后" })).body.requirement;
|
|
100
|
+
expect(next.seq).toBe(top.seq + 1);
|
|
101
|
+
await api(ownerToken, `/api/requirements/${next.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
|
|
102
|
+
expect((await create(ownerToken, { name: "归档之后" })).body.requirement.seq).toBe(next.seq + 1);
|
|
103
|
+
// 归档的卡按号照样能查到
|
|
104
|
+
expect((await api(ownerToken, `/api/requirements/%23${next.seq}`)).body.requirement.archived).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a create that loses on client_id / external_ref does not burn a number", async () => {
|
|
108
|
+
const a = (await create(ownerToken, { name: "幂等", client_id: "seq-client-1" })).body.requirement;
|
|
109
|
+
const again = await create(ownerToken, { name: "幂等", client_id: "seq-client-1" });
|
|
110
|
+
expect(again.body.requirement.seq).toBe(a.seq);
|
|
111
|
+
await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" });
|
|
112
|
+
expect((await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" })).status).toBe(409);
|
|
113
|
+
const after = (await create(ownerToken, { name: "之后" })).body.requirement;
|
|
114
|
+
expect(after.seq).toBe(a.seq + 2);
|
|
115
|
+
// upsert 新建也领号,改的时候号不变
|
|
116
|
+
const up = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步", external_ref: "github:acme/seq#2" }) });
|
|
117
|
+
expect(up.body.requirement.seq).toBe(after.seq + 1);
|
|
118
|
+
const up2 = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步改名", external_ref: "github:acme/seq#2" }) });
|
|
119
|
+
expect(up2.body.requirement.seq).toBe(after.seq + 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a user whose scope spans two networks gets 409 ambiguous_seq for #N, and network_id resolves it", async () => {
|
|
123
|
+
const { db } = await import("./db.js");
|
|
124
|
+
const { addNetworkMember } = await import("./auth.js");
|
|
125
|
+
addNetworkMember(otherNet, ownerId, "member", otherId);
|
|
126
|
+
const both = await api(ownerToken, "/api/requirements/%231");
|
|
127
|
+
expect(both.status).toBe(409);
|
|
128
|
+
expect(both.body.error).toBe("ambiguous_seq");
|
|
129
|
+
expect(both.body.networks.sort()).toEqual([ownerNet, otherNet].sort());
|
|
130
|
+
expect(both.body.message).toContain("pass network_id");
|
|
131
|
+
// MCP 上用户令牌(跨两个网络)同样:清楚的错误 + 带 network_id 就能查到
|
|
132
|
+
{
|
|
133
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
134
|
+
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
|
|
135
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
136
|
+
const { registerTools } = await import("./tools.js");
|
|
137
|
+
const mcp = new McpServer({ name: "seq-mcp-user", version: "1" });
|
|
138
|
+
registerTools(mcp, undefined, null, ownerId, "seq-owner", false, "tok_user");
|
|
139
|
+
const client = new Client({ name: "seq-mcp-user-client", version: "1" });
|
|
140
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
141
|
+
await mcp.connect(st); await client.connect(ct);
|
|
142
|
+
const call = async (name: string, args: Record<string, unknown>) => JSON.parse(((await client.callTool({ name, arguments: args })) as any).content[0].text);
|
|
143
|
+
try {
|
|
144
|
+
const amb = await call("requirements_get", { id: "#1" });
|
|
145
|
+
expect(amb.ok).toBe(false);
|
|
146
|
+
expect(amb.error).toBe("ambiguous_seq");
|
|
147
|
+
expect(amb.status).toBe(409);
|
|
148
|
+
expect(amb.message).toContain("pass network_id");
|
|
149
|
+
expect((await call("requirements_update", { id: "#1", priority: "low" })).error).toBe("ambiguous_seq");
|
|
150
|
+
expect((await call("requirements_get", { id: "#1", network_id: otherNet })).requirement.name).toBe("别的网络第一张");
|
|
151
|
+
expect((await call("requirements_get", { id: "#1", network_id: ownerNet })).requirement.name).toBe("第一张");
|
|
152
|
+
} finally { await client.close(); await mcp.close(); }
|
|
153
|
+
}
|
|
154
|
+
// 旧 id 不受影响
|
|
155
|
+
const firstId = (await api(ownerToken, `/api/requirements/%231?network_id=${ownerNet}`)).body.requirement.id;
|
|
156
|
+
expect((await api(ownerToken, `/api/requirements/${firstId}`)).status).toBe(200);
|
|
157
|
+
const scoped = await api(ownerToken, `/api/requirements/%231?network_id=${otherNet}`);
|
|
158
|
+
expect(scoped.status).toBe(200);
|
|
159
|
+
expect(scoped.body.requirement.name).toBe("别的网络第一张");
|
|
160
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [otherNet, ownerId]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("MCP tools accept #N and expose seq", async () => {
|
|
164
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
165
|
+
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
|
|
166
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
167
|
+
const { registerTools } = await import("./tools.js");
|
|
168
|
+
const { db } = await import("./db.js");
|
|
169
|
+
const tokenId = db.get<{ token_id: string }>("SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%' LIMIT 1", ownerNet)?.token_id ?? null;
|
|
170
|
+
const mcp = new McpServer({ name: "seq-mcp", version: "1" });
|
|
171
|
+
registerTools(mcp, undefined, ownerNet, ownerId, "seq-node", true, tokenId);
|
|
172
|
+
const client = new Client({ name: "seq-mcp-client", version: "1" });
|
|
173
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
174
|
+
await mcp.connect(st); await client.connect(ct);
|
|
175
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
176
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
177
|
+
return r.isError ? { mcpError: true } : JSON.parse(r.content[0].text);
|
|
178
|
+
};
|
|
179
|
+
try {
|
|
180
|
+
const made = await call("requirements_create", { name: "MCP 建的", checklist: [{ id: "k", text: "一" }] });
|
|
181
|
+
expect(Number.isInteger(made.requirement.seq)).toBe(true);
|
|
182
|
+
const n = `#${made.requirement.seq}`;
|
|
183
|
+
expect((await call("requirements_get", { id: n })).requirement.id).toBe(made.requirement.id);
|
|
184
|
+
expect((await call("requirements_update", { id: n, priority: "high" })).requirement.priority).toBe("high");
|
|
185
|
+
expect((await call("requirements_checklist_toggle", { id: n, item_id: "k", done: true })).requirement.checklist[0].done).toBe(true);
|
|
186
|
+
const listed = await call("requirements_list", { seq: made.requirement.seq });
|
|
187
|
+
expect(listed.requirements.map((r: any) => r.id)).toEqual([made.requirement.id]);
|
|
188
|
+
expect((await call("requirements_list", { seq: 0 })).mcpError).toBe(true);
|
|
189
|
+
expect((await call("requirements_get", { id: "#1" })).requirement.name).toBe("第一张"); // 节点令牌只看自己网络的 #1
|
|
190
|
+
} finally { await client.close(); await mcp.close(); }
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 任务短号 seq 的启动迁移:用没有 seq 列的旧表建库、塞行,再走真实启动路径(import db.ts + requirements.ts)。
|
|
2
|
+
// 补号按网络各自从 1 开始,按 created_at(新旧两种时间格式混在一起)排,同一时刻按 requirement_id。
|
|
3
|
+
import { afterAll, expect, test } from "bun:test";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-migrate-"));
|
|
10
|
+
const file = join(dir, "hub.db");
|
|
11
|
+
process.env.COMMHUB_DB = file;
|
|
12
|
+
|
|
13
|
+
const legacy = new Database(file);
|
|
14
|
+
legacy.exec(`CREATE TABLE requirements (
|
|
15
|
+
requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
16
|
+
column_name TEXT NOT NULL DEFAULT 'pool', priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
|
|
17
|
+
client_id TEXT, issues_json TEXT, created_by TEXT,
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
19
|
+
owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]')`);
|
|
20
|
+
// 插入顺序故意打乱;期望的号写在第三列。
|
|
21
|
+
const rows: [string, string, string, number][] = [
|
|
22
|
+
["req_c", "net_a", "2026-09-02T00:00:00.000Z", 5],
|
|
23
|
+
["req_a", "net_a", "2026-09-01 08:00:00", 1], // 旧默认格式(UTC,空格分隔)
|
|
24
|
+
["req_z", "net_b", "2026-08-01T00:00:00.000Z", 1],
|
|
25
|
+
["req_b2", "net_a", "2026-09-01T09:00:00.000Z", 3], // 与 req_b1 同一时刻 → 按 id
|
|
26
|
+
["req_b1", "net_a", "2026-09-01T09:00:00.000Z", 2],
|
|
27
|
+
["req_y", "net_b", "2026-08-02 00:00:00", 2],
|
|
28
|
+
["req_d", "net_a", "2026-09-01 23:59:59", 4], // 旧格式,晚于 09:00 的 ISO、早于 09-02
|
|
29
|
+
];
|
|
30
|
+
for (const [id, net, at] of rows) legacy.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4)", [id, net, `卡 ${id}`, at]);
|
|
31
|
+
legacy.close();
|
|
32
|
+
|
|
33
|
+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
34
|
+
|
|
35
|
+
test("startup backfills seq per network in created_at order, then id; counters and unique index follow", async () => {
|
|
36
|
+
const { db } = await import("./db.js");
|
|
37
|
+
await import("./requirements.js"); // 真实启动路径
|
|
38
|
+
const got = Object.fromEntries((db.all("SELECT requirement_id, network_id, seq FROM requirements") as any[]).map(r => [r.requirement_id, r.seq]));
|
|
39
|
+
// net_a 按时间:req_a 09-01 08:00 → req_b1 / req_b2 09-01 09:00(按 id)→ req_d 09-01 23:59:59 → req_c 09-02
|
|
40
|
+
expect(got).toEqual({ req_a: 1, req_b1: 2, req_b2: 3, req_d: 4, req_c: 5, req_z: 1, req_y: 2 });
|
|
41
|
+
// 按字符串排会把「2026-09-01 23:59:59」排到所有 ISO 的 09-01 前面 —— 证明排序真的按时刻,不按字面
|
|
42
|
+
expect("2026-09-01 23:59:59" < "2026-09-01T09:00:00.000Z").toBe(true);
|
|
43
|
+
const counters = Object.fromEntries((db.all("SELECT network_id, last_seq FROM requirement_seq_counters") as any[]).map(r => [r.network_id, r.last_seq]));
|
|
44
|
+
expect(counters).toEqual({ net_a: 5, net_b: 2 });
|
|
45
|
+
const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_network_seq");
|
|
46
|
+
expect(idx?.unique).toBe(1);
|
|
47
|
+
expect(() => db.run("UPDATE requirements SET seq = 1 WHERE requirement_id = 'req_c'")).toThrow();
|
|
48
|
+
|
|
49
|
+
// 重复执行:已有号的行一个不动;回滚到旧 Hub 期间建的卡(seq 为空)接在后面补号
|
|
50
|
+
const { ensureRequirementSeq, nextRequirementSeq } = await import("./requirements-migrate.js");
|
|
51
|
+
expect(ensureRequirementSeq(db).backfilled).toBe(0);
|
|
52
|
+
db.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES ('req_old_hub', 'net_a', 'x', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')");
|
|
53
|
+
expect(ensureRequirementSeq(db).backfilled).toBe(1);
|
|
54
|
+
expect((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_old_hub'") as any).seq).toBe(6);
|
|
55
|
+
expect(got.req_a).toBe((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_a'") as any).seq);
|
|
56
|
+
// 删掉最大号的卡,下一个号也不回收
|
|
57
|
+
db.run("DELETE FROM requirements WHERE requirement_id = 'req_old_hub'");
|
|
58
|
+
ensureRequirementSeq(db);
|
|
59
|
+
expect(nextRequirementSeq(db, "net_a")).toBe(7);
|
|
60
|
+
// 从没建过卡的网络从 1 开始
|
|
61
|
+
expect(nextRequirementSeq(db, "net_new")).toBe(1);
|
|
62
|
+
expect(nextRequirementSeq(db, "net_new")).toBe(2);
|
|
63
|
+
});
|
package/src/requirements.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
2
|
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
|
|
4
5
|
import { db } from "./db.js";
|
|
5
6
|
import { normalizeTags, storedTags } from "./requirement-tags.js";
|
|
6
7
|
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
7
8
|
import { isAgentRestricted, visibleAgents } from "./agent-access.js";
|
|
8
|
-
import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners, migrateRequirementPriorityCheck } from "./requirements-migrate.js";
|
|
9
|
+
import { ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
9
10
|
|
|
10
11
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
11
12
|
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
@@ -13,6 +14,7 @@ migrateRequirementPriorityCheck(db);
|
|
|
13
14
|
migrateRequirementAgentOwners(db);
|
|
14
15
|
ensureRequirementProjects(db);
|
|
15
16
|
ensureRequirementIndexes(db);
|
|
17
|
+
ensureRequirementSeq(db);
|
|
16
18
|
|
|
17
19
|
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
18
20
|
|
|
@@ -58,6 +60,7 @@ type Row = {
|
|
|
58
60
|
issues_json: string | null;
|
|
59
61
|
tags_json: string | null;
|
|
60
62
|
created_at: string;
|
|
63
|
+
seq: number | null;
|
|
61
64
|
};
|
|
62
65
|
|
|
63
66
|
function jsonError(error: string, status: number): Response {
|
|
@@ -283,6 +286,8 @@ function toPublic(row: Row) {
|
|
|
283
286
|
project_id: row.project_id || null,
|
|
284
287
|
participants: JSON.parse(row.participants_json || '[]'),
|
|
285
288
|
id: row.requirement_id,
|
|
289
|
+
// 短号:每个网络自己的 #1、#2…(不回收)。界面显示 #N;GET / PATCH / MCP 的 id 也收「#N」。旧 App 忽略它。
|
|
290
|
+
seq: row.seq == null ? null : Number(row.seq),
|
|
286
291
|
name: row.title,
|
|
287
292
|
priority: row.priority,
|
|
288
293
|
assignee: row.assignee || "",
|
|
@@ -357,7 +362,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
|
357
362
|
};
|
|
358
363
|
}
|
|
359
364
|
|
|
360
|
-
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, " +
|
|
365
|
+
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, " +
|
|
361
366
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
362
367
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
|
|
363
368
|
|
|
@@ -452,7 +457,8 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
452
457
|
return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
|
|
453
458
|
}
|
|
454
459
|
|
|
455
|
-
|
|
460
|
+
// search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
|
|
461
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging"] as const;
|
|
456
462
|
|
|
457
463
|
// ── 子需求 ──
|
|
458
464
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -602,12 +608,13 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
602
608
|
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
603
609
|
const actor = JSON.stringify(actorOf(ctx));
|
|
604
610
|
try {
|
|
605
|
-
|
|
611
|
+
// 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
|
|
612
|
+
db.transaction(() => db.run(
|
|
606
613
|
`INSERT INTO requirements
|
|
607
|
-
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on)
|
|
608
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23)`,
|
|
609
|
-
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null],
|
|
610
|
-
);
|
|
614
|
+
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq)
|
|
615
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24)`,
|
|
616
|
+
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, nextRequirementSeq(db, networkId)],
|
|
617
|
+
));
|
|
611
618
|
} catch {
|
|
612
619
|
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
613
620
|
if (ref) {
|
|
@@ -726,14 +733,30 @@ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: Pers
|
|
|
726
733
|
return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
|
|
727
734
|
}
|
|
728
735
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
736
|
+
/** 「#123」→ 123;别的形状(包括裸数字)→ null,仍按 requirement_id 查。 */
|
|
737
|
+
export function seqFromId(id: string): number | null {
|
|
738
|
+
const m = /^#([1-9]\d{0,14})$/.exec(id.trim());
|
|
739
|
+
return m ? Number(m[1]) : null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* 按 id 或「#N」找调用者作用域里的那张卡。短号只在一个网络里唯一:作用域跨多个网络、又各有一张 #N 时
|
|
744
|
+
* 不猜,回 409 ambiguous_seq(带 ?network_id= 再查)。
|
|
745
|
+
*/
|
|
746
|
+
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response {
|
|
747
|
+
const seq = seqFromId(id);
|
|
748
|
+
const params: unknown[] = [seq ?? id];
|
|
749
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE ${seq === null ? "requirement_id" : "seq"} = ?1`;
|
|
732
750
|
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
733
|
-
|
|
751
|
+
const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
|
|
752
|
+
if (rows.length > 1) {
|
|
753
|
+
const networks = rows.map(r => r.network_id);
|
|
754
|
+
return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
|
|
755
|
+
}
|
|
756
|
+
return rows[0] ?? jsonError("requirement_not_found", 404);
|
|
734
757
|
}
|
|
735
758
|
|
|
736
|
-
/** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
759
|
+
/** GET 的筛选(同步方 / MCP 用):seq(短号)、status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
737
760
|
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
738
761
|
const q = url.searchParams;
|
|
739
762
|
const status = q.get("status");
|
|
@@ -759,6 +782,11 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
759
782
|
if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
|
|
760
783
|
sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
|
|
761
784
|
}
|
|
785
|
+
const seq = q.get("seq");
|
|
786
|
+
if (seq !== null) {
|
|
787
|
+
if (!/^[1-9]\d{0,14}$/.test(seq)) return jsonError("invalid_seq", 400);
|
|
788
|
+
sql += ` AND seq = ?${params.push(Number(seq))}`;
|
|
789
|
+
}
|
|
762
790
|
const ref = q.get("external_ref");
|
|
763
791
|
if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
|
|
764
792
|
const parent = q.get("parent_id");
|
|
@@ -770,6 +798,55 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
770
798
|
return sql;
|
|
771
799
|
}
|
|
772
800
|
|
|
801
|
+
const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
|
|
802
|
+
type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
|
|
803
|
+
const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* 列表的一页(from = listFilters 拼好的「FROM requirements WHERE …」)。没有搜索词:SQL 直接 LIMIT。有搜索词:先按同样的筛选 + 顺序读轻量列(不算子需求计数),
|
|
807
|
+
* 在这里逐行匹配,凑够 limit + 1 张就停,再按 id 读这一页的完整行。
|
|
808
|
+
*/
|
|
809
|
+
function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unknown[], lq: ListQuery): { rows: Row[]; hasMore: boolean; nextCursor: string | null } {
|
|
810
|
+
const params = [...baseParams];
|
|
811
|
+
let where = from;
|
|
812
|
+
if (lq.cursor) {
|
|
813
|
+
const a = params.push(lq.cursor.createdAt), b = params.push(lq.cursor.id);
|
|
814
|
+
where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
|
|
815
|
+
}
|
|
816
|
+
let rows: Row[];
|
|
817
|
+
if (!lq.terms.length) {
|
|
818
|
+
rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
|
|
819
|
+
} else {
|
|
820
|
+
// seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
|
|
821
|
+
const light = db.all<LightRow>(`SELECT requirement_id, seq, network_id, created_at, title, description, assignee, tags_json, project_id, owner_json, agent_owner_json, participants_json ${where}${LIST_ORDER}`, ...params);
|
|
822
|
+
const maps = new Map<string, NameMaps>();
|
|
823
|
+
const hits: string[] = [];
|
|
824
|
+
for (const r of light) {
|
|
825
|
+
let m = maps.get(r.network_id);
|
|
826
|
+
if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
|
|
827
|
+
const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
|
|
828
|
+
if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
|
|
829
|
+
if (hits.length > lq.limit) break;
|
|
830
|
+
}
|
|
831
|
+
const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
|
|
832
|
+
rows = hits.map(id => byId.get(id)).filter((r): r is Row => !!r);
|
|
833
|
+
}
|
|
834
|
+
const hasMore = rows.length > lq.limit;
|
|
835
|
+
if (hasMore) rows = rows.slice(0, lq.limit);
|
|
836
|
+
const last = rows[rows.length - 1];
|
|
837
|
+
return { rows, hasMore, nextCursor: hasMore && last ? encodeCursor({ createdAt: last.created_at, id: last.requirement_id }) : null };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** 搜索用的名字表(与 GET /api/requirements/people 同一个显示名规则);调用者看不见的节点不放进来。 */
|
|
841
|
+
function searchNameMaps(ctx: RequirementsRequestContext, networkId: string): NameMaps {
|
|
842
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
843
|
+
const people = new Map<string, string>();
|
|
844
|
+
for (const u of db.all<{ id: string; name: string }>("SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1", networkId)) people.set(`user:${u.id}`, u.name);
|
|
845
|
+
for (const n of db.all<{ id: string; name: string }>("SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1", networkId)) if (!hidden?.(n.id)) people.set(`node:${n.id}`, n.name);
|
|
846
|
+
const projects = new Map(db.all<{ project_id: string; name: string }>("SELECT project_id, name FROM requirement_projects WHERE network_id=?1", networkId).map(p => [p.project_id, p.name] as const));
|
|
847
|
+
return { people, projects };
|
|
848
|
+
}
|
|
849
|
+
|
|
773
850
|
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
774
851
|
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
775
852
|
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
@@ -812,13 +889,18 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
812
889
|
|
|
813
890
|
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
814
891
|
const params: unknown[] = [];
|
|
815
|
-
|
|
892
|
+
// 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
|
|
893
|
+
let sql = "FROM requirements WHERE 1=1";
|
|
816
894
|
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
817
895
|
const filtered = listFilters(url, sql, params, ctx);
|
|
818
896
|
if (typeof filtered !== "string") return filtered;
|
|
819
|
-
|
|
897
|
+
// q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
|
|
898
|
+
const lq = parseListQuery(url.searchParams);
|
|
899
|
+
if ("error" in lq) return jsonError(lq.error, 400);
|
|
900
|
+
const page = listPage(ctx, filtered, params, lq);
|
|
820
901
|
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
821
|
-
|
|
902
|
+
// has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
|
|
903
|
+
return conditionalJson(ctx.req, { ok: true, requirements: page.rows.map(row => toPublicFor(ctx, row)), capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor });
|
|
822
904
|
}
|
|
823
905
|
|
|
824
906
|
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
@@ -859,7 +941,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
859
941
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
860
942
|
if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
|
|
861
943
|
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
862
|
-
if (
|
|
944
|
+
if (row instanceof Response) return row;
|
|
863
945
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
864
946
|
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
865
947
|
const items = storedChecklist(row.checklist_json);
|
|
@@ -877,12 +959,12 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
877
959
|
const id = decodeURIComponent(match[1]);
|
|
878
960
|
if (req.method === "GET") {
|
|
879
961
|
const row = scopedRow(ctx, id);
|
|
880
|
-
return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) })
|
|
962
|
+
return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
|
|
881
963
|
}
|
|
882
964
|
if (req.method === "DELETE") {
|
|
883
965
|
// 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
|
|
884
966
|
const row = scopedRow(ctx, id);
|
|
885
|
-
if (
|
|
967
|
+
if (row instanceof Response) return row;
|
|
886
968
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
887
969
|
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
888
970
|
db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
|
|
@@ -894,6 +976,6 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
894
976
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
895
977
|
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
896
978
|
const row = scopedRow(ctx, id);
|
|
897
|
-
if (
|
|
979
|
+
if (row instanceof Response) return row;
|
|
898
980
|
return patchRequirement(ctx, row, body);
|
|
899
981
|
}
|
package/src/retention.ts
CHANGED
|
@@ -197,10 +197,13 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
|
|
|
197
197
|
// DBs must run a one-time blocking `VACUUM` themselves (out of
|
|
198
198
|
// scope for the sweeper, which deliberately stays non-
|
|
199
199
|
// blocking).
|
|
200
|
+
// Both are SQLite PRAGMAs; on PostgreSQL autovacuum does this job and
|
|
201
|
+
// the two counters stay null (RFC-039 S3).
|
|
200
202
|
let walCheckpointPagesMoved: number | null = null;
|
|
201
203
|
let incrementalFreedPages: number | null = null;
|
|
202
204
|
let errored = false;
|
|
203
|
-
|
|
205
|
+
const sqliteVacuum = db.dialect === "sqlite";
|
|
206
|
+
if (sqliteVacuum) try {
|
|
204
207
|
// wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
|
|
205
208
|
// We expose `log` (page count moved into the main DB) for ops visibility.
|
|
206
209
|
const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
|
|
@@ -211,7 +214,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
|
|
|
211
214
|
console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
|
|
212
215
|
errored = true;
|
|
213
216
|
}
|
|
214
|
-
try {
|
|
217
|
+
if (sqliteVacuum) try {
|
|
215
218
|
// incremental_vacuum is a no-op on databases not created with
|
|
216
219
|
// PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
|
|
217
220
|
// weren't, the operator would have to run a full one-time VACUUM
|
|
@@ -8,8 +8,8 @@ import { db } from "./db.js";
|
|
|
8
8
|
import { assertScheduledTaskBackendSupported, nextOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
9
9
|
|
|
10
10
|
const dir = mkdtempSync(join(tmpdir(), "anet-scheduler-"));
|
|
11
|
-
const activeDbPath = process.env.COMMHUB_DB;
|
|
12
|
-
if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB before module import");
|
|
11
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
12
|
+
if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
13
13
|
let server: any;
|
|
14
14
|
let base = "";
|
|
15
15
|
let ownerToken = "";
|
|
@@ -128,8 +128,13 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
128
128
|
(db as any).atomicTransactions = original;
|
|
129
129
|
}
|
|
130
130
|
expect(() => assertScheduledTaskBackendSupported()).not.toThrow();
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
if (db.dialect === "sqlite") {
|
|
132
|
+
const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
|
|
133
|
+
expect(misfireColumn?.dflt_value).toBe("'catch_up_once'");
|
|
134
|
+
} else {
|
|
135
|
+
const misfireColumn = db.get<any>("SELECT column_default FROM information_schema.columns WHERE table_name = 'scheduled_tasks' AND column_name = 'misfire_policy'");
|
|
136
|
+
expect(misfireColumn?.column_default).toBe("'catch_up_once'::text");
|
|
137
|
+
}
|
|
133
138
|
});
|
|
134
139
|
|
|
135
140
|
test("owner creates; viewer and node token cannot mutate", async () => {
|
|
@@ -283,10 +288,17 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
283
288
|
// Test-only fault injection at the second durable write. The inbox row has
|
|
284
289
|
// already been attempted when this aborts, so any missing transaction
|
|
285
290
|
// boundary leaves an observable half-state.
|
|
286
|
-
db.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
291
|
+
if (db.dialect === "sqlite") {
|
|
292
|
+
db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
|
|
293
|
+
BEFORE INSERT ON tasks
|
|
294
|
+
WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
|
|
295
|
+
BEGIN SELECT RAISE(ABORT, 'test601_injected_task_failure'); END`);
|
|
296
|
+
} else {
|
|
297
|
+
db.exec(`CREATE FUNCTION test601_abort_scheduled_task_insert() RETURNS trigger AS $$
|
|
298
|
+
BEGIN RAISE EXCEPTION 'test601_injected_task_failure'; END; $$ LANGUAGE plpgsql;
|
|
299
|
+
CREATE TRIGGER test601_abort_scheduled_task_insert BEFORE INSERT ON tasks FOR EACH ROW
|
|
300
|
+
WHEN (NEW.meta_json LIKE '%${atomicScheduleId}%') EXECUTE FUNCTION test601_abort_scheduled_task_insert()`);
|
|
301
|
+
}
|
|
290
302
|
const failed = runDueScheduledTasks();
|
|
291
303
|
expect(failed.failed).toBe(1);
|
|
292
304
|
expect(db.get("SELECT run_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeNull();
|
|
@@ -294,7 +306,8 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
294
306
|
expect(db.get("SELECT task_id FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
|
|
295
307
|
expect(db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", atomicScheduleId)!.next_run_at).toBe(due);
|
|
296
308
|
|
|
297
|
-
db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
|
|
309
|
+
if (db.dialect === "sqlite") db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
|
|
310
|
+
else db.exec("DROP TRIGGER test601_abort_scheduled_task_insert ON tasks; DROP FUNCTION test601_abort_scheduled_task_insert()");
|
|
298
311
|
expect(runDueScheduledTasks().processed).toBe(1);
|
|
299
312
|
expect(db.get("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeTruthy();
|
|
300
313
|
});
|
package/src/server.ts
CHANGED
|
@@ -3459,7 +3459,7 @@ return Bun.serve({
|
|
|
3459
3459
|
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
3460
3460
|
if (node.alias) {
|
|
3461
3461
|
db.run(
|
|
3462
|
-
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
|
|
3462
|
+
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
|
|
3463
3463
|
[node.alias, node.network_id ?? null]
|
|
3464
3464
|
);
|
|
3465
3465
|
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
|
-
import { SQLiteAdapter } from "./db-adapter";
|
|
3
|
+
import { PgAdapter, SQLiteAdapter, resolveDatabaseTarget, type DbAdapter } from "./db-adapter";
|
|
4
4
|
import { DurableSideThreadCommandPort, SideThreadCommandStore, handleSideThreadCommandRequest } from "./side-thread-command-transport";
|
|
5
5
|
import { SideThreadCoordinator, SideThreadStore } from "./side-thread";
|
|
6
6
|
|
|
7
7
|
function actor(tokenId = "tok-node") { return { tokenId, networkId: "net-1", nodeId: "node-1" }; }
|
|
8
|
+
// RFC-039 S4: with COMMHUB_TEST_PG_URL (validated by the adapter guard) the
|
|
9
|
+
// same tests run on PostgreSQL, each fixture on an emptied schema.
|
|
10
|
+
function freshDb(): DbAdapter {
|
|
11
|
+
if (!process.env.COMMHUB_TEST_PG_URL) return new SQLiteAdapter(new Database(":memory:"));
|
|
12
|
+
const target = resolveDatabaseTarget();
|
|
13
|
+
if (target.kind !== "postgres") throw new Error("COMMHUB_TEST_PG_URL did not select PostgreSQL");
|
|
14
|
+
const pg = new PgAdapter(target.url);
|
|
15
|
+
pg.exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public");
|
|
16
|
+
return pg;
|
|
17
|
+
}
|
|
8
18
|
function fixture() {
|
|
9
|
-
const db =
|
|
19
|
+
const db = freshDb();
|
|
10
20
|
db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
|
|
11
21
|
const store = new SideThreadCommandStore(db, () => 123);
|
|
12
22
|
const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
|
package/src/side-thread.ts
CHANGED
|
@@ -450,6 +450,10 @@ export function installSideThreadSchema(database: DbAdapter): void {
|
|
|
450
450
|
if (!/duplicate column|already exists/i.test(error?.message ?? ""))
|
|
451
451
|
throw error;
|
|
452
452
|
}
|
|
453
|
+
// RFC-039 F3: operations are listed in insertion order via SQLite's
|
|
454
|
+
// implicit rowid; PostgreSQL needs a real column for that.
|
|
455
|
+
if (database.dialect === "postgres")
|
|
456
|
+
database.exec("ALTER TABLE side_chat_operations ADD COLUMN IF NOT EXISTS rowid BIGSERIAL");
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
type SideChatRow = {
|